Give every bet on the felt somewhere to be
The chips were the last thing on the table nobody had measured, and the sweep had never seen one: it audited a table that had never had a bet placed on it, so a third of what is on the felt during a hand went unlooked at for the tool's whole life. It posts blinds, deals and calls three times now before it measures anything. What that found, and what this fixes: - Chips accumulated. Seats are kept and updated in place rather than rebuilt, so clearing the bets layer stopped clearing the ones anchored inside a seat and nothing else ever did. An iPhone 14 had one chip per seat after the deal and five by the third render; a dealer's every tap left another disc on the felt. - The ring-or-cling decision asked how wide the window was. A phone held sideways is 844px across and gives the felt 117px: it passed the width test, took the ring, and every chip on it was painted under a puck — the seats carry z-index 1 and the bets layer carries none, so a chip that lands under one is gone, and with it the only number saying what that player has in. Measured now, and only measured. - Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only straight up, down or sideways. Two axis-aligned boxes are clear when they are apart on one axis, so the travel is the reach of the box you get by adding their widths and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and got 77, and came to rest 11px inside its own player's puck — every seat off the four compass points, which is most of them. - Every chip was laid out at the first chip's size. A chip is as wide as the number on it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were 6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the whole table give up a ring the other four had room for. - Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck is 55 of those pixels tall: clearing it takes 41px, the players facing each other are 70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping pairs on a twelve-handed table. Each chip now gives ground only to something it is actually standing on, a pixel at a time — a midline rule or a single scale factor both punish the seats at the ends of a rail, which were never going to hit anything. - Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65". Two things off the felt, from the same sweep: - The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while the hand was dealt and three players called: an offer to move the button, wired to a snapshot that predates the hand, so taking it would have thrown the hand away without saying so. It was also in the way of thirty small screens' seats and controls. The bar drops it as it takes the next action; the bar's own Undo names what it would actually take back. - The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows visible, the third below a hairline that reads as the end of the list — a dealer ticking the two they could see would have paid two thirds of a three-way pot. The 85vh budget was still subtracting the action bar's height from a dialog that has sat above the bar since its z-index was fixed, and a player's ceiling moves beside their name on a short screen. All three fit now, and where a list does run on, it runs on under a fade rather than stopping dead on a row boundary. The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the action bar prints three inches below it, standing in the only band a bet chip has. The audit stopped calling one thing another. An element clipped away by a panel that scrolls is not covered by whatever is painted at that height — the wizard's third winner came back for four viewports as "fully covered by button Confirm Winner(s)". 190 audits: 25 failing configurations to 7, and every one of those seven is the seat ring hanging past the rail on a landscape phone, which is the known residual from the last round. 663 unit tests, 44 of them new. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -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