Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
T
jheapsandClaude Sonnet 5 ee25e2a4fa Show the side-pot breakdown before the showdown, not after
Dealing guides are consistent that the most common side-pot error isn't
miscounting the chips, it's failing to announce the breakdown clearly before
the showdown. The tool had the arithmetic right but only revealed it after the
hand, one layer at a time, as the wizard asked who won each.

The breakdown is now visible while the hand is still running: as soon as an
all-in splits the money, the action bar lists the main pot and each side pot
with the amount and who can win it, and the wizard repeats it under "Announce
before the showdown" before asking for a winner. A hand with a single
undivided pot shows nothing, so the line means something when it appears.

Chips nobody could call are called what they are. Money above the largest live
contribution was never contested — usually an overbet by someone who then
folded — so it's listed as "Uncalled, back to X" rather than padding a side
pot nobody can win. The wizard's leftover warning says the same thing instead
of telling the dealer to un-fold someone to win it, which was the wrong advice
for chips that should simply be pushed back.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:10:19 -06:00

230 lines
9.2 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))])
);
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded,
}));
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);
},
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;
const text = document.createElement("span");
text.textContent = `${this.playerLabelById.get(c.id)} — up to ${Money.format(c.remaining)}`;
label.appendChild(checkbox);
label.appendChild(text);
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, 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);
const closeBtn = document.createElement("button");
closeBtn.className = "holdemBtn holdemBtnGhost";
closeBtn.textContent = "Close";
closeBtn.addEventListener("click", () => this.close());
actions.appendChild(closeBtn);
wrap.appendChild(actions);
return wrap;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = PotWizard;
}