Compare commits

..
Author SHA1 Message Date
jheapsandClaude Opus 5 c19575ab10 Count the house's cut instead of calling it missing chips
A game that takes a rake has chips leaving the table every hand, and the bank
check had no idea. It added up the buy-ins, added up the chips on the felt, and
told the dealer somebody had been paid for chips they were never given — a
shortfall that grows all night and points at a miscount that never happened.
Every home game that takes a drop for the beer fund hits this on hand one.

So the tool takes it, rather than watching it go. Under Table Setup: a
percentage, an optional cap, and "no flop, no drop" — the rule almost everyone
plays, on by default, where a hand that ends before the flop pays nothing. Zero
percent is a game that doesn't rake, which is most of them and the default, and
nothing about that game changes.

Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%,
capped at $5" above the payouts, before a chip moves, because the summary is the
dealer's last chance to disagree with the arithmetic and money taken quietly is
the one thing a tool like this must never do. The bank check gets a House take
line and says "balanced ... once the $2 in the house's cut is counted". The hand
log keeps the pot as the table saw it with the cut recorded beside it, and the
export has a column for it, so the night's total is one sum in a spreadsheet.

Two decisions worth stating. The rake rounds *down* to a chip the table actually
has: a house that can't make 40c takes nothing and the players keep it, which is
the only way to be wrong about this that nobody argues with. And on a split pot
it comes off the top, then what is left is shared in proportion — with the
rounding leftovers going back to the winners rather than to the house, which
asked for a number and gets that number, not that number plus dust.

Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and
banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank
check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays
the full $15 and takes nothing; with it off the same hand pays $14 and takes $1.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:12:33 -06:00
jheapsandClaude Opus 5 8aa90e90a4 Give a phone on its side the table shape it needs
The last two rounds left one thing failing and said so: a landscape phone with
six or twelve seats stacked two of them 25px apart at each end of the felt, with
pucks 64px tall. It has been there since seats were first spaced by distance
rather than by angle. Three things were wrong at once, and it needed all three.

The rim is the wrong shape when the table is very flat. An ellipse 616px wide and
96px tall has nowhere to put the seats near its ends: the curve doubles back on
itself, so two seats a long walk apart come out one above the other. Past two and
a half times wider than tall the rim is now a stadium — two straight rails with a
half-round cap at each end, which is how a real poker table is built and exactly
why it is built that way. A cap is a half-circle of the felt's own height, so an
end takes one seat and the rest is rail. Only past that ratio: measured, the
cut-off lands between a landscape phone at 4.4 and every other layout the tool
has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape.

The ring had less height than the felt did. The seat layer is inset by half a
puck to keep the outermost seats on screen, and 26px off the top and bottom of a
96px felt left 44px to work with — less than half the table, which is why no rim
shape could have fixed it alone. The inset is a percentage where the felt is
short. The landscape branch never had that rule at all, because it is matched on
height while the inset lives in a width query, so an 844px-wide phone on its side
drew its ring against the felt's whole box; with a flat-topped stadium rail that
put a dealer button over Resolve Pot in the header.

And the puck was a line too tall. In the tight state the hole cards give up their
row: of the four things on a puck, the cards are the one the dealer is holding in
their other hand, and the seat editor still shows them. That took the puck from
64px to 55px, which is what finally cleared the ends.

Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the
closest two seats at 640x360 go from 25px apart to 55px, and every combination is
clean. Then 216 audits across thirteen viewports, three seat counts and both a
mouse and a thumb: no failures, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:58:59 -06:00
jheapsandClaude Opus 5 dc5db16ffd Let the phone table use the width it has
Last round capped the felt so the ring would clear the action bar, and capped the
wrong dimension. The wide layout sizes the felt from its width, so capping the
width is how you shorten it there — on a phone the felt is already the full width
of the column, and capping that threw away 96px of a 360px screen: a 240x240
table inside a 336px space, with nine pucks crowding it.

A max-height instead leaves the width alone and lets the aspect ratio give way,
which turns a short screen into a wide oval — the shape a poker table is. At
360x640: 336x240 rather than 240x240, same clearance over the bar, and the
twelve-seat ring that was crowded at 240 comes out clean, which closes the one
combination left failing last round. Where the room isn't the binding constraint,
390x844 and up, nothing changes and the table stays round. The landscape branch
now says max-height: none, because it already spends the same measured room on
its own height and applying it twice took the felt to the floor value.

The measurement that decides whether pucks shrink was also wrong, in a way that
only showed up once it was asked a hard question. Rim length per seat measures
the walk between two seats, not the gap between them, and on a long oval the rim
doubles back at the ends: twelve seats at 640x360 had 193px of rim each and sat
60px apart. It now measures the closest two neighbours directly, off the ring
itself — and off the seat layer rather than the felt, because the layer is inset
by half a puck on a phone and the felt's box put the answer out by 30px.

That makes the rule geometric rather than about screen size, so it is no longer
scoped to a phone media query: a wide landscape felt crowds at its ends while its
rim claims there is room, and now it is told so.

Pucks still touch at nine and twelve seats on a small screen. Tried 46px against
58px: on a roomy phone 46 cleared every overlap where 58 left two, but in a hand
with a side pot, where the bar has grown and the felt has shrunk to suit, 46
overlapped four times and 58 twice. That is the felt being small, not the puck
being wide, and paying for it with an ellipsis on every long name buys nothing.
What has to hold is that each seat keeps its own centre, and that is measured at
every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:33:25 -06:00
jheapsandClaude Opus 5 b9b2237dbe Run the two checks the audit had and never used
runAll() did not include the contrast check — it simply wasn't in the list — and
touch targets sat behind an opt-in flag nothing passed. So "the audit is clean"
has meant nine checks rather than eleven for a long run of rounds, and the two
left out are the two whose faults a dealer meets in a dim room with their thumb.
Both were written, tested, and then never called; the contrast checker was built
across two rounds and every sweep since has been silent about colour.

The standalone runner had a second version of the same hole: the 44px rule
stands itself down where `hover: hover` says there's a pointer, and a headless
desktop browser is a pointer, so a single pass had never measured a touch target
at any viewport. It now runs every viewport twice, once with a mouse and once
with a thumb.

A check that decides it doesn't apply now says so, rather than reporting an empty
offenders list — which reads exactly like a pass.

Faded text is measured instead of filed as unchecked. `opacity` on a box fades
the box and its text together onto whatever is behind it, so a dimmed seat is not
grey text on a dark puck: it is a faded puck and faded text both landing on the
page. Both halves are composited that way now. Two exemptions remain and both say
so out loud: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.

What running them found, and this round fixes:

  - the chair number on every seat: #8a8a8a at 9px, 3.39:1 once the empty seat's
    fade is composited, on the label that answers "which seat is that?";
  - Quick Split's amount field at 37px tall and its delete button at 30px across,
    on a page whose targets had never been measured;
  - and on a 360x640 phone with the bar grown by a side pot, the bottom seat
    sitting 38px inside it.

That last one brings back a cap this project measured and rejected once: capping
the felt to the room left fixed the six-seat ring and broke the nine-seat one.
That trade was real then and isn't now — seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
across 30 combinations of five phone sizes and 2 to 12 seats: every one clean
except twelve seats at 360x640, where a 240px felt and a 64px puck cannot both be
true. Where the rim can't give each seat a puck's width, the pucks come down a
size, measured from the rim itself rather than from a media query.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:02:43 -06:00
jheapsandClaude Opus 5 fc866597eb Deal the straddle, instead of leaving the dealer to fake it
A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.

There are three at once:

  - the amount doubles as it goes left, and re-straddles carry on doubling;
  - first to act is left of the *straddler*, not left of the big blind, and the
    straddler keeps the option when it comes back round even if nobody raised;
  - the minimum raise is twice the straddle. Measured off the big blind
    underneath it, a $2/$4 game prices the next raise at $6, and every player at
    the table will argue — correctly.

Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.

The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.

20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:32:17 -06:00
18 changed files with 1453 additions and 99 deletions
@@ -86,6 +86,32 @@
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
</label>
</div>
<label class="fieldRow">
<span>No flop, no drop</span>
<input type="checkbox" id="rakeNoFlopInput" />
</label>
<p class="fieldHint">
Taken from the pot when it's awarded, and shown before anything is pushed.
Rounded down to a chip the table has, so the players keep what the house
can't make. Leave the percentage at zero for a game that doesn't rake.
</p>
<p class="fieldHint" id="rakeTakenHint" hidden></p>
</div>
</details>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
@@ -132,6 +158,10 @@
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<div class="bankReconcileRow" id="bankRakedRow" hidden>
<span>House take</span>
<span id="bankRaked"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
@@ -208,6 +238,7 @@
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
@@ -360,6 +391,23 @@
</p>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</section>
<section class="ruling" data-short="Short all-in">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
+149 -9
View File
@@ -586,12 +586,16 @@ button,
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
}
/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a
came out at 3.39:1 once the fade was composited, for the label a dealer reads to
answer "which seat is seat 6?". Lifted until it passes with a little to spare —
measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
font-size: 0.58rem;
color: #8a8a8a;
color: #aeaeae;
}
.seatPuckName {
@@ -796,6 +800,19 @@ button,
color: #241a05;
}
/* The straddle. It takes the big blind's slot on the puck, which is free by
construction: the chain starts left of the big blind and stops short of the small
one, so no seat ever wears two of these.
Violet rather than a third shade of gold or blue, because the badge a dealer is
looking for mid-hand is the one telling them where the action comes back to.
Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
.strBadge {
left: 22px;
background: linear-gradient(180deg, #cbaaf0, #a97fd6);
color: #1b1030;
}
/* A blind the dealer named by hand rather than one the button handed out. Without
the ring the table just looks like it's put the blinds in the wrong place. */
.blindBadge.blindManual {
@@ -803,6 +820,43 @@ button,
outline-offset: 1px;
}
/* Seats close enough together that full-size pucks would overlap. The class is set
from a measurement render.js takes off the ring itself — the closest two neighbours,
straight line — because no media query can see it: the same 390px phone is roomy at
six seats and crowded at nine, and a wide landscape felt crowds at its ends while
its rim says there is space. Not scoped to a screen size for the same reason. */
.seatsTight .seat {
/* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
cleared every overlap at nine seats where 58 left two — but in the state that
actually matters, a hand with a side pot where the bar has grown and the felt has
shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
density is the felt being small, not the puck being wide, and paying for it with
an ellipsis on every long name buys nothing. What has to hold is that each seat
keeps its own centre, so a tap lands on the player it was aimed at, and that is
measured at every size. */
width: 58px;
min-height: 38px;
padding: 3px 4px;
}
.seatsTight .seatPuckName {
margin-top: 0.35rem;
font-size: 0.62rem;
}
.seatsTight .seatPuckStack {
font-size: 0.55rem;
}
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
come from somewhere — and of the four things on a puck, the cards are the one the
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
still shows the cards in full. */
.seatsTight .seatPuckCards {
display: none;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
@@ -2844,6 +2898,20 @@ body.hasActionBar #toastHost {
color: #ff6b6b;
}
/* What the house takes, above the payouts it comes out of. Stated in the summary
rather than only in the log afterwards: this is the dealer's last chance to
disagree with the arithmetic before the chips move, and money taken quietly is the
one thing a tool like this must never do. */
.potWizardRake {
margin: 0 0 0.7rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(216, 180, 106, 0.1);
border: 1px solid rgba(216, 180, 106, 0.35);
color: #e8cf9a;
font-size: 0.82rem;
}
.potWizardSummary ul {
list-style: none;
padding: 0;
@@ -2893,14 +2961,31 @@ body.hasActionBar #toastHost {
}
@media (max-width: 700px) {
/* The narrow layout already had an answer for the bar: it reserves the bar's height
as scroll room below the table, and a phone is a thing you scroll. Capping the
felt here as well was measured and rejected — at 360x640 it took the table from
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
covered seats traded for two seats whose centre landed on a neighbour. The cap is
for the wide layout, which never consulted the bar's height at all. */
/* The cap lives here now, and the reason it didn't is worth keeping.
It was measured and rejected once: at 360x640 it took the table from 321px to
226px, which fixed the six-seat ring but broke the nine-seat one — two covered
seats traded for two seats whose centre landed on a neighbour. The narrow layout
fell back on reserving the bar's height as scroll room, on the grounds that a
phone is a thing you scroll.
That trade was real then and isn't now: seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
seat that is covered rather than merely below the fold.
The room is spent on height, not on both. Capping the *width* by it — the wide
layout's way, because there the felt is sized from its width — kept the table
square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
max-height leaves the width alone and lets the aspect ratio give way, so a short
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
that was crowded at 240 comes out clean. Where the room isn't the binding
constraint — 390x844 and up — nothing changes and the table stays round. */
#pokerTableWrap {
max-width: none;
max-height: var(--feltRoomPx, 100vh);
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
@@ -2972,10 +3057,18 @@ body.hasActionBar #toastHost {
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
so half a puck of padding is exactly enough to stop the overhang.
Half a puck is the right answer when the felt is tall enough to spare it. On a
phone held sideways it isn't: the felt is 96px tall there once the action bar has
grown, and 26px off the top and bottom left the ring 44px of height to work with
— less than half the table. That is what kept the seats at the ends stacked no
matter what shape the rim was.
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
#seatsLayer,
#betsLayer {
inset: 26px 44px;
inset: min(26px, 12%) 44px;
}
/* Narrower than the desktop puck: at a full ten-handed table the ring is
@@ -3264,6 +3357,11 @@ body.hasActionBar #toastHost {
a table that is already as short as the screen allows. */
aspect-ratio: auto;
max-width: none;
/* This branch already spends the measured room on its own height. The phone
block's max-height is the same number arriving twice, and applying it here
took the felt down to the floor value and crowded the ring — measured at
640x360, two seats lost their centres to their neighbours. */
max-height: none;
}
/* The page's own top padding is 32px of nothing, on the screen that has the least
@@ -3339,6 +3437,16 @@ body.hasActionBar #toastHost {
min-height: 40px;
}
/* The same inset the phone layout uses, which this branch never had because it is
matched on height rather than width — so an 844px-wide phone on its side drew its
ring against the felt's full box. With the rim a stadium, the top rail is flat,
which put the button seat's dealer chip over the Resolve Pot button in the header
above. Percentages of a short felt, so the ring keeps the height it needs. */
#seatsLayer,
#betsLayer {
inset: min(26px, 12%) 44px;
}
}
/* ---------- Short phones, upright ----------
@@ -3520,6 +3628,38 @@ textarea:focus-visible {
font-weight: 600;
}
/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is
flex: 1 and would have split the row evenly with the button the dealer presses every
hand. It sizes to its own words instead, and stays quiet next to the green one —
choosing a straddle is the exception, dealing is the rule. */
.actionBtn.actionStraddle {
flex: 0 1 auto;
background: linear-gradient(180deg, #33304a, #24223a);
border-color: #4b4770;
color: #e6e1ff;
font-size: 0.82rem;
padding-left: 0.7rem;
padding-right: 0.7rem;
}
.actionBtn.actionStraddleOff {
background: linear-gradient(180deg, #2e2e2e, #232323);
border-color: #4a4a4a;
color: #dcdcdc;
}
/* Three buttons across a 360px phone is a squeeze, and the two ways out are both
worse than this one. Letting the row wrap put the deal button on its own line and
made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths
behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the
max-width: none override, where capping it was tried and rejected). Letting flex
squash freely took the deal button to 84px with its label over three lines.
A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle
costs no felt at all. */
.dealPromptButtons .actionDeal {
min-width: 7rem;
}
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
would otherwise only discover afterwards. */
.dealPromptLine.dealPromptAside {
@@ -148,9 +148,13 @@ button,
color: #8f8f8f;
}
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
44px everywhere else since the round that set it; this page never got measured for
it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
min-height: 46px;
background: none;
border: none;
outline: none;
@@ -180,10 +184,12 @@ button,
color: #ffd6d6;
}
/* 30px across, and it deletes a player's entry — the one control on the row where a
near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
width: 46px;
height: 46px;
border: none;
background: none;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
@@ -89,6 +89,10 @@ const ActionBar = {
document.documentElement.style.setProperty(
"--feltRoomPx",
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
@@ -592,6 +596,43 @@ const ActionBar = {
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
// name doesn't need the verb again, and the bar has no room for it.
straddleClause(preview) {
const straddles = preview.straddles || [];
if (straddles.length === 0) return "";
const name = i => HoldemState.displayName(this.state.seats[i], i);
const [first, ...rest] = straddles;
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
},
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
// the big blind to straddle from, so nothing is offered rather than a button that
// would do nothing.
straddleButtons(preview) {
const on = (preview.straddles || []).length;
const max = preview.maxStraddles || 0;
if (max <= 0) return [];
const buttons = [];
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
if (on < max) {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off",
() => { this.state.straddleCount = 0; })));
}
return buttons;
},
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
@@ -689,11 +730,14 @@ const ActionBar = {
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
line.textContent = preview.deadSmallBlind
const blinds = document.createElement("span");
blinds.textContent = (preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
this.straddleClause(preview) +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
line.appendChild(blinds);
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
@@ -767,7 +811,12 @@ const ActionBar = {
}
const row = document.createElement("div");
row.className = "actionButtons";
row.className = "actionButtons dealPromptButtons";
// Beside the deal button rather than on a line of its own. Measured at 390x844:
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
// and this row is already there and already 44px tall. It reads in the order it
// happens, too — say the straddle, then deal.
this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
@@ -93,9 +93,60 @@ const Betting = {
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
// What the straddles will be if the hand is dealt as it stands, and how
// many more this table could take.
straddles: this.straddleSeats(state),
maxStraddles: this.maxStraddles(state),
};
},
// ---- Straddles ----
//
// A straddle is a blind raise put out before the cards, by the player left of the
// big blind, for twice it. It buys the last word before the flop: everyone else
// acts first, and the straddler still has the option when it comes back round even
// if nobody raised. Re-straddles carry on left, each doubling again.
//
// Home games run them constantly and a dealer has to get three things right at
// once — the amount, who is first to act, and what the minimum raise becomes. The
// tool used to do none of them, so a dealer's only way through was to deal, have
// the straddler "raise" to $4, and hand the action to the wrong player.
// How many straddles this table could take. Everyone but the two blinds can put
// one out; heads-up that is nobody, which is the right answer — the button is
// already the small blind and there is no seat left of the big blind to straddle
// from.
maxStraddles(state) {
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
},
// Who straddles and for how much, walking left from the big blind and doubling.
// Asked of the table as it will be dealt, so a seat about to be sat out for having
// no chips is never handed a straddle it can't post.
straddleSeats(state, count = state.straddleCount) {
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
if (wanted <= 0) return [];
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return [];
const straddles = [];
let index = bbIndex;
let amount = this.round((Number(state.bigBlind) || 0) * 2);
for (let n = 0; n < wanted; n++) {
index = this.nextToAct(asDealt, index);
if (index === null || index === bbIndex) break;
straddles.push({ index, amount });
amount = this.round(amount * 2);
}
return straddles;
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
@@ -475,6 +526,7 @@ const Betting = {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
state.postedStraddles = [];
return;
}
@@ -505,17 +557,35 @@ const Betting = {
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
state.currentBet = this.round(Math.max(
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Straddles go out after the blinds and before the cards, each one left of the
// last. Posted here rather than left to the dealer to fake as a raise, because
// a raise from that seat would hand the action to the wrong player.
const straddles = this.straddleSeats(state);
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
state.postedStraddles = straddles.map(({ index }) => index);
state.currentBet = this.round(state.seats.reduce(
(most, player) => Math.max(most, this.committed(player)), 0));
// A straddle is a blind raise, so what has to be matched to raise again is the
// straddle itself, not the big blind underneath it: $2 blinds with a $4
// straddle make the next raise $8, the same way the $2 blind makes it $4.
// Left alone, the minimum would come out at $6 and the dealer would be talked
// out of it by every player at the table.
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
//
// A straddle moves that seat along: first to act is left of the last straddler,
// and the straddler keeps the option, which falls out of nobody having acted
// yet.
const lastPosted = straddles.length > 0
? straddles[straddles.length - 1].index
: bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, bbIndex);
: this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
@@ -20,7 +20,11 @@ const HandHistoryExport = {
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = [], { notes = "" } = {}) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// The rake gets its own column rather than being folded into the pot: the two
// numbers answer different questions — what was in the middle, and what the
// house took out of it — and a night's worth of the second one is what the box
// is counted against.
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -30,6 +34,7 @@ const HandHistoryExport = {
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
Money.forSheet(entry.rake || 0),
payouts,
]);
});
+26 -3
View File
@@ -192,11 +192,15 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.save(state);
Render.renderAll();
PotWizard.open(state, payouts => {
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
if (rake > 0) {
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
}
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts);
HoldemState.recordHand(state, payouts, rake);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
@@ -462,6 +466,25 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// The house's cut. Kept as a group rather than three loose fields, because
// migrate() guarantees the shape and half a rake object would be a table nobody
// could read back.
const readRake = () => {
const percent = Number(document.getElementById("rakePercentInput").value);
const cap = Number(document.getElementById("rakeCapInput").value);
state.rake = {
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
};
hooks.save(state);
Render.renderAll();
};
["rakePercentInput", "rakeCapInput"].forEach(id => {
document.getElementById(id).addEventListener("input", readRake);
});
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
@@ -86,6 +86,21 @@ const Money = {
return this.fromCents(Math.ceil(cents / step) * step);
},
// And down, for money coming *out* of a pot rather than going into it.
//
// The rake is the one amount in this tool that leaves the table, so the direction
// of the rounding is a decision about whose money it is. Down: a house that can't
// make 40c out of its chips takes nothing rather than a dollar, and the players
// keep the difference. Being wrong in the players' favour is the only way to be
// wrong about this that nobody has to argue about.
downToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return 0;
return this.fromCents(Math.floor(cents / step) * step);
},
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
@@ -232,6 +232,24 @@ const PotWizard = {
return wrap;
},
// What the house takes out of this pot, and what is left to push.
//
// Worked out here rather than at the end so the dealer sees it before anything
// moves: the summary is the last chance to disagree with the arithmetic, and a
// rake that only appeared in the hand log afterwards would be the tool taking
// money quietly.
rakeSplit() {
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
const sawFlop = (this.state.street || "preflop") !== "preflop";
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -240,6 +258,20 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
const split = this.rakeSplit();
const rakeSet = Number((this.state.rake || {}).percent) > 0;
if (rakeSet && this.totals.size > 0) {
const line = document.createElement("p");
line.className = "potWizardRake";
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
line.textContent = split.rake > 0
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
: split.sawFlop
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
wrap.appendChild(line);
}
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
@@ -248,7 +280,7 @@ const PotWizard = {
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
for (const { id, amount } of split.payouts) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
@@ -275,13 +307,12 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
// Recomputed rather than reused from the render: the same numbers, but
// taken at the moment the dealer commits rather than when they last
// looked at the screen.
const paid = this.rakeSplit();
this.close();
this.onFinished(payouts);
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
});
actions.appendChild(applyBtn);
+160 -15
View File
@@ -47,6 +47,19 @@ const Render = {
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
// wants to see the running total right there.
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
const hint = document.getElementById("rakeTakenHint");
hint.hidden = taken <= 0;
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
// Whether this device can actually drag a seat. HTML5 drag events are never
@@ -91,12 +104,21 @@ const Render = {
// circle when there's nothing to measure — the same answer the ring gave before it
// asked.
feltAspect() {
if (typeof document === "undefined") return 1;
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return 1;
const box = wrap.getBoundingClientRect();
if (!box.width || !box.height) return 1;
return box.width / box.height;
const box = this.ringBox();
return box ? box.width / box.height : 1;
},
// The box a seat's left/top percentages actually resolve against. Not the felt: on
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
// off the screen, and measuring the felt instead put the ring's shape out by that
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
// which is the difference between "fine" and "overlapping".
ringBox() {
if (typeof document === "undefined") return null;
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
if (!layer) return null;
const box = layer.getBoundingClientRect();
return box.width && box.height ? box : null;
},
// Bets sit on the felt between their player and the pot, the way chips actually
@@ -108,8 +130,11 @@ const Render = {
// 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()) {
const angle = this.ringAngles(count, 44, 42, aspect)[index];
return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
const seat = this.seatPosition(index, count, aspect);
return {
left: 50 + (seat.left - 50) * (27 / 44),
top: 50 + (seat.top - 50) * (25 / 42),
};
},
// Seats spaced evenly around the rim — by the distance between them, not by the
@@ -127,16 +152,111 @@ const Render = {
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
// few hundred pixels long, and it runs once per render.
positionOnRing(index, count, rx, ry, aspect = 1) {
const target = this.ringAngles(count, rx, ry, aspect)[index];
return {
left: 50 + rx * Math.cos(target),
top: 50 + ry * Math.sin(target),
};
return this.ringPositions(count, rx, ry, aspect)[index];
},
// Past this much wider than tall, the rim stops being an ellipse and becomes a
// stadium — two straight rails with a semicircular cap at each end.
//
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
// an end holds one seat and the rest of the table is rail — which is how a real
// poker table is built, and why it is shaped that way.
//
// Only where it is needed. Anything rounder than this keeps the ellipse it has
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
// upright), so no currently-working table changes shape.
STADIUM_RATIO: 2.5,
// Seats spaced evenly around whichever rim this table has, as percentages.
ringPositions(count, rx, ry, aspect = 1) {
if (count <= 0) return [];
const halfWidth = rx * aspect;
const halfHeight = ry;
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
left: 50 + rx * Math.cos(angle),
top: 50 + ry * Math.sin(angle),
}));
}
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
},
// The same walk, around a stadium instead. Its perimeter has a closed form, so
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
stadiumPositions(count, halfWidth, halfHeight, aspect) {
const rail = halfWidth - halfHeight;
const cap = Math.PI * halfHeight;
const perimeter = 4 * rail + 2 * cap;
return Array.from({ length: count }, (_, seat) => {
// Clockwise from the top centre, so seat one sits where it always has.
let walked = (perimeter * seat) / count;
let x;
let y;
if (walked <= rail) { // top rail, going right
x = walked;
y = -halfHeight;
} else if (walked <= rail + cap) { // right cap
const turned = (walked - rail) / halfHeight;
x = rail + halfHeight * Math.sin(turned);
y = -halfHeight * Math.cos(turned);
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
x = rail - (walked - rail - cap);
y = halfHeight;
} else if (walked <= 3 * rail + 2 * cap) { // left cap
const turned = (walked - 3 * rail - cap) / halfHeight;
x = -rail - halfHeight * Math.sin(turned);
y = halfHeight * Math.cos(turned);
} else { // top rail, back to the start
x = -rail + (walked - 3 * rail - 2 * cap);
y = -halfHeight;
}
return { left: 50 + x / aspect, top: 50 + y };
});
},
RING_STEPS: 720,
ringCache: {},
// The closest two neighbouring seats come to each other, straight line, in pixels.
//
// Rim length divided by seat count was the first answer and it is the wrong one:
// it measures the walk between two seats rather than the gap between them, and on
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
// and two seats a long walk apart are stacked one above the other.
closestSeatGapPx(count, box = this.ringBox()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
return points.reduce((closest, point, i) => {
const next = points[(i + 1) % points.length];
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
},
// A puck is 76px across, so neighbours whose centres are closer than that overlap
// by definition; a few pixels more keeps them from touching. Under it the pucks
// come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 84,
// Called on every render and again whenever the felt is resized — the action bar
// growing mid-hand shortens the table, and the seats have to answer that without
// waiting for the next render to come along.
updateSeatDensity() {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
// Every seat on a ring wants the same answer, so it is worked out once per shape
// and kept — the ring only changes when the table is resized or the window is.
@@ -245,6 +365,7 @@ const Render = {
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
this.updateSeatDensity();
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
@@ -310,6 +431,14 @@ const Render = {
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
// A straddle is a blind too, and the seat wearing it is the one the action
// comes back to. Marked from what the deal actually posted rather than from
// the count, so the badge can't outlive the chips it stands for — before
// the cards there is nothing out there yet.
const straddleBadge = node.querySelector(".strBadge");
const straddled = (this.state.postedStraddles || []).includes(index);
straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
@@ -1029,10 +1158,26 @@ const Render = {
}
}
// Same idea for the house's cut, and the more important of the two: a raked
// game moves chips off the table every single hand, so without this line the
// check reads as a shortfall that grows all night.
const rakedRow = document.getElementById("bankRakedRow");
if (rakedRow) {
rakedRow.hidden = !(bank.raked > 0);
if (bank.raked > 0) {
document.getElementById("bankRaked").textContent = Money.format(bank.raked);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = bank.discarded > 0
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
verdict.textContent = accountedFor.length > 0
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
+106 -3
View File
@@ -100,6 +100,20 @@ const HoldemState = {
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
// How many straddles are on for the hand about to be dealt. A straddle is
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
// What the house takes, if it takes anything. Percent zero is a game that
// doesn't rake, which is nearly all of them and the default.
rake: { percent: 0, cap: null, noFlopNoDrop: true },
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
postedStraddles: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
@@ -201,6 +215,20 @@ const HoldemState = {
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// None on is the right reading of a save from before straddles existed:
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
// A table saved before the house took anything takes nothing, which is
// also the right answer for one that never will.
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
parsed.rake = {
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
@@ -643,6 +671,10 @@ const HoldemState = {
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
// So is a straddle. It is announced hand by hand at a table, and a straddle
// that stayed on would take chips off somebody who never said the word.
state.straddleCount = 0;
state.postedStraddles = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -662,11 +694,15 @@ const HoldemState = {
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
recordHand(state, payouts, rake = 0) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
// The pot as it was, house cut included — the number the table saw in the
// middle. What each player took is separately recorded and is net of it, so
// the two together say where every chip went.
const taken = Money.round(Math.max(0, Number(rake) || 0));
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
@@ -681,6 +717,7 @@ const HoldemState = {
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
rake: taken,
payouts,
ledger,
});
@@ -801,6 +838,67 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
// and until now the bank check called them missing: it added up the buy-ins,
// added up the chips on the felt, and told the dealer somebody had been paid for
// chips they never got. The house's cut is the one thing on this table that is
// supposed to go and not come back, so it is counted rather than mourned.
//
// Percentage with a cap, which is what nearly every room and home game runs. Zero
// percent is a game that doesn't rake, which is the default and the common case.
// What the house takes from a pot of this size.
//
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
// flop pays nothing, because the players never got a hand out of it. It is on by
// default and can be turned off for a house that rakes every pot.
rakeOn(state, pot, sawFlop = true) {
const rake = state.rake || {};
const percent = Number(rake.percent) || 0;
const amount = Money.round(Number(pot) || 0);
if (percent <= 0 || amount <= 0) return 0;
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
const uncapped = (amount * percent) / 100;
const cap = Number(rake.cap);
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
// Never more than the pot, however the numbers were typed in.
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
},
// The same payouts with the house's cut taken out of them.
//
// Off the top of the whole pot, then shared out in proportion — which for the hand
// a dealer actually deals, with one winner, is just "winner gets the pot less the
// rake". Split pots take it proportionally, and whatever the rounding leaves over
// goes back to the winners in turn rather than to the house: the house asked for a
// number, and it gets that number, not that number plus dust.
applyRake(payouts, rake) {
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
const taken = Money.round(Math.min(Math.max(0, rake), total));
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
const kept = Money.toCents(total) - Money.toCents(taken);
const shares = payouts.map(p => {
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
return { payout: p, cents: Math.floor(exact) };
});
// Floor everybody, then hand the leftover cents out one at a time, biggest
// share first — the same shape as the odd-chip rule the split calculator uses.
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
[...shares]
.sort((a, b) => b.payout.amount - a.payout.amount)
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
return {
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
rake: taken,
};
},
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
@@ -820,11 +918,16 @@ const HoldemState = {
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
const difference = Money.round(chipsInPlay + discarded - cashIn);
// Chips the house has taken. Off the table for good, like the discards, and
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+16 -1
View File
@@ -36,7 +36,22 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. 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.
phones on their side — each viewport twice, once with a mouse and once with a thumb.
That last part, and two of those checks, are newer than this paragraph claiming them.
`runAll()` did not include the contrast check at all, and touch targets sat behind an
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
eleven for a long run of rounds. Both were written and tested and simply never called.
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
where `hover: hover` says there is a pointer, and a headless desktop browser is a
pointer, so a single pass had never measured a touch target at any viewport. Between
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
button on Quick Split.
A check that decides it doesn't apply says so in the output rather than reporting no
offenders, which reads identically to a pass. The same goes for the two exemptions
that exist: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
this one needs a browser and a running site. Run it after any change that adds or
+108
View File
@@ -0,0 +1,108 @@
// The sums the contrast check runs on, tested away from a browser.
//
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
// and the arithmetic is the part that decides whether a real fault is reported or
// waved through. Faded text used to be filed as "not checked" rather than measured;
// this is the compositing that replaced that.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const LayoutAudit = require("./uiAudit");
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
describe("the ratio itself", () => {
test("black on white is the full 21:1, either way round", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
});
test("a colour against itself is 1:1", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
});
});
describe("text on a box that is being faded", () => {
// opacity on a box fades the box and its text together onto whatever is behind
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
// faded text both landing on the page.
const fade = alpha => ({ alpha, behind: PAGE });
test("at full strength nothing is composited", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
test("fading moves both the text and its background, not just the text", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
assert.ok(painted.fg.r < WHITE.r, "the text dims");
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
});
test("which is what stops a fade being reported as a fault it isn't", () => {
// Dimming light text on a dark puck over a dark page costs contrast, but far
// less than measuring the faded text against the *unfaded* puck would claim.
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
});
test("a fade all the way out lands everything on the page behind", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
assert.deepEqual(painted.fg, PAGE);
assert.deepEqual(painted.bg, PAGE);
});
test("with nothing known to be behind it, the fade is left alone", () => {
// Better to measure the unfaded pair than to invent a backdrop.
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
});
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
// Deliberately not asserting a particular CSS colour here: the page's own greys are
// measured in the browser, against the real gradients, where they live. What this
// pins is the direction — that dimming a box costs its text contrast, which is why
// filing faded text as "not checked" hid faults rather than avoiding false ones.
test("the same grey scores lower once its box is dimmed", () => {
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
LayoutAudit.contrastRatio(solid.fg, solid.bg));
});
test("and a lighter grey scores higher at the same fade", () => {
const at = colour => {
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
};
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
});
});
describe("reading a colour off the page", () => {
test("rgb and rgba both come back as numbers", () => {
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
});
test("a gradient gives up every stop, because text could be over any of them", () => {
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
assert.equal(stops.length, 2);
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
});
test("no background image, no stops", () => {
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
});
});
+230
View File
@@ -0,0 +1,230 @@
// The house's cut.
//
// A game that rakes has chips leaving the table every hand, and the bank check used to
// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
// paid for chips they never got. The cut is the one thing here that is supposed to go
// and not come back.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
const raking = (rake, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, ...extra });
state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
return state;
};
describe("what the house takes", () => {
test("nothing, unless somebody says otherwise", () => {
const state = makeTable({ seatCount: 6 });
assert.equal(state.rake.percent, 0);
assert.equal(HoldemState.rakeOn(state, 100), 0);
});
test("a percentage of the pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
});
test("no more than the cap", () => {
const state = raking({ percent: 5, cap: 5 });
assert.equal(HoldemState.rakeOn(state, 100), 5);
assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
});
test("no flop, no drop", () => {
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
assert.equal(HoldemState.rakeOn(state, 100, true), 5);
});
test("unless the house rakes every pot", () => {
const state = raking({ percent: 5, noFlopNoDrop: false });
assert.equal(HoldemState.rakeOn(state, 100, false), 5);
});
test("nothing it can't make out of the table's chips, rounded the players' way", () => {
// 5% of $30 is $1.50, and this table's smallest chip is a dollar.
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 30), 1);
// 5% of $10 is 50c — under a chip, so the house takes nothing at all.
assert.equal(HoldemState.rakeOn(state, 10), 0);
});
test("never more than the pot, whatever gets typed in", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
});
test("nothing out of an empty pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
});
});
describe("taking it out of the pot", () => {
const winner = amount => [{ id: "a", label: "Ann", amount }];
test("one winner just gets the pot less the cut", () => {
const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
assert.equal(rake, 5);
assert.deepEqual(payouts.map(p => p.amount), [95]);
});
test("a split pot shares what's left, in proportion", () => {
const { payouts } = HoldemState.applyRake(
[{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
});
test("the rounding leftovers go back to the players, not to the house", () => {
// $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
// it has to land on somebody.
const { payouts, rake } = HoldemState.applyRake([
{ id: "a", label: "Ann", amount: 34 },
{ id: "b", label: "Ben", amount: 33 },
{ id: "c", label: "Cal", amount: 33 },
], 7);
assert.equal(rake, 7);
assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
});
test("what goes out plus what the house keeps is what was in the middle", () => {
[[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
const result = HoldemState.applyRake(winner(pot), rake);
assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
});
});
test("asking for more than the pot takes the pot, not more", () => {
const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
assert.equal(rake, 20);
assert.deepEqual(payouts.map(p => p.amount), [0]);
});
test("no rake, no change — the same payouts come back", () => {
const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
assert.equal(rake, 0);
assert.deepEqual(payouts.map(p => p.amount), [80]);
});
});
describe("what the box is checked against", () => {
test("raked chips are gone but not missing", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
// The house takes $10 out of the middle: somebody's stack is $10 lighter.
state.seats[0].stack = 190;
state.rakeTaken = 10;
const after = HoldemState.bankReconciliation(state);
assert.equal(after.raked, 10);
assert.equal(after.balanced, true, "the check still balances");
});
test("and are told apart from the ones that went astray", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
state.seats[0].stack = 180;
state.rakeTaken = 10;
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 10);
assert.equal(check.difference, -10, "ten dollars really are missing");
assert.equal(check.balanced, false);
});
test("a table that has never raked reads exactly as it did before", () => {
const state = makeTable({ seatCount: 6 });
state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 0);
assert.equal(check.balanced, true);
});
});
describe("the record of it", () => {
test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
const [entry] = state.handHistory;
assert.equal(entry.pot, 100, "the pot in the middle");
assert.equal(entry.rake, 5);
assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
});
test("a hand with no rake records a zero rather than nothing", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
assert.equal(state.handHistory[0].rake, 0);
assert.equal(state.handHistory[0].pot, 40);
});
test("a table saved before the house took anything takes nothing", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.rake;
delete parsed.rakeTaken;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.noFlopNoDrop, true);
assert.equal(migrated.rakeTaken, 0);
});
test("nonsense in a saved rake is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
parsed.rakeTaken = -20;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.cap, null);
assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
assert.equal(migrated.rakeTaken, 0);
});
});
describe("the file the dealer hands over", () => {
const { HandHistoryExport } = require("./load");
test("has a column for what the house took", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
// Oldest hand first, and money as bare numbers so a spreadsheet can total it.
const csv = HandHistoryExport.toCsv(state.handHistory);
const [header, raked, unraked] = csv.split("\n");
assert.match(header, /Pot,House take,Payouts/);
assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
});
});
describe("a raked hand, end to end", () => {
test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
// 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
// At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
// nothing — which is the rule working, not a hand worth testing the bank on.
const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
// Everyone calls the big blind, then it is checked down.
state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
Betting.sweepToHandTotals(state);
const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
const rake = HoldemState.rakeOn(state, pot, true);
assert.equal(pot, 60);
assert.equal(rake, 3);
const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
state.rakeTaken = rake;
HoldemState.recordHand(state, payouts, rake);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
+55 -38
View File
@@ -68,57 +68,74 @@ async function main() {
}
const browser = await chromium.launch();
const page = await browser.newPage();
const failures = [];
const warnings = [];
let checks = 0;
for (const [width, height, label] of VIEWPORTS) {
await page.setViewportSize({ width, height });
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
// down where `hover: hover` says there's a pointer, and a headless desktop browser
// is a pointer — so a single pass here has never once measured a touch target,
// however many viewports it visited. The phone-sized ones are exactly the pass that
// needs it.
for (const touch of [false, true]) {
const context = await browser.newContext(touch
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
: {});
const page = await context.newPage();
const hand = touch ? "touch" : "mouse";
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
for (const [width, height, rawLabel] of VIEWPORTS) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
}
await context.close();
}
await browser.close();
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
+94
View File
@@ -92,6 +92,100 @@ describe("a felt wider than it is tall", () => {
});
});
describe("how close the closest two seats come", () => {
// What decides whether the pucks shrink. Rim length per seat was the first answer
// and it is the wrong question: it measures the walk between two seats, not the
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
// apart end up stacked one above the other.
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
test("a flat table no longer stacks two seats at each end", () => {
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
// is a stadium at this shape, so an end holds one seat.
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
});
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
// Same felt, forced through the ellipse the tool uses for rounder tables.
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
}));
const closest = asEllipse.reduce((least, point, i) => {
const next = asEllipse[(i + 1) % asEllipse.length];
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
assert.ok(gap(6, 616, 96) > closest * 1.3,
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
});
test("a round table's neighbours are about as far apart as the rim says", () => {
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
});
test("more seats, less room, in that order", () => {
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
});
test("nothing to measure between fewer than two seats", () => {
assert.equal(gap(1, 400, 400), Infinity);
assert.equal(gap(9, 0, 0), Infinity);
});
});
describe("which rim a table gets", () => {
// An ellipse until the table is much wider than tall, then a stadium: two rails
// and a half-round cap at each end, which is how a real poker table is built.
const shape = (width, height) => {
const aspect = width / height;
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
};
test("every layout the tool had keeps the ellipse", () => {
assert.equal(shape(844, 608), "ellipse", "desktop");
assert.equal(shape(600, 600), "ellipse", "tablet");
assert.equal(shape(278, 314), "ellipse", "phone upright");
assert.equal(shape(336, 240), "ellipse", "short phone upright");
});
test("a phone on its side gets the stadium", () => {
assert.equal(shape(528, 73), "stadium");
assert.equal(shape(780, 113), "stadium");
});
test("the cap holds one seat instead of stacking two", () => {
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
// the cap, or two of them end up on the same half-circle.
const halfWidth = 44 * (616 / 96), halfHeight = 42;
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
});
test("seat one is still at the top centre, and the ring still mirrors", () => {
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
assert.ok(seats[0].top < 50);
for (let seat = 1; seat < 4; seat++) {
const mirrored = seats[8 - seat];
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
}
});
test("nobody is drawn off the felt", () => {
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
});
});
});
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);
+188
View File
@@ -0,0 +1,188 @@
// The straddle: a blind raise put out before the cards by the player left of the big
// blind, for twice it.
//
// Home games run them constantly, and it is three rules at once — the amount, who is
// first to act, and what the minimum raise becomes. Faking one by dealing and having
// the straddler "raise" gets the last of those right and the other two wrong.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
// straddle seat 3.
const table = (straddles = 0, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
state.straddleCount = straddles;
return state;
};
const dealt = (straddles = 0, extra = {}) => {
const state = table(straddles, extra);
Betting.beginHand(state);
return state;
};
describe("what goes in the middle", () => {
test("the seat left of the big blind puts out twice it", () => {
const state = dealt(1);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
});
test("a re-straddle doubles again, one seat further round", () => {
const state = dealt(2);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
});
test("none of it comes from anywhere but the straddler's stack", () => {
const before = chipsOnTable(table(2));
assert.equal(chipsOnTable(dealt(2)), before);
});
test("the felt is told who straddled, not left to work it out", () => {
assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
assert.deepEqual(dealt(0).postedStraddles, []);
});
});
describe("who is on the clock", () => {
test("action starts left of the straddler, not left of the big blind", () => {
assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
assert.equal(dealt(1).actingSeatIndex, 4);
assert.equal(dealt(2).actingSeatIndex, 5);
});
test("the straddler still has the option when it comes back to them", () => {
const state = dealt(1);
// Everyone calls the straddle round to the big blind.
[4, 5, 0, 1, 2].forEach(seat => {
assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
Betting.call(state, seat);
});
assert.equal(state.actingSeatIndex, 3, "back to the straddler");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
});
test("the straddler checking closes it", () => {
const state = dealt(1);
[4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
Betting.check(state, 3);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("the big blind acts in turn, not last", () => {
const state = dealt(1);
Betting.call(state, 4);
Betting.call(state, 5);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
});
});
describe("what it costs to raise", () => {
test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
// $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
// instead it comes out at $6, and every player at the table would argue.
assert.equal(Betting.minRaiseTo(dealt(1)), 8);
assert.equal(Betting.minRaiseTo(dealt(2)), 16);
assert.equal(Betting.minRaiseTo(dealt(0)), 4);
});
test("what the table owes is measured off the straddle", () => {
const state = dealt(1);
assert.equal(state.currentBet, 4);
assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
});
test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
const state = dealt(1);
Betting.raiseTo(state, 4, 12);
assert.equal(state.currentBet, 12);
assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
});
});
describe("how many a table can take", () => {
test("everyone but the two blinds", () => {
assert.equal(Betting.maxStraddles(table(0)), 4);
assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
});
test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
const heads = table(1, { seatCount: 6, seated: [0, 1] });
assert.equal(Betting.maxStraddles(heads), 0);
assert.deepEqual(Betting.straddleSeats(heads), []);
Betting.beginHand(heads);
assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
});
test("asking for more than the table has stops at the last seat", () => {
const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
state.straddleCount = 99;
assert.equal(Betting.straddleSeats(state).length, 2);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
});
test("a seat with no chips is never handed one", () => {
const state = table(1);
state.seats[3].stack = 0;
// Seat 3 is sat out for the deal, so the straddle passes to seat 4.
assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
});
});
describe("what it doesn't outlive", () => {
test("the hand it was called for", () => {
const state = dealt(1);
HoldemState.startNextHand(state);
assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
assert.deepEqual(state.postedStraddles, []);
});
test("but it survives the button being pushed around before the deal", () => {
// repostBlinds takes every posted chip back and deals again; a straddle that
// vanished there would have been paid once and never returned.
const state = dealt(1);
const before = chipsOnTable(state);
Betting.repostBlinds(state);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
assert.equal(chipsOnTable(state), before);
});
test("a table saved before straddles existed reads as having none", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.straddleCount;
delete parsed.postedStraddles;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.straddleCount, 0);
assert.deepEqual(migrated.postedStraddles, []);
});
});
describe("what the dealer is shown before dealing", () => {
test("the straddles that are coming, and how many more it could take", () => {
const preview = Betting.blindPreview(table(1));
assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
assert.equal(preview.maxStraddles, 4);
});
test("and the preview matches what dealing actually does", () => {
const state = table(2);
const preview = Betting.blindPreview(state);
Betting.beginHand(state);
assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
preview.straddles.forEach(({ index, amount }) => {
assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
});
});
});
+78 -11
View File
@@ -393,6 +393,37 @@ const LayoutAudit = {
}
},
// How much of this element actually reaches the screen, and what is behind the
// thing doing the fading.
//
// `opacity` on a box fades the whole box — its text and its own background together
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
// halves have to be composited the same way or the number means nothing.
fading(el) {
let alpha = 1;
let outermost = null;
for (let node = el; node; node = node.parentElement) {
const own = parseFloat(getComputedStyle(node).opacity);
if (own < 0.99) {
alpha *= own;
outermost = node;
}
}
if (!outermost) return { alpha: 1, behind: null };
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
},
// The two colours as the screen actually shows them, once the fading is applied.
asPainted(fgRaw, bg, fade) {
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
return {
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
};
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -426,12 +457,17 @@ const LayoutAudit = {
// exempt — there's nothing to read at zero — but anything in between is
// reported as unmeasured rather than passed over in silence, because a
// number nobody checked is not the same as a number that's fine.
const opacity = parseFloat(style.opacity);
if (opacity <= 0.02) return;
const faded = opacity < 0.99 ||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
if (faded) {
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
const fade = this.fading(el);
if (fade.alpha <= 0.02) return;
// A control that is switched off is the one exemption WCAG itself grants —
// an inactive component has no contrast requirement, which is why every
// toolkit dims them. Said out loud rather than skipped in silence: this is
// the only exemption in here, and the moment a dimmed thing turns out to be
// live it stops applying.
const off = el.closest("[disabled], [aria-disabled='true']");
if (off) {
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
return;
}
// aria-hidden used to exempt text here, on the grounds that decoration
@@ -459,8 +495,8 @@ const LayoutAudit = {
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
best = Math.max(best, this.contrastRatio(fg, bg));
const painted = this.asPainted(fgRaw, bg, fade);
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
});
const sizePx = parseFloat(style.fontSize);
@@ -543,6 +579,17 @@ const LayoutAudit = {
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
// A link inside a sentence is the one target WCAG itself carves out:
// its size is set by the line it sits in, and padding a word out to
// 44px would break the paragraph around it. The rule is about controls
// a thumb goes hunting for, not words in prose. Said out loud, and
// narrow — display:inline, and real text either side of it.
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
el.parentElement &&
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
return;
}
// A control inside a scrolling sheet can sit below its container's
// visible area while still having a viewport rect. elementFromPoint at
// its centre then returns the sheet, effectiveTarget gives up, and the
@@ -674,6 +721,20 @@ const LayoutAudit = {
// ---- Runner ----
// Every check this file knows how to make, every time.
//
// Two of them used not to be here. Contrast was simply missing from the list, and
// touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
// has meant nine checks rather than eleven, and the two left out are the two whose
// faults a dealer meets in a dim room with their thumb. Both were written, tested
// and then never run: the contrast checker was built across two rounds and every
// sweep since has been silent about colour.
//
// A check that decides it can't apply here says so out loud rather than reporting
// an empty offenders list, which reads as a pass. touchTargets stands itself down
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
// on it — but "not measured" and "measured and fine" are different answers and the
// runner now prints which one it got.
runAll(options = {}) {
const checks = [
this.touchAction(),
@@ -686,8 +747,9 @@ const LayoutAudit = {
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
return {
viewport: `${innerWidth}x${innerHeight}`,
@@ -695,8 +757,13 @@ const LayoutAudit = {
// Things worth knowing that aren't faults — see occlusion() on toasts.
// Controls a check couldn't probe go here too: not a fault, but not a pass
// either, and a silent skip is how faults hide.
warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
warnings: checks.flatMap(c => [
...(c.warnings || []),
...(c.unmeasured || []),
...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
]),
passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
skipped: checks.filter(c => c.skipped).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
},