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