"Ann - $135" is the answer to the arithmetic. The dealer's actual job at that moment
is working out that $135 is a black, a green and two reds, while four people watch
them do it.
The chip legend has known the denominations all along. It was only being used to read
chips *into* an amount -- counting a seat's stack -- and never to turn an amount back
into chips.
Final Payouts
Ann - $135
1x$100, 1x$25, 2x$5
Largest first, which is greedy and so not optimal for an arbitrary set of values. Poker
chips are not arbitrary: every real set is canonical -- each denomination divides the
next, 1/5/25/100/500 -- and greedy is exactly optimal on those. A dealer who invents a
3-and-7 chip set still gets a valid answer, possibly one chip longer than it needed to
be, which is a fair trade for not solving a knapsack in a modal.
Two cases where saying nothing is the right answer, and one where it is not.
A table with no chip legend gets the amount on its own -- it has not told us its
denominations, so there is no advice to give. But a legend that exists and cannot reach
the amount does say so: "$3 no chip covers" when the smallest chip is $5. Half a dollar
in a dollar-chip game is the dealer's problem to solve and they can only solve it if
they are told, so it is never rounded away.
12 tests (244 total): the breakdown adds up to the amount across eight amounts, cent
chips make cent amounts exactly, a jumbled legend still pays largest first, the colour
comes along for a future caller, and the two no-advice cases are told apart from the
cannot-reach one.
Driven through a real all-in hand at four sizes: $135 reads "1x$100, 1x$25, 2x$5" and
a tournament-sized $1,350 reads "2x$500, 3x$100, 2x$25", fitting without spill down to
360px. With the legend emptied the line disappears and the payout stands alone.
Contrast clean, no console errors.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
316 lines
14 KiB
JavaScript
316 lines
14 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 legend = (this.state && this.state.chipLegend) || [];
|
|
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)}`;
|
|
|
|
// The amount is the answer to the arithmetic; the chips are the answer
|
|
// to the dealer's job. Only when the legend has been filled in — a
|
|
// table that never set up chip colours gets the number on its own,
|
|
// which is what it had before.
|
|
const chips = Money.formatChipBreakdown(amount, legend);
|
|
if (chips) {
|
|
const note = document.createElement("span");
|
|
note.className = "payoutChips";
|
|
note.textContent = chips;
|
|
li.appendChild(note);
|
|
}
|
|
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;
|
|
}
|