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
230 lines
9.2 KiB
JavaScript
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;
|
|
}
|