From 784315f2ea5141b4320426c58640066099ed9d51 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 12:16:00 -0600 Subject: [PATCH 01/55] Put one dealer button on the table A poker table has one dealer button. This one drew nine. Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all eight showing the letter D, one of them the button. Finding it meant comparing nine identical shapes for the one that's gold, when it should be the only thing of its kind on the felt. The ghosts also sat on their neighbours. Every remaining "something important is covered" offender on a crowded table was a live seat covered by the seat-next-door's ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were all the same thing. And a screen reader heard nine buttons named "D". So a seat without the button doesn't draw one. Hovering a seat fades its slot in -- a preview of where the button would go, one click as before -- and on touch, where there is no hover, "Give the button" has been in the seat's long-press menu all along. Keyboard reaches it by focus, where the slot appears too. pointer-events matters as much as the opacity: an invisible puck that still took taps would be worse than a visible one. visible pucks 9 -> 1 at every size layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2 clean viewports 4 of 9 -> 6 of 9 labels nine "D" -> "Ann has the dealer button" and "Give the dealer button to Ben" The audit now checks it, because this is a rule about the table rather than about CSS: one puck drawn, and no two announcing themselves the same way. Verified against the old behaviour put back -- it reports 9 and 9. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 2 +- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 33 ++++++++++++++++--- .../wwwroot/js/casino/holdem/render.js | 12 ++++++- tests/holdem/uiAudit.js | 29 ++++++++++++++++ 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 36c00b3..7973bb2 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -163,7 +163,7 @@ diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index eabcf70..fe8e893 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1816,31 +1816,44 @@ button, white-space: nowrap; } -/* The per-hand columns are detail: how the night's swings happened. They're the - first thing to go when there isn't room, because Net and Cash out are what the - banker is actually reading. */ +/* The per-hand columns are detail: how the night's swings happened. They're the first + to go when there isn't room, and Net goes with them — it only moves when a pot is + resolved through the wizard, so on a table where the dealer settles pots by hand it + reads $0 all night beside two columns that don't. + + What survives at every width is the pair the banker actually works from: what the + player is holding, and whether that leaves them up or down. */ .standingsIn, .standingsWon, +.standingsNet, .standingsInHead, -.standingsWonHead { +.standingsWonHead, +.standingsNetHead { display: none; } @container (min-width: 30rem) { .standingsRow { - grid-template-columns: minmax(5rem, 1fr) auto auto auto auto; + grid-template-columns: minmax(5rem, 1fr) auto auto auto auto auto; } .standingsIn, .standingsWon, + .standingsNet, .standingsInHead, - .standingsWonHead { + .standingsWonHead, + .standingsNetHead { display: block; } } -/* The settle-up column is the one a banker reads while counting out cash, so it - carries the weight the per-hand columns don't. */ +/* The chips column is the one a banker's hands follow while counting money onto the + table, so it carries the weight. Up/down is read once, at the end. */ +.standingsChips { + font-weight: 600; + color: #e6e6e6; +} + .standingsCashOut { font-weight: 600; } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 836d0bd..5a2755f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -818,9 +818,15 @@ const Render = { netEl.classList.toggle("netUp", row.net > 0); netEl.classList.toggle("netDown", row.net < 0); - // What they'd actually be handed if the night ended now: chips in front of - // them less the cash they gave the banker. This was computed and written to - // the CSV all along, but never shown to the person doing the paying out. + // The cash to count out, which is a plain amount and never signed — this + // is the one the banker's hands follow. It sat only in the CSV: on screen + // the column headed "Cash out" held the difference against their buy-in + // instead, so a player up $140 on a $340 stack read as "$140" at the exact + // moment somebody was counting money onto the table. + node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront); + + // And whether that leaves them up or down, which is the question the rest + // of the table asks and the one the old column was actually answering. const cashEl = node.querySelector(".standingsCashOut"); cashEl.textContent = Money.formatSigned(row.cashPosition); cashEl.classList.toggle("netUp", row.cashPosition > 0); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index a950af9..01b4b80 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -683,6 +683,14 @@ const HoldemState = { net: Money.round(t.won - t.in), boughtIn, chipsNow, + // Everything still theirs: the stack in front of them plus whatever + // is out in the middle for the hand in progress. This is the number + // a banker counts out when somebody leaves, and it was the one + // figure the standings never showed — the column called "Cash out" + // held the difference against their buy-in, so a player up $140 on + // a $340 stack read as "$140" at the exact moment cash changed + // hands. + chipsInFront: Money.round(chipsNow + inFrontOfThem), cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn), notes, }; diff --git a/tests/holdem/bankAudit.test.js b/tests/holdem/bankAudit.test.js index eb2f2b2..9223d5b 100644 --- a/tests/holdem/bankAudit.test.js +++ b/tests/holdem/bankAudit.test.js @@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => { assert.equal(HoldemState.bankReconciliation(state).balanced, true); }); }); + +describe("the number the banker counts out", () => { + // The standings had two signed columns and neither was an amount to pay. A player + // up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the + // exact moment somebody was counting money onto the table. + test("chips in front is what they are holding, not what they are up", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 }); + state.seats[0].name = "Ann"; + state.seats[0].buyIns = 200; + state.seats[1].buyIns = 200; + state.seats[1].stack = 60; + + const rows = HoldemState.sessionStandings(state); + const ann = rows.find(r => r.name === "Ann"); + assert.equal(ann.chipsInFront, 340, "the cash to count out"); + assert.equal(ann.cashPosition, 140, "and separately, that they are up $140"); + }); + + test("a player who is down still has chips to be handed", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 }); + state.seats[0].name = "Cal"; + state.seats.forEach(p => { p.buyIns = 200; }); + + const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal"); + assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips"); + assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought"); + }); + + test("chips still out in the middle are counted as theirs", () => { + // Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly + // and a stack that drops by the size of a bet looks like money going missing. + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 }); + state.seats[0].name = "Dee"; + state.seats.forEach(p => { p.buyIns = 200; }); + Betting.beginHand(state); + Betting.raiseTo(state, 0, 50); + + const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee"); + assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them"); + assert.equal(dee.cashPosition, 0, "so they are still level"); + }); + + test("chips and up/down always differ by exactly the buy-in", () => { + const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 }); + [340, 150, 60, 250].forEach((chips, i) => { + state.seats[i].stack = chips; + state.seats[i].buyIns = 200 + i * 50; + }); + + HoldemState.sessionStandings(state).forEach(row => { + assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition, + `${row.name}'s two columns should tell one story`); + }); + }); +}); From 09eac536b5203ff91051378818a9d44ba87e3d8e Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 23:51:41 -0600 Subject: [PATCH 35/55] Make the pot button offer a pot-sized raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raise pad worked its quick amounts out as "the current bet plus the pot as it stands". That leaves out the raiser's own call, which is in the pot before the raise is measured off it, so every amount came out short. Measured pre-flop at 1/2: offered: Min $4 ½ pot $4 Pot $5 correct: Min $4 ½ pot $4.50 Pot $7 A pot raise there is: call the $2, which makes the pot $5, then raise $5. And the half-pot was so short it clamped up to the minimum and sat next to the Min button showing the same $4 twice, which is how it went unnoticed — it looked like a table where those two happened to coincide. "Pot" is not a suggestion. It is a definite number players say out loud, and a dealer tapping the button that claims to be it should be recording what was actually said. The rule moves into Betting.potRaiseTo, next to the other rules and testable on its own, rather than living as arithmetic inside a DOM renderer: raise to = the current bet + (pot + what they owe) × fraction Facing no bet it comes out as a plain pot-sized bet, because both the bet and the amount owed are zero. Facing a bet it comes out as three times the bet plus the dead money, which is the shape a player checks it against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .../wwwroot/js/casino/holdem/actionBar.js | 11 +- .../wwwroot/js/casino/holdem/betting.js | 21 ++++ tests/holdem/potRaise.test.js | 113 ++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 tests/holdem/potRaise.test.js diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index caf7f93..79070b5 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -430,7 +430,6 @@ const ActionBar = { const player = this.state.seats[index]; const stack = Betting.stackOf(this.state, player); const maxTo = Betting.round(Betting.committed(player) + stack); - const pot = Betting.potSoFar(this.state); // Two different minimums, and conflating them was a bug. `minTo` is clamped to // what this player can actually put in, which is what the Min button should // offer. `legalMin` is what the rules require — for a short stack the two are @@ -446,10 +445,16 @@ const ActionBar = { const quick = document.createElement("div"); quick.className = "raiseQuick"; + // The pot amounts come from Betting, which knows that a pot raise is measured + // off the pot as it stands *after* the caller's own call goes in. The pad used + // to work it out here as "the bet plus the pot", which left the call out and + // made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot + // raise is $7, and a half-pot that clamped up to the minimum and sat next to + // the Min button showing the same $4 twice. const options = [ { label: "Min", value: minTo }, - { label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) }, - { label: "Pot", value: Betting.round(this.state.currentBet + pot) }, + { label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) }, + { label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) }, { label: "All-in", value: maxTo }, ]; options.forEach(option => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 6dfebba..fa4a5a3 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -518,6 +518,27 @@ const Betting = { : this.nextToAct(state, bbIndex); }, + // What a pot-sized raise actually is, as a "raise to" total. + // + // Not "the bet plus the pot as it stands". The raiser calls first, and their call + // is in the pot before the raise is measured off it, so the pot being matched is + // the one that exists after the call: + // + // raise to = the current bet + (pot + what they owe) × fraction + // + // Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then + // raise $5. Leaving the call out understates every one of these, and players say + // "pot" out loud meaning a definite number. + // + // Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is + // zero, so it comes out as the pot, which is the right answer there too. + potRaiseTo(state, index, fraction = 1) { + const player = state.seats[index]; + if (!player) return 0; + const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player)); + return this.round((state.currentBet || 0) + potAfterCall * fraction); + }, + // Who turns their hand over first. // // A call the dealer makes at every showdown that isn't a fold, and one nobody at diff --git a/tests/holdem/potRaise.test.js b/tests/holdem/potRaise.test.js new file mode 100644 index 0000000..8a3e262 --- /dev/null +++ b/tests/holdem/potRaise.test.js @@ -0,0 +1,113 @@ +// What a pot-sized raise actually is. +// +// The raise pad worked it out as "the current bet plus the pot as it stands", which +// leaves out the fact that the raiser calls first and their call is in the pot before +// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it +// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a +// definite number, and a dealer tapping the button that claims to be it should record +// what was actually said. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Betting, makeTable } = require("./load"); + +describe("pre-flop at 1/2", () => { + const dealt = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 }); + Betting.beginHand(state); + return state; + }; + + test("a pot raise from the first player to act is $7", () => { + // Call the $2, which makes the pot $5, then raise $5. + const state = dealt(); + assert.equal(Betting.potSoFar(state), 3, "the blinds"); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7); + }); + + test("half pot is $4.50, which is above the $4 minimum rather than equal to it", () => { + // The old arithmetic gave $3.50, which clamped up to the minimum and put the + // same $4 on two buttons side by side. + const state = dealt(); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5); + assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), + Betting.minRaiseTo(state), "the two quick buttons say different things"); + }); + + test("the small blind owes less, so their pot raise is smaller", () => { + // They have $1 in already: call $1, pot becomes $4, raise $4, total $6. + const state = dealt(); + const { sbIndex } = require("./load").HoldemState.getBlindSeats(state); + assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1); + assert.equal(Betting.potRaiseTo(state, sbIndex), 6); + }); + + test("the big blind owes nothing, so it is just the pot", () => { + const state = dealt(); + const { bbIndex } = require("./load").HoldemState.getBlindSeats(state); + assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0); + assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot"); + }); +}); + +describe("after the flop", () => { + const flopWithBet = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 }); + Betting.beginHand(state); + while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); + Betting.closeStreet(state); + return state; + }; + + test("a pot raise facing a bet is three times the bet plus the dead money", () => { + const state = flopWithBet(); + const potBefore = Betting.potSoFar(state); // $6 carried over + const bettor = state.actingSeatIndex; + Betting.raiseTo(state, bettor, 10); + + const raiser = state.actingSeatIndex; + // Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6. + assert.equal(Betting.potRaiseTo(state, raiser), 36); + assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore); + }); + + test("half pot facing that bet is $23", () => { + const state = flopWithBet(); + Betting.raiseTo(state, state.actingSeatIndex, 10); + // Call $10, pot becomes $26, raise $13 → $23. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23); + }); + + test("facing no bet at all it is a plain pot-sized bet", () => { + const state = flopWithBet(); + assert.equal(state.currentBet, 0, "nobody has bet the flop"); + assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state)); + }); +}); + +describe("it stays sane at the edges", () => { + test("a seat that doesn't exist gets zero rather than throwing", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + assert.equal(Betting.potRaiseTo(state, 99), 0); + }); + + test("the amount is always at least a call", () => { + // A raise-to below the current bet would be nonsense to offer. Every fraction + // from a tenth up lands at or above what the player owes. + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 }); + Betting.beginHand(state); + const index = state.actingSeatIndex; + [0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => { + assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet, + `${fraction} of the pot should still cover the bet`); + }); + }); + + test("cent-sized blinds don't drift", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 }); + Betting.beginHand(state); + // Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); + }); +}); From 40fa16920b0d820c7e54ef543b25d3c874016672 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 00:05:26 -0600 Subject: [PATCH 36/55] Offer bet sizes the table's chips can actually make MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Half the pot lands on a half chip constantly. Pre-flop at 1/2 the honest half-pot is $4.50, and the smallest chip in the default legend is a dollar — the tool's own chip breakdown calls it "4×$1, $0.50 no chip covers". The button offered it anyway, so the dealer rounded it themselves every single time. The pot-derived quick amounts now round up to something the table can build. Up, not down or nearest: these are bet sizes, a pot-sized bet that has been shaved is no longer a pot-sized bet, and rounding up cannot drop an amount below a legal minimum the way rounding the other way can. Only when the legend describes this game. A smallest chip larger than the big blind means the denominations were never set for these stakes, and rounding a 5c/10c table up to whole dollars would be the tool doing real damage on the strength of a field somebody forgot to fill in. Those tables keep exact amounts, and the wrong legend stays visible rather than being quietly applied. A table with no legend at all is told nothing about chips and so has nothing applied either. Verified across five tables: default dollar chips at 1/2 give $4/$5/$7, cent chips at 5c/10c keep $0.20/$0.23/$0.35, five-dollar chips at 5/10 give $20/$25/$35, and both the stale-legend and no-legend tables are left exact. The minimum raise is deliberately not rounded. It is what the rules require, and moving it to suit the chip tray would change what is legal rather than what is convenient. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .../wwwroot/js/casino/holdem/betting.js | 17 ++++- .../wwwroot/js/casino/holdem/money.js | 23 ++++++ tests/holdem/potRaise.test.js | 75 ++++++++++++++++++- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index fa4a5a3..baa240e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -532,11 +532,26 @@ const Betting = { // // Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is // zero, so it comes out as the pot, which is the right answer there too. + // Rounded up to something the table's chips can actually build. Half the pot is + // constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest + // chip in the default legend is a dollar, so the button offered a number the + // dealer would have to round themselves every single time. + // + // Only when the legend describes this game. A smallest chip *larger* than the big + // blind means the denominations were never set for these stakes, and rounding a + // 5c/10c table up to whole dollars would be the tool doing real damage on the + // strength of a field somebody forgot to fill in. Left exact there, and the wrong + // legend stays visible rather than being quietly applied. potRaiseTo(state, index, fraction = 1) { const player = state.seats[index]; if (!player) return 0; const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player)); - return this.round((state.currentBet || 0) + potAfterCall * fraction); + const exact = this.round((state.currentBet || 0) + potAfterCall * fraction); + + const step = Money.fromCents(Money.chipStepCents(state.chipLegend)); + const bigBlind = Number(state.bigBlind) || 0; + if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact; + return Money.upToChip(exact, state.chipLegend); }, // Who turns their hand over first. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index 9f120b2..f0fe135 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -63,6 +63,29 @@ const Money = { // Anything the smallest chip can't make comes back as `remainder` rather than being // rounded away: a 50c share in a game whose smallest chip is a dollar is a real // problem for the dealer to solve, not a rounding error to hide. + // The smallest chip the table has said it plays with, in cents. Zero when no + // denominations are set, which means the tool has been told nothing about what + // chips exist and shouldn't pretend otherwise. + chipStepCents(legend) { + const values = (legend || []) + .map(chip => this.toCents(chip.value)) + .filter(cents => cents > 0); + return values.length > 0 ? Math.min(...values) : 0; + }, + + // Rounds an amount up to something the table can actually build out of its chips. + // + // Up rather than down or nearest: these are bet sizes, and a pot-sized bet that + // has been shaved is no longer a pot-sized bet. Rounding up also can't drop an + // amount below a legal minimum, which rounding the other way can. + upToChip(amount, legend) { + const step = this.chipStepCents(legend); + if (step <= 0) return this.round(amount); + const cents = this.toCents(amount); + if (cents <= 0) return this.round(amount); + return this.fromCents(Math.ceil(cents / step) * step); + }, + chipBreakdown(amount, legend) { let cents = this.toCents(amount); if (cents <= 0) return { chips: [], remainder: 0 }; diff --git a/tests/holdem/potRaise.test.js b/tests/holdem/potRaise.test.js index 8a3e262..d72a995 100644 --- a/tests/holdem/potRaise.test.js +++ b/tests/holdem/potRaise.test.js @@ -9,7 +9,7 @@ const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); -const { Betting, makeTable } = require("./load"); +const { Betting, Money, makeTable } = require("./load"); describe("pre-flop at 1/2", () => { const dealt = () => { @@ -25,11 +25,13 @@ describe("pre-flop at 1/2", () => { assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7); }); - test("half pot is $4.50, which is above the $4 minimum rather than equal to it", () => { + test("half pot is $5 — $4.50 rounded up to something the chips can make", () => { // The old arithmetic gave $3.50, which clamped up to the minimum and put the - // same $4 on two buttons side by side. + // same $4 on two buttons side by side. The honest half-pot is $4.50, and the + // smallest chip in the default legend is a dollar, so the button would have + // offered an amount the dealer had to round themselves every single time. const state = dealt(); - assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5); assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), Betting.minRaiseTo(state), "the two quick buttons say different things"); }); @@ -111,3 +113,68 @@ describe("it stays sane at the edges", () => { assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); }); }); + +describe("the amounts are ones the table can actually build", () => { + const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }]; + const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }]; + + const table = (legend, smallBlind, bigBlind, stack = 200) => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind }); + state.chipLegend = legend; + Betting.beginHand(state); + return state; + }; + + test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => { + const state = table(DOLLARS, 1, 2); + // Honestly $4.50. No dollar chip makes fifty cents. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5); + assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0); + }); + + test("up rather than down — a shaved pot bet is not a pot bet", () => { + const state = table(DOLLARS, 1, 2); + assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5); + }); + + test("an amount the chips already make is left exactly alone", () => { + const state = table(DOLLARS, 1, 2); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole"); + }); + + test("a cent table keeps its cents", () => { + const state = table(CENTS, 0.05, 0.1); + // Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make. + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); + assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0); + }); + + test("a legend that plainly isn't for these stakes is not applied", () => { + // Dollar chips on a 5c/10c game means somebody never filled the legend in. + // Rounding those blinds up to whole dollars would be the tool doing real + // damage on the strength of a stale field, so the amounts stay exact and the + // wrong legend stays visible. + const state = table(DOLLARS, 0.05, 0.1); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35); + }); + + test("a table with no legend at all gets exact amounts", () => { + const state = table([], 1, 2); + assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5); + }); + + test("every fraction comes out buildable, and never shaved", () => { + const state = table(DOLLARS, 1, 2); + const index = state.actingSeatIndex; + const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]); + + [0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => { + const value = Betting.potRaiseTo(state, index, fraction); + const exact = state.currentBet + potAfterCall * fraction; + assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`); + assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`); + assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0, + `${fraction} of the pot should be buildable from the table's chips`); + }); + }); +}); From 3be93cdadfc797ed89dd483c43d68ae44a32b120 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 00:24:07 -0600 Subject: [PATCH 37/55] Chop a side pot on the screen that worked it out, and fix Quick Split's contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick Split lists the pots when somebody is all-in — main, sides, uncalled — and then leaves the dealer there. Two players tying for a side pot is ordinary, and working out each one's share meant switching to the other mode and retyping an amount the screen had just calculated. Retyping a number with money on it is a transcription error waiting to happen, and the number was already on screen. Each pot with two or more people who can win it now carries a Chop, opening underneath its own row: as many ways as there are contenders by default, a stepper for a chop between fewer of them, and the same odd-chip and dust wording the main split uses, because it is the same rule and the dealer shouldn't have to notice they are reading a different screen. Per-row state, so opening the main pot's chop doesn't disturb what the side pot below it says. Not offered on the uncalled row, which goes back to one player, nor on the total. Separately, this page had never been through the layout audit the dealer table gets every round, and it was carrying nine contrast failures: .unevenCurrency 4.11:1 the $ beside every amount typed .unevenRemoveBtn 3.66:1 the ✕ that deletes a player's entry .splitFootnote 3.94:1 the instruction for what to do with the pot list All under the 4.5:1 small-text minimum, the worst of them on a destructive control. Lifted to pass. The large $ on the even-mode pot input is left alone: at 1.6rem it is large text, where 3:1 is the bar and 4.11:1 clears it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .../wwwroot/css/casino/quicksplit.css | 87 +++++++++++++++++- .../wwwroot/js/casino/holdem/quickSplit.js | 88 ++++++++++++++++++- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css index 582aa85..257c887 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css +++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css @@ -143,7 +143,9 @@ button, } .unevenCurrency { - color: #777; + /* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits + against every amount a dealer types, so it is read constantly. */ + color: #8f8f8f; } .unevenAmount { @@ -184,7 +186,9 @@ button, height: 44px; border: none; background: none; - color: #6a6a6a; + /* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a + player's entry. A destructive action should not be the faintest thing in view. */ + color: #9a9a9a; font-size: 0.85rem; cursor: pointer; } @@ -209,11 +213,86 @@ button, .unevenPot { display: flex; align-items: baseline; + /* Wraps so a chop opens underneath its own pot rather than squeezing in beside the + eligible-players list and pushing it off the row. */ + flex-wrap: wrap; gap: 0.6rem; padding: 0.3rem 0; font-size: 0.85rem; } +.unevenChopToggle { + flex: none; + order: 3; + /* 44px on touch: it moves money, like everything else that does. */ + min-height: 32px; + padding: 0.2rem 0.6rem; + border: 1px solid rgba(216, 180, 106, 0.45); + border-radius: 999px; + background: rgba(216, 180, 106, 0.1); + color: #e3c887; + font: inherit; + font-size: 0.74rem; + cursor: pointer; +} + +.unevenChopToggle:hover { + background: rgba(216, 180, 106, 0.18); +} + +.unevenChopToggle:focus-visible { + outline: 2px solid #d8b46a; + outline-offset: 2px; +} + +.unevenChopDetail { + order: 4; + flex: 0 0 100%; + /* Or the padding is added on top of the 100% basis and the panel sits wider than + the row it belongs to — measured 328px inside a 309px row on a phone. */ + box-sizing: border-box; + margin: 0.15rem 0 0.2rem; + padding: 0.45rem 0.6rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.unevenChopWays { + display: flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.3rem; +} + +.unevenChopWaysLabel { + color: #cfcfcf; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.unevenChopEach { + margin: 0; + color: #f2f2f2; + font-weight: 600; +} + +.unevenChopNote { + margin: 0.2rem 0 0; + color: #9a9a9a; + font-size: 0.76rem; +} + +.unevenChopDust { + color: #e0b169; +} + +@media (max-width: 700px), (pointer: coarse) { + .unevenChopToggle { + min-height: 44px; + padding: 0.4rem 0.9rem; + } +} + .unevenPot + .unevenPot { border-top: 1px solid rgba(255, 255, 255, 0.06); } @@ -510,7 +589,9 @@ button, .splitFootnote { margin-top: 1.5rem; - color: #6f6f6f; + /* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot + list, which is not a decorative line. */ + color: #8d8d8d; font-size: 0.78rem; line-height: 1.5; text-align: center; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js index 3ffbcef..73a4c03 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/quickSplit.js @@ -197,6 +197,80 @@ const QuickSplit = { }); }, + // Chopping one of the pots listed above, without leaving the screen that worked it + // out. Starts closed and at as many ways as there are players who can win the pot, + // which is the common case; the stepper covers a chop between fewer of them, which + // is the other one. + // + // The per-pot ways live on the row rather than in `this.ways`, so opening a chop on + // the main pot doesn't change what the side pot below it is saying. + addChopControl(row, amount, contenderCount) { + let ways = contenderCount; + let open = false; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "unevenChopToggle"; + + const detail = document.createElement("div"); + detail.className = "unevenChopDetail"; + detail.hidden = true; + + const draw = () => { + toggle.textContent = open ? "Hide chop" : "Chop"; + toggle.setAttribute("aria-expanded", String(open)); + detail.hidden = !open; + if (!open) return; + + const result = SplitCalculator.split(amount, ways, this.chip); + detail.innerHTML = ""; + + const stepper = document.createElement("div"); + stepper.className = "unevenChopWays"; + stepper.appendChild(this.makeStepButton("−", () => { + if (ways > 2) { ways -= 1; draw(); } + })); + const label = document.createElement("span"); + label.className = "unevenChopWaysLabel"; + label.textContent = `${ways} ways`; + stepper.appendChild(label); + stepper.appendChild(this.makeStepButton("+", () => { + if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); } + })); + detail.appendChild(stepper); + + const each = document.createElement("p"); + each.className = "unevenChopEach"; + each.textContent = `${Money.format(result.each)} each`; + detail.appendChild(each); + + // Same wording as the main split, because it is the same rule and a dealer + // shouldn't have to notice they're reading a different screen. + if (result.valid && result.oddChips > 0) { + const odd = document.createElement("p"); + odd.className = "unevenChopNote"; + odd.textContent = result.oddChips === 1 + ? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button` + : `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` + + `first ${result.oddChips} players left of the button`; + detail.appendChild(odd); + } + if (result.valid && result.dust > 0) { + const dust = document.createElement("p"); + dust.className = "unevenChopNote unevenChopDust"; + dust.textContent = + `${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` + + `pick a smaller denomination.`; + detail.appendChild(dust); + } + }; + + toggle.addEventListener("click", () => { open = !open; draw(); }); + draw(); + row.appendChild(toggle); + row.appendChild(detail); + }, + updateUneven() { const players = this.entries .map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded })) @@ -218,7 +292,7 @@ const QuickSplit = { } empty.hidden = true; - const addRow = (label, amount, who, className) => { + const addRow = (label, amount, who, className, chopWays) => { const row = document.createElement("div"); row.className = `unevenPot ${className || ""}`; const name = document.createElement("span"); @@ -234,9 +308,19 @@ const QuickSplit = { row.appendChild(value); row.appendChild(eligible); host.appendChild(row); + + // Two players tying for a side pot is ordinary, and working out each one's + // share meant copying the amount into the other mode by hand — retyping a + // number the screen had just worked out, which is a transcription error + // waiting to happen with money on it. The chop happens here instead. + // + // Only offered where it means something: a pot with at least two people + // who can win it. Not the uncalled row, which goes back to one player, and + // not the total. + if (chopWays >= 2) this.addChopControl(row, amount, chopWays); }; - pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "))); + pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length)); returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn")); const total = players.reduce((sum, p) => sum + p.contribution, 0); From b64da7a7871bca099bb76d86ff1c32c3f05c7a2d Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 00:37:47 -0600 Subject: [PATCH 38/55] Make the layout audit usable inside modals, and stop it passing what it can't measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round turned up that Quick Split had never been audited. Chasing that, the reason the dealer table's modals had never been audited either turns out to be the harness, not neglect: it reported the pot wizard covering the seats as ten faults and the rulings sheet as seven. A modal covering the page is what a modal is for, and an instrument that calls that a fault is one nobody can use in those states. It now finds whatever is open on top and scopes the reachability checks to it — the overlays, plus the phone seat sheet, which is a body class rather than an element. The seat-ownership check is skipped outright while a sheet is open: seats behind it aren't fighting each other for taps, nothing is meant to reach them at all. Two worse faults came out of proving the fix rather than assuming it. Injecting a 20x14 button into the open wizard, the contrast check caught it and the touch-target check did not: * effectiveTarget returns null when elementFromPoint can't find the control, and the check treated that as a pass. Silent skips are how faults hide — the same lesson as the aria-hidden and faded-text exemptions before this. Unmeasurable controls are now reported as warnings. * The probe failed on anything scrolled out of its container. That was hiding a real control: "Confirm Winner(s)", the button that pays the pot out, had gone unmeasured for thirty-six rounds. It is scrolled into view before probing, and passes. A checkbox inside a label is now measured by its label, since tapping the label is what activates it. Without that, the dedicated-dealer toggle read as an unprobeable 24px target when it has a 335x44 label around it. The tool itself came out clean: no failures and no warnings across the table, the pot wizard, the rulings sheet and the seat editor, on a real touch context at 44px. The value here is that those states can be checked at all from now on — verified by injection, which the fixed check catches and the old one did not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- tests/holdem/uiAudit.js | 86 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 0c1bca8..7056cd6 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -93,10 +93,44 @@ const LayoutAudit = { return false; }, + // The modal that is currently on top, if any. + // + // Every check below asks "is this thing reachable", and while a sheet or dialog is + // open the answer for everything behind it is legitimately no — that is what a + // modal is for. Without knowing that, the audit reported the pot wizard covering + // the seats as ten faults and the rulings sheet as seven, which is the noise that + // made it useless in exactly the states nobody had audited. Now it scopes to what + // is actually on top and says so. + MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " + + "#confirmOverlay, #addChipsOverlay, #seatEditorOverlay", + + openModal() { + const open = [...document.querySelectorAll(this.MODAL_SELECTOR)] + .filter(el => !el.hidden && getComputedStyle(el).display !== "none"); + // Last in the DOM wins, which is how they stack. + return open.length > 0 ? open[open.length - 1] : null; + }, + + // A seat sheet on a phone isn't an overlay element, it's a body class — but it + // covers the felt just the same. + modalScope() { + const overlay = this.openModal(); + if (overlay) return overlay; + if (document.body.classList.contains("seatSheetOpen")) { + return document.getElementById("seatEditorPanel") || null; + } + return null; + }, + occlusion(selector = "#actionBar button, #holdemHeader button, .seat") { const offenders = []; const warnings = []; - [...document.querySelectorAll(selector)].forEach(el => { + // While something is open on top, only what is inside it can be judged. The + // rest of the page is meant to be unreachable. + const scope = this.modalScope(); + const candidates = [...document.querySelectorAll(selector)] + .filter(el => !scope || scope.contains(el)); + candidates.forEach(el => { const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; @@ -504,10 +538,25 @@ const LayoutAudit = { if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) { return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" }; } + const unmeasured = []; this.visibleControls() .filter(el => el.type !== "color") .filter(el => !(exempt && el.matches(exempt))) .forEach(el => { + // 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 + // check used to pass it in silence — which is how "Confirm Winner(s)", + // the button that pays the pot out, went unmeasured for 36 rounds. + // Bring it into view before probing. + const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel"); + if (scroller && scroller.scrollHeight > scroller.clientHeight) { + const box = scroller.getBoundingClientRect(); + const own = el.getBoundingClientRect(); + if (own.bottom > box.bottom || own.top < box.top) { + el.scrollIntoView({ block: "center" }); + } + } const rect = el.getBoundingClientRect(); // Drawn big enough is big enough — no need to probe, and probing wide // elements is where the cap above used to produce nonsense. Seats sit @@ -515,15 +564,35 @@ const LayoutAudit = { // measuring their reach would report the crowding as a fault. if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return; + // A checkbox inside a label is tapped by tapping the label, so the + // label is the target. Measured rather than probed, because probing + // fails on anything scrolled off the viewport — which is how a 24px + // checkbox with a 335x44 label around it came up as unprobeable + // instead of as the perfectly good target it is. + const label = el.closest("label"); + if (label) { + const lr = label.getBoundingClientRect(); + if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return; + } + const size = this.effectiveTarget(el); - if (!size) return; + // Still can't measure it. That is not a pass — it is the check failing + // to run, and quietly returning here is exactly the kind of exemption + // that hides real faults. Say so instead. + if (!size) { + unmeasured.push( + `${this.describe(el)} :: could not be probed` + + ` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})` + ); + return; + } if (size.width >= minimum - 1 && size.height >= minimum - 1) return; offenders.push( `${this.describe(el)} :: hittable ${size.width}x${size.height}` + ` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})` ); }); - return { name: `touch targets at least ${minimum}px`, offenders }; + return { name: `touch targets at least ${minimum}px`, offenders, unmeasured }; }, // Tapping the middle of a seat must give you that seat. @@ -537,6 +606,13 @@ const LayoutAudit = { // Reported separately from occlusion because the cause is different: not an // overlay on top of the table, but two pieces of the table fighting over a tap. seatOwnership() { + // Seats behind an open sheet aren't fighting each other for taps — nothing is + // meant to reach them at all. Asking the question there produced six offenders + // per modal, every one of them the modal doing its job. + if (this.modalScope()) { + return { name: "a seat's centre belongs to that seat", offenders: [], + skipped: "a sheet is open over the table" }; + } const seats = [...document.querySelectorAll(".seat")]; const offenders = []; @@ -617,7 +693,9 @@ const LayoutAudit = { viewport: `${innerWidth}x${innerHeight}`, failed: checks.filter(c => c.offenders.length > 0), // Things worth knowing that aren't faults — see occlusion() on toasts. - warnings: checks.flatMap(c => c.warnings || []), + // 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), clean: checks.every(c => c.offenders.length === 0), }; From 8a2fc3e540c9b1090c3ca7e078035f1b5497f251 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 00:55:56 -0600 Subject: [PATCH 39/55] Give undo and the card picker's buttons a fingertip to land on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first thing the repaired audit found, run on a real touch context at 44px: three controls a dealer taps on a phone that are too small to tap. .actionUndo 145x30 the "I recorded that wrong" pill #cardPickerClearBtn 50x40 wipes a player's cards #cardPickerCloseBtn 50x40 closes the picker Undo is the worst of them. It is the control a dealer reaches for having just entered the wrong action with five people waiting, and it was fourteen pixels short of a fingertip. The picker's two are short because the touch rule that fixes every other small button is scoped to .panelBody, and the picker is a sheet — it never reached them. One of them is destructive. All three now sit at 44px on touch. Nothing changes on a pointer device. I tried an invisible hit-area pad on undo first, to avoid spending bar height, and the measurement said it did nothing: the pill clips its overflow to keep a long label on one line, and giving it a positioning context made it clip the pad too. Growing the box is the honest fix, and it turned out to cost nothing anyway — the bar goes from 138px to 152px and the felt stays at 366px, because the felt had slack against its 220px floor rather than sitting on it as I had assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index fe8e893..6fc5ec8 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3138,7 +3138,18 @@ body.hasActionBar #toastHost { } .panelBody .holdemBtnSmall, - .handHistoryActions .holdemBtn { + .handHistoryActions .holdemBtn, + /* The card picker's Clear and Done sat at 40px: the touch rule was scoped to + .panelBody and the picker is a sheet, so it never reached them. Clear wipes a + player's cards, which is not a thing to make a dealer aim at. */ + .cardPickerHeader .holdemBtn, + /* Undo was 30px, and it is the control a dealer reaches for having just recorded + the wrong thing with five people waiting — the worst place to be fourteen pixels + short of a fingertip. Growing the box costs bar height, which comes out of the + felt, but measured at 390x844 the felt is 366px against a 220px floor, so there + is room for it. A hit-area pad was tried first and does not work here: the pill + clips its overflow to keep a long label on one line, which clips the pad too. */ + .actionUndo { min-height: 44px; } @@ -3525,7 +3536,9 @@ textarea:focus-visible { .panelBody .holdemBtnSmall, .handHistoryActions .holdemBtn, - .toastAction { + .toastAction, + .cardPickerHeader .holdemBtn, + .actionUndo { min-height: 44px; } From 419852a1f4ab2c9e53b8a32fb7a34c00152325fa Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 01:07:13 -0600 Subject: [PATCH 40/55] Let a dealer take the table off the device it's stuck on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The night lives in one browser's localStorage and nowhere else. A dealer whose phone dies, or who wants to carry on from the tablet after the break, loses the stacks, the buy-ins, where the button is and the whole hand log. The CSV export can't bring any of it back: it records what happened, not where the table stands. The table can now be written to a file and read back. Round-tripped through the real UI, a hand in progress comes back mid-hand — blinds posted, the same seat on the clock, the button where it was. Loading replaces everything, so it asks first and names both sides rather than saying "are you sure": how many players are in the file and when it was saved, against how many players and how many logged hands are about to go. It goes through the same undoable path as the header buttons, so the toast offers the way back, and the confirm says so instead of overstating it. Reading a file goes through exactly the same migration a save from storage does — load() had that logic inline and it is now a function both call. A second, looser path for imports is how a format grows two meanings of the same field. What it refuses, each with something a dealer can act on and never a half-loaded table: anything that isn't JSON, JSON with no seats in it, a save from some other tool that happens to carry a `kind`, and a file from a newer version — that last one because loading it risks reading a field whose meaning has changed, and the dealer would find out from the money. What it accepts anyway: a bare table with no envelope, for somebody who copied the storage value out by hand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 11 ++ .../wwwroot/js/casino/holdem/main.js | 66 ++++++++ .../wwwroot/js/casino/holdem/state.js | 92 ++++++++++- tests/holdem/tableFile.test.js | 155 ++++++++++++++++++ 4 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 tests/holdem/tableFile.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 64c9b18..a984cab 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -155,6 +155,17 @@ + +

The table itself

+

+ The night lives in this browser and nowhere else. Save it to a file to carry it + to another device, or to have something to come back to if this one dies. +

+
+ + + +
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index a87c244..6439cf9 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -598,6 +598,72 @@ document.addEventListener("DOMContentLoaded", () => { Toast.show("Hand history cleared.", { duration: 2500 }); }); + document.getElementById("saveTableBtn").addEventListener("click", () => { + const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = HoldemState.backupFileName(); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + Toast.show(`Table saved as ${link.download}.`, { duration: 3500 }); + }); + + document.getElementById("loadTableBtn").addEventListener("click", () => { + document.getElementById("loadTableInput").click(); + }); + + document.getElementById("loadTableInput").addEventListener("change", async e => { + const file = e.target.files && e.target.files[0]; + // Cleared straight away so picking the same file twice still fires a change. + e.target.value = ""; + if (!file) return; + + let text; + try { + text = await file.text(); + } catch { + Toast.show("That file couldn't be read.", { duration: 4000 }); + return; + } + + const result = HoldemState.fromBackup(text); + if (result.error) { + Toast.show(result.error, { duration: 5000 }); + return; + } + + // Loading replaces everything: stacks, buy-ins, the button, the log. Naming + // what is about to be lost is the difference between a dealer choosing that + // and discovering it — and the current table might be the live one. + const here = HoldemState.sessionStandings(state).length; + const incoming = HoldemState.sessionStandings(result.table).length; + const when = result.savedAt ? new Date(result.savedAt) : null; + const saidWhen = when && !Number.isNaN(when.getTime()) + ? ` It was saved ${when.toLocaleString()}.` + : ""; + const ok = await Dialog.confirm({ + title: "Replace this table?", + message: + `The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` + + `Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` + + `${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` + + `Undo will bring it back for a few seconds; after that it is gone.`, + confirmLabel: "Load it", + danger: true, + }); + if (!ok) return; + + runUndoable("loading a saved table", () => { + // Replace the contents rather than the reference: everything on the page + // holds a pointer to this object. + Object.keys(state).forEach(key => { delete state[key]; }); + Object.assign(state, result.table); + }); + }); + // The filter is how the dealer looks at the log, not something about the table, so // it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other // tab. Clearing the history clears it too, since there is nothing left to filter. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 01b4b80..fdc7e4a 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -164,13 +164,18 @@ const HoldemState = { return this.createDefault(); }, - load() { - let raw = null; - try { - raw = localStorage.getItem(this.STORAGE_KEY); - if (!raw) return this.createDefault(); - const parsed = JSON.parse(raw); - if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw); + // Everything a table has to be put through before it can be trusted, whichever + // direction it arrived from. + // + // Pulled out of load() so a table read from a file goes through exactly the same + // checks as one read from storage. A second, looser path for imports is how a + // format grows two meanings of the same field. + // + // Returns null for anything that isn't a table at all; every other repair is + // silent and forward, the way a save from an older version is treated. + migrate(parsed) { + if (!parsed || !Array.isArray(parsed.seats)) return null; + { if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1; if (!Array.isArray(parsed.handHistory)) parsed.handHistory = []; if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0; @@ -215,12 +220,85 @@ const HoldemState = { }); return parsed; + } + }, + + load() { + let raw = null; + try { + raw = localStorage.getItem(this.STORAGE_KEY); + if (!raw) return this.createDefault(); + const migrated = this.migrate(JSON.parse(raw)); + if (!migrated) return this.failedLoad(raw); + return migrated; } catch { // Malformed JSON — a truncated write, another tab, a browser hiccup. return this.failedLoad(raw); } }, + // ---- Taking the table off this device ------------------------------------ + // + // A night lives in one browser's localStorage and nowhere else. A dealer whose + // phone dies, or who wants to carry on from the tablet after the break, loses the + // stacks, the buy-ins, where the button is and the whole hand log — and the CSV + // export can't bring any of that back, because it records what happened rather + // than where the table stands. + // + // So the table can be written out as a file and read back in. Same shape that goes + // into storage, wrapped in enough to recognise it later. + BACKUP_KIND: "casino.holdem.table", + BACKUP_VERSION: 1, + + toBackup(state) { + return JSON.stringify({ + kind: this.BACKUP_KIND, + version: this.BACKUP_VERSION, + savedAt: new Date().toISOString(), + table: state, + }, null, 2); + }, + + // Reads a file back. Returns { table } on success, or { error } with something a + // dealer can act on — never a half-loaded table, because the failure mode there is + // a night that looks restored and isn't. + fromBackup(text) { + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return { error: "That file isn't a saved table — it couldn't be read as JSON." }; + } + if (!parsed || typeof parsed !== "object") { + return { error: "That file isn't a saved table." }; + } + // A bare table, from someone who copied the storage value out by hand, is + // still a table. The envelope is a convenience, not a password. + const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed; + if (parsed.kind && parsed.kind !== this.BACKUP_KIND) { + return { error: "That file is a save from something else, not a Hold'em table." }; + } + const migrated = this.migrate(table); + if (!migrated) { + return { error: "That file doesn't have a table in it — no seats were found." }; + } + if (parsed.version > this.BACKUP_VERSION) { + // Newer than this build understands. Loading it anyway risks reading a + // field that has changed meaning, and the dealer would find out from the + // money, so say so instead. + return { error: "That table was saved by a newer version of this tool. Update, then load it." }; + } + return { table: migrated, savedAt: parsed.savedAt || null }; + }, + + // What the file is called. Dated, because a dealer who saves twice in a night + // should not have to guess which is which. + backupFileName(now = new Date()) { + const pad = n => String(n).padStart(2, "0"); + return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `-${pad(now.getHours())}${pad(now.getMinutes())}.json`; + }, + // True once a save has failed. The UI reads this to keep saying so, because a // toast is a moment and this is a fact about the rest of the night. saveFailed: false, diff --git a/tests/holdem/tableFile.test.js b/tests/holdem/tableFile.test.js new file mode 100644 index 0000000..f01fbec --- /dev/null +++ b/tests/holdem/tableFile.test.js @@ -0,0 +1,155 @@ +// Taking the table off this device. +// +// A night lives in one browser's localStorage and nowhere else. A dealer whose phone +// dies, or who wants to carry on from the tablet after the break, loses the stacks, +// the buy-ins, where the button is and the whole hand log — and the CSV export can't +// bring any of that back, because it records what happened rather than where the +// table stands. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { HoldemState, Betting, makeTable } = require("./load"); + +const aNight = () => { + const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 }); + ["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => { + state.seats[i].name = name; + state.seats[i].buyIns = 200; + }); + state.seats[2].stack = 340; + state.notes = "Cal owes the bar $12"; + Betting.beginHand(state); + HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]); + return state; +}; + +describe("a table written out and read back", () => { + test("comes back the same table", () => { + const before = aNight(); + const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; + + assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name)); + assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack)); + assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns)); + assert.equal(after.dealerSeatIndex, before.dealerSeatIndex); + assert.equal(after.handNumber, before.handNumber); + assert.equal(after.notes, before.notes); + assert.equal(after.handHistory.length, before.handHistory.length); + }); + + test("keeps the things a CSV can't carry", () => { + // The whole reason this exists: an export records what happened, not where the + // table stands. + const before = aNight(); + const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; + + assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress"); + assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right"); + assert.equal(after.street, before.street); + assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed)); + assert.deepEqual(after.chipLegend, before.chipLegend); + }); + + test("the standings and the bank check agree across the trip", () => { + const before = aNight(); + const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; + + assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before)); + assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before)); + }); + + test("it can be dealt from straight away", () => { + const before = aNight(); + HoldemState.startNextHand(before); + const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table; + + Betting.beginHand(after); + assert.equal(after.bettingOpen, true); + assert.ok(after.actingSeatIndex !== null, "somebody is on the clock"); + }); +}); + +describe("what it refuses", () => { + const refuses = (text, because) => { + const result = HoldemState.fromBackup(text); + assert.ok(result.error, because); + assert.equal(result.table, undefined, "and never hands back half a table"); + }; + + test("something that isn't JSON", () => { + refuses("not a file", "unreadable"); + refuses("", "empty"); + }); + + test("JSON that isn't a table", () => { + refuses('{"hello":"world"}', "no seats in it"); + refuses("[1,2,3]", "an array of nothing"); + refuses("null", "null"); + }); + + test("a save from something else that happens to be JSON", () => { + refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind"); + }); + + test("a file from a newer version, rather than guessing at it", () => { + // Loading it risks reading a field whose meaning has changed, and the dealer + // would find out from the money. + const backup = JSON.parse(HoldemState.toBackup(aNight())); + backup.version = HoldemState.BACKUP_VERSION + 1; + const result = HoldemState.fromBackup(JSON.stringify(backup)); + assert.match(result.error, /newer version/i); + }); +}); + +describe("what it accepts anyway", () => { + test("a bare table, with no envelope around it", () => { + // Somebody who copied the storage value out by hand. The envelope is a + // convenience, not a password. + const state = aNight(); + const result = HoldemState.fromBackup(JSON.stringify(state)); + assert.ok(!result.error); + assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name)); + }); + + test("a table from before fields that exist now, migrated on the way in", () => { + // Same repairs a save from an older version gets, because it is the same + // function — a second, looser path for imports is how a format grows two + // meanings of the same field. + const state = aNight(); + const raw = JSON.parse(JSON.stringify(state)); + delete raw.deadButton; + delete raw.postedBlinds; + delete raw.dealtLastHand; + delete raw.discarded; + raw.seats.forEach(p => { delete p.buyIns; }); + + const { table, error } = HoldemState.fromBackup(JSON.stringify({ + kind: HoldemState.BACKUP_KIND, version: 1, table: raw, + })); + assert.ok(!error); + assert.equal(table.deadButton, false); + assert.deepEqual(table.dealtLastHand, []); + assert.equal(table.discarded, 0); + assert.equal(table.seats[0].buyIns, 0); + }); + + test("the time it was saved comes back with it", () => { + const result = HoldemState.fromBackup(HoldemState.toBackup(aNight())); + assert.ok(result.savedAt, "so the dealer can tell two files apart"); + assert.ok(!Number.isNaN(new Date(result.savedAt).getTime())); + }); +}); + +describe("the file itself", () => { + test("is named with the date and time, so two saves in a night are told apart", () => { + const at = new Date(2026, 8, 2, 21, 5); + assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json"); + }); + + test("says what it is, for anyone finding it in a downloads folder", () => { + const parsed = JSON.parse(HoldemState.toBackup(aNight())); + assert.equal(parsed.kind, "casino.holdem.table"); + assert.equal(parsed.version, 1); + assert.ok(parsed.table.seats.length > 0); + }); +}); From b97453fba1eddf4cf8bba954e62e665f18603142 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 03:13:00 -0600 Subject: [PATCH 41/55] Seat the table properly on a phone held sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landscape is the one shape this layout has never really fitted, and the audit was calling two separate faults there. The felt worked its own height out as `100vh - bar - 7rem`, guessing that everything above it came to 7rem. That is 108px at 640x360, which the guess covers, and 119px at 844x390, which it doesn't — so on an iPhone on its side the bottom row of seats sat up to 26px inside the action bar. The measurement it needed was already being published for the wide layout: the bar's real height, the wrap's real offset and the bottom seat's overhang, all taken off the window. It uses that now. The floor under it drops from 220px to 96px, because 220 was not a size a 390px-tall window has to give — holding out for it was itself how seats ended up behind the Fold button, which is the one thing the floor exists to prevent. That still left the header taking two rows, and the second row is 44px of the 390. The title block and the five buttons came to 829px inside a 765px header, missing one line by 64px. The page title goes — the one thing on this screen a dealer already knows — hidden from the eye rather than removed, so the heading is still there for a screen reader. The buttons tighten across but never down: they stay a 44px thumb target. The seats themselves were spaced by angle, which is only even spacing on a circle. On a 601x126 felt, ten seats at 36° apart put four of them down each steep end, 20px apart, with pucks 72px tall — a seat's centre belonged to its neighbour, so tapping a player got the wrong one. They now walk the rim by distance instead, which puts them along the two long rails the way a real table seats people. Closest neighbours go from 33px apart to 59px. Measured at 640x360, 667x375 and 844x390 with 9-, 10- and 12-seat tables: every combination the audit used to fail is clean, bar a 10-seat table at 640x360, where two seats at the end of a rim that flat still overlap. Portrait, tablet and desktop are unchanged — the floor never bound there, and on a rounder felt walking the rim gives back the same angles it always did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 60 ++++++- .../wwwroot/js/casino/holdem/actionBar.js | 23 ++- .../wwwroot/js/casino/holdem/render.js | 92 +++++++++-- tests/holdem/feltRoom.test.js | 73 +++++++++ tests/holdem/seatRing.test.js | 147 ++++++++++++++++++ 5 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 tests/holdem/feltRoom.test.js create mode 100644 tests/holdem/seatRing.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 6fc5ec8..c752e8f 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3247,11 +3247,18 @@ body.hasActionBar #toastHost { why a seat's centre belonged to its neighbour. Landscape has width and no height, so the table takes the width it has and - is only as tall as the room left over. */ - --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem); + is only as tall as the room left over. + + The height comes from --feltRoomPx, the same measured value the wide layout + caps its width by: the bar's real height, the wrap's real offset and the + bottom seat's overhang, all taken off the window. This branch used to work + it out itself as `100vh - bar - 7rem`, guessing that everything above the + felt came to 7rem. It comes to 108px at 640x360, which the guess covers, and + 119px at 844x390, which it doesn't — so on an iPhone on its side the bottom + three seats sat up to 26px inside the action bar. Nothing here needs to + guess; the measurement was already being published. */ width: 100%; - height: var(--feltRoom); - min-height: 7rem; + height: var(--feltRoomPx, calc(100vh - var(--actionBarHeight, 0px) - 7rem)); /* This branch sets its own height outright, so neither the aspect ratio nor the cap derived from it has anything to say here — and the cap would only narrow a table that is already as short as the screen allows. */ @@ -3259,16 +3266,55 @@ body.hasActionBar #toastHost { max-width: none; } + /* The page's own top padding is 32px of nothing, on the screen that has the least + to spare. */ + #holdemApp { + padding-top: 0.5rem; + } + /* The header ate 148px of a 390px-tall window, which was most of the reason the - table started below the fold. Here it only needs to be a title bar. */ + table started below the fold. Here it only needs to be a title bar. + + It still wrapped to two rows, and that second row is 44px of the 390 — the title + block and the five buttons came to 829px inside a 765px header at 844x390, so + they missed sharing one line by 64px, and at 640x360 by rather more. What goes is + the page title: the one thing on this screen a dealer already knows. It is hidden + from the eye rather than removed, so the page keeps its heading for a screen + reader and for the browser's own outline. The hand number stays — nothing else on + the screen says which hand this is. */ #holdemHeader { margin-bottom: 0.4rem; gap: 0.35rem; + flex-wrap: nowrap; + align-items: center; } #holdemHeader h1 { - font-size: 1.05rem; - padding-bottom: 0.15rem; + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .handCounterBadge { + margin-top: 0; + } + + /* Tightened across, never down: the buttons keep the 44px a thumb needs. */ + #holdemHeaderActions { + flex-wrap: nowrap; + gap: 0.35rem; + } + + #holdemHeaderActions .holdemBtn { + padding-left: 0.55rem; + padding-right: 0.55rem; + font-size: 0.78rem; + white-space: nowrap; } .holdemSubtitle { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 79070b5..0ec7dbd 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -53,7 +53,14 @@ const ActionBar = { // moving the seats, and getting a different answer on the next pass. SEAT_OVERHANG_PX: 37, FELT_GAP_PX: 8, - FELT_MIN_PX: 220, + // The floor is a guard against a measurement that has gone wrong — not a size the + // window has agreed to hand over. It used to be 220px, which a phone on its side + // hasn't got: at 844x390 there are 99px between the header and the bar, and holding + // out for 220 is how the bottom row of seats came to sit 26px inside the Fold + // button. A floor that puts seats behind the bar defeats the one thing the floor is + // for, so it drops to the smallest felt still worth drawing and the measurement + // wins above it. + FELT_FLOOR_PX: 96, // How tall the felt can be and still leave the whole ring of seats above the bar. // @@ -66,14 +73,22 @@ const ActionBar = { // // The wrap's offset is taken in document space rather than viewport space, so // scrolling the page doesn't resize the table under the dealer's hands. + feltRoomFor(windowHeight, barHeight, topInDocument) { + const room = windowHeight - barHeight - topInDocument - + this.SEAT_OVERHANG_PX - this.FELT_GAP_PX; + return Math.max(this.FELT_FLOOR_PX, Math.round(room)); + }, + publishFeltRoom(barHeight) { const wrap = document.getElementById("pokerTableWrap"); if (!wrap) return; + // Taking the top before the height is set matters: the wrap sits below the + // header in normal flow, so its own height never moves its top and this can't + // chase itself around the screen. const topInDocument = wrap.getBoundingClientRect().top + window.scrollY; - const room = window.innerHeight - barHeight - topInDocument - - this.SEAT_OVERHANG_PX - this.FELT_GAP_PX; document.documentElement.style.setProperty( - "--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`); + "--feltRoomPx", + `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`); }, // Anything to act on? A hand with nobody dealt in doesn't get a bar — and that diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 5a2755f..e64bc97 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -82,26 +82,100 @@ const Render = { // ---- Seats around the table (compact pucks) ---- - seatPosition(index, count) { - return this.positionOnRing(index, count, 44, 42); + seatPosition(index, count, aspect = this.feltAspect()) { + return this.positionOnRing(index, count, 44, 42, aspect); + }, + + // How many times wider than tall the felt actually is, which is the one thing the + // ring needs from the layout and can't work out from percentages. Falls back to a + // 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; }, // Bets sit on the felt between their player and the pot, the way chips actually // do — closer in than the seats, on the same spoke. The bets layer is inset to // match the seat ring (see the CSS), so this one radius holds at every size. - betPosition(index, count) { - return this.positionOnRing(index, count, 27, 25); + // + // The angle is the seat's own rather than one walked again round a tighter ellipse. + // The two answers are within half a degree of each other, so this is not a fix for + // anything visible — it is just the honest way to say "on that player's spoke", + // and it saves the second walk. + betPosition(index, count, aspect = this.feltAspect()) { + const angle = this.ringAngles(count, 44, 42, aspect)[index]; + return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) }; }, - positionOnRing(index, count, rx, ry) { - const angle = -90 + (360 / count) * index; - const radians = (angle * Math.PI) / 180; + // Seats spaced evenly around the rim — by the distance between them, not by the + // angle they sit at. + // + // Equal angles are only equal spacing on a circle. The felt is an ellipse, and a + // phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36° + // apart then put four of them down each steep side, where 36° of turn is barely any + // distance — seats 3 and 4 came out at the same x and 20px apart vertically, each + // puck 72px tall. Their centres landed on each other, so tapping a player got their + // neighbour, and the layout audit had been calling it at 640x360 and 667x375. + // + // Walking the perimeter instead puts the seats where a real table has them: along + // the two long rails, one at each end. The arc length is summed numerically because + // 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(radians), - top: 50 + ry * Math.sin(radians), + left: 50 + rx * Math.cos(target), + top: 50 + ry * Math.sin(target), }; }, + RING_STEPS: 720, + ringCache: {}, + + // 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. + ringAngles(count, rx, ry, aspect) { + if (count <= 0) return []; + const key = `${count}:${rx}:${ry}:${aspect.toFixed(3)}`; + if (this.ringCache[key]) return this.ringCache[key]; + + const start = -Math.PI / 2; + // Percentages of the width and of the height are not the same distance, so the + // horizontal radius is scaled into the vertical one's units before measuring. + const a = rx * aspect; + const b = ry; + const step = (2 * Math.PI) / this.RING_STEPS; + const cumulative = [0]; + for (let i = 1; i <= this.RING_STEPS; i++) { + const from = start + (i - 1) * step; + const to = start + i * step; + const dx = a * (Math.cos(to) - Math.cos(from)); + const dy = b * (Math.sin(to) - Math.sin(from)); + cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy)); + } + + const perimeter = cumulative[this.RING_STEPS]; + const angles = []; + let walked = 0; + for (let seat = 0; seat < count; seat++) { + const wanted = (perimeter * seat) / count; + while (walked < this.RING_STEPS && cumulative[walked + 1] < wanted) walked++; + // Interpolate inside the step the seat falls in, so the answer doesn't + // jump in 0.5° increments. + const spanned = cumulative[walked + 1] - cumulative[walked]; + const into = spanned > 0 ? (wanted - cumulative[walked]) / spanned : 0; + angles.push(start + (walked + into) * step); + } + this.ringCache[key] = angles; + return angles; + }, + // Colour a bet by the biggest chip that fits it, so a table with a chip legend // gets bets that look like the chips actually being pushed out. chipColorFor(amount) { diff --git a/tests/holdem/feltRoom.test.js b/tests/holdem/feltRoom.test.js new file mode 100644 index 0000000..6c21c0b --- /dev/null +++ b/tests/holdem/feltRoom.test.js @@ -0,0 +1,73 @@ +// How tall the felt is allowed to be. +// +// The bar is fixed to the bottom of the screen and grows with the hand, so the table +// has to be told what is left. Everything below is the arithmetic that decides it — +// the part that can be wrong without anyone noticing until a seat is behind the Fold +// button. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { ActionBar } = require("./load"); + +// window height, bar height, and how far down the page the felt starts. +const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top); + +describe("the room left for the felt", () => { + test("is what the window has after the bar, the header and the bottom seat", () => { + const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX; + assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved); + }); + + test("shrinks when the bar grows mid-hand", () => { + // Side pots make the bar taller exactly when the dealer most needs to see who + // is still in. + const quiet = room(900, 117, 146); + const sidePots = room(900, 172, 146); + assert.equal(quiet - sidePots, 55); + }); + + test("leaves the bottom seat's overhang out of it", () => { + // The seat pucks hang below the felt's edge; the felt has to stop short by that + // much or the ring ends up in the bar even though the felt doesn't. + assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck"); + assert.ok(room(900, 127, 146) < 900 - 127 - 146); + }); +}); + +describe("a window too short to give what was asked", () => { + // A phone on its side: 390px tall, 127px of that is the bar and 119px is + // everything above the felt. What's left is under 100. + const landscape = () => room(390, 127, 119); + + test("gets the measurement, not a comfortable number", () => { + // The floor used to be 220px, which this window hasn't got. Holding out for it + // put the bottom row of seats 26px inside the action bar — the exact fault the + // measurement exists to prevent. + assert.ok(landscape() < 150, `${landscape()}px`); + assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar"); + }); + + test("still draws a table rather than a line", () => { + assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX); + assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX); + }); + + test("never returns something a browser can't lay out", () => { + [[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => { + const answer = ActionBar.feltRoomFor(h, bar, top); + assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`); + }); + }); +}); + +describe("a window with room to spare", () => { + test("hands over everything that's left, floor or no floor", () => { + const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX; + assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved); + assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved); + }); + + test("gives a whole number of pixels", () => { + assert.equal(room(844.4, 127.6, 223.2) % 1, 0); + }); +}); diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js new file mode 100644 index 0000000..7ed6901 --- /dev/null +++ b/tests/holdem/seatRing.test.js @@ -0,0 +1,147 @@ +// Where the seats sit on the rim. +// +// The ring used to space seats by angle, which is only the same as spacing them evenly +// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal +// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a +// seat's centre belonged to its neighbour and tapping a player got the wrong one. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render } = require("./load"); + +// The seats as a dealer sees them: pixels on a felt of a given size. +const ringPixels = (count, width, height, rx = 44, ry = 42) => { + const aspect = width / height; + return Array.from({ length: count }, (_, i) => { + const pos = Render.positionOnRing(i, count, rx, ry, aspect); + return { x: (pos.left / 100) * width, y: (pos.top / 100) * height }; + }); +}; + +const gaps = (points) => points.map((p, i) => { + const next = points[(i + 1) % points.length]; + return Math.hypot(next.x - p.x, next.y - p.y); +}); + +// What the ring did before: one turn cut into equal angles. +const byAngle = (index, count, rx, ry) => { + const radians = ((-90 + (360 / count) * index) * Math.PI) / 180; + return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) }; +}; + +describe("a round table", () => { + test("still spaces seats by angle, because on a circle that is the same thing", () => { + for (let seat = 0; seat < 9; seat++) { + const now = Render.positionOnRing(seat, 9, 40, 40, 1); + const before = byAngle(seat, 9, 40, 40); + assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`); + assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`); + } + }); + + test("seat one is at the top, whatever shape the felt is", () => { + [[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => { + const first = Render.positionOnRing(0, 10, 44, 42, w / h); + assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`); + assert.ok(first.top < 50, `${w}x${h} is above the middle`); + }); + }); +}); + +describe("a felt wider than it is tall", () => { + // 640x360 landscape: the size the audit was failing at. + const wide = () => ringPixels(10, 601, 126); + + const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => { + const pos = byAngle(i, 10, 44, 42); + return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 }; + }); + + // The two seats at the right-hand end, which is where they crowd. + const endPairApart = (seats) => { + const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2); + return Math.abs(rightmost[0].y - rightmost[1].y); + }; + + test("pulls the pair at each end apart", () => { + // They still share the end — ten seats on a rim this flat have nowhere else to + // be — but they stop sitting on top of each other. + const now = endPairApart(wide()); + const before = endPairApart(byAnglePixels()); + assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`); + }); + + test("nearly doubles the room between the closest two neighbours", () => { + // Not equal spacing in a straight line — at the ends the rim turns back on + // itself, so the same walk along it covers less ground across. Even there the + // gap goes from a third of a puck to most of one. + const now = Math.min(...gaps(wide())); + const before = Math.min(...gaps(byAnglePixels())); + assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`); + assert.ok(now > 55, `${now.toFixed(1)}px`); + }); + + test("never sits two seats on the same spot", () => { + const seats = wide(); + seats.forEach((seat, i) => { + seats.slice(i + 1).forEach((other, j) => { + const apart = Math.hypot(seat.x - other.x, seat.y - other.y); + assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`); + }); + }); + }); +}); + +describe("the shape of the ring", () => { + test("is a mirror image left to right", () => { + const seats = ringPixels(10, 601, 126); + for (let seat = 1; seat < 5; seat++) { + const mirrored = seats[10 - seat]; + assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`); + assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`); + } + }); + + test("holds for the smallest and largest tables the seat count allows", () => { + [2, 3, 4, 9, 12, 24].forEach(count => { + const seats = ringPixels(count, 601, 126); + assert.equal(seats.length, count); + seats.forEach((seat, i) => { + assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`); + assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`); + }); + assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`); + }); + }); + + test("shares the rim out equally on a round table at any size", () => { + [3, 6, 9, 10, 24].forEach(count => { + const spacing = gaps(ringPixels(count, 500, 500, 40, 40)); + const shortest = Math.min(...spacing); + const longest = Math.max(...spacing); + assert.ok(longest - shortest < 0.5, + `${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`); + }); + }); + + test("asked for no seats, offers no positions", () => { + assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []); + }); + + test("puts each bet between its own player and the pot", () => { + // Where a dealer pushes the chips: on that player's line to the middle, short + // of the seat. + const bearing = (point) => Math.atan2(point.top - 50, point.left - 50); + const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50); + + [[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => { + for (let seat = 0; seat < 9; seat++) { + const player = Render.seatPosition(seat, 9, w / h); + const bet = Render.betPosition(seat, 9, w / h); + const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI); + assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`); + assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`); + } + }); + }); +}); From fc866597eb3e9d0f8988754af550f07856d11a4b Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 03:32:17 -0600 Subject: [PATCH 42/55] Deal the straddle, instead of leaving the dealer to fake it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 18 ++ JoshHeaps.Net/wwwroot/css/casino/holdem.css | 45 +++++ .../wwwroot/js/casino/holdem/actionBar.js | 53 ++++- .../wwwroot/js/casino/holdem/betting.js | 80 +++++++- .../wwwroot/js/casino/holdem/render.js | 8 + .../wwwroot/js/casino/holdem/state.js | 17 ++ tests/holdem/straddle.test.js | 188 ++++++++++++++++++ 7 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 tests/holdem/straddle.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index a984cab..d2bb9d2 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -208,6 +208,7 @@ SB BB + STR
@@ -360,6 +361,23 @@

+
+

A straddle is a blind raise

+

+ 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. +

+

+ Re-straddles carry on to the left, each doubling again, and stop before + the small blind. 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. +

+
+

A short all-in doesn't reopen the betting

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index c752e8f..4903923 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -796,6 +796,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 { @@ -3520,6 +3533,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 { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 0ec7dbd..5455772 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -592,6 +592,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 +726,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 +807,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)); })); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index baa240e..07f9b21 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -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. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index e64bc97..3ec0850 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -310,6 +310,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); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index fdc7e4a..d3404ae 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -100,6 +100,14 @@ 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, + // 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 +209,11 @@ 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; + 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 +656,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 = ["", ""]; diff --git a/tests/holdem/straddle.test.js b/tests/holdem/straddle.test.js new file mode 100644 index 0000000..e50693f --- /dev/null +++ b/tests/holdem/straddle.test.js @@ -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}`); + }); + }); +}); From b9b2237dbe6d0dcf90d1266df0bb3c473a4a1dc2 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 04:02:43 -0600 Subject: [PATCH 43/55] Run the two checks the audit had and never used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 51 +++++++-- .../wwwroot/css/casino/quicksplit.css | 10 +- .../wwwroot/js/casino/holdem/actionBar.js | 4 + .../wwwroot/js/casino/holdem/render.js | 29 +++++ tests/holdem/README.md | 17 ++- tests/holdem/auditContrast.test.js | 108 ++++++++++++++++++ tests/holdem/runUiAudit.js | 93 +++++++++------ tests/holdem/uiAudit.js | 89 +++++++++++++-- 8 files changed, 341 insertions(+), 60 deletions(-) create mode 100644 tests/holdem/auditContrast.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 4903923..baf7970 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -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 { @@ -2906,14 +2910,45 @@ 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: capped, the felt is 240px and the ring clears the bar by 50, with the + audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is + covered rather than merely below the fold. */ #pokerTableWrap { - max-width: none; + max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect)); + } + + /* A felt small enough that the rim can't give each seat its own puck width. Set + from the measured rim in render.js rather than from the window, because the + question is how much table there is per player, which no media query can see: + nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */ + .seatsTight .seat { + width: 58px; + min-height: 38px; + padding: 3px 4px; + } + + .seatsTight .seatPuckName { + margin-top: 0.35rem; + font-size: 0.62rem; + } + + .seatsTight .seatPuckStack { + font-size: 0.55rem; + } + + .seatsTight .seatPuckCards { + font-size: 0.58rem; } /* Stacked, with the action that ends the hand furthest from a stray thumb. */ diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css index 257c887..252e3c7 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css +++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css @@ -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 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 5455772..93c57d7 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -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 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 3ec0850..bde0ef3 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -137,6 +137,34 @@ const Render = { RING_STEPS: 720, ringCache: {}, + // How much rim each seat gets, in pixels. Ramanujan's approximation for an + // ellipse's perimeter, which is well inside a pixel at these sizes. + rimPerSeat(count) { + const wrap = document.getElementById("pokerTableWrap"); + if (!wrap || !count) return Infinity; + const box = wrap.getBoundingClientRect(); + const a = (box.width * 44) / 100; + const b = (box.height * 42) / 100; + if (!a || !b) return Infinity; + const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))); + return perimeter / count; + }, + + // A puck is 76px across. Under about that much rim each, neighbours start covering + // each other's middles, and a tap meant for one player lands on the next — so the + // pucks come down a size rather than the table pretending it has room it hasn't. + SEAT_ROOM_PX: 88, + + // 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.rimPerSeat(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 +273,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 diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 10ad8f7..9b2a211 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -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 diff --git a/tests/holdem/auditContrast.test.js b/tests/holdem/auditContrast.test.js new file mode 100644 index 0000000..d942f23 --- /dev/null +++ b/tests/holdem/auditContrast.test.js @@ -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"), []); + }); +}); diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js index 33ba18f..08090bd 100644 --- a/tests/holdem/runUiAudit.js +++ b/tests/holdem/runUiAudit.js @@ -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"); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 7056cd6..ab5dd96 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -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), }; }, From dc5db16ffd091276307d179512dc35447a394e91 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 04:33:25 -0600 Subject: [PATCH 44/55] Let the phone table use the width it has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 77 ++++++++++++------- .../wwwroot/js/casino/holdem/render.js | 60 +++++++++------ tests/holdem/seatRing.test.js | 30 ++++++++ 3 files changed, 118 insertions(+), 49 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index baf7970..017b86e 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -820,6 +820,38 @@ 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; +} + +.seatsTight .seatPuckCards { + font-size: 0.58rem; +} + /* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */ .seatEditorPanel { @@ -2921,34 +2953,20 @@ body.hasActionBar #toastHost { 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: capped, the felt is 240px and the ring clears the bar by 50, with the - audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is - covered rather than merely below the fold. */ + 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: calc(var(--feltRoomPx, 100vh) / var(--feltAspect)); - } - - /* A felt small enough that the rim can't give each seat its own puck width. Set - from the measured rim in render.js rather than from the window, because the - question is how much table there is per player, which no media query can see: - nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */ - .seatsTight .seat { - width: 58px; - min-height: 38px; - padding: 3px 4px; - } - - .seatsTight .seatPuckName { - margin-top: 0.35rem; - font-size: 0.62rem; - } - - .seatsTight .seatPuckStack { - font-size: 0.55rem; - } - - .seatsTight .seatPuckCards { - font-size: 0.58rem; + max-width: none; + max-height: var(--feltRoomPx, 100vh); } /* Stacked, with the action that ends the hand furthest from a stray thumb. */ @@ -3312,6 +3330,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 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index bde0ef3..e5a76ad 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -91,12 +91,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 @@ -137,23 +146,30 @@ const Render = { RING_STEPS: 720, ringCache: {}, - // How much rim each seat gets, in pixels. Ramanujan's approximation for an - // ellipse's perimeter, which is well inside a pixel at these sizes. - rimPerSeat(count) { - const wrap = document.getElementById("pokerTableWrap"); - if (!wrap || !count) return Infinity; - const box = wrap.getBoundingClientRect(); - const a = (box.width * 44) / 100; - const b = (box.height * 42) / 100; - if (!a || !b) return Infinity; - const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))); - return perimeter / count; + // 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.ringAngles(count, 44, 42, box.width / box.height).map(angle => ({ + x: ((50 + 44 * Math.cos(angle)) / 100) * box.width, + y: ((50 + 42 * Math.sin(angle)) / 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. Under about that much rim each, neighbours start covering - // each other's middles, and a tap meant for one player lands on the next — so the - // pucks come down a size rather than the table pretending it has room it hasn't. - SEAT_ROOM_PX: 88, + // 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 @@ -162,7 +178,7 @@ const Render = { const layer = document.getElementById("seatsLayer"); if (!layer || !this.state) return; layer.classList.toggle("seatsTight", - this.rimPerSeat(this.state.seatCount) < this.SEAT_ROOM_PX); + this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX); }, // The angles that cut the rim into `count` equal lengths, starting at the top. diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js index 7ed6901..72f5f94 100644 --- a/tests/holdem/seatRing.test.js +++ b/tests/holdem/seatRing.test.js @@ -92,6 +92,36 @@ 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's ends are far closer than its rim suggests", () => { + // 616x96: the landscape felt on a 640x360 phone once the bar has grown. + const rim = Math.PI * (3 * (271 + 40) - Math.sqrt((3 * 271 + 40) * (271 + 3 * 40))) / 6; + assert.ok(gap(6, 616, 96) < rim / 3, `${gap(6, 616, 96).toFixed(0)}px apart, ${rim.toFixed(0)}px of rim each`); + }); + + 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("the shape of the ring", () => { test("is a mirror image left to right", () => { const seats = ringPixels(10, 601, 126); From 8aa90e90a4b823076eb159b92ab416a1da447758 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 04:58:59 -0600 Subject: [PATCH 45/55] Give a phone on its side the table shape it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 29 ++++++- .../wwwroot/js/casino/holdem/render.js | 83 ++++++++++++++++--- tests/holdem/seatRing.test.js | 72 +++++++++++++++- 3 files changed, 167 insertions(+), 17 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 017b86e..4d9912e 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -848,8 +848,13 @@ button, 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 { - font-size: 0.58rem; + display: none; } /* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */ @@ -3038,10 +3043,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 @@ -3410,6 +3423,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 ---------- diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index e5a76ad..566bb15 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -117,8 +117,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 @@ -136,11 +139,71 @@ 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, @@ -156,9 +219,9 @@ const Render = { closestSeatGapPx(count, box = this.ringBox()) { if (count < 2 || !box || !box.width || !box.height) return Infinity; - const points = this.ringAngles(count, 44, 42, box.width / box.height).map(angle => ({ - x: ((50 + 44 * Math.cos(angle)) / 100) * box.width, - y: ((50 + 42 * Math.sin(angle)) / 100) * box.height, + 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]; diff --git a/tests/holdem/seatRing.test.js b/tests/holdem/seatRing.test.js index 72f5f94..ad9f62a 100644 --- a/tests/holdem/seatRing.test.js +++ b/tests/holdem/seatRing.test.js @@ -99,10 +99,27 @@ describe("how close the closest two seats come", () => { // apart end up stacked one above the other. const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height }); - test("a flat table's ends are far closer than its rim suggests", () => { - // 616x96: the landscape felt on a 640x360 phone once the bar has grown. - const rim = Math.PI * (3 * (271 + 40) - Math.sqrt((3 * 271 + 40) * (271 + 3 * 40))) / 6; - assert.ok(gap(6, 616, 96) < rim / 3, `${gap(6, 616, 96).toFixed(0)}px apart, ${rim.toFixed(0)}px of rim each`); + 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", () => { @@ -122,6 +139,53 @@ describe("how close the closest two seats come", () => { }); }); +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); From c19575ab10ffe4c895d22b16bb0fbb83a46ab55a Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 05:12:33 -0600 Subject: [PATCH 46/55] Count the house's cut instead of calling it missing chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 30 +++ JoshHeaps.Net/wwwroot/css/casino/holdem.css | 14 ++ .../js/casino/holdem/handHistoryExport.js | 7 +- .../wwwroot/js/casino/holdem/main.js | 29 ++- .../wwwroot/js/casino/holdem/money.js | 15 ++ .../wwwroot/js/casino/holdem/potWizard.js | 45 +++- .../wwwroot/js/casino/holdem/render.js | 33 ++- .../wwwroot/js/casino/holdem/state.js | 92 ++++++- tests/holdem/rake.test.js | 230 ++++++++++++++++++ 9 files changed, 479 insertions(+), 16 deletions(-) create mode 100644 tests/holdem/rake.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index d2bb9d2..558d334 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -86,6 +86,32 @@

Blinds post themselves each hand. Seats without their own stack start here.

+
+ House take +
+
+ + +
+ +

+ 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. +

+ +
+
+
Without live betting
@@ -132,6 +158,10 @@ Taken off the table
+

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 4d9912e..b2f67c2 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2898,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; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 0257d3f..1d0bf38 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -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, ]); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 6439cf9..327cc9a 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -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()); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index f0fe135..1d03ef4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -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 }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index 036a4ca..7ab20d4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -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); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 566bb15..19bf744 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -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 @@ -1145,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 = diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index d3404ae..34cfa20 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -104,6 +104,12 @@ const HoldemState = { // 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. @@ -213,6 +219,15 @@ const HoldemState = { // 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 @@ -679,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) => ({ @@ -698,6 +717,7 @@ const HoldemState = { handNumber: state.handNumber, timestamp: Date.now(), pot, + rake: taken, payouts, ledger, }); @@ -818,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( @@ -837,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, diff --git a/tests/holdem/rake.test.js b/tests/holdem/rake.test.js new file mode 100644 index 0000000..cafda51 --- /dev/null +++ b/tests/holdem/rake.test.js @@ -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); + }); +}); From 0e2821257f0d8e50673da8784510b8eb9cad8a09 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 05:40:06 -0600 Subject: [PATCH 47/55] Run the clock on a player, instead of counting in your head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar has had a stopwatch on the acting seat for a while: how long this player has been thinking, so the dealer knows when it's worth a nudge. What it never had was the thing a dealer actually does about it. Calling the clock is a procedure — a player asks, the dealer grants it, the player gets a set time and then a countdown said out loud, and if they still haven't acted the hand is decided for them. Running that from this tool meant counting in your head while the table argued about whether it had been thirty seconds yet. Thirty and then ten, which is the common form and what the TDA writes down. The countdown is the part the table hears, so it is marked apart rather than being ten more seconds of the same: the readout switches to a bare number and turns red. At zero the bar says the ruling in the dealer's own words and offers it as one button. The ruling depends on what they were facing, and getting that backwards is the part worth being careful about. Facing a bet, the hand is dead. Facing nothing, it is checked — nobody gets folded for being slow when checking was free. Three things about where it sits. The button appears only once somebody has been thinking long enough for the table to start asking, which is the same threshold the stopwatch already marks, so an ordinary hand costs no room at all: measured at 390x844, the bar is 152px with or without it. It is 44px like everything else a thumb has to find — it was drawn at 36 and the audit called it at every touch viewport the moment the check ran, which is the rule doing its job on a control that kills a hand. And once the count is over the Stop button goes, because there is nothing left to stop: at 640x360 its 94px pushed the undo control onto a second line and the taller bar covered the two bottom seats. The ordinary Fold/Check/Raise stay underneath the ruling. A player who speaks up as the count ends is still acting, and whether it was in time is the dealer's call, not the tool's. 11 new tests, and both halves of the ruling were inverted on purpose to check they catch it. Watched end to end in the browser at shortened intervals: the button appears, the count runs down, the ruling lands, and the hand is killed — or checked — with the undo control naming what happened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 17 ++ JoshHeaps.Net/wwwroot/css/casino/holdem.css | 48 ++++++ .../wwwroot/js/casino/holdem/actionBar.js | 163 +++++++++++++++++- tests/holdem/callingTheClock.test.js | 129 ++++++++++++++ 4 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 tests/holdem/callingTheClock.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 558d334..5db9c99 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -391,6 +391,23 @@

+
+

Calling the clock on a player

+

+ Any player may ask for a clock once a decision is taking unreasonably + long; it is the dealer, not the table, who grants it. The player then + gets a set time and a countdown out loud — thirty seconds and ten is the + usual form. +

+

+ If the count reaches zero with no action, the hand is dead if + they were facing a bet, and checked if they were not: nobody is + folded for slowness when checking was free. The action bar runs the + count once you tap Call the clock, and offers the ruling as one + button when it ends. +

+
+

A straddle is a blind raise

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index b2f67c2..0f0df21 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2254,6 +2254,46 @@ body.hasActionBar #holdemSidebar { font-weight: 600; } +/* Starting and stopping a clock. Quiet, because it only appears once somebody has + been thinking long enough for the table to ask. + + 44px like everything else a thumb has to find. It was drawn at 36 to keep the status + line short, and the audit called it at every touch viewport the moment it ran — which + is the rule doing its job: this control kills a hand, and the argument for shaving it + was that the row it sits in is made of text. */ +.actionClockBtn { + flex: none; + min-height: 44px; + padding: 0.25rem 0.6rem; + border-radius: 8px; + border: 1px solid #5b4a2a; + background: rgba(216, 180, 106, 0.12); + color: #e8cf9a; + font-family: inherit; + font-size: 0.72rem; + cursor: pointer; +} + +/* The count has run out. Said in the dealer's own words, above the button that + carries it out — and coloured like the thing it is, which is a hand about to be + killed for slow play. */ +.clockExpired { + margin: 0 0 0.5rem; + padding: 0.45rem 0.6rem; + border-radius: 8px; + background: rgba(224, 123, 123, 0.12); + border: 1px solid rgba(224, 123, 123, 0.45); + color: #f3c9c9; + font-size: 0.8rem; +} + +.actionBtn.actionEnforceClock { + background: linear-gradient(180deg, #6b2b2b, #4a1d1d); + border-color: #8c3a3a; + color: #ffe2e2; + font-weight: 600; +} + /* ---------- Side-pot breakdown ---------- */ .potBreakdown { @@ -3431,6 +3471,14 @@ body.hasActionBar #toastHost { bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px)); } + /* The clock's ruling, said in a sentence, is a line this screen cannot pay for: + measured at 640x360, the bar goes from 152px to 269px when the count runs out and + the bottom seats end up behind it. The button carries the same ruling in three + words — "Kill Dee's hand" — so the sentence is what goes, not the call. */ + .clockExpired { + display: none; + } + /* Nine seats around a table this small overlap each other, so they shrink to suit. */ .seat { width: 62px; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 93c57d7..b0bc889 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -194,6 +194,32 @@ const ActionBar = { tank.title = "How long this player has been thinking"; tank.textContent = "0:00"; row.appendChild(tank); + + // Rendered every time and shown by the tick once the wait is long enough + // to be worth it, so a clock never costs a re-render at the moment the + // dealer reaches for it — and never occupies the bar in the ordinary case + // where nobody is holding anybody up. + const clock = document.createElement("button"); + clock.type = "button"; + clock.id = "actionClockBtn"; + clock.className = "actionClockBtn"; + // Once the count is over there is nothing to stop: the ruling has been + // made, and the dealer either carries it out or takes an ordinary action, + // both of which end the clock themselves. Leaving the button there also + // cost real room — at 640x360 its 94px pushed the undo control onto a + // second line, and the taller bar covered the two bottom seats. + const expiredNow = this.clockRunning() && this.clockLeft() === 0; + clock.hidden = !this.clockRunning() || expiredNow; + if (this.clockRunning()) { + clock.textContent = "Stop the clock"; + clock.title = "Put the decision back in the player's own time"; + clock.addEventListener("click", () => { this.stopClock(); this.render(); }); + } else { + clock.textContent = "Call the clock"; + clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`; + clock.addEventListener("click", () => this.callClock()); + } + row.appendChild(clock); } // A standing control rather than a toast per action: a dealer taps this bar @@ -233,6 +259,74 @@ const ActionBar = { NUDGE_AFTER_S: 30, SLOW_AFTER_S: 60, + // ---- Calling the clock ---- + // + // The stopwatch above says how long somebody has been thinking. This is what a + // dealer does about it, and it is a procedure with a shape: a player asks for a + // clock, the dealer grants it, the player gets a set time and then a countdown, + // and if they still haven't acted the hand is decided for them — dead if they were + // facing a bet, checked if they weren't. + // + // The tool knew none of that. A dealer running it had to count in their head while + // the table argued about whether it had been thirty seconds, which is the moment + // the count needs to be somewhere everybody can see. + // + // Thirty seconds and then a ten-second countdown is the common form of the rule and + // what the TDA writes down. The countdown is the part the table hears out loud, so + // it is marked separately rather than being ten more seconds of the same. + CLOCK_THINK_S: 30, + CLOCK_COUNTDOWN_S: 10, + + clockSeatIndex: null, + clockStartedAt: null, + + clockRunning() { + return this.clockSeatIndex !== null && + this.clockSeatIndex === this.state.actingSeatIndex && + this.clockStartedAt !== null; + }, + + // Seconds left before the hand is decided, or null when no clock is running. + // Negative is impossible: it stops at zero and stays there until the dealer acts. + clockLeft(now = Date.now()) { + if (!this.clockRunning()) return null; + const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S; + return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000)); + }, + + // What happens when it reaches zero, in the words the dealer says out loud. + // Facing a bet, the hand is dead. Facing none, it is checked — a player who could + // have checked for free is not folded for being slow. + clockRuling() { + const index = this.state.actingSeatIndex; + if (index === null || index === undefined) return null; + const player = this.state.seats[index]; + const owed = Betting.toCall(this.state, player); + const who = HoldemState.displayName(player, index); + return owed > 0 + ? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` } + : { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` }; + }, + + callClock() { + const index = this.state.actingSeatIndex; + if (index === null || index === undefined) return; + this.clockSeatIndex = index; + this.clockStartedAt = Date.now(); + this.clockExpiredShown = false; + this.render(); + }, + + stopClock() { + this.clockSeatIndex = null; + this.clockStartedAt = null; + this.clockExpiredShown = false; + }, + + // Whether the bar has already been rebuilt into its "time is up" shape, so the + // one-second tick doesn't rebuild it again every second afterwards. + clockExpiredShown: false, + tankSeatIndex: null, tankStartedAt: null, tankInterval: null, @@ -262,6 +356,11 @@ const ActionBar = { if (this.tankSeatIndex !== null) this.tankUnknownFor = null; this.tankSeatIndex = index; this.tankStartedAt = Date.now(); + // A clock belongs to one player's decision. The moment the action moves — + // because they acted, or because the dealer took something back — it is + // over, and leaving it running would put the next player on a countdown + // nobody called on them. + this.stopClock(); } if (!this.tankInterval) { @@ -283,6 +382,36 @@ const ActionBar = { const el = document.getElementById("actionTank"); if (!el || this.tankStartedAt === null) return; + // A clock takes the readout over: the number the table is waiting on is no + // longer how long this has taken but how long is left. At zero the bar has to + // change shape — a ruling and the button that carries it out — so that one + // gets a full render, once, rather than being painted in by the tick. + const left = this.clockLeft(); + if (left !== null) { + if (left === 0 && !this.clockExpiredShown) { + this.clockExpiredShown = true; + this.render(); + return; + } + el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left); + el.title = "Time left before the hand is decided"; + el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S); + el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S); + return; + } + + // The button that starts a clock is offered once somebody has been thinking + // long enough for the table to start asking — which is the same threshold the + // stopwatch already marks. Before that it is a control nobody needs, and this + // bar is docked over the seats. + const nudge = document.getElementById("actionClockBtn"); + if (nudge && !this.clockRunning()) { + const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000); + nudge.hidden = this.tankUnknownFor === this.tankSeatIndex + ? false + : waited < this.NUDGE_AFTER_S; + } + if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) { el.textContent = "—"; el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves."; @@ -405,6 +534,36 @@ const ActionBar = { const stack = Betting.stackOf(this.state, player); const who = HoldemState.displayName(player, index); + // Time is up. The ruling goes on the bar in the words the dealer says out loud, + // with the one action that carries it out — and the ordinary buttons stay + // underneath, because a player who speaks up as the count ends is still acting + // and the dealer is the one who decides whether it was in time. + const expired = this.clockLeft() === 0 ? this.clockRuling() : null; + const wrap = document.createElement("div"); + if (expired) { + const line = document.createElement("p"); + line.className = "clockExpired"; + // The ruling in one sentence. `label` is the same call in three words for + // the button beside it; saying both left the line doubling back on itself + // — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead." + line.textContent = `Time — ${expired.say}`; + wrap.appendChild(line); + + const enforce = document.createElement("div"); + enforce.className = "actionButtons"; + enforce.appendChild(this.button( + expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`, + "actionEnforceClock", + () => this.commit( + expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`, + () => { + this.stopClock(); + if (expired.verb === "fold") Betting.fold(this.state, index); + else Betting.check(this.state, index); + }))); + wrap.appendChild(enforce); + } + const row = document.createElement("div"); row.className = "actionButtons"; @@ -431,7 +590,9 @@ const ActionBar = { })); } - return row; + if (!expired) return row; + wrap.appendChild(row); + return wrap; }, // Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a diff --git a/tests/holdem/callingTheClock.test.js b/tests/holdem/callingTheClock.test.js new file mode 100644 index 0000000..8e34fbe --- /dev/null +++ b/tests/holdem/callingTheClock.test.js @@ -0,0 +1,129 @@ +// Calling the clock on a player. +// +// The bar already had a stopwatch saying how long somebody had been thinking. This is +// what a dealer does about it: a procedure with a shape — a set time, a countdown said +// out loud, and a ruling at the end that depends on what the player was facing. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { ActionBar, Betting, makeTable } = require("./load"); + +// The bar rebuilds the DOM when a clock starts; none of the rules below need it. +const onTable = (state) => { + ActionBar.state = state; + ActionBar.render = () => {}; + ActionBar.stopClock(); + return ActionBar; +}; + +const dealt = () => { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 }); + Betting.beginHand(state); + return state; +}; + +describe("starting one", () => { + test("it runs on the player who is actually on the clock", () => { + const state = dealt(); + const bar = onTable(state); + assert.equal(bar.clockRunning(), false, "nothing runs until it is called"); + bar.callClock(); + assert.equal(bar.clockRunning(), true); + assert.equal(bar.clockSeatIndex, state.actingSeatIndex); + }); + + test("with nobody to act there is nothing to call", () => { + const state = dealt(); + state.actingSeatIndex = null; + const bar = onTable(state); + bar.callClock(); + assert.equal(bar.clockRunning(), false); + assert.equal(bar.clockLeft(), null); + }); + + test("the dealer can put it away again", () => { + const bar = onTable(dealt()); + bar.callClock(); + bar.stopClock(); + assert.equal(bar.clockRunning(), false); + assert.equal(bar.clockLeft(), null); + }); +}); + +describe("the count", () => { + const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000); + + test("is the player's own time and then a countdown", () => { + const bar = onTable(dealt()); + bar.callClock(); + assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S); + assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts"); + assert.equal(at(bar, 35), 5); + }); + + test("stops at zero rather than going negative", () => { + const bar = onTable(dealt()); + bar.callClock(); + assert.equal(at(bar, 40), 0); + assert.equal(at(bar, 600), 0, "ten minutes later it is still just up"); + }); +}); + +describe("what happens at zero", () => { + test("facing a bet, the hand is dead", () => { + const state = dealt(); + const bar = onTable(state); + // Whoever is up pre-flop is facing the big blind. + const ruling = bar.clockRuling(); + assert.equal(ruling.verb, "fold"); + assert.match(ruling.label, /hand is dead/); + assert.match(ruling.say, /facing \$2/); + }); + + test("facing nothing, it is checked — slowness doesn't fold a free hand", () => { + const state = dealt(); + // Everyone calls round to the big blind, who has nothing to call. + [3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat)); + const bar = onTable(state); + assert.equal(state.actingSeatIndex, 2, "the big blind is up"); + const ruling = bar.clockRuling(); + assert.equal(ruling.verb, "check"); + assert.match(ruling.label, /check for/i); + }); + + test("the ruling names the player, because the dealer has to say it out loud", () => { + const state = dealt(); + state.seats[state.actingSeatIndex].name = "Priya"; + const ruling = onTable(state).clockRuling(); + assert.match(ruling.label, /Priya/); + assert.match(ruling.say, /Priya/); + }); + + test("with nobody to act there is no ruling to make", () => { + const state = dealt(); + state.actingSeatIndex = null; + assert.equal(onTable(state).clockRuling(), null); + }); +}); + +describe("what ends it", () => { + test("the player acting", () => { + const state = dealt(); + const bar = onTable(state); + bar.callClock(); + const wasOn = state.actingSeatIndex; + Betting.call(state, wasOn); + assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved"); + assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's"); + }); + + test("and it doesn't follow the action to the next player", () => { + // A clock is called on one decision. Left running it would put a countdown on + // somebody nobody asked about. + const state = dealt(); + const bar = onTable(state); + bar.callClock(); + Betting.call(state, state.actingSeatIndex); + assert.equal(bar.clockLeft(), null); + }); +}); From f966d8c340c6dccae4daf698d7dca9439885fc5e Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 08:08:46 -0600 Subject: [PATCH 48/55] Pay somebody out and keep the chair, the cash and the record straight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most ordinary thing that happens at a home game after midnight — somebody counts up and goes home — and the tool had nothing for it. Freeing the chair meant wiping the seat by hand, and the bank check then declared their whole stack missing: "someone has paid for chips they haven't been given", for the rest of the night. Measured on a balanced table, cashing a player out with $350 in front of them left the check $350 short. Wiping their buy-in as well silenced it, at the price of losing their entire night from the standings and from the file the dealer hands over at the end. Cashing out is two facts, and both have to survive the empty chair: the box took their cash, and the box handed their chips back. Both are kept now, so the chair opens and the money still adds up. Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" — what they bought in for, and whether they leave up or down, because that is the sentence the dealer says while their hands are moving. The bank check gains a Paid out line and reads "balanced ... once the $350 paid out to players who have left is counted", alongside the discards and the house's cut. The standings keep them with a stripe and the words "· paid out", so nobody counts money out to them twice at 2am, and the export marks them the same way. It refuses to pay anybody with money still in the middle: taking chips out of a pot other people can win, or handing the same chips over twice, are both worse than waiting for the hand to finish. The chair gets a fresh id, because the person who had it has gone and the next player to sit down is not them. Two things this turned up. The first draft worked out what a seat was holding by itself, read `Number(null)` as a perfectly good zero, and offered to pay a player with no stack set nothing at all — it asks Betting now, which owns that rule. The second was mine and the audit caught it: marking the departed row with opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer settles from. It is a stripe instead. 19 new tests, with the departed buy-in dropped from the check and the pot guard disabled on purpose to confirm they fail. Walked through in the browser: menu, confirm, payout, bank, standings, undo, and the refusal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 4 + JoshHeaps.Net/wwwroot/css/casino/holdem.css | 18 ++ .../js/casino/holdem/handHistoryExport.js | 5 +- .../wwwroot/js/casino/holdem/main.js | 33 +++ .../wwwroot/js/casino/holdem/render.js | 21 +- .../wwwroot/js/casino/holdem/seatMenu.js | 7 + .../wwwroot/js/casino/holdem/state.js | 133 +++++++++- tests/holdem/cashOut.test.js | 229 ++++++++++++++++++ 8 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 tests/holdem/cashOut.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 5db9c99..40dbd8c 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -162,6 +162,10 @@ House take +

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 0f0df21..ebabf22 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2294,6 +2294,24 @@ body.hasActionBar #holdemSidebar { font-weight: 600; } +/* A player who has taken their chips and gone. Still on the list, because the box took + their cash and the night's record has to say so, but set apart so nobody counts money + out to them twice at 2am. + + Set apart by a stripe rather than by fading the row, which is what this first did: + at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the + list a dealer settles the night from. The row says "paid out" in words next to the + name — it doesn't need to be hard to read as well. */ +.standingsRow.standingsGone { + background: rgba(255, 255, 255, 0.04); + border-left: 2px solid #4a4a4a; + padding-left: 0.35rem; +} + +.standingsRow.standingsGone .standingsName { + font-style: italic; +} + /* ---------- Side-pot breakdown ---------- */ .potBreakdown { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 1d0bf38..582f8ba 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -54,7 +54,10 @@ const HandHistoryExport = { rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]); standings.forEach(row => { rows.push([ - row.name, + // Somebody who has gone home is marked in the file the same way the + // panel marks them, because this is the copy that gets read at the + // end of the night when nobody remembers who has already been paid. + row.cashedOut ? `${row.name} (paid out)` : row.name, Money.forSheet(row.in), Money.forSheet(row.won), Money.forSheet(row.net), diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 327cc9a..8f48301 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -96,6 +96,39 @@ document.addEventListener("DOMContentLoaded", () => { }); }, + // Somebody is done for the night. The dealer counts their chips out, the chair + // opens, and both halves of what happened stay on the record — the cash the box + // took from them, and the chips it handed back. Wiping the seat by hand did + // neither: it left the bank check declaring their whole stack missing for the + // rest of the night. + cashOut: async index => { + const allowed = HoldemState.canCashOut(state, index); + if (!allowed.ok) { + Toast.show(allowed.reason, { duration: 4000 }); + return; + } + + const preview = HoldemState.cashOutPreview(state, index); + const result = preview.net === 0 + ? "even for the night" + : preview.net > 0 + ? `up ${Money.format(preview.net)}` + : `down ${Money.format(Math.abs(preview.net))}`; + const chips = preview.breakdown ? ` — ${preview.breakdown}` : ""; + + const ok = await Dialog.confirm({ + title: `Cash out ${preview.name}?`, + message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` + + `${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` + + `night's record keeps both their buy-in and this payout so the bank still balances.`, + confirmLabel: `Pay ${Money.format(preview.chips)}`, + }); + if (!ok) return; + + runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`, + () => HoldemState.cashOut(state, index)); + }, + // Two players change chairs. Undoable like every other seat action, because a // swap is easy to aim wrong and the seats look alike at a glance. swapSeats: (from, to) => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 19bf744..f9c38f5 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -1012,7 +1012,14 @@ const Render = { const template = document.getElementById("standingsRowTemplate"); standings.forEach(row => { const node = template.content.firstElementChild.cloneNode(true); - node.querySelector(".standingsName").textContent = row.name; + // Somebody who has gone home is still on the list — they played, and the + // box took their cash — but the number beside them is money already handed + // over, not chips waiting to be counted. Marked, or a dealer settling up at + // 2am pays them twice. + node.classList.toggle("standingsGone", !!row.cashedOut); + node.querySelector(".standingsName").textContent = row.cashedOut + ? `${row.name} · paid out` + : row.name; node.querySelector(".standingsIn").textContent = Money.format(row.in); node.querySelector(".standingsWon").textContent = Money.format(row.won); @@ -1169,11 +1176,23 @@ const Render = { } } + // Chips handed back to people who have gone home. The third way money leaves + // this table on purpose, and the one a dealer is asked about most: "where did + // Ann's four hundred go?" + const paidRow = document.getElementById("bankPaidOutRow"); + if (paidRow) { + paidRow.hidden = !(bank.paidOut > 0); + if (bank.paidOut > 0) { + document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut); + } + } + 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, + bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null, ].filter(Boolean); if (bank.balanced) { verdict.textContent = accountedFor.length > 0 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js index 6feb934..a90c5b2 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js @@ -47,6 +47,13 @@ const SeatMenu = { run: () => this.hooks.toggleDealtIn(index), }); + // Leaving for the night, which is a different thing from sitting out a hand: + // the chips go back over the rail and the chair opens. Offered only for a seat + // that has somebody in it, since an empty chair has nobody to pay. + if ((player.name || "").trim() || Number(player.buyIns) > 0 || Number(player.stack) > 0) { + actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) }); + } + if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) { actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) }); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 34cfa20..c8ae787 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -110,6 +110,11 @@ const HoldemState = { // 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, + // People who have taken their chips and gone. Kept for the night rather + // than forgotten with the seat: the box took their cash and paid it back + // out, and both halves have to stay on the record or the bank check reads + // as a shortfall for the rest of the evening. + cashedOut: [], // 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. @@ -228,6 +233,17 @@ const HoldemState = { noFlopNoDrop: rake.noFlopNoDrop !== false, }; parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0)); + parsed.cashedOut = Array.isArray(parsed.cashedOut) + ? parsed.cashedOut + .filter(row => row && row.id) + .map(row => ({ + id: row.id, + name: String(row.name || "").slice(0, 40), + buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)), + chips: Math.max(0, Money.round(Number(row.chips) || 0)), + at: Number(row.at) || 0, + })) + : []; if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = []; // A table saved before dealing and betting were separate has no flag, and @@ -753,6 +769,15 @@ const HoldemState = { // Cash bought in and chips currently in front don't come from the hand log at // all, so they're read off the seat rather than summed — a rebuy isn't a hand. const seatById = new Map(state.seats.map(p => [p.id, p])); + // And for anyone who has gone home, off the record their cash-out left behind. + // Without this they read as a player who bought in for nothing and is holding + // nothing, in the very list the dealer settles the night from. + const goneById = new Map((state.cashedOut || []).map(row => [row.id, row])); + + goneById.forEach((row, id) => { + if (totals.has(id)) return; + totals.set(id, { id, name: row.name, in: 0, won: 0 }); + }); // Anyone who has money in the game belongs in the settle-up, whether or not // they've been in a logged hand yet. Building the list from the hand log alone @@ -774,8 +799,16 @@ const HoldemState = { return Array.from(totals.values()) .map(t => { const seat = seatById.get(t.id); - const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0; - const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0; + const gone = goneById.get(t.id); + const boughtIn = seat + ? Money.round(Number(seat.buyIns) || 0) + : (gone ? gone.buyIns : 0); + // What a departed player is "holding" is what the box handed them on + // the way out. It is no longer on the table, so it is marked as paid + // rather than counted again at the end of the night. + const chipsNow = seat + ? Money.round(Number(seat.stack) || 0) + : (gone ? gone.chips : 0); // Chips already pushed into the hand in progress are still this // player's money until it's won. Leaving them out makes the cash // position disagree with the hand log for as long as a hand is open — @@ -806,6 +839,10 @@ const HoldemState = { // a $340 stack read as "$140" at the exact moment cash changed // hands. chipsInFront: Money.round(chipsNow + inFrontOfThem), + // Set once they have taken their chips and left, so the panel and + // the export can say so rather than showing a player still sitting + // there with a stack. + cashedOut: !!gone && !seat, cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn), notes, }; @@ -838,6 +875,84 @@ 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. + // ---- Somebody leaving with their chips ---- + // + // The most ordinary thing that happens at a home game after midnight, and the tool + // had nothing for it. A dealer's only way to free the chair was to wipe the seat by + // hand, and then the bank check — which adds up cash taken against chips on the + // table — declared the departing player's whole stack missing: "someone has paid + // for chips they haven't been given", for the rest of the night. Wiping their + // buy-in as well silenced it, at the price of losing their night from the standings + // and the file the dealer hands over. + // + // Cashing out is two facts, and both have to survive the empty chair: the box took + // their cash, and the box paid their chips back out. + + // Can this seat be cashed out right now? + canCashOut(state, index) { + const player = state.seats[index]; + if (!player) return { ok: false, reason: "There's nobody in that seat." }; + const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0)); + if (inPot > 0) { + return { + ok: false, + // Paying someone out with money still in the middle would either take + // chips out of a pot other people can win, or hand them chips twice. + reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`, + }; + } + return { ok: true }; + }, + + // What the dealer counts out, and what the seat's night comes to. + cashOutPreview(state, index) { + const player = state.seats[index]; + if (!player) return null; + // Betting owns the answer to "what is this seat holding" — including the rule + // that a seat which has never been given a stack is holding the table's + // starting one. Asking it rather than working it out again here: the first + // draft of this did, read `Number(null)` as a perfectly good zero, and offered + // to pay a player nothing. + const chips = Money.round(Math.max(0, Betting.stackOf(state, player))); + const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0)); + return { + index, + name: this.displayName(player, index), + chips, + boughtIn, + // Their result for the night, in the direction a banker says it: positive + // means the box is paying out more than it took from them. + net: Money.round(chips - boughtIn), + breakdown: Money.formatChipBreakdown(chips, state.chipLegend), + }; + }, + + // Hand them their chips and free the chair, keeping both halves on the record. + cashOut(state, index) { + const allowed = this.canCashOut(state, index); + if (!allowed.ok) return allowed; + + const preview = this.cashOutPreview(state, index); + const leavingId = state.seats[index].id; + if (!Array.isArray(state.cashedOut)) state.cashedOut = []; + state.cashedOut.push({ + id: leavingId, + name: preview.name, + buyIns: preview.boughtIn, + chips: preview.chips, + at: Date.now(), + }); + + // The chair opens for the next player. A fresh id, because the person who had + // it has gone and their night is now filed under the old one — reusing it would + // merge two people's results the moment somebody new sits down. + const seat = this.makePlayer(); + state.seats[index] = seat; + // Anyone owed a hand by this seat is no longer owed one. + state.postingIn = (state.postingIn || []).filter(id => id !== leavingId); + return { ok: true, ...preview }; + }, + // ---- The house's cut ---- // // A game that takes a rake or a time charge has chips leaving the table every hand, @@ -900,7 +1015,12 @@ const HoldemState = { }, bankReconciliation(state) { - const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0); + const gone = Array.isArray(state.cashedOut) ? state.cashedOut : []; + // Cash the box took from people who have since left counts as taken: it is in + // the box, and forgetting it with the seat is what used to make a cash-out read + // as a shortfall all night. + const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) + + gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0); const chipsInPlay = state.seats.reduce( (sum, p) => sum + @@ -922,12 +1042,17 @@ const HoldemState = { // 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); + // Chips handed back over the rail. Off the table like the other two, and the + // one the dealer can point at when somebody asks where the money went. + const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0)); + + const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn); return { cashIn: Money.round(cashIn), chipsInPlay: Money.round(chipsInPlay), discarded, raked, + paidOut, difference, // Half a cent of float dust isn't a discrepancy worth alarming anyone over. balanced: Math.abs(difference) <= Money.EPSILON, diff --git a/tests/holdem/cashOut.test.js b/tests/holdem/cashOut.test.js new file mode 100644 index 0000000..56aa2b4 --- /dev/null +++ b/tests/holdem/cashOut.test.js @@ -0,0 +1,229 @@ +// Somebody leaving with their chips. +// +// The most ordinary thing that happens at a home game after midnight, and the tool had +// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank +// check — cash taken against chips on the table — declared the departing player's whole +// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the +// price of losing their night from the standings and the file the dealer hands over. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Money, HoldemState, Betting, makeTable } = require("./load"); + +const table = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 }); + ["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => { + state.seats[i].name = name; + state.seats[i].buyIns = 200; + }); + return state; +}; + +describe("counting them out", () => { + test("the preview is what the dealer's hands follow", () => { + const state = table(); + state.seats[0].stack = 350; + const preview = HoldemState.cashOutPreview(state, 0); + assert.equal(preview.name, "Ann"); + assert.equal(preview.chips, 350, "what they are holding"); + assert.equal(preview.boughtIn, 200); + assert.equal(preview.net, 150, "up for the night"); + assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it"); + }); + + test("a player who is down reads as down, not as a smaller stack", () => { + const state = table(); + state.seats[1].stack = 60; + assert.equal(HoldemState.cashOutPreview(state, 1).net, -140); + }); + + test("a seat that never set a stack is holding the table's starting stack", () => { + const state = table(); + state.defaultStack = 200; + state.seats[2].stack = null; + assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200); + }); +}); + +describe("what it refuses", () => { + test("paying somebody with money still in the middle", () => { + const state = table(); + Betting.beginHand(state); + const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0); + const refusal = HoldemState.canCashOut(state, bb); + assert.equal(refusal.ok, false); + assert.match(refusal.reason, /in the pot/); + // And the refusal is the whole story: nothing moved. + assert.equal(HoldemState.cashOut(state, bb).ok, false); + assert.equal((state.cashedOut || []).length, 0); + assert.ok(state.seats[bb].name, "the seat is untouched"); + }); + + test("an empty chair, which has nobody to pay", () => { + const state = table(); + const refusal = HoldemState.canCashOut(state, 99); + assert.equal(refusal.ok, false); + }); +}); + +describe("the chair afterwards", () => { + test("is empty and ready for the next player", () => { + const state = table(); + state.seats[0].stack = 350; + HoldemState.cashOut(state, 0); + const seat = state.seats[0]; + assert.equal(seat.name, ""); + assert.equal(seat.stack, null); + assert.equal(seat.buyIns, 0); + assert.equal(seat.inHand, false); + }); + + test("belongs to somebody new, so two people's nights can't merge", () => { + const state = table(); + const wasId = state.seats[0].id; + HoldemState.cashOut(state, 0); + assert.notEqual(state.seats[0].id, wasId); + }); + + test("and isn't owed the blind the person before them was posting", () => { + const state = table(); + state.postingIn = [state.seats[0].id, state.seats[1].id]; + HoldemState.cashOut(state, 0); + assert.deepEqual(state.postingIn, [state.seats[1].id]); + }); +}); + +describe("the bank check", () => { + test("balanced before, balanced after — the chips went somewhere known", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + + HoldemState.cashOut(state, 0); + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.paidOut, 350); + assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took"); + assert.equal(bank.chipsInPlay, 450); + assert.equal(bank.balanced, true); + }); + + test("this is the fault it fixes: wiping the seat by hand loses $350", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; // so the table starts out balanced + // What a dealer had to do before: empty the chair themselves. + state.seats[0].name = ""; + state.seats[0].stack = null; + state.seats[0].inHand = false; + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.balanced, false); + assert.equal(bank.difference, -350, "the tool called it missing money"); + }); + + test("chips that really are missing still show as missing", () => { + // Counting a cash-out must not become a way of swallowing a genuine shortfall. + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; // balanced to start with + HoldemState.cashOut(state, 0); + state.seats[2].stack = 150; // and now fifty chips have gone astray + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.balanced, false); + assert.equal(bank.difference, -50); + }); + + test("a rake and a cash-out are counted apart", () => { + const state = table(); + state.seats[0].stack = 350; + state.rakeTaken = 12; + state.seats[1].stack = 38; // the rake came out of Ben's stack + HoldemState.cashOut(state, 0); + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.raked, 12); + assert.equal(bank.paidOut, 350); + assert.equal(bank.balanced, true); + }); +}); + +describe("the record of their night", () => { + test("they stay in the standings with what they bought and what they left with", () => { + const state = table(); + state.seats[0].stack = 350; + HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]); + HoldemState.cashOut(state, 0); + + const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann"); + assert.ok(ann, "still on the list"); + assert.equal(ann.boughtIn, 200); + assert.equal(ann.chipsInFront, 350, "what the box handed over"); + assert.equal(ann.cashPosition, 150); + assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice"); + }); + + test("somebody still sitting there is not marked", () => { + const state = table(); + HoldemState.cashOut(state, 0); + const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben"); + assert.equal(ben.cashedOut, false); + }); + + test("two people leaving are both kept", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; + HoldemState.cashOut(state, 0); + HoldemState.cashOut(state, 1); + assert.equal(state.cashedOut.length, 2); + assert.equal(HoldemState.bankReconciliation(state).paidOut, 400); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); + + test("a table saved before any of this existed has nobody cashed out", () => { + const parsed = JSON.parse(JSON.stringify(table())); + delete parsed.cashedOut; + const migrated = HoldemState.migrate(parsed); + assert.deepEqual(migrated.cashedOut, []); + assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0); + }); + + test("nonsense in a saved record is cleaned up rather than trusted", () => { + const parsed = JSON.parse(JSON.stringify(table())); + parsed.cashedOut = [ + { id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" }, + { name: "no id" }, + null, + ]; + const migrated = HoldemState.migrate(parsed); + assert.equal(migrated.cashedOut.length, 1); + assert.equal(migrated.cashedOut[0].buyIns, 0); + assert.equal(migrated.cashedOut[0].chips, 350); + assert.equal(migrated.cashedOut[0].at, 0); + }); +}); + +describe("the table carries on", () => { + test("the next hand deals round the empty chair", () => { + const state = table(); + state.seats[0].stack = 350; + HoldemState.cashOut(state, 0); + HoldemState.startNextHand(state); + Betting.beginHand(state); + + assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair"); + assert.equal(Betting.liveSeats(state).length, 3); + assert.ok(state.actingSeatIndex !== null, "somebody is on the clock"); + }); + + test("and the money still adds up once it has", () => { + const state = table(); + state.seats[0].stack = 350; + state.seats[1].stack = 50; + HoldemState.cashOut(state, 0); + HoldemState.startNextHand(state); + Betting.beginHand(state); + const bank = HoldemState.bankReconciliation(state); + assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn); + assert.equal(bank.balanced, true); + }); +}); From 86886ac126bb3473256b74ee4b0fd664e8f6af37 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 08:23:27 -0600 Subject: [PATCH 49/55] Take a hand back, which the rulings sheet has been asking for all along MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This tool has told dealers to redeal on a misdeal since the rulings sheet was written — first card to the wrong seat, a seat entitled to a hand dealt out, one of the first two cards exposed — and it had no way to do it. The only control that ended a live hand was Next Hand, which says out loud that it takes the money off the table without paying anybody. That is the right answer for an abandoned pot and the wrong one for a misdeal, where the blinds and the calls belong to the people who put them in. Undo covers the first second of it: "↶ dealing the hand" is one tap. But the action history lives in memory, so a dealer who is three calls in, or who has refreshed the page mid-hand, has nothing — and a refresh mid-hand is exactly the situation this tool has spent rounds making survivable everywhere else. Misdeal appears in the header while a hand is out and goes away when it isn't: before the deal there is nothing to take back, and afterwards it would be offering to unwind a hand that has already been paid. It says what it will do to the money — "$25 goes back to the players who put it in, cards are cleared, and the same button deals again" — and adds a note when two or more players have already acted, because most rooms let a deal stand at that point. It doesn't refuse: which conditions make a misdeal is the dealer's call and the sheet is right there. What comes back is everything, including chips already swept into hand totals on an earlier street — the first version returned only the current street's and would have quietly kept a flop's betting. What stays is the button, the blinds, the straddle and anyone posting in: a misdeal is the same hand dealt again, not the next one, so the hand number doesn't move and nothing is logged. 13 new tests, with the sweep and the straddle broken on purpose to confirm they catch it. Measured through the UI: pot $25 with three players in, take it back, every stack is exactly $200 again, the bank still balances, and dealing again posts the same blinds on the same players from the same button. 156 audits with the button live, including a landscape header that stays one row at 46px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 1 + .../wwwroot/js/casino/holdem/betting.js | 49 ++++++ .../wwwroot/js/casino/holdem/main.js | 25 +++ .../wwwroot/js/casino/holdem/render.js | 11 ++ tests/holdem/misdeal.test.js | 155 ++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 tests/holdem/misdeal.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 40dbd8c..7a65cbb 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -14,6 +14,7 @@
Quick Split + diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 07f9b21..94aada2 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -459,6 +459,55 @@ const Betting = { return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed); }, + // A misdeal: the hand never happened, so every chip goes back where it came from. + // + // The rulings sheet in this tool has told dealers to redeal since the day it was + // written — first card to the wrong seat, a seat entitled to a hand dealt out, one + // of the first two cards exposed — and the tool had no way to do it. The only + // control that ended a live hand was Next Hand, which says out loud that it takes + // the money off the table without paying anybody. That is right for an abandoned + // pot and wrong for a misdeal, where the blinds and any calls belong to the people + // who put them in. + // + // Undo covers the first second of it — "↶ dealing the hand" is one tap — but only + // while the action history is still there. It lives in memory, so a dealer who + // refreshes the page mid-hand, or who is three calls in, has nothing. + // + // What is deliberately kept: the button, the blinds, the straddle and anyone + // posting in. A misdeal is the *same* hand dealt again, not the next one. + takeHandBack(state) { + const returned = []; + state.seats.forEach((player, index) => { + const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0)); + if (back > 0) { + player.stack = this.round(this.stackOf(state, player) + back); + returned.push({ index, name: HoldemState.displayName(player, index), amount: back }); + } + player.committed = 0; + player.amountOverride = null; + player.hasActed = false; + player.folded = false; + player.cards = ["", ""]; + }); + + state.bettingOpen = false; + state.street = "preflop"; + state.currentBet = 0; + state.lastRaiseSize = 0; + state.actingSeatIndex = null; + state.streetAggressorIndex = null; + state.handAggressorIndex = null; + // Nothing was posted, as far as the next rotation is concerned: the hand this + // would have counted as is being dealt again from the same button. + state.postedBlinds = null; + state.postedStraddles = []; + + return { + returned, + total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)), + }; + }, + // Pulls the blinds back off the table and posts them again from wherever the // button and the blind assignment now say they belong. Refunding first is what // keeps the chip count straight when the button gets passed round three or four diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 8f48301..4f40c65 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -401,6 +401,31 @@ document.addEventListener("DOMContentLoaded", () => { Render.renderAll(); }); + // A misdeal. Everything in the middle goes back to whoever put it there, and the + // same button deals the same hand again. + document.getElementById("misdealBtn").addEventListener("click", async () => { + const inPlay = HoldemState.unresolvedContributions(state); + const acted = state.seats.filter(p => p.hasActed).length; + + const ok = await Dialog.confirm({ + title: "Misdeal — take the hand back?", + message: inPlay > Betting.EPSILON_CHIPS + ? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` + + `button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}` + : "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.", + confirmLabel: "Take it back", + }); + if (!ok) return; + + // The label is both the toast a dealer reads now and the words on the undo + // control afterwards, so it says what happened to the money rather than naming + // the ruling: "the misdeal" told a table nothing about where their chips went. + const label = inPlay > Betting.EPSILON_CHIPS + ? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.` + : "Misdeal — nothing was in yet, same button deals again."; + runUndoable(label, () => Betting.takeHandBack(state)); + }); + document.getElementById("nextHandBtn").addEventListener("click", async () => { // Moving on wipes every seat's contribution, so an unresolved pot is simply // destroyed — measured at a four-handed table, pressing this with $8 in the diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index f9c38f5..43a4cba 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -33,6 +33,7 @@ const Render = { this.renderNotes(); this.updatePotBadge(); this.renderSetupFields(); + this.renderHeaderActions(); this.renderTableHint(); this.renderStandings(); this.renderHandHistory(); @@ -62,6 +63,16 @@ const Render = { if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`; }, + // Misdeal is only a thing you can call on a hand that is out. Before the deal there + // is nothing to take back, and afterwards the header would be offering to unwind a + // hand that has already been paid — so the button comes and goes with the hand + // rather than sitting there all night waiting to be pressed by accident. + renderHeaderActions() { + const misdeal = document.getElementById("misdealBtn"); + if (!misdeal) return; + misdeal.hidden = !this.state.bettingOpen; + }, + // Whether this device can actually drag a seat. HTML5 drag events are never // generated from a touch screen, so on a phone the last clause of the hint below // described something impossible — on the device the rest of the sentence was diff --git a/tests/holdem/misdeal.test.js b/tests/holdem/misdeal.test.js new file mode 100644 index 0000000..2a4b737 --- /dev/null +++ b/tests/holdem/misdeal.test.js @@ -0,0 +1,155 @@ +// Taking a hand back. +// +// The rulings sheet in this tool has told dealers to redeal since the day it was +// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of +// the first two cards exposed — and the tool had no way to do it. The only control that +// ended a live hand was Next Hand, which says out loud that it takes the money off the +// table without paying anybody. That is right for an abandoned pot and wrong for a +// misdeal, where the blinds and the calls belong to the people who put them in. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load"); + +const dealt = (extra = {}) => { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra }); + Betting.beginHand(state); + return state; +}; + +describe("the money", () => { + test("goes back to the people who put it in", () => { + const state = dealt(); + const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p)); + Betting.takeHandBack(state); + assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before); + }); + + test("including calls and raises, not just the blinds", () => { + const state = dealt(); + Betting.raiseTo(state, state.actingSeatIndex, 40); + Betting.call(state, state.actingSeatIndex); + assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle"); + + const taken = Betting.takeHandBack(state); + assert.equal(taken.total, 95, "blinds and both wagers"); + assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]); + }); + + test("and across streets, once chips have been swept into hand totals", () => { + const state = dealt(); + [3, 4, 5, 0, 1, 2].forEach(seat => { + if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); + }); + Betting.closeStreet(state); + assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked"); + + Betting.takeHandBack(state); + assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]); + assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]); + }); + + test("the table has exactly the chips it started with", () => { + const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 }); + const total = chipsOnTable(fresh); + const state = dealt(); + Betting.raiseTo(state, state.actingSeatIndex, 40); + Betting.takeHandBack(state); + assert.equal(chipsOnTable(state), total); + }); + + test("nothing is thrown away — the discard pile doesn't move", () => { + const state = dealt(); + state.discarded = 8; + Betting.takeHandBack(state); + assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot"); + }); + + test("it says what went back and to whom", () => { + const state = dealt(); + const taken = Betting.takeHandBack(state); + assert.equal(taken.returned.length, 2, "the two blinds"); + assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]); + assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud"); + }); +}); + +describe("the table afterwards", () => { + test("is waiting to deal the same hand again", () => { + const state = dealt(); + Betting.takeHandBack(state); + assert.equal(state.bettingOpen, false, "the dealer says when"); + assert.equal(state.street, "preflop"); + assert.equal(state.actingSeatIndex, null); + assert.equal(state.currentBet, 0); + }); + + test("keeps the button where it was — a misdeal is not the next hand", () => { + const state = dealt({ dealerSeatIndex: 3 }); + const handNumber = state.handNumber; + Betting.takeHandBack(state); + assert.equal(state.dealerSeatIndex, 3); + assert.equal(state.handNumber, handNumber, "no hand has been played"); + assert.equal((state.handHistory || []).length, 0, "and none is logged"); + }); + + test("keeps the straddle, which was announced and still stands", () => { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 }); + state.straddleCount = 1; + Betting.beginHand(state); + assert.deepEqual(state.postedStraddles, [3]); + + Betting.takeHandBack(state); + assert.equal(state.straddleCount, 1, "still on for the redeal"); + assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet"); + + Betting.beginHand(state); + assert.deepEqual(state.postedStraddles, [3], "and it goes out again"); + }); + + test("clears the cards and the folds, because none of it happened", () => { + const state = dealt(); + state.seats[3].cards = ["As", "Kd"]; + Betting.fold(state, 3); + Betting.takeHandBack(state); + assert.deepEqual(state.seats[3].cards, ["", ""]); + assert.equal(state.seats[3].folded, false); + assert.ok(state.seats.every(p => !p.hasActed)); + }); + + test("deals again cleanly, with the same blinds on the same players", () => { + const state = dealt(); + const first = state.seats.map(p => Betting.committed(p)); + Betting.takeHandBack(state); + Betting.beginHand(state); + assert.deepEqual(state.seats.map(p => Betting.committed(p)), first); + }); +}); + +describe("the bank still balances", () => { + test("through a misdeal and the redeal after it", () => { + const state = dealt(); + state.seats.forEach(p => { p.buyIns = 200; }); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + + Betting.raiseTo(state, state.actingSeatIndex, 60); + Betting.takeHandBack(state); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + + Betting.beginHand(state); + const bank = HoldemState.bankReconciliation(state); + assert.equal(bank.balanced, true); + assert.equal(bank.cashIn, 1200); + }); +}); + +describe("taking back a hand nobody had money in", () => { + test("is harmless: no blinds, nothing moves", () => { + const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 }); + Betting.beginHand(state); + const taken = Betting.takeHandBack(state); + assert.equal(taken.total, 0); + assert.deepEqual(taken.returned, []); + assert.equal(state.bettingOpen, false); + }); +}); From 55eb569d3b366a6015b620044606e4b6e3fe48c5 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Wed, 2 Sep 2026 08:37:44 -0600 Subject: [PATCH 50/55] Keep the list of who's next, instead of leaving it on a napkin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full home game always has two or three people standing behind the chairs, and the list of who's next lives on a napkin or in the dealer's head. That is where the argument an hour later comes from. The tool had just learned to open a chair — somebody cashes out and the seat is free — and had nothing to say about who should sit in it. The Session panel keeps the list in order of arrival, which is the only ordering anybody has ever agreed to. Whoever is at the front is offered the first open chair by number, and everybody else waits their turn: a list that isn't kept in order is a pile of names, and jumping the queue is the thing a dealer gets shouted at for. With the table full it says so rather than offering a seat that doesn't exist. The same offer appears on the empty chair's own menu — "Seat Priya" — because tapping the chair somebody is about to sit in is what a dealer's hand does anyway. Sitting somebody down puts their name on the chair and nothing else. No stack, no buy-in, not dealt in: they haven't paid yet, and inventing chips for them would put money on the table nobody has handed over, which is the one thing the bank check exists to catch. Add chips is the next thing the dealer does, and it is one tap away in the same menu. Two people called Mike are two people — the list is keyed by id, not by name. Names are trimmed, blanks are refused, and a saved list from a table that never had one comes back empty rather than undefined. 15 new tests, with the occupied-chair guard removed and a stack handed to the new player on purpose to confirm they fail. Walked through the browser: two names added to a full table, a player cashed out to open seat 2, the front of the queue seated there with nothing in front of them, the bank still balanced, and the second name taken off the list. 156 audits with the list populated, including a name long enough to test the row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 8 + JoshHeaps.Net/wwwroot/css/casino/holdem.css | 80 +++++++++ .../wwwroot/js/casino/holdem/main.js | 39 ++++ .../wwwroot/js/casino/holdem/render.js | 54 ++++++ .../wwwroot/js/casino/holdem/seatMenu.js | 15 ++ .../wwwroot/js/casino/holdem/state.js | 67 +++++++ tests/holdem/waitingList.test.js | 167 ++++++++++++++++++ 7 files changed, 430 insertions(+) create mode 100644 tests/holdem/waitingList.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 7a65cbb..ad5b098 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -146,6 +146,14 @@

Everyone who has bought in shows up here, with what they'd be owed if the night ended now.

+

Waiting list

+
+ + +
+
    +

    Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.

    +