Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
T
jheapsandClaude Opus 5 cc6a1902bc Stop the tool from throwing a pot away without saying so
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
2026-09-01 04:39:51 -06:00

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