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" + ); + }); +});