Two ways to lose a hand's money by accident, both found by walking the settle flow and checking the chip count afterwards. Next Hand destroyed an unresolved pot silently Starting the next hand clears every seat's contribution, so money still in the middle simply vanished. Measured at a four-handed table with $8 in the pot: the table went from 800 chips to 792, and the toast said "Next hand — button on seat 3. Blinds posted." — not a word about the eight chips that had just left. It now asks, names the amount, and says what will happen to it. Confirming still works and the toast says what went. The undo was always there, but a dealer can't undo something they were never told about. The wizard's "Close" abandoned a payout that looked finished After picking winners and confirming, the wizard shows "Final Payouts — Ann $8" above two buttons: "Start Next Hand" and "Close". Close pays nobody. Under that heading, with a name and an amount beside it, a dealer taps Close believing the pot has been pushed — and walks away with the chips still in the middle and the hand unlogged. The button now says "Cancel — don't pay yet", with a line underneath spelling out that nothing has been paid. The warning measures the right thing The first version counted the pot badge, which was wrong in a way the tests caught before it shipped: the table-wide base pot is a standing ante for dealers who don't track stacks, and it re-applies to the next hand rather than being lost. Quoting it would have put a confirmation in front of those dealers before every single hand — a nag, on the simplest way to run a table, for money that wasn't going anywhere. HoldemState.unresolvedContributions() measures exactly the fields startNextHand clears: banked contributions, chip counts, and whatever is still in front of players on this street. Verified both ways in the browser — a base-pot table showing a $100 pot is not asked at all, a stack-tracking table with $8 live is. 6 tests (115 total): the quoted number equals what actually leaves the table, mid-street chips are included, a settled hand leaves nothing to warn about, five settled hands conserve every chip, an empty table has no phantom pot, and a standing ante is never warned about. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
// Walks the dealer through awarding the pot: "who won?", and if that player was
|
|
// all-in for less than everyone else, "who's next?" — repeating until every dollar
|
|
// in the pot has been assigned. Built on PotCalculator's pure round-resolution math.
|
|
const PotWizard = {
|
|
state: null,
|
|
onFinished: null,
|
|
entries: [],
|
|
playerLabelById: new Map(),
|
|
log: [],
|
|
|
|
open(state, onFinished) {
|
|
this.state = state;
|
|
this.onFinished = onFinished;
|
|
|
|
const players = state.seats.filter(
|
|
p => HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount) > 0
|
|
);
|
|
this.playerLabelById = new Map(
|
|
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
|
|
);
|
|
this.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
|
|
// The label carries name and cards together for the log and the payout lines;
|
|
// the choice rows want them separately so each can be styled and read on its own.
|
|
this.nameById = new Map(
|
|
players.map(p => [p.id, HoldemState.displayName(p, state.seats.indexOf(p))])
|
|
);
|
|
this.cardsById = new Map(
|
|
players.map(p => [p.id, (p.cards || []).filter(Boolean)])
|
|
);
|
|
const contributions = players.map(p => ({
|
|
id: p.id,
|
|
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
|
|
// 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
|
|
// it happens to be asking about.
|
|
this.layers = PotCalculator.layers(contributions);
|
|
this.returns = PotCalculator.uncalledReturns(contributions);
|
|
this.log = [];
|
|
this.totals = new Map();
|
|
|
|
document.getElementById("potWizardOverlay").hidden = false;
|
|
this.renderStep();
|
|
},
|
|
|
|
close() {
|
|
document.getElementById("potWizardOverlay").hidden = true;
|
|
},
|
|
|
|
labelFor(player, seatIndex) {
|
|
const name = HoldemState.displayName(player, seatIndex);
|
|
const cards = (player.cards || []).filter(Boolean).map(c => Render.formatCard(c)).join(" ");
|
|
return cards ? `${name} (${cards})` : name;
|
|
},
|
|
|
|
addToTotal(id, amount) {
|
|
this.totals.set(id, (this.totals.get(id) || 0) + amount);
|
|
},
|
|
|
|
// A chopped pot rarely divides exactly, and the odd chip goes to the first player
|
|
// left of the button — not to whoever the dealer happened to tick first. The
|
|
// calculator hands leftovers out in the order it's given the winners, so ordering
|
|
// them here is what makes the payout match the rule.
|
|
orderFromButton(ids) {
|
|
const distance = new Map();
|
|
for (let step = 1; step <= this.state.seatCount; step++) {
|
|
distance.set(HoldemState.nextSeatIndex(this.state, this.state.dealerSeatIndex, step), step);
|
|
}
|
|
return [...ids].sort((a, b) =>
|
|
(distance.get(this.seatIndexById.get(a)) ?? 0) - (distance.get(this.seatIndexById.get(b)) ?? 0)
|
|
);
|
|
},
|
|
|
|
renderStep() {
|
|
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
|
let contenders = PotCalculator.getContenders(this.entries);
|
|
while (contenders.length === 1) {
|
|
const result = PotCalculator.resolveRound(this.entries, [contenders[0].id]);
|
|
this.recordRound(result);
|
|
this.entries = result.updatedEntries;
|
|
contenders = PotCalculator.getContenders(this.entries);
|
|
}
|
|
|
|
const body = document.getElementById("potWizardBody");
|
|
body.innerHTML = "";
|
|
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
|
body.appendChild(this.renderLog());
|
|
|
|
const remaining = PotCalculator.totalPot(this.entries.filter(e => e.remaining > PotCalculator.EPSILON));
|
|
|
|
if (contenders.length === 0 && remaining > PotCalculator.EPSILON) {
|
|
const warning = document.createElement("p");
|
|
warning.className = "potWizardPrompt";
|
|
|
|
// Money left over because nobody could call it isn't a pot anyone won —
|
|
// it goes back to whoever put it in. Telling the dealer to un-fold someone
|
|
// to "win" it would be wrong.
|
|
const returned = (this.returns || []).reduce((sum, r) => sum + r.amount, 0);
|
|
if (Math.abs(returned - remaining) <= PotCalculator.EPSILON && returned > 0) {
|
|
const who = this.returns.map(r => this.playerLabelById.get(r.id)).join(", ");
|
|
warning.textContent = `${Money.format(remaining)} was never called — push it back to ${who} rather than paying it out.`;
|
|
} else {
|
|
warning.textContent = `${Money.format(remaining)} is still unassigned, but every remaining player is marked Folded. Uncheck Fold on whoever should win it.`;
|
|
}
|
|
body.appendChild(warning);
|
|
body.appendChild(this.renderSummary());
|
|
return;
|
|
}
|
|
|
|
if (contenders.length === 0 || remaining <= PotCalculator.EPSILON) {
|
|
body.appendChild(this.renderSummary());
|
|
return;
|
|
}
|
|
|
|
const round = document.createElement("div");
|
|
round.className = "potWizardRound";
|
|
|
|
const prompt = document.createElement("p");
|
|
prompt.className = "potWizardPrompt";
|
|
prompt.innerHTML = this.log.length === 0
|
|
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
|
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
|
round.appendChild(prompt);
|
|
|
|
const choices = document.createElement("div");
|
|
choices.className = "potWizardChoices";
|
|
contenders.forEach(c => {
|
|
const label = document.createElement("label");
|
|
label.className = "potWizardChoice";
|
|
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.value = c.id;
|
|
// The row is the target, not the box — so the row has to look chosen too.
|
|
checkbox.addEventListener("change", () => {
|
|
label.classList.toggle("chosen", checkbox.checked);
|
|
});
|
|
label.appendChild(checkbox);
|
|
|
|
const text = document.createElement("span");
|
|
text.className = "potWizardChoiceText";
|
|
|
|
const name = document.createElement("span");
|
|
name.className = "potWizardChoiceName";
|
|
name.textContent = this.nameById.get(c.id);
|
|
text.appendChild(name);
|
|
|
|
const upTo = document.createElement("span");
|
|
upTo.className = "potWizardChoiceUpTo";
|
|
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
|
text.appendChild(upTo);
|
|
label.appendChild(text);
|
|
|
|
// Cards sit apart from the name rather than inside it, so a dealer reading
|
|
// a row across the table sees the hand, not a sentence.
|
|
const cards = this.cardsById.get(c.id) || [];
|
|
if (cards.length > 0) {
|
|
const cardsEl = document.createElement("span");
|
|
cardsEl.className = "potWizardChoiceCards";
|
|
cards.forEach((card, i) => {
|
|
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
|
|
const span = document.createElement("span");
|
|
if (/[HD]$/i.test(card)) span.className = "cardRed";
|
|
span.textContent = Render.formatCard(card);
|
|
cardsEl.appendChild(span);
|
|
});
|
|
label.appendChild(cardsEl);
|
|
}
|
|
|
|
choices.appendChild(label);
|
|
});
|
|
round.appendChild(choices);
|
|
|
|
const hint = document.createElement("p");
|
|
hint.className = "fieldHint";
|
|
hint.textContent = "Check more than one to split the pot on a tie.";
|
|
round.appendChild(hint);
|
|
|
|
const confirmBtn = document.createElement("button");
|
|
confirmBtn.className = "holdemBtn holdemBtnAccent";
|
|
confirmBtn.textContent = "Confirm Winner(s)";
|
|
confirmBtn.addEventListener("click", () => {
|
|
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
|
|
if (selected.length === 0) return;
|
|
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(selected));
|
|
this.recordRound(result);
|
|
this.entries = result.updatedEntries;
|
|
this.renderStep();
|
|
});
|
|
round.appendChild(confirmBtn);
|
|
|
|
body.appendChild(round);
|
|
},
|
|
|
|
recordRound(result) {
|
|
result.perWinner.forEach(w => this.addToTotal(w.id, w.amount));
|
|
this.log.push({
|
|
amount: result.payoutPool,
|
|
winners: result.perWinner.map(w => `${this.playerLabelById.get(w.id)} (${Money.format(w.amount)})`),
|
|
});
|
|
},
|
|
|
|
// The announcement, spelled out before anyone is asked to pick a winner: what the
|
|
// main pot is worth, what each side pot is worth, and who can win each.
|
|
renderBreakdown() {
|
|
const node = Render.buildPotBreakdown(
|
|
this.layers || [],
|
|
this.returns || [],
|
|
id => this.playerLabelById.get(id),
|
|
"Announce before the showdown"
|
|
);
|
|
if (!node) return document.createDocumentFragment();
|
|
node.classList.add("potBreakdownWizard");
|
|
return node;
|
|
},
|
|
|
|
renderLog() {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "potWizardLog";
|
|
if (this.log.length === 0) return wrap;
|
|
|
|
this.log.forEach((entry, idx) => {
|
|
const line = document.createElement("p");
|
|
line.className = "potWizardLogLine";
|
|
line.textContent = `Pot ${idx + 1}: ${Money.format(entry.amount)} → ${entry.winners.join(", ")}`;
|
|
wrap.appendChild(line);
|
|
});
|
|
return wrap;
|
|
},
|
|
|
|
renderSummary() {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "potWizardSummary";
|
|
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = "Final Payouts";
|
|
wrap.appendChild(heading);
|
|
|
|
if (this.totals.size === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "fieldHint";
|
|
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
|
wrap.appendChild(empty);
|
|
} else {
|
|
const list = document.createElement("ul");
|
|
for (const [id, amount] of this.totals.entries()) {
|
|
const li = document.createElement("li");
|
|
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
|
list.appendChild(li);
|
|
}
|
|
wrap.appendChild(list);
|
|
}
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "potWizardActions";
|
|
|
|
const applyBtn = document.createElement("button");
|
|
applyBtn.className = "holdemBtn holdemBtnAccent";
|
|
applyBtn.textContent = "Start Next Hand";
|
|
applyBtn.addEventListener("click", () => {
|
|
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
|
id,
|
|
label: this.playerLabelById.get(id),
|
|
amount,
|
|
}));
|
|
this.close();
|
|
this.onFinished(payouts);
|
|
});
|
|
actions.appendChild(applyBtn);
|
|
|
|
// This button cancels — it does not pay anybody. Under a heading reading
|
|
// "Final Payouts" with an amount beside a name, "Close" reads like dismissing
|
|
// something already done, and a dealer who taps it walks away believing the
|
|
// pot has been pushed. It hasn't: the chips are still in the middle and the
|
|
// hand isn't logged. So it says what it does.
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.className = "holdemBtn holdemBtnGhost";
|
|
cancelBtn.textContent = "Cancel — don't pay yet";
|
|
cancelBtn.addEventListener("click", () => this.close());
|
|
actions.appendChild(cancelBtn);
|
|
|
|
if (this.totals.size > 0) {
|
|
const note = document.createElement("p");
|
|
note.className = "fieldHint potWizardPendingNote";
|
|
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
|
wrap.appendChild(actions);
|
|
wrap.appendChild(note);
|
|
return wrap;
|
|
}
|
|
|
|
wrap.appendChild(actions);
|
|
return wrap;
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = PotWizard;
|
|
}
|