Hold'em dealer: a felt that fits, and a table that reads its own showdown #118
@@ -597,23 +597,39 @@ button,
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Clinging to a puck rather than sitting on its own ring — the phone layout. */
|
||||
/* Clinging to a puck rather than sitting on its own ring — the phone layout.
|
||||
|
||||
It has to fit the puck it is clinging to, which it did not: measured on a Galaxy S8,
|
||||
the puck was 48px across and every one of the five chips on the felt was 50, so each
|
||||
one hung over both edges of its own seat and three of them reached onto the seat next
|
||||
door. The same fault the small blind badge had, on the other thing that hangs off a
|
||||
puck — and the one that carries a number rather than two letters.
|
||||
|
||||
The disc is what gives way. It is 13px of decoration plus a 4px gap next to a figure
|
||||
the dealer has to read, and dropping it takes the chip from 50px to 33 — inside the
|
||||
narrowest puck the layout will ever draw. Its colour moves to the chip's own rim, so
|
||||
the denomination the legend assigns is still there at no width at all. Same trade the
|
||||
tight puck already makes with the hole cards. */
|
||||
.betChipAnchored {
|
||||
left: 50%;
|
||||
z-index: 3;
|
||||
max-width: 100%;
|
||||
padding: var(--sp-0) var(--sp-1);
|
||||
border-color: var(--betChipColour, rgba(255, 255, 255, 0.14));
|
||||
}
|
||||
|
||||
.betChipAbove {
|
||||
.betChipAnchored .betChipDisc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Where along the puck's edge it lands is set by render.js, which walks the seat's own
|
||||
spoke out to just past the puck — see anchoredChipOffset(). The `top` here is only what
|
||||
it wears before that lands, and on the one path where there is nothing to measure. */
|
||||
.betChipAnchored {
|
||||
top: -11px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.betChipBelow {
|
||||
top: auto;
|
||||
bottom: -11px;
|
||||
transform: translate(-50%, 50%);
|
||||
}
|
||||
|
||||
/* ---------- Seats (compact pucks on the table) ---------- */
|
||||
|
||||
.seat {
|
||||
@@ -3207,13 +3223,19 @@ body.hasActionBar #toastHost {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
/* So the fade over the bottom of the list has something to hang off. */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
/* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over
|
||||
the bottom of it — so the last of the wizard sat underneath the bar and its
|
||||
controls couldn't be tapped. Leaving the bar's own height out of the budget
|
||||
keeps the whole dialog above it. */
|
||||
max-height: calc(85vh - var(--actionBarHeight, 0px));
|
||||
keeps the whole dialog above it.
|
||||
That was true while this overlay sat at z-index 50, below the bar's 55. It sits
|
||||
at 95 now and the bar cannot draw on it, so the subtraction buys nothing and
|
||||
costs 110px of a 640px screen — which on a Galaxy S8 is the difference between
|
||||
a three-way pot showing all three names and showing two of them. */
|
||||
max-height: 85vh;
|
||||
/* A column of three: the title, the part that scrolls, and the buttons. The modal
|
||||
itself no longer scrolls — only its middle does — which is what keeps Confirm on
|
||||
screen without asking a sticky footer to do something it cannot. */
|
||||
@@ -3239,8 +3261,29 @@ body.hasActionBar #toastHost {
|
||||
the same full-width treatment as one that appends a pair — the winner step used to
|
||||
put a lone Confirm in the left half of the dialog, looking like one option out of
|
||||
several that hadn't drawn yet. */
|
||||
/* There is more list above the buttons than the dialog can show.
|
||||
|
||||
Measured on a Galaxy S8: three players are in the pot, the list holds three rows, and
|
||||
two of them fit. The third was cut off exactly on a row boundary, immediately under a
|
||||
hairline that reads as the end of the list — so the dialog that pays the table looked
|
||||
finished with a name missing from it, and a dealer ticking the two they could see
|
||||
would have paid two thirds of a three-way pot. The list does scroll; nothing said so.
|
||||
Now the cut runs through a row rather than between them, under a fade, which is what
|
||||
a list that continues looks like. */
|
||||
#potWizardModal.potWizardMoreBelow #potWizardFooter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: 2.25rem;
|
||||
background: linear-gradient(rgba(30, 30, 30, 0), var(--surface-1) 85%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#potWizardFooter {
|
||||
flex: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
@@ -3961,6 +4004,20 @@ body.hasActionBar #toastHost {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The same trade, for the pot total. Measured on a phone held sideways, the badge is
|
||||
86x66 on a felt that is 692x113 — it takes 58% of the table's height for a number
|
||||
the action bar is already printing three inches below it, in the line that starts
|
||||
"PRE-FLOP Pot $65". Two seat pucks are 55px tall and the felt has to hold two rows
|
||||
of them, so the badge is not overlapping some empty middle: it is standing in the
|
||||
only band a player's bet chip has to go in, and the bottom-middle seat's chip had
|
||||
nowhere left to be put but on top of its own player's name.
|
||||
|
||||
Upright it stays. There the felt is 248x280, the badge is a fifth of it, and it is
|
||||
the only thing in the middle of the table. */
|
||||
#potBadge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Seats around a table this small overlap each other, so they shrink to suit — by
|
||||
measurement now rather than by this block's old guess of 62px. */
|
||||
.seat {
|
||||
@@ -4120,6 +4177,23 @@ textarea:focus-visible {
|
||||
here so the note costs a line rather than a paragraph — the bar is a transient
|
||||
input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */
|
||||
@media (max-height: 700px) {
|
||||
/* And the same economy for the pot dialog's list of winners, which is the other
|
||||
place on a short screen where a line costs a name. The player's ceiling moves
|
||||
from under their name to beside it: two lines become one, a row goes from 78px
|
||||
to 56, and a three-way pot fits without scrolling on a 640px phone. The row is
|
||||
still 56px, so it is still a target a thumb can hit — this is where the money is
|
||||
decided and that floor does not move. */
|
||||
.potWizardChoiceText {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.potWizardChoiceUpTo {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.raiseNote {
|
||||
margin-top: var(--sp-1);
|
||||
font-size: var(--fs-xs);
|
||||
|
||||
@@ -611,6 +611,19 @@ const ActionBar = {
|
||||
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
||||
// tool a dealer can't talk their way out of.
|
||||
commit(label, mutate) {
|
||||
// A toast is news about the last thing that happened, and this is the next
|
||||
// thing: whatever it says is now out of date. Two reasons it goes rather than
|
||||
// waiting out its eight seconds. It lies — "Next hand — button on seat 2.
|
||||
// [Undo]" reads as an offer to move the button back, but the snapshot behind
|
||||
// that Undo predates the hand you have since dealt, so taking it would throw
|
||||
// the hand away without saying so. And it is in the way: measured across the
|
||||
// sweep, a toast left up over a dealt table covered seats, the clock button
|
||||
// or the bar's own Undo on thirty of the small screens it was checked at,
|
||||
// because there is nowhere on a 360x640 phone for a 560px strip to go. The
|
||||
// bar's own Undo control takes over from here, and it names the step it
|
||||
// would actually take back.
|
||||
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
||||
|
||||
this.hooks.beginAction(label);
|
||||
mutate();
|
||||
this.hooks.save(this.state);
|
||||
|
||||
@@ -76,6 +76,39 @@ const PotWizard = {
|
||||
},
|
||||
|
||||
renderStep() {
|
||||
this.renderStepInto();
|
||||
this.markOverflow();
|
||||
},
|
||||
|
||||
// Say when the list runs on past the bottom of the dialog.
|
||||
//
|
||||
// The body scrolls and the footer stays put, which is the right shape and was as far
|
||||
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
|
||||
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
|
||||
// under a hairline that reads as the end of the list. A dealer would have ticked the
|
||||
// two they could see. On the dialog that pays the table, a list that silently
|
||||
// continues is not a small thing.
|
||||
markOverflow() {
|
||||
const body = document.getElementById("potWizardBody");
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
if (!body || !modal) return;
|
||||
|
||||
const update = () => {
|
||||
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
|
||||
modal.classList.toggle("potWizardMoreBelow", below > 1);
|
||||
};
|
||||
if (!this.watchingOverflow) {
|
||||
body.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
this.watchingOverflow = true;
|
||||
}
|
||||
update();
|
||||
// Again once the browser has laid the step out — on the first open the body has
|
||||
// no height yet, and an answer taken then is always "nothing below".
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
renderStepInto() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
|
||||
@@ -241,14 +241,42 @@ const Render = {
|
||||
// The two answers are within half a degree of each other, so this is not a fix for
|
||||
// anything visible — it is just the honest way to say "on that player's spoke",
|
||||
// and it saves the second walk.
|
||||
betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
|
||||
betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii(), spacing = null) {
|
||||
const seat = this.seatPosition(index, count, aspect, radii);
|
||||
// Fractions of the seat's own reach, not of the felt — so when the ring pulls in
|
||||
// to keep the pucks on the table, the chips come with it and stay on the same
|
||||
// spoke, the same proportion of the way to the pot.
|
||||
if (!spacing || !spacing.box || !spacing.box.width || !spacing.box.height) {
|
||||
// Fractions of the seat's own reach, not of the felt — so when the ring pulls
|
||||
// in to keep the pucks on the table, the chips come with it and stay on the
|
||||
// same spoke, the same proportion of the way to the pot. This is what the
|
||||
// ring always did, and it is still the answer when there is nothing to
|
||||
// measure: every call from the unit suite, and the first render.
|
||||
return {
|
||||
left: 50 + (seat.left - 50) * (27 / 44),
|
||||
top: 50 + (seat.top - 50) * (25 / 42),
|
||||
};
|
||||
}
|
||||
|
||||
// With measurements to hand, the chip is placed by clearance rather than by
|
||||
// proportion. A fixed fraction is a guess about how much room there is between a
|
||||
// puck's inner edge and the pot, and the answer changes with the shape of the
|
||||
// felt: on a phone held sideways the ring pulls right in, 27/44 of a small reach
|
||||
// is inside the puck, and every one of the five chips on the felt was painted
|
||||
// behind a seat — the seats carry `z-index: 1` and the bets layer carries none,
|
||||
// so a chip that lands under a puck is simply gone.
|
||||
const { box, puck, chip, clearance } = spacing;
|
||||
const dx = ((seat.left - 50) / 100) * box.width;
|
||||
const dy = ((seat.top - 50) / 100) * box.height;
|
||||
const reach = Math.hypot(dx, dy);
|
||||
if (reach < 1) return { left: seat.left, top: seat.top };
|
||||
|
||||
const ux = dx / reach, uy = dy / reach;
|
||||
const inset = this.clearanceReach(puck, chip, ux, uy) + clearance;
|
||||
// Never nearer the middle than this: the pot badge lives there, and a chip on
|
||||
// top of the pot total is the same fault at the other end of the spoke.
|
||||
const pulled = Math.max(reach * 0.38, reach - inset);
|
||||
const scale = pulled / reach;
|
||||
return {
|
||||
left: 50 + (seat.left - 50) * (27 / 44),
|
||||
top: 50 + (seat.top - 50) * (25 / 42),
|
||||
left: 50 + ((dx * scale) / box.width) * 100,
|
||||
top: 50 + ((dy * scale) / box.height) * 100,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -382,6 +410,8 @@ const Render = {
|
||||
SEAT_TIGHT_BELOW_PX: 76,
|
||||
// Daylight between two pucks. They are rounded, so touching reads as overlapping.
|
||||
SEAT_CLEARANCE_PX: 4,
|
||||
// And between a puck's inner edge and the chip sitting in front of it.
|
||||
BET_CLEARANCE_PX: 5,
|
||||
// How far a blind badge hangs off the puck it belongs to — the `top: -9px` and
|
||||
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
|
||||
// takes no room between two seats; what it must not do is reach *sideways*, which is
|
||||
@@ -681,6 +711,9 @@ const Render = {
|
||||
makeBetChip(bet) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "betChip";
|
||||
// The denomination's colour, published for the anchored layout to put on the
|
||||
// chip's rim — there the disc itself is 17px the puck hasn't got.
|
||||
chip.style.setProperty("--betChipColour", this.chipColorFor(bet));
|
||||
|
||||
const disc = document.createElement("span");
|
||||
disc.className = "betChipDisc";
|
||||
@@ -695,41 +728,337 @@ const Render = {
|
||||
return chip;
|
||||
},
|
||||
|
||||
// How far it is from the centre of an axis-aligned box to its edge, in a given
|
||||
// direction. Exact, and it has to be: a puck is half again as wide as it is tall, so
|
||||
// a single radius would be wrong on one axis or the other.
|
||||
edgeReach(width, height, ux, uy) {
|
||||
const ax = Math.abs(ux), ay = Math.abs(uy);
|
||||
return Math.min(ax > 1e-6 ? (width / 2) / ax : Infinity,
|
||||
ay > 1e-6 ? (height / 2) / ay : Infinity);
|
||||
},
|
||||
|
||||
// How far one box has to travel from the centre of another, along a given direction,
|
||||
// before the two are clear of each other.
|
||||
//
|
||||
// Not `edgeReach(puck) + edgeReach(chip)`, which is what this used to be and is only
|
||||
// right when the direction is straight up, down or sideways. Two axis-aligned boxes
|
||||
// are clear when they are apart on *one* axis, so the distance is the reach of the
|
||||
// one box you get by adding their widths and adding their heights — and adding the
|
||||
// two reaches is always less than that. Measured at 768x1024, a $10 leaving a seat on
|
||||
// the diagonal wanted 88px and was given 77, and the chip came to rest 11px inside
|
||||
// its own player's puck. Every seat off the four compass points had the same error,
|
||||
// which is most of them on any table with more than four players.
|
||||
clearanceReach(a, b, ux, uy) {
|
||||
return this.edgeReach(a.width + b.width, a.height + b.height, ux, uy);
|
||||
},
|
||||
|
||||
// Where an anchored chip sits relative to its own puck's centre: on the spoke,
|
||||
// just clear of the puck's edge, on the side facing the pot.
|
||||
//
|
||||
// This started as four choices — above, below, and the two sides — which is better
|
||||
// than the one it replaced (always above or below, so a seat at the side of the ring
|
||||
// wore its bet over its own name) and still wrong, because four choices have three
|
||||
// seams. Measured on a Galaxy S8: seat 4 is on the right arc and took the left edge,
|
||||
// seat 5 is on the bottom arc and took the top edge, and at the corner between the
|
||||
// two arcs their chips overlapped by 11px. The seam is the bug, so there are no
|
||||
// sides now — the direction is the spoke itself, which is also where a chip actually
|
||||
// goes. A player on the right of the table pushes their bet to their left.
|
||||
// Off the rim rather than down the spoke, which are the same direction only on a
|
||||
// round table. A phone held sideways gives the felt 692x117: the ring is a slot, the
|
||||
// spoke to a seat on the top arc points diagonally inward, and "inward" there runs
|
||||
// along the rim straight into the next seat. Off the rim it is very nearly straight
|
||||
// down, which is where the empty felt is. The inward normal of the ellipse the ring
|
||||
// is drawn on gives both answers from one expression.
|
||||
anchoredChipOffset(seatPos, puck, chip, box, radii, clearance = this.BET_CLEARANCE_PX) {
|
||||
const { x: ux, y: uy } = this.rimNormal(seatPos, box, radii);
|
||||
const travel = this.clearanceReach(puck, chip, ux, uy) + clearance;
|
||||
return { x: ux * travel, y: uy * travel };
|
||||
},
|
||||
|
||||
// Straight off the rim, inward: the unit normal of the ellipse the seat ring is
|
||||
// drawn on, at that seat.
|
||||
rimNormal(seatPos, box, radii) {
|
||||
const dx = ((seatPos.left - 50) / 100) * box.width;
|
||||
const dy = ((seatPos.top - 50) / 100) * box.height;
|
||||
const a = Math.max(1, (radii.rx / 100) * box.width);
|
||||
const b = Math.max(1, (radii.ry / 100) * box.height);
|
||||
const nx = -dx / (a * a);
|
||||
const ny = -dy / (b * b);
|
||||
const length = Math.hypot(nx, ny);
|
||||
// A seat sitting on the middle of the felt has no rim to be off. Send its chip
|
||||
// upward rather than dividing by nothing.
|
||||
if (length < 1e-9) return { x: 0, y: -1 };
|
||||
return { x: nx / length, y: ny / length };
|
||||
},
|
||||
|
||||
// The pot total's box, in the bets layer's own pixels. It lives on `#pokerTable`
|
||||
// rather than on either overlay, and the two are not concentric — the layers are
|
||||
// inset by different amounts top and bottom — so it is measured rather than assumed
|
||||
// to be at 50%, 50%.
|
||||
potBadgeBox(box) {
|
||||
const badge = document.getElementById("potBadge");
|
||||
if (!badge) return null;
|
||||
const at = badge.getBoundingClientRect();
|
||||
if (!at.width || !at.height) return null;
|
||||
return {
|
||||
x: at.left + at.width / 2 - box.left,
|
||||
y: at.top + at.height / 2 - box.top,
|
||||
width: at.width, height: at.height,
|
||||
};
|
||||
},
|
||||
|
||||
// How far each anchored chip actually gets to travel, once the others are taken
|
||||
// into account.
|
||||
//
|
||||
// Off the rim is only somewhere to go if there is felt out there. At 640x360 the
|
||||
// table is 528x83 and a seat puck is 55 of those 83 pixels tall: clearing it takes
|
||||
// 41px, the players facing each other across the table are 70px apart, and both
|
||||
// chips set off 41px into a gap that is 70 wide. Measured on the running site, a
|
||||
// twelve-handed table had three pairs of chips on top of each other and a six-handed
|
||||
// one had two — every pair a number about one player's money hiding a number about
|
||||
// another's.
|
||||
//
|
||||
// Each chip asks for the room it wants and gives ground only to a chip it is
|
||||
// actually on top of, a pixel at a time until the pair is clear. Two rules that
|
||||
// looked simpler were tried first and both cost more than they saved. Stopping every
|
||||
// chip at the felt's midline punishes the seats at the ends of a long rail, which
|
||||
// are 70px from their nearest neighbour and were never going to hit anything —
|
||||
// measured at 692x113, it parked two chips on top of their own player's stack to
|
||||
// solve a collision that did not exist. Scaling every chip's travel by one factor
|
||||
// has the same fault in the other direction: the end seats give up the same
|
||||
// proportion as the crowded ones and end up inside their own pucks.
|
||||
//
|
||||
// Where a chip is pulled all the way back it rests on its own puck's inner edge.
|
||||
// Overlapping the edge of your own puck costs a corner of your own stack;
|
||||
// overlapping the chip opposite costs both amounts outright.
|
||||
CHIP_PULLBACK_STEP_PX: 1,
|
||||
CHIP_PULLBACK_PASSES: 80,
|
||||
|
||||
anchoredChipTravel(chips, count, aspect, radii, spacing) {
|
||||
const { box, puck, clearance, pot } = spacing;
|
||||
const centreOf = pos => ({ x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height });
|
||||
const pucks = Array.from({ length: count }, (_, i) =>
|
||||
centreOf(this.seatPosition(i, count, aspect, radii)));
|
||||
|
||||
const rows = chips.map(({ index, width, height }) => {
|
||||
const seatPos = this.seatPosition(index, count, aspect, radii);
|
||||
const dir = this.rimNormal(seatPos, box, radii);
|
||||
const want = this.clearanceReach(puck, { width, height }, dir.x, dir.y) + clearance;
|
||||
return { index, width, height, dir, travel: want, seat: centreOf(seatPos) };
|
||||
});
|
||||
const at = row => ({ x: row.seat.x + row.dir.x * row.travel,
|
||||
y: row.seat.y + row.dir.y * row.travel });
|
||||
// Touching counts as clear. Two boxes that share an edge to within half a pixel
|
||||
// are not hiding anything from anybody, and treating that as a collision costs a
|
||||
// whole step of travel: on a felt where the seats are symmetric about the middle,
|
||||
// a facing pair lands exactly edge to edge and every one of them was then pulled
|
||||
// back another pixel for nothing.
|
||||
const TOUCHING = 0.5;
|
||||
const hits = (a, b, w, h) =>
|
||||
Math.abs(a.x - b.x) < w - TOUCHING && Math.abs(a.y - b.y) < h - TOUCHING;
|
||||
|
||||
for (let pass = 0; pass < this.CHIP_PULLBACK_PASSES; pass++) {
|
||||
let moved = false;
|
||||
const back = row => {
|
||||
if (row.travel <= 0) return;
|
||||
row.travel = Math.max(0, row.travel - this.CHIP_PULLBACK_STEP_PX);
|
||||
moved = true;
|
||||
};
|
||||
|
||||
// Somebody else's puck. It cannot move, so the chip does all the giving —
|
||||
// and it is the neighbour's puck that matters, not its own, which the chip
|
||||
// is anchored to and is allowed to rest on. Off the rim on a felt 83px tall
|
||||
// is straight into the row of seats opposite: measured at 640x360, six of
|
||||
// the seven chips on a ten-handed table were painted under a stranger's
|
||||
// puck, which on this layer means painted out of existence.
|
||||
rows.forEach(row => {
|
||||
const p = at(row);
|
||||
if (pucks.some((seat, i) => i !== row.index &&
|
||||
hits(p, seat, (row.width + puck.width) / 2,
|
||||
(row.height + puck.height) / 2))) back(row);
|
||||
});
|
||||
|
||||
// And the pot total, which is what is in the middle of the felt and the one
|
||||
// number on the table that belongs to everybody. A seat on the middle of a
|
||||
// rail sends its chip straight at it: on a Galaxy S8 on its side, seat 1's
|
||||
// $10 was sitting on top of "Pot $65".
|
||||
if (pot) {
|
||||
rows.forEach(row => {
|
||||
if (hits(at(row), pot, (row.width + pot.width) / 2,
|
||||
(row.height + pot.height) / 2)) back(row);
|
||||
});
|
||||
}
|
||||
|
||||
for (let a = 0; a < rows.length; a++) {
|
||||
for (let b = a + 1; b < rows.length; b++) {
|
||||
if (!hits(at(rows[a]), at(rows[b]),
|
||||
(rows[a].width + rows[b].width) / 2,
|
||||
(rows[a].height + rows[b].height) / 2)) continue;
|
||||
// The one that has come further gives way; if they are level, both
|
||||
// do, which is what a facing pair across a narrow table always is.
|
||||
const lead = Math.max(rows[a].travel, rows[b].travel);
|
||||
[rows[a], rows[b]].forEach(row => {
|
||||
if (row.travel < lead - 0.01) return;
|
||||
back(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
const travels = {};
|
||||
rows.forEach(row => {
|
||||
travels[row.index] = { x: row.dir.x * row.travel, y: row.dir.y * row.travel };
|
||||
});
|
||||
return travels;
|
||||
},
|
||||
|
||||
// A phone's felt has no room for a third ring: measured every radius between the
|
||||
// seats and the pot badge and each one collides with one or the other. So on a
|
||||
// narrow screen the bet clings to its own puck instead — same chip, no ring —
|
||||
// sitting on whichever side faces the middle of the table.
|
||||
renderBets() {
|
||||
//
|
||||
// `radii` is the ring the seats were actually drawn on, handed down by renderSeats.
|
||||
// Asking for it again here was wrong once the ring stopped being a fixed pair: the
|
||||
// width search settles on a radius that can be wider than the one seatRingRadii()
|
||||
// derives on its own, so the chips were being laid out on a ring the seats had left.
|
||||
renderBets(aspect = this.feltAspect(), radii = this.seatRingRadii()) {
|
||||
const layer = document.getElementById("betsLayer");
|
||||
if (!layer) return;
|
||||
layer.innerHTML = "";
|
||||
// The phone layout puts the chip *inside* its seat, and seats are no longer
|
||||
// thrown away and rebuilt on every render — they are kept and updated in place,
|
||||
// which is what made renderAll fast enough to run after every tap. Clearing the
|
||||
// layer therefore stopped clearing these, and nothing else ever did: measured on
|
||||
// an iPhone 14, a seat had one chip after the deal, four after three more
|
||||
// renders, and five by the third call. A dealer's every tap left another disc on
|
||||
// the felt, and the pile only ever grew.
|
||||
document.querySelectorAll(".seat .betChip").forEach(chip => chip.remove());
|
||||
|
||||
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
||||
// Measured once for the whole ring. Asked per seat, this reads the layout back
|
||||
// nine times in a loop that is also writing to it, which is the read-after-write
|
||||
// pattern that makes the browser recompute layout on every iteration.
|
||||
const aspect = this.feltAspect();
|
||||
const radii = this.seatRingRadii();
|
||||
|
||||
// Put them on the felt first, position them second. A chip has to be in the
|
||||
// document before it can say how wide it is, and how wide it is decides both
|
||||
// where it goes and whether there is room for a ring at all — so this is one
|
||||
// write, one read, one write, rather than a read for every chip in a loop that
|
||||
// is also writing to the same layout.
|
||||
const placed = [];
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const bet = Betting.committed(player);
|
||||
if (bet <= 0) return;
|
||||
|
||||
const chip = this.makeBetChip(bet);
|
||||
const pos = this.betPosition(index, this.state.seatCount, aspect, radii);
|
||||
layer.appendChild(chip);
|
||||
placed.push({ chip, index });
|
||||
});
|
||||
|
||||
if (!placed.length) return;
|
||||
const box = this.ringBox();
|
||||
const puck = this.measuredSeatBox();
|
||||
// Each chip's own size, not the first one's standing in for all of them. A chip
|
||||
// is as wide as the number on it: measured on a phone held sideways, "$5" is
|
||||
// 21px and "$10" is 27px, so taking the first chip's width laid out every $10 on
|
||||
// the felt as if it were 6px narrower than it is — six pixels of somebody else's
|
||||
// puck. Taking the *widest* instead is wrong the other way: at 768x1024 a single
|
||||
// 47px chip made the whole table give up a ring that the other four had room for.
|
||||
const sizes = placed.map(({ chip, index }) => {
|
||||
const at = chip.getBoundingClientRect();
|
||||
return { index, width: at.width, height: at.height };
|
||||
});
|
||||
if (!box || !puck || !sizes.some(s => s.width)) return;
|
||||
const spacing = { box, puck, clearance: this.BET_CLEARANCE_PX, pot: this.potBadgeBox(box) };
|
||||
|
||||
// Whether the chips get a ring of their own is a question about how much felt
|
||||
// there is between the seats and the pot, and it was being answered by asking
|
||||
// how wide the *window* is. A phone held sideways is 844px across and has 117px
|
||||
// of felt to work with: it took the ring, and every chip on it was painted behind
|
||||
// a puck — the seats carry `z-index: 1` and the bets layer carries none, so a
|
||||
// chip that lands under a seat is simply gone.
|
||||
//
|
||||
// Measured instead, and only measured. The width test was left in front of the
|
||||
// measurement as a belt-and-braces `||`, which meant every phone still took the
|
||||
// anchored layout whatever the felt said — including the ones with room for a
|
||||
// ring, where clinging to the puck is the worse of the two: a chip that has been
|
||||
// pulled back onto its own puck sits on that player's stack.
|
||||
const anchorToSeat = !this.chipsClearTheSeats(sizes, aspect, radii, spacing);
|
||||
// Worked out across all of them at once, because how far one chip can go off the
|
||||
// rim depends on where the others have got to.
|
||||
const offsets = anchorToSeat
|
||||
? this.anchoredChipTravel(sizes, this.state.seatCount, aspect, radii, spacing)
|
||||
: null;
|
||||
const sizeOf = index => sizes.find(s => s.index === index);
|
||||
|
||||
placed.forEach(({ chip, index }) => {
|
||||
if (anchorToSeat) {
|
||||
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (!seat) return;
|
||||
const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||||
chip.classList.add("betChipAnchored");
|
||||
seat.appendChild(chip);
|
||||
const offset = offsets[index];
|
||||
chip.style.left = `calc(50% + ${offset.x.toFixed(1)}px)`;
|
||||
chip.style.top = `calc(50% + ${offset.y.toFixed(1)}px)`;
|
||||
return;
|
||||
}
|
||||
|
||||
const size = sizeOf(index);
|
||||
const pos = this.betPosition(index, this.state.seatCount, aspect, radii,
|
||||
{ ...spacing, chip: { width: size.width, height: size.height } });
|
||||
chip.style.left = `${pos.left}%`;
|
||||
chip.style.top = `${pos.top}%`;
|
||||
layer.appendChild(chip);
|
||||
});
|
||||
},
|
||||
|
||||
// Would a ring of chips keep clear of every puck on the felt, of the pot, and of
|
||||
// each other?
|
||||
//
|
||||
// The same box-against-box question the seats settle among themselves, asked across
|
||||
// the two layers. betPosition() walks a chip in along its own spoke until it is clear
|
||||
// of its own puck, but it will not walk past 38% of the way to the middle, because
|
||||
// the pot badge is there — and on a felt 117px tall that floor leaves the chip
|
||||
// sitting on the seat opposite.
|
||||
//
|
||||
// That 38% is a guess at where the pot badge ends. Where the badge has actually been
|
||||
// measured, the guess gives way to it: a ring that lands a chip on the pot total is
|
||||
// no more use than one that lands it on a puck.
|
||||
chipsClearTheSeats(chips, aspect, radii, spacing) {
|
||||
const { box, puck, pot } = spacing;
|
||||
const count = this.state.seatCount;
|
||||
const seats = this.state.seats.map((_, i) => {
|
||||
const pos = this.seatPosition(i, count, aspect, radii);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
});
|
||||
// Each chip is as wide as the number on it, and asking the question with one
|
||||
// size for all of them answers it for a table that isn't there: at 768x1024 a
|
||||
// single 47px "$1,200" made the whole felt give up a ring the other four 40px
|
||||
// chips had room for, and dropped them all onto their own players' stacks.
|
||||
const at = ({ index, width, height }) => {
|
||||
const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } });
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height, width, height };
|
||||
};
|
||||
const placed = chips.map(at);
|
||||
|
||||
// Chips against each other, which this never asked. A ring is a smaller circle
|
||||
// than the seats' one and holds the same number of things, so it is the tighter
|
||||
// fit of the two: on a Pixel 7 with twelve players, two $10s on the ring sat on
|
||||
// top of each other while every puck on the felt was clear. The anchored layout
|
||||
// resolves its own collisions one chip at a time; the ring has no such recourse,
|
||||
// so a ring that collides is a ring this table cannot have.
|
||||
for (let a = 0; a < placed.length; a++) {
|
||||
for (let b = a + 1; b < placed.length; b++) {
|
||||
if (Math.abs(placed[a].x - placed[b].x) < (placed[a].width + placed[b].width) / 2 &&
|
||||
Math.abs(placed[a].y - placed[b].y) < (placed[a].height + placed[b].height) / 2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chips.every(({ index, width, height }) => {
|
||||
const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } });
|
||||
const x = (pos.left / 100) * box.width;
|
||||
const y = (pos.top / 100) * box.height;
|
||||
if (pot && Math.abs(x - pot.x) < (width + pot.width) / 2 &&
|
||||
Math.abs(y - pot.y) < (height + pot.height) / 2) {
|
||||
return false;
|
||||
}
|
||||
return seats.every(seat =>
|
||||
Math.abs(x - seat.x) >= (width + puck.width) / 2 ||
|
||||
Math.abs(y - seat.y) >= (height + puck.height) / 2);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -866,7 +1195,7 @@ const Render = {
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
});
|
||||
|
||||
this.renderBets();
|
||||
this.renderBets(aspect, radii);
|
||||
},
|
||||
|
||||
isRedSuit(card) {
|
||||
|
||||
+12
-2
@@ -34,10 +34,20 @@ node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, that no
|
||||
text is painted outside its box, and that no seat covers another seat's writing. It
|
||||
text is painted outside its box, that no seat covers another seat's writing, and that
|
||||
no bet chip is hidden behind a seat or another chip. It
|
||||
runs over the table, all five overlays and Quick Split, at thirteen viewports from a
|
||||
360px phone to a 1600px desktop and the same phones on their side — each viewport
|
||||
twice, once with a mouse and once with a thumb, and at two table shapes.
|
||||
twice, once with a mouse and once with a thumb, and at two table shapes, mid-hand:
|
||||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||||
which is a third of what is on the felt during a hand left unlooked at.
|
||||
|
||||
"Covered" and "out of view" are told apart before anything is hit-tested. An element
|
||||
clipped away by a panel that scrolls is not covered by whatever happens to be painted
|
||||
at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
Two table shapes is also newer than it sounds. The sweep ran on one — five players at
|
||||
a nine-handed table — for its whole life, and how much room the felt has depends on
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
// Where a player's bet sits on the felt.
|
||||
//
|
||||
// The chips were the last thing on the table nobody had measured. They were laid out on
|
||||
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
|
||||
// seat ring was itself fixed — and the choice between that ring and clinging to the
|
||||
// puck was made by asking how wide the *window* was. Neither of those is a question
|
||||
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
|
||||
// height: it passed the width test, took the ring, and every chip on it was painted
|
||||
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
|
||||
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
|
||||
// number saying what that player has in.
|
||||
//
|
||||
// Everything below is geometry, so it runs without a browser. The felts are the ones
|
||||
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const RADII = { rx: 44, ry: 42 };
|
||||
const PUCK = { width: 96, height: 64 };
|
||||
const CHIP = { width: 44, height: 22 };
|
||||
|
||||
const felt = (width, height) => ({ width, height });
|
||||
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
|
||||
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
|
||||
|
||||
// A chip's centre in pixels on a given felt.
|
||||
const chipPixels = (index, count, box, spacing, radii = RADII) => {
|
||||
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
const seatPixels = (index, count, box, radii = RADII) => {
|
||||
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
// Two axis-aligned boxes, by their centres. The same test the seats settle among
|
||||
// themselves — see seatFootprint.test.js — because it is the same question.
|
||||
const boxesOverlap = (a, b, sizeA, sizeB) =>
|
||||
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
|
||||
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
|
||||
|
||||
describe("reaching the edge of a box", () => {
|
||||
test("straight out the side is half the width, straight up half the height", () => {
|
||||
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
|
||||
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
|
||||
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
|
||||
});
|
||||
|
||||
test("a corner is whichever edge the ray meets first", () => {
|
||||
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
|
||||
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
|
||||
// would put a chip 25px further out than it needs to be, which on a 117px felt
|
||||
// is the difference between clear of the puck and off the table.
|
||||
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
|
||||
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
|
||||
assert.ok(diagonal < 48, "and never further than the wider half-edge");
|
||||
});
|
||||
|
||||
test("a square is the same in every direction it is asked about", () => {
|
||||
const straight = Render.edgeReach(50, 50, 0, 1);
|
||||
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getting one box clear of another", () => {
|
||||
const PUCK96 = { width: 96, height: 88 };
|
||||
const CHIP50 = { width: 50, height: 21 };
|
||||
|
||||
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
|
||||
// apart. This is the question clearanceReach is supposed to answer, asked the long
|
||||
// way round so the answer can be checked rather than restated.
|
||||
const apart = (t, ux, uy) =>
|
||||
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
|
||||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
|
||||
|
||||
test("straight up and down it is what adding the two half-heights gives", () => {
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
|
||||
});
|
||||
|
||||
test("on a diagonal it is more than the two reaches added together", () => {
|
||||
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
|
||||
// one axis, and the travel that achieves that is the reach of the box you get by
|
||||
// adding their widths and their heights — which is never less, and on a diagonal
|
||||
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
|
||||
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
|
||||
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
|
||||
let shortfalls = 0;
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
|
||||
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
|
||||
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
|
||||
if (proper <= summed + 1e-9) return;
|
||||
// Where they differ, the old answer was not merely smaller — it was wrong:
|
||||
// travelling that far leaves the two boxes still on top of each other.
|
||||
shortfalls++;
|
||||
assert.ok(!apart(summed, ux, uy),
|
||||
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
|
||||
});
|
||||
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
|
||||
});
|
||||
|
||||
test("and it is exactly enough — a hair less is not", () => {
|
||||
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
|
||||
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip on the ring", () => {
|
||||
test("with nothing measured it keeps the proportions the ring always used", () => {
|
||||
// The unit suite and the very first render both call it this way. 27/44 and
|
||||
// 25/42 of the seat's own reach, so the chips move with the ring rather than
|
||||
// with the felt.
|
||||
const seat = Render.seatPosition(3, 9, 1.4, RADII);
|
||||
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
|
||||
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
|
||||
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
|
||||
});
|
||||
|
||||
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
|
||||
const box = felt(560, 400);
|
||||
const spacing = spacingOn(box);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const seat = seatPixels(i, 9, box);
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
|
||||
`seat ${i + 1}'s own chip is under its own puck`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and never wanders into the pot badge in the middle", () => {
|
||||
// The floor: 38% of the way out. Without it a chip walked in past a puck that
|
||||
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
|
||||
// is the same fault at the other end of the spoke.
|
||||
const box = felt(300, 216);
|
||||
const spacing = spacingOn(box, { width: 200, height: 150 });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const seat = seatPixels(i, 6, box);
|
||||
const chip = chipPixels(i, 6, box, spacing);
|
||||
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
|
||||
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
|
||||
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
|
||||
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
|
||||
const box = felt(400, 300);
|
||||
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deciding whether a ring of chips fits at all", () => {
|
||||
// The shape the layout works in: one entry per chip on the felt, carrying its own
|
||||
// measured size, because a chip is as wide as the number printed on it.
|
||||
const tableOf = (count, chip = CHIP) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
({ index: i, width: chip.width, height: chip.height }));
|
||||
};
|
||||
|
||||
test("a desktop felt has room for one", () => {
|
||||
const box = felt(700, 500);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
|
||||
});
|
||||
|
||||
test("a phone on its side has not, whatever the window's width says about it", () => {
|
||||
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
|
||||
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
|
||||
// on the seat across the table from it.
|
||||
const box = felt(692, 117);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
|
||||
"this is the felt where every chip was painted behind a puck");
|
||||
});
|
||||
|
||||
test("its answer is the one the geometry gives", () => {
|
||||
// Not a restatement of the two cases above: whatever it returns, it has to agree
|
||||
// with laying the chips out and measuring them against every puck on the felt.
|
||||
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
|
||||
shapes.forEach(box => {
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const aspect = box.width / box.height;
|
||||
const clear = all.every(({ index: i }) => {
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
|
||||
});
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
|
||||
`${box.width}x${box.height}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("each chip is asked about at its own size", () => {
|
||||
// A chip is as wide as the number on it: at 768x1024, four players' chips were
|
||||
// 40px and one holding a four-figure bet was 47. Answering the question once, at
|
||||
// the widest, told the whole felt it had no room for a ring — and dropped all
|
||||
// five chips onto their own players' stacks to solve a problem four of them
|
||||
// did not have.
|
||||
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
|
||||
const box = felt(672, 672);
|
||||
const narrow = { width: 40, height: 20 };
|
||||
const huge = { width: 420, height: 380 };
|
||||
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
|
||||
"a felt this size has room for a ring of ordinary chips");
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
|
||||
"and none at all for a ring of chips that size");
|
||||
|
||||
// Now one of each. The answer has to come from the sizes in the list, not from
|
||||
// one size standing in for all of them: with one huge chip the ring is off, and
|
||||
// taking that chip out of the question puts it back.
|
||||
const mixed = tableOf(9, narrow);
|
||||
mixed[0] = { index: 0, ...huge };
|
||||
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
|
||||
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
|
||||
"the eight ordinary ones were never the problem");
|
||||
});
|
||||
|
||||
test("and at the chips against each other", () => {
|
||||
// The ring is a smaller circle than the seats' one and holds the same number of
|
||||
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
|
||||
// players, two $10s on the ring sat on top of each other while every puck on the
|
||||
// felt was clear — so a check that only looks at the pucks says yes to a layout
|
||||
// that hides one player's bet behind another's.
|
||||
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
|
||||
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
|
||||
const box = felt(300, 336);
|
||||
const radii = { rx: 44, ry: 40.84 };
|
||||
const puck = { width: 44, height: 55 };
|
||||
const pot = { x: 150, y: 168, width: 64, height: 53 };
|
||||
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const bettors = [2, 3, 4, 5, 6, 7, 8];
|
||||
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
|
||||
|
||||
const chips = all.map(entry => ({
|
||||
...entry,
|
||||
...chipPixels(entry.index, 12, box,
|
||||
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
|
||||
}));
|
||||
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
|
||||
|
||||
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
|
||||
"this table is chosen so the pucks are not the problem");
|
||||
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
|
||||
"nor the pot");
|
||||
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
|
||||
boxesOverlap(chip, other, chip, other))), "and so the chips are");
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
|
||||
});
|
||||
|
||||
test("it looks at every puck, not just the chip's own", () => {
|
||||
// The rule this replaced walked each chip in until it was clear of its *own*
|
||||
// seat and stopped there, which says nothing about the eight other pucks on the
|
||||
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
|
||||
// good: every chip clears the puck it belongs to, and one of them is sitting on
|
||||
// a stranger's.
|
||||
const box = felt(560, 190);
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
|
||||
|
||||
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
|
||||
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
|
||||
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
|
||||
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
|
||||
"expected a chip on somebody else's puck — what the old rule could not see");
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip clinging to its puck", () => {
|
||||
// The fallback when there is no room for a ring: the chip hangs off the puck on the
|
||||
// side facing the empty middle of the felt.
|
||||
//
|
||||
// The felts the app actually draws, with the puck size each one settles on — read
|
||||
// off the running site at each viewport rather than guessed here, because the tight
|
||||
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
|
||||
// a 528x83 felt is describing a table the app never draws.
|
||||
const REAL = [
|
||||
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
|
||||
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
|
||||
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
|
||||
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
|
||||
];
|
||||
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
|
||||
// one at that size.
|
||||
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
|
||||
|
||||
// What each chip is told to do, worked out for the whole table at once.
|
||||
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
const indices = [...Array(count).keys()];
|
||||
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
|
||||
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
|
||||
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
|
||||
return indices.map(i => {
|
||||
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
|
||||
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
|
||||
const off = offs[i];
|
||||
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
|
||||
});
|
||||
};
|
||||
|
||||
test("on its own, a chip asks to sit just clear of its puck", () => {
|
||||
const box = felt(692, 113);
|
||||
const puck = { width: 72, height: 55 };
|
||||
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
|
||||
const travel = Math.hypot(off.x, off.y);
|
||||
const ux = off.x / travel, uy = off.y / travel;
|
||||
assert.ok(Math.abs(travel -
|
||||
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
|
||||
});
|
||||
|
||||
test("and gets it, where nothing else is in the way", () => {
|
||||
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
|
||||
// puck and none of them has to give ground.
|
||||
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
|
||||
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
|
||||
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it goes off the rim, not down the spoke", () => {
|
||||
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
|
||||
// top rail has a spoke pointing diagonally inward — which along a slot this flat
|
||||
// runs sideways into the next seat — while off the rim is very nearly straight
|
||||
// down, into the empty middle. This is the seam that put two chips on top of
|
||||
// each other on a Galaxy S8.
|
||||
const box = felt(692, 113);
|
||||
const rows = placeAll(9, box, { width: 72, height: 55 });
|
||||
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
|
||||
assert.ok(top, "expected a seat up on the right of the top rail");
|
||||
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
|
||||
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
|
||||
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
|
||||
});
|
||||
|
||||
test("no two chips on a full table land on each other", () => {
|
||||
// Four sides gave three seams. There are no sides now, and this is what that is
|
||||
// worth — on every felt the app actually draws, at every table size.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck);
|
||||
for (let a = 0; a < rows.length; a++) {
|
||||
for (let b = a + 1; b < rows.length; b++) {
|
||||
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
|
||||
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a chip only gives ground when it is standing on something", () => {
|
||||
// The point of pulling chips back one at a time rather than by a rule that
|
||||
// applies to all of them. Stopping every chip at the felt's midline, or scaling
|
||||
// them all by one factor, takes travel from the seats at the ends of a long
|
||||
// rail — measured at 692x113, that parked two chips on their own player's stack
|
||||
// to solve a collision that did not exist.
|
||||
//
|
||||
// So: every chip either got everything it asked for, or putting the last step
|
||||
// back reintroduces a collision it is part of. The step goes back for all of
|
||||
// them at once, because a facing pair gives ground together and the pass that
|
||||
// finally separates such a pair moves both ends of it.
|
||||
const step = Render.CHIP_PULLBACK_STEP_PX;
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck).map(row => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
|
||||
const asked = travel < 1e-6 ? 0 :
|
||||
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
const back = Math.min(asked, travel + step);
|
||||
return { ...row, travel, asked,
|
||||
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
|
||||
});
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
|
||||
const onSomething = rows.some((other, j) => j !== i && (
|
||||
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
|
||||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
|
||||
assert.ok(onSomething,
|
||||
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("nobody is pushed further out than they asked to go", () => {
|
||||
// The pull-back only ever takes travel away. A chip pushed past its clearance
|
||||
// would be floating out on the felt with nothing to say whose it is.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
placeAll(12, box, puck).forEach((row, i) => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
if (travel < 1e-6) return;
|
||||
const ux = row.off.x / travel, uy = row.off.y / travel;
|
||||
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
assert.ok(travel <= asked + 0.5,
|
||||
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("the direction turns smoothly all the way round the ring", () => {
|
||||
// The property a set of discrete sides cannot have, and the reason there are
|
||||
// none: neighbouring seats get neighbouring directions, with no step anywhere.
|
||||
const box = felt(560, 400);
|
||||
const angles = Array.from({ length: 24 }, (_, i) => {
|
||||
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
|
||||
return Math.atan2(off.y, off.x);
|
||||
});
|
||||
angles.forEach((angle, i) => {
|
||||
const next = angles[(i + 1) % angles.length];
|
||||
let turn = Math.abs(next - angle);
|
||||
if (turn > Math.PI) turn = 2 * Math.PI - turn;
|
||||
assert.ok(turn < Math.PI / 4,
|
||||
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it stops short of the pot total as well", () => {
|
||||
// The other thing in the middle of the felt, and the one number on the table
|
||||
// that belongs to everybody. A seat on the middle of a rail sends its chip
|
||||
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
|
||||
// "Pot $65". The badge is measured rather than assumed to be at the centre —
|
||||
// it lives on the table, not on either overlay, and the two are not concentric.
|
||||
const box = felt(528, 83);
|
||||
const pot = { x: 264, y: 40, width: 96, height: 34 };
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const indices = [...Array(12).keys()];
|
||||
const entries = indices.map(i =>
|
||||
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
|
||||
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
|
||||
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
|
||||
|
||||
indices.forEach(i => {
|
||||
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
|
||||
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
|
||||
y: (seat.top / 100) * box.height + offs[i].y };
|
||||
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
|
||||
`chip ${i + 1} is on the pot total`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a seat with no rim to be off still gets an answer", () => {
|
||||
const box = felt(400, 300);
|
||||
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
|
||||
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
|
||||
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
|
||||
});
|
||||
});
|
||||
@@ -103,8 +103,12 @@ describe("the corner scale", () => {
|
||||
describe("the tokens themselves", () => {
|
||||
test("every var() the stylesheet reaches for exists", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js.
|
||||
const fromScript = new Set(["--actionBarHeight", "--feltRoomPx", "--seatPuckW"]);
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
const missing = [...used].filter(t =>
|
||||
|
||||
@@ -108,6 +108,27 @@ async function main() {
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
// Deal it, and get chips in front of a few players. The sweep used to
|
||||
// audit a table that had never had a bet on it, so the bets layer was
|
||||
// empty at every viewport it visited and nothing it drew was ever looked
|
||||
// at — which is a third of what is on the felt during a hand.
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.waitForTimeout(220);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const acted = await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
});
|
||||
if (!acted) break;
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// The toast that outlived the thing it was talking about.
|
||||
//
|
||||
// Toasts are news about the last action, and they carry an Undo. Take another action
|
||||
// and both of those go wrong at once: the sentence describes a step that is now two
|
||||
// steps back, and the Undo behind it restores a snapshot from before everything since.
|
||||
// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three
|
||||
// players call is an offer to move the button, wired to a button that deletes the hand.
|
||||
//
|
||||
// It is also simply in the way. The toast is a fixed strip near the bottom of the
|
||||
// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the
|
||||
// felt and the action bar, so a stale one covers seats and controls for eight seconds.
|
||||
//
|
||||
// So the bar drops it as it takes the next action. These tests hold that funnel shut:
|
||||
// every action a dealer can take from the bar goes through `commit`, and `commit`
|
||||
// clears the toast before it does anything else.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Betting, makeTable } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
|
||||
|
||||
// A bar sat at a real table, with the toast replaced by something that keeps a log
|
||||
// and the re-render stubbed out — none of these rules need the DOM back.
|
||||
function onTable() {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||
const log = [];
|
||||
globalThis.Toast = {
|
||||
onScreen: null,
|
||||
show(message) { this.onScreen = message; log.push(`show ${message}`); },
|
||||
dismiss() { this.onScreen = null; log.push("dismiss"); },
|
||||
};
|
||||
ActionBar.state = state;
|
||||
ActionBar.render = () => {};
|
||||
ActionBar.hooks = {
|
||||
beginAction: label => log.push(`begin ${label}`),
|
||||
save: () => log.push("save"),
|
||||
rerender: () => log.push("rerender"),
|
||||
};
|
||||
return { bar: ActionBar, state, log };
|
||||
}
|
||||
|
||||
test.afterEach(() => { delete globalThis.Toast; });
|
||||
|
||||
describe("a toast does not outlive its own news", () => {
|
||||
test("the next action from the bar clears it", () => {
|
||||
const { bar, state, log } = onTable();
|
||||
Toast.show("Next hand — button on seat 2.");
|
||||
|
||||
bar.commit("dealing the hand", () => Betting.beginHand(state));
|
||||
|
||||
assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more");
|
||||
assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"),
|
||||
`the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`);
|
||||
});
|
||||
|
||||
test("a toast raised by the action itself survives", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("something that talks back", () => Toast.show("Bob is all in."));
|
||||
|
||||
assert.equal(Toast.onScreen, "Bob is all in.",
|
||||
"commit clears what was there when it started, not what its own work puts up");
|
||||
assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]);
|
||||
});
|
||||
|
||||
test("with no toast on screen it is still just one dismiss", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("a fold", () => {});
|
||||
assert.equal(log.filter(l => l === "dismiss").length, 1);
|
||||
});
|
||||
|
||||
test("a page with no Toast at all does not throw", () => {
|
||||
const { bar, state } = onTable();
|
||||
delete globalThis.Toast;
|
||||
assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("every bar action really does go through that funnel", () => {
|
||||
// The dismiss is worth nothing if some button reaches past `commit` to mutate the
|
||||
// table itself. Actions are written as `this.commit(...)`; a `Betting.` call made
|
||||
// straight from a click handler would be one that skips the clean-up — and the
|
||||
// undo history with it, which is the older reason this funnel exists.
|
||||
test("no click handler calls the betting engine directly", () => {
|
||||
const strays = barSource
|
||||
.split("\n")
|
||||
.map((line, i) => [i + 1, line])
|
||||
.filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line))
|
||||
.filter(([, line]) => !/this\.commit\(/.test(line))
|
||||
.map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`);
|
||||
|
||||
assert.deepEqual(strays, [], "these change the table without going through commit");
|
||||
});
|
||||
|
||||
test("commit is what clears the toast, not each button", () => {
|
||||
const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length;
|
||||
// Two: this one, and the raise pad — which opens without committing anything,
|
||||
// and needs the strip clear because the pad makes the bar tall enough to push
|
||||
// a toast up over the seats.
|
||||
assert.equal(dismissals, 2,
|
||||
"a third dismissal means a button is doing by hand what commit already does");
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,20 @@ const LayoutAudit = {
|
||||
return false;
|
||||
},
|
||||
|
||||
// The scrolling ancestor whose box this element has fallen outside of, if any.
|
||||
// Wholly outside — an element straddling the edge is still half on show, and the
|
||||
// half that is showing is worth hit-testing.
|
||||
clippedOutBy(el, rect) {
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
if (!/auto|scroll|hidden|overlay|clip/.test(style.overflowY + style.overflowX)) continue;
|
||||
const box = node.getBoundingClientRect();
|
||||
if (rect.top >= box.bottom - 0.5 || rect.bottom <= box.top + 0.5 ||
|
||||
rect.left >= box.right - 0.5 || rect.right <= box.left + 0.5) return node;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// The modal that is currently on top, if any.
|
||||
//
|
||||
// Every check below asks "is this thing reachable", and while a sheet or dialog is
|
||||
@@ -143,6 +157,22 @@ const LayoutAudit = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Still on screen, but clipped away by a panel that scrolls. Hit-testing it
|
||||
// is meaningless: the point lands on whatever the panel's own edge has next
|
||||
// to it, and the answer comes back as a diagnosis of something that isn't
|
||||
// happening. The pot wizard's third winner, below the fold in a list that
|
||||
// scrolls, was reported for four viewports running as "fully covered by
|
||||
// button Confirm Winner(s)" — the button is simply what is painted at that
|
||||
// height. Whether it is a fault is the same question as for anything else
|
||||
// out of view: can it be scrolled to.
|
||||
const clip = this.clippedOutBy(el, rect);
|
||||
if (clip) {
|
||||
if (!this.reachableByScrolling(el)) {
|
||||
offenders.push(`${this.describe(el)} :: clipped out of ${this.describe(clip)}, which cannot be scrolled to it`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const points = [
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
|
||||
@@ -848,6 +878,69 @@ const LayoutAudit = {
|
||||
return { name, offenders };
|
||||
},
|
||||
|
||||
// The other thing on the felt that carries a number.
|
||||
//
|
||||
// A chip says what a player has put in this street, which is what a call costs. Two
|
||||
// of them on top of each other is two figures a dealer can't read, and one behind a
|
||||
// puck is a figure that isn't there at all.
|
||||
//
|
||||
// Geometry rather than hit-testing, which is the opposite of how every other check
|
||||
// here works and is forced: #betsLayer is `pointer-events: none`, so elementFromPoint
|
||||
// never returns a chip — it returns whatever is behind it, for every chip, always.
|
||||
// A first version of this probe hit-tested and duly reported all forty-five
|
||||
// configurations as broken, including the ones that were fine. Paint order is known
|
||||
// and fixed instead: #betsLayer comes before #seatsLayer in the document and a seat
|
||||
// carries `z-index: 1`, so a puck a chip overlaps is a puck that chip is behind.
|
||||
chipsKeepTheirAmounts() {
|
||||
const name = "no bet chip is hidden behind a seat or another chip";
|
||||
if (this.modalScope()) {
|
||||
return { name, offenders: [], skipped: "a sheet is open over the table" };
|
||||
}
|
||||
const chips = [...document.querySelectorAll(".betChip")]
|
||||
.filter(c => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
||||
if (!chips.length) return { name, offenders: [], skipped: "no bets on the felt" };
|
||||
|
||||
const overlap = (A, B) =>
|
||||
Math.min(A.right, B.right) - Math.max(A.left, B.left) > 0.5 &&
|
||||
Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 0.5;
|
||||
const offenders = [];
|
||||
const seats = [...document.querySelectorAll(".seat")];
|
||||
|
||||
chips.forEach(chip => {
|
||||
const label = chip.textContent.trim() || "a chip";
|
||||
const box = chip.getBoundingClientRect();
|
||||
seats.forEach((seat, index) => {
|
||||
// Its own puck is the one thing it is allowed to touch: on a narrow felt
|
||||
// the chip clings to the seat rather than taking a ring of its own.
|
||||
if (seat.contains(chip)) return;
|
||||
if (overlap(box, seat.getBoundingClientRect())) {
|
||||
offenders.push(`chip ${label} is behind seat ${index + 1}`);
|
||||
}
|
||||
});
|
||||
// A chip wider than the puck it is clinging to is on its neighbours by
|
||||
// construction, whatever the ring does.
|
||||
const host = chip.closest(".seat");
|
||||
if (host && chip.classList.contains("betChipAnchored")) {
|
||||
const puck = host.getBoundingClientRect();
|
||||
if (box.width > puck.width + 0.5) {
|
||||
offenders.push(`chip ${label} is ${Math.round(box.width - puck.width)}px ` +
|
||||
`wider than its own puck`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (let a = 0; a < chips.length; a++) {
|
||||
for (let b = a + 1; b < chips.length; b++) {
|
||||
if (overlap(chips[a].getBoundingClientRect(), chips[b].getBoundingClientRect())) {
|
||||
offenders.push(`chip ${chips[a].textContent.trim()} and chip ` +
|
||||
`${chips[b].textContent.trim()} are on top of each other`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name, offenders: [...new Set(offenders)] };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
// Every check this file knows how to make, every time.
|
||||
@@ -969,6 +1062,7 @@ const LayoutAudit = {
|
||||
this.hiddenButPainted(),
|
||||
this.seatLabelsClear(),
|
||||
this.seatsKeepTheirText(),
|
||||
this.chipsKeepTheirAmounts(),
|
||||
this.contrast(options.contrast),
|
||||
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user