diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 94aada2..6f4048b 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -459,6 +459,47 @@ const Betting = { return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed); }, + // Chips the pot has been priced with but nobody has actually paid. + // + // Two ways a contribution gets into this tool. Tapping the action moves chips: the + // bet leaves the stack, sits in front of the player, and is swept into their hand + // total at the end of the street. The other way is the "without live betting" panel + // — a base pot amount, or chips counted out on a seat — where the dealer is + // recording a hand they are watching rather than driving, and nothing ever leaves + // anybody's stack. + // + // The pot wizard prices both the same way, and awarding it credits the winner. So + // in that second mode the table simply grew: measured at a three-handed table with + // a $50 base pot, paying the pot took the chips on the table from $600 to $750 and + // left the bank check blaming a rebuy that never happened. + // + // So before the pot is pushed, whatever was assumed rather than taken comes off the + // stacks that assumed it. Chips already committed or swept are left alone; they + // have been paid once already, and taking them twice is the same bug the other way + // round. + collectAssumedContributions(state) { + const taken = []; + state.seats.forEach((player, index) => { + const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount); + const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0)); + const owed = this.round(priced - alreadyPaid); + if (owed <= this.EPSILON_CHIPS) return; + + // Never more than they are holding. A seat priced above its own stack is a + // dealer's typo or a player who is short, and inventing the difference is + // what this whole function exists to stop. + const moved = this.round(Math.min(owed, this.stackOf(state, player))); + if (moved <= this.EPSILON_CHIPS) return; + + player.stack = this.round(this.stackOf(state, player) - moved); + // Recorded where a swept bet lives, so the money is in exactly the place + // the rest of the tool looks for chips that are in the middle this hand. + player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved); + taken.push({ index, name: HoldemState.displayName(player, index), amount: moved }); + }); + return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) }; + }, + // 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 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 667a39a..d7555ac 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -261,6 +261,11 @@ document.addEventListener("DOMContentLoaded", () => { if (rake > 0) { state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake); } + // Anything the pot was priced with but nobody actually paid — the + // base pot and counted chips of the no-live-betting flow — comes off + // the stacks that owe it before the winner is paid. Otherwise the + // table gains the whole pot out of nowhere. + Betting.collectAssumedContributions(state); if (payouts.length > 0) { HoldemState.recordHand(state, payouts, rake); // Winnings go straight back in front of the player, so the next diff --git a/tests/holdem/assumedContributions.test.js b/tests/holdem/assumedContributions.test.js new file mode 100644 index 0000000..cba7a5d --- /dev/null +++ b/tests/holdem/assumedContributions.test.js @@ -0,0 +1,175 @@ +// Chips the pot was priced with that nobody actually paid. +// +// There are two ways a contribution gets into this tool. Tapping the action moves +// chips: the bet leaves the stack, sits in front of the player, and is swept into their +// hand total at the end of the street. The other is the "without live betting" panel — +// a base pot amount, or chips counted out on a seat — where the dealer is recording a +// hand they are watching rather than driving, and nothing ever leaves a stack. +// +// The pot wizard prices both the same way and awarding it credits the winner, so in +// that second mode the table simply grew. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Money, Betting, HoldemState, chipsOnTable } = require("./load"); + +const basePotTable = (base = 50, stack = 200) => { + const state = HoldemState.createDefault(); + HoldemState.resizeSeats(state, 4); + state.basePotAmount = base; + ["Ann", "Ben", "Cal"].forEach((name, i) => { + state.seats[i].name = name; + state.seats[i].inHand = true; + state.seats[i].stack = stack; + state.seats[i].buyIns = stack; + }); + return state; +}; + +const pricedPot = (state) => state.seats.reduce( + (sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0); + +describe("what nobody paid", () => { + test("comes off the stacks that owe it", () => { + const state = basePotTable(); + const collected = Betting.collectAssumedContributions(state); + assert.equal(collected.total, 150); + assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]); + assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]); + }); + + test("and lands where the rest of the tool looks for chips in the middle", () => { + const state = basePotTable(); + Betting.collectAssumedContributions(state); + assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]); + assert.equal(HoldemState.unresolvedContributions(state), 150); + }); + + test("chips counted out on a seat are money too", () => { + const state = basePotTable(0); + state.seats[0].chipCounts = { red: 4 }; // four $5 chips + const collected = Betting.collectAssumedContributions(state); + assert.equal(collected.total, 20); + assert.equal(state.seats[0].stack, 180); + }); +}); + +describe("what has already been paid", () => { + test("a live bet is left exactly alone", () => { + const state = basePotTable(0); + state.smallBlind = 5; + state.bigBlind = 10; + Betting.beginHand(state); + const before = state.seats.map(p => Betting.stackOf(state, p)); + + const collected = Betting.collectAssumedContributions(state); + assert.equal(collected.total, 0, "the blinds are already off their stacks"); + assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before); + }); + + test("nor is a swept street taken a second time", () => { + const state = basePotTable(0); + state.smallBlind = 5; + state.bigBlind = 10; + Betting.beginHand(state); + Betting.sweepToHandTotals(state); + const before = state.seats.map(p => Betting.stackOf(state, p)); + + assert.equal(Betting.collectAssumedContributions(state).total, 0); + assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before); + }); + + test("a base pot on top of a live bet only collects the difference", () => { + // The base pot says "assume everyone is in for fifty". Somebody who has already + // put twenty in owes thirty of it, not fifty. + const state = basePotTable(50); + state.seats[0].committed = 20; + state.seats[0].stack = 180; + const collected = Betting.collectAssumedContributions(state); + const ann = collected.taken.find(row => row.name === "Ann"); + assert.equal(ann.amount, 30); + assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way"); + }); +}); + +describe("what it will not do", () => { + test("take more than a player is holding", () => { + const state = basePotTable(50); + state.seats[1].stack = 20; + Betting.collectAssumedContributions(state); + assert.equal(state.seats[1].stack, 0, "all of it, and no further"); + assert.equal(state.seats[1].amountOverride, 20); + }); + + test("touch an empty chair", () => { + const state = basePotTable(); + const before = { ...state.seats[3] }; + Betting.collectAssumedContributions(state); + assert.equal(state.seats[3].stack, before.stack); + assert.equal(state.seats[3].amountOverride, before.amountOverride); + }); + + test("or a seat that isn't in the hand", () => { + // The base pot stands in for "everyone put the same in", and somebody sitting + // out did not. + const state = basePotTable(); + state.seats[2].inHand = false; + const collected = Betting.collectAssumedContributions(state); + assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]); + assert.equal(state.seats[2].stack, 200); + }); +}); + +describe("the whole hand", () => { + test("conserves the chips on the table, which it did not before", () => { + const state = basePotTable(); + const total = chipsOnTable(state); + const pot = pricedPot(state); + + Betting.collectAssumedContributions(state); + Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]); + HoldemState.startNextHand(state); + + assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after"); + assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); + + test("and the bank check stays balanced all the way through", () => { + const state = basePotTable(); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + Betting.collectAssumedContributions(state); + assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone"); + Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]); + HoldemState.startNextHand(state); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); + + test("a live-betting hand is unchanged by any of this", () => { + const state = basePotTable(0); + state.smallBlind = 5; + state.bigBlind = 10; + const total = chipsOnTable(state); + Betting.beginHand(state); + [3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); }); + Betting.sweepToHandTotals(state); + const pot = HoldemState.unresolvedContributions(state); + + Betting.collectAssumedContributions(state); + Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]); + HoldemState.startNextHand(state); + + assert.equal(chipsOnTable(state), total); + assert.equal(HoldemState.bankReconciliation(state).balanced, true); + }); + + test("collecting twice takes nothing the second time", () => { + // The pot wizard can be opened, cancelled and opened again. + const state = basePotTable(); + Betting.collectAssumedContributions(state); + const stacks = state.seats.map(p => p.stack); + const again = Betting.collectAssumedContributions(state); + assert.equal(again.total, 0); + assert.deepEqual(state.seats.map(p => p.stack), stacks); + }); +});