diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 3e71d26..c592157 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3006,3 +3006,10 @@ textarea:focus-visible { color: #ecffef; font-weight: 600; } + +/* The quieter half of the deal prompt: things the deal is about to do that a dealer + would otherwise only discover afterwards. */ +.dealPromptLine.dealPromptAside { + font-size: 0.72rem; + color: #9a9a9a; +} diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index ec2032e..2befb48 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -494,6 +494,17 @@ const ActionBar = { `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; wrap.appendChild(line); + // Dealing relocates a button sitting on an empty chair. That has to be said + // before it happens rather than discovered afterwards — a fresh table starts + // the button on seat 1 whether or not anyone is sitting there. + if (preview.buttonMoves) { + const moved = document.createElement("p"); + moved.className = "dealPromptLine dealPromptAside"; + moved.textContent = + `Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`; + wrap.appendChild(moved); + } + const row = document.createElement("div"); row.className = "actionButtons"; row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 764d406..734ba37 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -33,10 +33,22 @@ const Betting = { // while the table is still being set, so a dealer can see where they'll land // before any money moves. blindPreview(state) { - const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(state); + // Dealing relocates a button parked on an empty chair before it works out who + // posts, so the preview has to answer for the button where it *will* be. Asking + // from where it currently sits gave a different pair of blinds than the deal + // produced — the preview promising one thing and doing another is exactly the + // guessing this whole step exists to remove. + const buttonIndex = HoldemState.buttonSeatForDeal(state); + const asDealt = buttonIndex === state.dealerSeatIndex + ? state + : { ...state, dealerSeatIndex: buttonIndex }; + + const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt); if (sbIndex === null || bbIndex === null) return null; return { sbIndex, bbIndex, headsUp, manual: !!manual, + buttonIndex, + buttonMoves: buttonIndex !== state.dealerSeatIndex, smallBlind: this.round(state.smallBlind || 0), bigBlind: this.round(state.bigBlind || 0), }; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 8173a22..00fd555 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -96,13 +96,21 @@ document.addEventListener("DOMContentLoaded", () => { }); }, - 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); - }), + makeDealer: index => { + // Before the deal there's nothing posted, so this is just moving a marker. + // After it, the blinds have to follow the button or the badges and the + // money disagree — but that's a real thing happening to real chips, and + // the dealer gets told rather than finding out later. + const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) && + HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS; + + seatAction(index, willRepost + ? `Button on ${nameOf(index)} — blinds taken back and re-posted.` + : `Button on ${nameOf(index)}.`, () => { + state.dealerSeatIndex = index; + if (willRepost) Betting.repostBlinds(state); + }); + }, assignBlind: (index, which) => { const blinds = HoldemState.getBlindSeats(state); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index fdcf299..a3621ec 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -273,15 +273,25 @@ const HoldemState = { // 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; + // Where the button will be when the hand is dealt — without moving it. + // + // Kept separate from ensureButtonOnPlayer because the deal preview has to be able + // to ask the question without answering it. It couldn't, and so the preview lied: + // with the button parked on an empty chair it promised one pair of blinds and + // dealing produced another, because beginHand relocates the button first and then + // works out who posts. + buttonSeatForDeal(state) { + const seated = state.seats[state.dealerSeatIndex]; + if (seated && seated.inHand) return state.dealerSeatIndex; 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; - } + if (state.seats[index].inHand) return index; } + return state.dealerSeatIndex; + }, + + ensureButtonOnPlayer(state) { + state.dealerSeatIndex = this.buttonSeatForDeal(state); }, // A hand-picked blind is only usable if that seat is actually in the hand — a diff --git a/tests/holdem/dealStep.test.js b/tests/holdem/dealStep.test.js index 86874a1..9c73529 100644 --- a/tests/holdem/dealStep.test.js +++ b/tests/holdem/dealStep.test.js @@ -161,3 +161,45 @@ describe("a table saved mid-hand comes back mid-hand", () => { assert.equal(state.bettingOpen, false); }); }); + +describe("the preview tells the truth about the button", () => { + test("a button parked on an empty chair is answered for where it will be", () => { + // The preview used to read the button where it sat, while dealing relocated it + // first — so it promised one pair of blinds and produced another. + const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 }); + state.bettingOpen = false; + assert.equal(state.seats[0].inHand, false, "the button is on an empty chair"); + + const preview = Betting.blindPreview(state); + assert.equal(preview.buttonMoves, true, "and the preview knows it will move"); + + Betting.beginHand(state); + + assert.equal(Betting.committed(state.seats[preview.sbIndex]), preview.smallBlind); + assert.equal(Betting.committed(state.seats[preview.bbIndex]), preview.bigBlind); + assert.equal(state.dealerSeatIndex, preview.buttonIndex, "and it moved where it said"); + }); + + test("asking where the button will be doesn't move it", () => { + const state = makeTable({ seatCount: 6, seated: [1, 2, 3], dealerSeatIndex: 0 }); + state.bettingOpen = false; + + Betting.blindPreview(state); + Betting.blindPreview(state); + + assert.equal(state.dealerSeatIndex, 0, "the preview is a question, not an action"); + }); + + test("a button already on a player is left alone", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], dealerSeatIndex: 0 }); + state.bettingOpen = false; + const preview = Betting.blindPreview(state); + assert.equal(preview.buttonMoves, false); + assert.equal(preview.buttonIndex, 0); + }); + + test("buttonSeatForDeal copes with a table nobody is sitting at", () => { + const state = makeTable({ seatCount: 6, seated: [] }); + assert.equal(HoldemState.buttonSeatForDeal(state), state.dealerSeatIndex); + }); +});