Home games don't run like a cardroom. People walk up mid-hand, storm off mid-hand, and pass the button round three times before anyone deals. Probing each of those found four problems, one of which loses chips. A player taken out mid-hand took their chips with them `computeContribution` opened with `if (!player.inHand) return 0`, so the moment a dealer switched a seat off, everything that seat had already put in vanished from the pot. Measured: four players in for $2 each, one taken out, pot drops from $8 to $6. Their $2 was still sitting on the seat record — it just stopped counting. A dealer following the tool would have paid out $6 of an $8 pot. Chips already pushed into the middle are dead money. They stay in the pot and go to whoever wins, whether or not the player is still in the hand, and it is not theirs to take back. The `inHand` check now guards only the table-wide base-pot default, which does have to stay tied to being dealt in or every empty chair would be treated as having anted. The pot wizard counts a departed seat as folded, so its chips are in the pot but it can never be picked as a winner. Passing the button before the cards come out Moving the button left the posted blinds where they were while everything positional started reading off the new spot, so the money and the blind positions silently disagreed. While nothing but the blinds are in, moving the button now refunds them and re-posts from where it lands — pass it round six times and the chip count is unchanged. Once anybody has acted the blinds are settled and it stops offering. Naming the blinds by hand Blinds were purely positional with no way to overrule them. The seat menu now offers "Make small blind" / "Make big blind" while only the blinds are in, and a badge with a ring marks a blind that was named rather than dealt. Naming one pins the other where it is. It's refused if it would put both blinds on one player or post to somebody who isn't dealt in, and it lasts one hand — carrying it forward would freeze the blinds on two people for the night. The first hand, and a table that empties out A fresh table puts the button on seat 1 whether or not anyone is sitting there, and a table can empty out around it. The button is moved onto a real player before a hand is dealt. Joining mid-hand A newcomer was added straight into the live seats and would be asked to act in a hand they hadn't been paying into. They now sit the hand out and are a full player from the next one. Nothing is covered on a phone any more Swept 13 viewport sizes with elementFromPoint, five points per control, on the table and every overlay. Before: the action bar covered the bottom seats on short screens (62px of felt at 360x640), the toast covered up to six seats at once — Undo button included, so a tap meant for a player hit "Undo" — and in landscape the table came out 765px tall in a 390px window, putting six of nine seats off-screen entirely. - the action bar's real height is published as `--actionBarHeight` and drives both the page's bottom padding and the toast, replacing a hardcoded 9rem that was wrong the moment the raise pad opened - landscape sizes the felt from the height that's left, not from width. The first attempt did nothing: `padding-bottom` is a percentage of the containing block's *width*, so narrowing the wrap left it full height, and a `max-width: 900px` rule later in the file was overriding it anyway. It's set outright now and lives at the end of the sheet. - on short screens the toast floats over the action bar's status readout rather than over the seats. Nowhere is clear of both; covering something you re-read beats covering something you tap. - headers go compact on short screens, which is where the vertical room came from 13/13 sizes clean, 360x640 through 844x390, portrait and landscape. Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles rather than clearing, and `manual` is undefined for positional blinds, so the hand-picked ring appeared on alternate renders. 18 new tests (76 total, all passing). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
289 lines
12 KiB
JavaScript
289 lines
12 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);
|
|
|
|
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;
|
|
}
|