A game that takes a rake has chips leaving the table every hand, and the bank check had no idea. It added up the buy-ins, added up the chips on the felt, and told the dealer somebody had been paid for chips they were never given — a shortfall that grows all night and points at a miscount that never happened. Every home game that takes a drop for the beer fund hits this on hand one. So the tool takes it, rather than watching it go. Under Table Setup: a percentage, an optional cap, and "no flop, no drop" — the rule almost everyone plays, on by default, where a hand that ends before the flop pays nothing. Zero percent is a game that doesn't rake, which is most of them and the default, and nothing about that game changes. Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%, capped at $5" above the payouts, before a chip moves, because the summary is the dealer's last chance to disagree with the arithmetic and money taken quietly is the one thing a tool like this must never do. The bank check gets a House take line and says "balanced ... once the $2 in the house's cut is counted". The hand log keeps the pot as the table saw it with the cut recorded beside it, and the export has a column for it, so the night's total is one sum in a spreadsheet. Two decisions worth stating. The rake rounds *down* to a chip the table actually has: a house that can't make 40c takes nothing and the players keep it, which is the only way to be wrong about this that nobody argues with. And on a split pot it comes off the top, then what is left is shared in proportion — with the rounding leftovers going back to the winners rather than to the house, which asked for a number and gets that number, not that number plus dust. Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays the full $15 and takes nothing; with it off the same hand pays $14 and takes $1. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
347 lines
15 KiB
JavaScript
347 lines
15 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;
|
|
},
|
|
|
|
// What the house takes out of this pot, and what is left to push.
|
|
//
|
|
// Worked out here rather than at the end so the dealer sees it before anything
|
|
// moves: the summary is the last chance to disagree with the arithmetic, and a
|
|
// rake that only appeared in the hand log afterwards would be the tool taking
|
|
// money quietly.
|
|
rakeSplit() {
|
|
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
|
id,
|
|
label: this.playerLabelById.get(id),
|
|
amount,
|
|
}));
|
|
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
|
|
const sawFlop = (this.state.street || "preflop") !== "preflop";
|
|
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
|
|
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
|
|
},
|
|
|
|
renderSummary() {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "potWizardSummary";
|
|
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = "Final Payouts";
|
|
wrap.appendChild(heading);
|
|
|
|
const split = this.rakeSplit();
|
|
const rakeSet = Number((this.state.rake || {}).percent) > 0;
|
|
if (rakeSet && this.totals.size > 0) {
|
|
const line = document.createElement("p");
|
|
line.className = "potWizardRake";
|
|
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
|
|
line.textContent = split.rake > 0
|
|
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
|
|
: split.sawFlop
|
|
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
|
|
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
|
|
wrap.appendChild(line);
|
|
}
|
|
|
|
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 split.payouts) {
|
|
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", () => {
|
|
// Recomputed rather than reused from the render: the same numbers, but
|
|
// taken at the moment the dealer commits rather than when they last
|
|
// looked at the screen.
|
|
const paid = this.rakeSplit();
|
|
this.close();
|
|
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
|
|
});
|
|
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;
|
|
}
|