From 3d63ba50ee610876ec70359080e28f7f5be1145d Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 03:40:02 -0600 Subject: [PATCH] Handle the messy table: people joining, leaving, and arguing about blinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home games don't run like a cardroom. People walk up mid-hand, storm off mid-hand, and pass the button round three times before anyone deals. Probing each of those found four problems, one of which loses chips. A player taken out mid-hand took their chips with them `computeContribution` opened with `if (!player.inHand) return 0`, so the moment a dealer switched a seat off, everything that seat had already put in vanished from the pot. Measured: four players in for $2 each, one taken out, pot drops from $8 to $6. Their $2 was still sitting on the seat record — it just stopped counting. A dealer following the tool would have paid out $6 of an $8 pot. Chips already pushed into the middle are dead money. They stay in the pot and go to whoever wins, whether or not the player is still in the hand, and it is not theirs to take back. The `inHand` check now guards only the table-wide base-pot default, which does have to stay tied to being dealt in or every empty chair would be treated as having anted. The pot wizard counts a departed seat as folded, so its chips are in the pot but it can never be picked as a winner. Passing the button before the cards come out Moving the button left the posted blinds where they were while everything positional started reading off the new spot, so the money and the blind positions silently disagreed. While nothing but the blinds are in, moving the button now refunds them and re-posts from where it lands — pass it round six times and the chip count is unchanged. Once anybody has acted the blinds are settled and it stops offering. Naming the blinds by hand Blinds were purely positional with no way to overrule them. The seat menu now offers "Make small blind" / "Make big blind" while only the blinds are in, and a badge with a ring marks a blind that was named rather than dealt. Naming one pins the other where it is. It's refused if it would put both blinds on one player or post to somebody who isn't dealt in, and it lasts one hand — carrying it forward would freeze the blinds on two people for the night. The first hand, and a table that empties out A fresh table puts the button on seat 1 whether or not anyone is sitting there, and a table can empty out around it. The button is moved onto a real player before a hand is dealt. Joining mid-hand A newcomer was added straight into the live seats and would be asked to act in a hand they hadn't been paying into. They now sit the hand out and are a full player from the next one. Nothing is covered on a phone any more Swept 13 viewport sizes with elementFromPoint, five points per control, on the table and every overlay. Before: the action bar covered the bottom seats on short screens (62px of felt at 360x640), the toast covered up to six seats at once — Undo button included, so a tap meant for a player hit "Undo" — and in landscape the table came out 765px tall in a 390px window, putting six of nine seats off-screen entirely. - the action bar's real height is published as `--actionBarHeight` and drives both the page's bottom padding and the toast, replacing a hardcoded 9rem that was wrong the moment the raise pad opened - landscape sizes the felt from the height that's left, not from width. The first attempt did nothing: `padding-bottom` is a percentage of the containing block's *width*, so narrowing the wrap left it full height, and a `max-width: 900px` rule later in the file was overriding it anyway. It's set outright now and lives at the end of the sheet. - on short screens the toast floats over the action bar's status readout rather than over the seats. Nowhere is clear of both; covering something you re-read beats covering something you tap. - headers go compact on short screens, which is where the vertical room came from 13/13 sizes clean, 360x640 through 844x390, portrait and landscape. Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles rather than clearing, and `manual` is undefined for positional blinds, so the hand-picked ring appeared on alternate renders. 18 new tests (76 total, all passing). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 116 +++++++- .../wwwroot/js/casino/holdem/actionBar.js | 27 ++ .../wwwroot/js/casino/holdem/betting.js | 27 ++ .../wwwroot/js/casino/holdem/main.js | 67 ++++- .../wwwroot/js/casino/holdem/potWizard.js | 5 +- .../wwwroot/js/casino/holdem/render.js | 23 +- .../wwwroot/js/casino/holdem/seatMenu.js | 18 ++ .../wwwroot/js/casino/holdem/state.js | 76 ++++- tests/holdem/messyTables.test.js | 260 ++++++++++++++++++ 9 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 tests/holdem/messyTables.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index c01911a..90424e3 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -306,8 +306,10 @@ button, position: relative; width: 100%; padding-bottom: 72%; + margin: 0 auto; } + #pokerTable { position: absolute; inset: 6%; @@ -627,6 +629,13 @@ button, color: #fff7e6; } +/* 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 { + outline: 1.5px solid #f2f2f2; + outline-offset: 1px; +} + /* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */ .seatEditorPanel { @@ -1955,8 +1964,13 @@ body.hasActionBar #holdemSidebar { /* Sit above the action bar rather than on top of it, so an Undo never covers the Fold button the dealer is reaching for. */ +/* Sit the toast directly on top of the action bar, whatever height it happens to be. + This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the + moment the raise pad opens — and on a short phone it put the toast, Undo button and + all, straight over the bottom seats of the table, so a tap meant for a player hit + "Undo" instead. */ body.hasActionBar #toastHost { - bottom: calc(9rem + env(safe-area-inset-bottom, 0px)); + bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px)); } .toast { @@ -2287,8 +2301,13 @@ body.hasActionBar #toastHost { seat looks like it did nothing. Inset the ring by half a puck, and float the editor up as a bottom sheet over the table. */ @media (max-width: 700px) { + /* The bottom padding has to clear the fixed action bar, plus room for a toast to + sit above it. Without it the table just carries on underneath the bar and the + lower seats of the oval are behind the Fold button — measured at 360x640, where + 62px of felt was covered. `--actionBarHeight` is published by actionBar.js and + is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */ #holdemApp { - padding: 1rem 0.75rem 2rem; + padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px)); } #holdemHeader { @@ -2560,3 +2579,96 @@ body.hasActionBar #toastHost { justify-content: center; } } + +/* ---------- A phone on its side ---------- + Deliberately last in the file. The felt is an aspect-ratio box whose height comes + from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100% + — so a landscape rule placed any earlier is overridden at equal specificity and + does nothing, which is exactly what happened on the first attempt. + + Landscape is where this layout falls apart worst: sized from width, the table came + out 765px tall in a 390px-tall window, putting six of the nine seats below the fold + and the rest behind the action bar. Sizing it from the height that's actually left + makes every seat reachable without scrolling. The table ends up small, but a seat + you have to go hunting for is worse than a seat that's small. */ +@media (orientation: landscape) and (max-height: 560px) { + /* Set outright rather than via padding-bottom: a percentage padding resolves + against the containing block's width, not the element's own, so narrowing the + wrap on its own left it full height regardless. */ + #pokerTableWrap { + --feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 8.5rem); + width: min(100%, calc(var(--feltRoom) / 0.72)); + height: var(--feltRoom); + padding-bottom: 0; + } + + /* 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. */ + #holdemHeader { + margin-bottom: 0.4rem; + gap: 0.35rem; + } + + #holdemHeader h1 { + font-size: 1.05rem; + padding-bottom: 0.15rem; + } + + .holdemSubtitle { + display: none; + } + + /* There's no vertical room left, but plenty either side of a table this narrow, + so the toast moves out of the middle and sits beside the felt instead of on + top of it. Centred, it was covering up to six seats at once — and it carries + an Undo button, so a tap meant for a player was landing on "Undo". */ + body.hasActionBar #toastHost, + #toastHost { + left: auto; + right: 0.5rem; + max-width: 17rem; + bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px)); + } + + /* Nine seats around a table this small overlap each other, so they shrink to suit. */ + .seat { + width: 62px; + min-height: 40px; + } +} + +/* ---------- Short phones, upright ---------- + A 640px-tall screen spends ~150px on the header before the felt even starts, which + left the bottom of the table sitting behind the action bar until you scrolled. The + header carries no information a dealer needs mid-hand, so it gives the space up. */ +@media (max-height: 700px) { + /* Nowhere on a screen this short is the toast clear of both the table and the + action bar, so it overlaps the one that costs least: it sits over the bar's + status line, which is information a dealer can re-read a second later, rather + than over the seats and buttons, which are things they need to hit. Overlapping + a readout beats covering a control. */ + body.hasActionBar #toastHost { + bottom: calc(var(--actionBarHeight, 0px) - 3.1rem); + } + + .toast { + padding: 0.5rem 0.7rem; + font-size: 0.78rem; + } +} + +@media (max-width: 700px) and (max-height: 700px) { + #holdemHeader { + margin-bottom: 0.5rem; + gap: 0.4rem; + } + + #holdemHeader h1 { + font-size: 1.15rem; + padding-bottom: 0.2rem; + } + + .holdemSubtitle { + display: none; + } +} diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 3673f21..6029bcc 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -11,9 +11,35 @@ const ActionBar = { mount(state, hooks) { this.state = state; this.hooks = hooks; + this.watchHeight(); this.render(); }, + // The bar is fixed to the bottom of the screen, so everything else has to know how + // tall it is or the table simply runs underneath it — on a 640px-tall phone the + // bottom seats of the oval were sitting behind the Fold button. Its height isn't a + // constant either: opening the raise pad grows it. Publishing the measured height + // as a custom property lets the layout and the toast both stay clear of it without + // anyone guessing a number. + watchHeight() { + const host = document.getElementById("actionBar"); + if (!host || this.heightWatcher) return; + + const publish = () => { + const visible = !host.hidden; + const height = visible ? host.getBoundingClientRect().height : 0; + document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`); + }; + + if (typeof ResizeObserver === "function") { + this.heightWatcher = new ResizeObserver(publish); + this.heightWatcher.observe(host); + } + window.addEventListener("resize", publish); + window.addEventListener("orientationchange", publish); + publish(); + }, + // Anything to act on? A hand with nobody dealt in doesn't get a bar. isRunning() { return Betting.liveSeats(this.state).length > 0; @@ -27,6 +53,7 @@ const ActionBar = { if (!this.isRunning()) { host.hidden = true; document.body.classList.remove("hasActionBar"); + document.documentElement.style.setProperty("--actionBarHeight", "0px"); this.stopTankTimer(); this.tankSeatIndex = null; return; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index ff13cce..3c928ed 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -243,6 +243,28 @@ const Betting = { return this.liveSeats(state).length > 1 && !this.canStillBet(state); }, + // True while nothing has happened in this hand except the blinds going in. Up to + // that point the dealer can still move the button or rename the blinds and have + // it cost nothing — which is the state a home game spends a lot of time in, + // arguing about whose turn it is before the cards come out. + onlyBlindsIn(state) { + return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed); + }, + + // 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 + // times before anybody is happy with it. + repostBlinds(state) { + state.seats.forEach(player => { + const back = this.committed(player); + if (back > 0) player.stack = this.round(this.stackOf(state, player) + back); + player.committed = 0; + player.hasActed = false; + }); + this.beginHand(state); + }, + // Deals a fresh betting round: blinds posted, action to the seat after the big // blind, which is where a dealer expects to be looking when the cards go out. beginHand(state) { @@ -250,6 +272,11 @@ const Betting = { state.currentBet = 0; state.lastRaiseSize = 0; + // Position is read off the button all through the hand, so it has to be on + // somebody before the blinds are worked out — not on the empty seat 1 a + // brand-new table starts on. + HoldemState.ensureButtonOnPlayer(state); + state.seats.forEach(player => { player.committed = 0; player.hasActed = false; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 5c8a1c2..aea2b0d 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -52,6 +52,7 @@ document.addEventListener("DOMContentLoaded", () => { }, onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index), + onGiveButton: index => seatMenuHooks.makeDealer(index), }; const seatMenuHooks = { @@ -59,13 +60,71 @@ document.addEventListener("DOMContentLoaded", () => { unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }), allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)), toggleDealtIn: index => { - const dealtIn = state.seats[index].inHand; - seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => { + const player = state.seats[index]; + const dealtIn = player.inHand; + const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0; + const alreadyIn = Betting.handTotal(player); + + // Somebody walking away mid-hand doesn't get their chips back — they're + // dead money the winner collects. Say so, because the pot total pointedly + // does not go down and a dealer needs to know that's deliberate. + const message = dealtIn + ? (alreadyIn > 0 + ? `${nameOf(index)} is out — their ${Money.format(alreadyIn)} stays in the pot.` + : `${nameOf(index)} sits out.`) + : (handRunning + ? `${nameOf(index)} is in from the next hand.` + : `${nameOf(index)} is dealt in.`); + + seatAction(index, message, p => { p.inHand = !dealtIn; - if (!p.inHand) p.folded = false; + if (!p.inHand) { + // Their chips stay put; only the fold flag is cleared, since being + // out of the hand already covers not winning it. + p.folded = false; + if (state.actingSeatIndex === index) Betting.passAction(state, index); + } else if (handRunning) { + // Joining a hand already in progress means sitting this one out — + // you can't buy into a pot you haven't been paying for. `folded` + // is exactly that state, and the next hand clears it. + p.folded = true; + } }); }, - makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }), + + makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { + state.dealerSeatIndex = index; + // Before anyone has acted the blinds are still just sitting there, so + // moving the button moves them too — a home game passes it round several + // times before the cards come out, and the posted chips have to follow. + if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); + }), + + assignBlind: (index, which) => { + const blinds = HoldemState.getBlindSeats(state); + const other = which === "sb" ? blinds.bbIndex : blinds.sbIndex; + // Naming one blind pins the other where it already is, so the dealer only + // has to state the part they're changing. If that would land both on one + // player, the other blind moves to the next seat along. + let partner = other !== null && other !== undefined && other !== index + ? other + : Betting.nextLiveIndex(state, index); + if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index); + + const label = which === "sb" ? "small blind" : "big blind"; + seatAction(index, `${nameOf(index)} posts the ${label}.`, () => { + state.blindOverride = which === "sb" + ? { sbIndex: index, bbIndex: partner } + : { sbIndex: partner, bbIndex: index }; + if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); + }); + }, + + clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => { + state.blindOverride = null; + if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); + }), + editSeat: index => Render.toggleSeatEditor(index), addChips: index => AddChips.open(state, index, (seatIndex, amount) => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index 866658c..f9d23fe 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -30,7 +30,10 @@ const PotWizard = { const contributions = players.map(p => ({ id: p.id, contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), - folded: p.folded, + // A seat switched off mid-hand still has its chips in the pot, but it + // can no more win them than a folded one can — same dead money, and the + // wizard must not offer it as a winner. + folded: p.folded || HoldemState.isDeadMoney(p), })); this.entries = PotCalculator.buildEntries(contributions); // Kept so the wizard can show the whole breakdown up front, not just the layer diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index d25950f..92af29c 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -158,7 +158,7 @@ const Render = { const layer = document.getElementById("seatsLayer"); layer.innerHTML = ""; const template = document.getElementById("seatTemplate"); - const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state); + const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); this.state.seats.forEach((player, index) => { const node = template.content.firstElementChild.cloneNode(true); @@ -172,15 +172,26 @@ const Render = { const dealerChip = node.querySelector(".dealerChip"); dealerChip.classList.toggle("active", index === dealerIndex); dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer); + // Tapping the puck is the same act as "Give the button" in the seat menu, + // so it goes through the same hook — otherwise this one path would move + // the button without moving the blinds that follow it. dealerChip.addEventListener("click", e => { e.stopPropagation(); - this.state.dealerSeatIndex = index; - this.hooks.save(this.state); - this.renderSeats(); + this.hooks.onGiveButton(index); }); - node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex); - node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex); + const sbBadge = node.querySelector(".sbBadge"); + const bbBadge = node.querySelector(".bbBadge"); + sbBadge.classList.toggle("visible", index === sbIndex); + bbBadge.classList.toggle("visible", index === bbIndex); + // A blind the dealer named by hand isn't where the button would have put + // it, so it's marked — otherwise the table looks like it's just wrong. + // The `!!` matters: getBlindSeats leaves `manual` undefined when the + // blinds are positional, and classList.toggle with an undefined second + // argument *flips* the class instead of clearing it, so the marker + // appeared and disappeared on alternate renders. + sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex); + bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex); this.updatePuckContent(node, player, index); node.classList.toggle("seatSelected", index === this.selectedSeatIndex); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js index 7c05825..e80d900 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js @@ -45,6 +45,24 @@ const SeatMenu = { actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) }); } + // Naming a blind by hand. Only offered while nothing but the blinds are in — + // once somebody has called, moving a blind would mean unpicking a bet that + // has already been answered, and that's a decision for the dealer's mouth, + // not a menu item. Which blind a seat already has is shown rather than + // offered again. + if (dealtIn && Betting.onlyBlindsIn(this.state)) { + const blinds = HoldemState.getBlindSeats(this.state); + if (blinds.sbIndex !== index) { + actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") }); + } + if (blinds.bbIndex !== index) { + actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") }); + } + if (this.state.blindOverride) { + actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() }); + } + } + actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) }); return actions; }, diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 9ae4830..9eac9b8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -58,6 +58,12 @@ const HoldemState = { currentBet: 0, lastRaiseSize: 0, actingSeatIndex: null, + // Set when the dealer names the blinds by hand instead of letting them + // follow the button — { sbIndex, bbIndex }. Home games do this constantly + // ("you had it last time", someone arriving late owing a blind), and the + // button order is the wrong answer often enough that it has to be + // overridable. Cleared at the start of every hand. + blindOverride: null, }; }, @@ -73,7 +79,8 @@ const HoldemState = { // Tables saved before live betting existed are missing its fields; fill // them in rather than making the dealer reset a table mid-session. const defaults = this.createDefault(); - ["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", "actingSeatIndex"] + ["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize", + "actingSeatIndex", "blindOverride"] .forEach(key => { if (parsed[key] === undefined) parsed[key] = defaults[key]; }); @@ -124,9 +131,14 @@ const HoldemState = { }, // Contribution the table calculator will treat this player as having put in. + // + // Chips already pushed into the middle count whether or not the player is still + // in the hand. Someone who storms off, gets taken out by the dealer, or is + // dealt out mid-hand leaves their money on the felt — it's dead money owed to + // whoever wins, and it is not theirs to take back. Reading `inHand` first is + // how those chips used to disappear out of the pot the instant a seat was + // switched off, quietly shrinking the payout by whatever they had posted. computeContribution(player, chipLegend, basePotAmount) { - if (!player.inHand) return 0; - const override = player.amountOverride; if (override !== null && override !== undefined && String(override).trim() !== "") { const parsed = Number(override); @@ -139,10 +151,22 @@ const HoldemState = { }, 0); if (chipTotal > 0) return chipTotal; + // The table-wide default is the one thing that stays tied to being dealt in: + // it's a stand-in for "everyone put the same amount in", so applying it to + // empty chairs would invent a contribution from every seat nobody is using. + if (!player.inHand) return 0; + const base = Number(basePotAmount); return Number.isNaN(base) ? 0 : Math.max(0, base); }, + // Anyone whose chips are in the pot, whether or not they're still in the hand. + // A seat that's been switched off mid-hand is dead money: it counts towards the + // pot but can never be picked as a winner, exactly like a fold. + isDeadMoney(player) { + return !player.inHand; + }, + totalPot(state) { return state.seats.reduce( (sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount), @@ -188,12 +212,52 @@ const HoldemState = { return order; }, + // A button sitting on an empty chair is only cosmetic until you try to read + // position off it. On the very first hand of a fresh table it starts on seat 1 + // whether or not anybody is sitting there, and a table that empties out around it + // leaves it stranded. Move it to a real player before a hand is dealt. + ensureButtonOnPlayer(state) { + if (state.seats[state.dealerSeatIndex] && state.seats[state.dealerSeatIndex].inHand) return; + for (let step = 0; step < state.seatCount; step++) { + const index = this.nextSeatIndex(state, state.dealerSeatIndex, step); + if (state.seats[index].inHand) { + state.dealerSeatIndex = index; + return; + } + } + }, + + // A hand-picked blind is only usable if that seat is actually in the hand — a + // dealer who names a blind and then deals that player out shouldn't have the + // table quietly post to nobody. + validBlindOverride(state) { + const o = state.blindOverride; + if (!o) return null; + const ok = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i] && state.seats[i].inHand; + if (!ok(o.sbIndex) || !ok(o.bbIndex) || o.sbIndex === o.bbIndex) return null; + return o; + }, + // Who posts what. Heads-up is its own rule: with two players the button posts the // small blind, which is the opposite of the usual order. getBlindSeats(state) { const live = this.liveOrderFromButton(state); const dealerIndex = state.dealerSeatIndex; + // A blind the dealer named by hand wins over anything positional. Heads-up is + // still reported honestly so the betting order stays right — who posts what + // is the dealer's call, but two-handed play acting in reverse is a rule. + const override = this.validBlindOverride(state); + if (override) { + return { + dealerIndex, + sbIndex: override.sbIndex, + bbIndex: override.bbIndex, + headsUp: live.length === 2, + manual: true, + }; + } + if (live.length < 2) { return { dealerIndex, sbIndex: null, bbIndex: null, headsUp: false }; } @@ -210,6 +274,10 @@ const HoldemState = { startNextHand(state) { this.advanceButton(state); + // A hand-picked blind applies to the hand it was picked for. Carrying it into + // the next one would freeze the blinds on two players for the rest of the + // night, which is the opposite of what naming them once is for. + state.blindOverride = null; state.seats.forEach(p => { p.folded = false; p.cards = ["", ""]; @@ -218,6 +286,8 @@ const HoldemState = { p.committed = 0; p.hasActed = false; }); + // Anyone who joined mid-hand is a full player again from here. + this.ensureButtonOnPlayer(state); }, // Appends a resolved-pot record to the running session log a dealer can point to diff --git a/tests/holdem/messyTables.test.js b/tests/holdem/messyTables.test.js new file mode 100644 index 0000000..0e9fc13 --- /dev/null +++ b/tests/holdem/messyTables.test.js @@ -0,0 +1,260 @@ +// Home games are messy. People walk up mid-hand, storm off mid-hand, argue about +// whose blind it is, and pass the button round three times before anyone deals. +// These are the cases the positional rules quietly get wrong. +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Betting, HoldemState, PotCalculator, Money, makeTable, chipsOnTable, contributions } = require("./load"); + +const potOf = state => HoldemState.totalPot(state); + +describe("a player leaves in the middle of a hand", () => { + test("their chips stay in the pot", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + const before = potOf(state); + assert.equal(before, 8); + + state.seats[1].inHand = false; // dealer takes them out + + assert.equal(potOf(state), before, "the pot must not shrink when a player is taken out"); + }); + + test("they cannot be paid out of a pot they walked away from", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + state.seats[1].inHand = false; + + const players = state.seats + .filter(p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0) + .map(p => ({ + id: p.id, + contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), + folded: p.folded || HoldemState.isDeadMoney(p), + })); + + const layers = PotCalculator.layers(players); + assert.ok(!layers[0].eligible.includes(state.seats[1].id), "a departed player can't win"); + assert.equal(layers.reduce((s, l) => s + l.amount, 0), 6, "but their money is still being played for"); + }); + + test("taking out the player who is to act moves the action on", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + const acting = state.actingSeatIndex; + + state.seats[acting].inHand = false; + Betting.passAction(state, acting); + + assert.notEqual(state.actingSeatIndex, acting, "the table can't wait on somebody who left"); + assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand); + }); + + test("an empty chair still contributes nothing, base pot or not", () => { + // The table-wide default must not be read as an ante from seats nobody is in. + const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0 }); + state.basePotAmount = 25; + assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, 25), 0); + }); +}); + +describe("a player joins in the middle of a hand", () => { + test("they sit the hand out rather than joining a pot they never paid into", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2); + Betting.sweepToHandTotals(state); + + // Walks up and is dealt in — the app marks them folded for this hand. + state.seats[4].inHand = true; + state.seats[4].stack = 100; + state.seats[4].folded = true; + + assert.ok(!Betting.liveSeats(state).some(s => s.index === 4), "not live this hand"); + assert.equal(HoldemState.computeContribution(state.seats[4], state.chipLegend, state.basePotAmount), 0); + }); + + test("they are a full player again on the next hand", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + Betting.beginHand(state); + state.seats[4].inHand = true; + state.seats[4].stack = 100; + state.seats[4].folded = true; + + HoldemState.startNextHand(state); + Betting.beginHand(state); + + assert.equal(state.seats[4].folded, false); + assert.ok(Betting.liveSeats(state).some(s => s.index === 4), "dealt in like anyone else"); + }); +}); + +describe("passing the button around before the cards come out", () => { + test("moving the button re-posts the blinds and keeps the chip count straight", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + const startingChips = chipsOnTable(state); + Betting.beginHand(state); + + assert.equal(state.seats[1].committed, 1); + assert.equal(state.seats[2].committed, 2); + + state.dealerSeatIndex = 2; + Betting.repostBlinds(state); + + assert.equal(state.seats[1].committed, 0, "the old small blind got their chip back"); + assert.equal(state.seats[3].committed, 1, "and the new one has posted"); + assert.equal(state.seats[0].committed, 2); + assert.equal(chipsOnTable(state), startingChips, "no chips created or lost in the shuffle"); + }); + + test("passing it round many times still balances", () => { + const state = makeTable({ seatCount: 8, seated: [0, 2, 3, 5, 7], dealerSeatIndex: 0 }); + const startingChips = chipsOnTable(state); + Betting.beginHand(state); + + [2, 5, 7, 3, 0, 5].forEach(seat => { + state.dealerSeatIndex = seat; + Betting.repostBlinds(state); + assert.equal(chipsOnTable(state), startingChips, `chips drifted after passing to seat ${seat}`); + }); + + const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); + assert.equal(Betting.committed(state.seats[sbIndex]), 1); + assert.equal(Betting.committed(state.seats[bbIndex]), 2); + }); + + test("once somebody has acted, the blinds are settled", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + assert.equal(Betting.onlyBlindsIn(state), true, "nothing but blinds so far"); + + Betting.call(state, 3); + assert.equal(Betting.onlyBlindsIn(state), false, "a call means the blinds are set where they are"); + }); +}); + +describe("naming the blinds by hand", () => { + test("a named blind wins over the button order", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + + const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state); + assert.equal(sbIndex, 3); + assert.equal(bbIndex, 0); + assert.equal(manual, true); + + Betting.beginHand(state); + assert.equal(state.seats[3].committed, 1); + assert.equal(state.seats[0].committed, 2); + assert.equal(state.seats[1].committed, 0, "the positional small blind posts nothing"); + }); + + test("a named blind on a player who isn't dealt in is ignored", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + state.blindOverride = { sbIndex: 4, bbIndex: 0 }; // seat 4 is an empty chair + + const { sbIndex, manual } = HoldemState.getBlindSeats(state); + assert.ok(!manual, "falls back to the button order rather than posting to nobody"); + assert.ok(state.seats[sbIndex].inHand); + }); + + test("both blinds on one player is refused", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + state.blindOverride = { sbIndex: 1, bbIndex: 1 }; + const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); + assert.notEqual(sbIndex, bbIndex); + }); + + test("naming blinds heads-up still acts in the right order", () => { + const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 }); + state.blindOverride = { sbIndex: 5, bbIndex: 2 }; // deliberately the reverse + + Betting.beginHand(state); + assert.equal(state.seats[5].committed, 1); + assert.equal(state.seats[2].committed, 2); + assert.equal(state.actingSeatIndex, 5, "the small blind acts first pre-flop heads-up"); + + Betting.call(state, 5); + Betting.check(state, 2); + Betting.closeStreet(state); + assert.equal(state.actingSeatIndex, 2, "and the big blind leads after the flop"); + }); + + test("the naming only lasts the hand it was made for", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + Betting.beginHand(state); + + HoldemState.startNextHand(state); + assert.equal(state.blindOverride, null, "blinds go back to following the button"); + }); +}); + +describe("the first hand on a fresh table", () => { + test("the button never starts on an empty chair", () => { + const state = HoldemState.createDefault(); + HoldemState.resizeSeats(state, 9); + [4, 6, 7].forEach(i => { state.seats[i].inHand = true; state.seats[i].stack = 100; }); + assert.equal(state.dealerSeatIndex, 0, "a fresh table starts the button on seat 1"); + assert.equal(state.seats[0].inHand, false, "which nobody is sitting in"); + + Betting.beginHand(state); + + assert.ok(state.seats[state.dealerSeatIndex].inHand, "the button moved to a real player"); + const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state); + assert.ok(state.seats[sbIndex].inHand); + assert.ok(state.seats[bbIndex].inHand); + assert.notEqual(sbIndex, bbIndex); + }); + + test("a table that empties out around the button recovers", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 3 }); + state.seats[3].inHand = false; // the player holding the button leaves + HoldemState.ensureButtonOnPlayer(state); + assert.ok(state.seats[state.dealerSeatIndex].inHand); + }); + + test("two players left after everyone else walks is heads-up, blinds reversed", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + [2, 3].forEach(i => { state.seats[i].inHand = false; }); + + HoldemState.startNextHand(state); + Betting.beginHand(state); + + const { sbIndex, headsUp } = HoldemState.getBlindSeats(state); + assert.equal(headsUp, true); + assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind"); + }); +}); + +describe("chips survive a messy table", () => { + test("joining, leaving and passing the button never changes the chip count", () => { + const state = makeTable({ seatCount: 8, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + state.seats[6].stack = 100; // sitting down but not dealt in yet + const startingChips = chipsOnTable(state); + + Betting.beginHand(state); + state.dealerSeatIndex = 2; + Betting.repostBlinds(state); + + state.seats[6].inHand = true; // walks up mid-hand + state.seats[6].folded = true; + assert.equal(chipsOnTable(state), startingChips); + + Betting.call(state, state.actingSeatIndex); + state.seats[1].inHand = false; // and someone storms off + Betting.sweepToHandTotals(state); + + assert.equal(chipsOnTable(state), startingChips, "every chip is still accounted for"); + assert.equal( + Money.toCents(potOf(state) + state.seats.reduce((s, p) => s + (Number(p.stack) || 0), 0)), + Money.toCents(startingChips), + "and the pot plus the stacks is the whole table" + ); + }); +});