Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant

New /casino hub page (Texas Hold'em live, other table games marked coming
soon) and /casino/texasholdem: a drag-and-drop seating table with
auto-rotating blinds, per-seat chip/dollar contribution tracking, and a
side-pot resolution wizard that asks who won and who's next until every
dollar in the pot (including dead money from folds) is assigned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
This commit is contained in:
2026-08-31 14:25:44 -06:00
co-authored by Claude Sonnet 5
parent b7c9227bb8
commit 98f95df8fb
13 changed files with 1721 additions and 1 deletions
@@ -0,0 +1,70 @@
(function () {
const state = HoldemState.load();
const hooks = {
save: s => HoldemState.save(s),
rerender: () => Render.renderAll(),
};
Render.mount(state, hooks);
document.getElementById("seatCountInput").addEventListener("change", e => {
HoldemState.resizeSeats(state, Number(e.target.value));
hooks.save(state);
Render.renderAll();
});
document.getElementById("directionSelect").addEventListener("change", e => {
state.direction = e.target.value;
hooks.save(state);
Render.renderSeats();
});
document.getElementById("basePotInput").addEventListener("input", e => {
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
hooks.save(state);
Render.renderAll();
});
document.getElementById("tableNotes").addEventListener("input", e => {
state.notes = e.target.value;
hooks.save(state);
});
document.getElementById("addChipColorBtn").addEventListener("click", () => {
state.chipLegend.push({
id: HoldemState.makeId("chip"),
color: "#888888",
value: 1,
});
hooks.save(state);
Render.renderAll();
});
document.getElementById("nextHandBtn").addEventListener("click", () => {
if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
HoldemState.startNextHand(state);
hooks.save(state);
Render.renderAll();
});
document.getElementById("resetTableBtn").addEventListener("click", () => {
if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
HoldemState.clear();
Object.assign(state, HoldemState.createDefault());
Render.mount(state, hooks);
});
document.getElementById("resolvePotBtn").addEventListener("click", () => {
PotWizard.open(state, () => {
HoldemState.startNextHand(state);
hooks.save(state);
Render.renderAll();
});
});
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close();
});
})();
@@ -0,0 +1,78 @@
// Pure side-pot math. No DOM, no globals besides the PotCalculator namespace, so the
// dealer's payout logic can be reasoned about (and unit tested) on its own.
//
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
// that player put in the pot and is whittled down round by round as money gets paid out.
const PotCalculator = {
EPSILON: 0.004,
// Builds the initial entry list from raw contributions, keeping folded players in
// (their chips are still "dead money" owed to whoever wins) while excluding anyone
// who put in nothing at all.
buildEntries(players) {
return players
.filter(p => p.contribution > this.EPSILON)
.map(p => ({ id: p.id, remaining: p.contribution, folded: !!p.folded }));
},
totalPot(entries) {
return entries.reduce((sum, e) => sum + e.remaining, 0);
},
// Players still eligible to be picked as a winner for the money that's left.
getContenders(entries) {
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
},
// Awards the current pot layer to `winnerIds` (one id, or several for a split/tie).
// The payout is capped at the smallest remaining contribution among the winners —
// that's the classic side-pot rule: an all-in player can only win what everyone at
// the table matched, and the excess rolls forward to the next round.
resolveRound(entries, winnerIds) {
const winners = entries.filter(e => winnerIds.includes(e.id) && e.remaining > this.EPSILON);
if (winners.length === 0) {
throw new Error("No valid winners selected for this round.");
}
const cap = Math.min(...winners.map(w => w.remaining));
let payoutPool = 0;
const updatedEntries = entries.map(e => {
if (e.remaining <= this.EPSILON) return e;
const used = Math.min(e.remaining, cap);
payoutPool += used;
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
});
payoutPool = Math.round(payoutPool * 100) / 100;
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
const perWinner = winnerIds
.filter(id => winners.some(w => w.id === id))
.map(id => ({ id, amountCents: baseShareCents }));
// Odd cents (can't split evenly) go one at a time to the winners, in the order
// they were selected, so nothing is ever lost to rounding.
let i = 0;
while (leftoverCents > 0 && perWinner.length > 0) {
perWinner[i % perWinner.length].amountCents += 1;
leftoverCents--;
i++;
}
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
const remainingContenders = this.getContenders(updatedEntries);
return {
payoutPool,
capApplied: cap,
perWinner: result,
updatedEntries,
remainingContenders,
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = PotCalculator;
}
@@ -0,0 +1,196 @@
// 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.entries = PotCalculator.buildEntries(
players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded,
}))
);
this.log = [];
this.totals = new Map();
document.getElementById("potWizardOverlay").hidden = false;
this.renderStep();
},
close() {
document.getElementById("potWizardOverlay").hidden = true;
},
labelFor(player, seatIndex) {
const name = player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
const cards = (player.cards || []).filter(Boolean).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 = "";
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";
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)})`),
});
},
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", () => {
this.close();
this.onFinished();
});
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;
}
@@ -0,0 +1,273 @@
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
// re-draws everything (used after structural changes like resizing seats or the chip
// legend), hooks.rerenderPotBadge(state) just updates the pot total display.
const Render = {
state: null,
hooks: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.renderAll();
},
renderAll() {
this.renderSeats();
this.renderChipLegend();
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
},
renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
},
renderNotes() {
const notesEl = document.getElementById("tableNotes");
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
},
updatePotBadge() {
const total = HoldemState.totalPot(this.state);
document.getElementById("potBadgeAmount").textContent = Money.format(total);
},
commitAndRedrawSeat(seatEl, seatIndex) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(seatEl, this.state.seats[seatIndex]);
},
renderSeatTotal(seatEl, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
seatEl.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
// ---- Seats around the table ----
seatPosition(index, count) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
const rx = 44;
const ry = 40;
const left = 50 + rx * Math.cos(radians);
const top = 50 + ry * Math.sin(radians);
return { left, top };
},
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex } = HoldemState.getBlindSeats(this.state);
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
const pos = this.seatPosition(index, this.state.seatCount);
node.style.left = `${pos.left}%`;
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
node.querySelector(".seatChairNumber").textContent = `Seat ${index + 1}`;
const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.addEventListener("click", () => {
this.state.dealerSeatIndex = index;
this.hooks.save(this.state);
this.renderSeats();
});
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
this.wireSeatFields(node, player, index);
this.renderChipCounts(node, player);
this.renderCustomFields(node, player);
this.renderSeatTotal(node, player);
node.classList.toggle("seatInactive", !player.inHand);
node.classList.toggle("seatFolded", player.folded);
this.wireDragAndDrop(node, index);
layer.appendChild(node);
});
},
wireSeatFields(node, player, index) {
const nameInput = node.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
this.hooks.save(this.state);
});
const inHandCheckbox = node.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
this.renderSeats();
this.hooks.save(this.state);
this.updatePotBadge();
});
const foldCheckbox = node.querySelector(".foldCheckbox");
foldCheckbox.checked = player.folded;
foldCheckbox.disabled = !player.inHand;
foldCheckbox.addEventListener("change", () => {
player.folded = foldCheckbox.checked;
node.classList.toggle("seatFolded", player.folded);
this.hooks.save(this.state);
});
const card1 = node.querySelector(".card1Input");
const card2 = node.querySelector(".card2Input");
card1.value = player.cards[0] || "";
card2.value = player.cards[1] || "";
[card1, card2].forEach((input, cardIdx) => {
input.addEventListener("input", () => {
input.value = input.value.toUpperCase();
player.cards[cardIdx] = input.value;
this.hooks.save(this.state);
});
});
const amountInput = node.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitAndRedrawSeat(node, index);
});
const addFieldBtn = node.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(node, player);
this.hooks.save(this.state);
});
},
renderChipCounts(node, player) {
const container = node.querySelector(".chipCounts");
container.innerHTML = "";
const template = document.getElementById("chipCountTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
row.title = `$${chip.value} chips`;
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitAndRedrawSeat(node, Number(node.dataset.seatIndex));
});
container.appendChild(row);
});
},
renderCustomFields(node, player) {
const container = node.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(node, player);
this.hooks.save(this.state);
});
container.appendChild(row);
});
},
wireDragAndDrop(node, index) {
node.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
node.classList.add("dragging");
});
node.addEventListener("dragend", () => node.classList.remove("dragging"));
node.addEventListener("dragover", e => {
e.preventDefault();
node.classList.add("dropTarget");
});
node.addEventListener("dragleave", () => node.classList.remove("dropTarget"));
node.addEventListener("drop", e => {
e.preventDefault();
node.classList.remove("dropTarget");
const sourceIndex = Number(e.dataTransfer.getData("text/plain"));
const targetIndex = index;
if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return;
const seats = this.state.seats;
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
this.hooks.save(this.state);
this.renderSeats();
});
},
// ---- Chip legend ----
renderChipLegend() {
const list = document.getElementById("chipLegendList");
list.innerHTML = "";
const template = document.getElementById("chipLegendRowTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
const colorInput = row.querySelector(".chipColorInput");
const valueInput = row.querySelector(".chipValueInput");
colorInput.value = chip.color;
valueInput.value = chip.value;
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
this.renderSeats();
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
this.hooks.save(this.state);
this.renderSeats();
this.updatePotBadge();
});
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
this.hooks.save(this.state);
this.renderAll();
});
list.appendChild(row);
});
},
};
const Money = {
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Render, Money };
}
@@ -0,0 +1,143 @@
// Table state: seats, chips, blinds, notes. Persisted to localStorage so a dealer's
// browser refresh mid-game doesn't lose the table.
const HoldemState = {
STORAGE_KEY: "casino.holdem.state.v1",
DEFAULT_SEAT_COUNT: 10,
DEFAULT_CHIP_LEGEND: [
{ id: "white", color: "#f5f5f5", value: 1 },
{ id: "red", color: "#c0392b", value: 5 },
{ id: "green", color: "#1e8449", value: 25 },
{ id: "black", color: "#111111", value: 100 },
{ id: "purple", color: "#6c3483", value: 500 },
],
makeId(prefix) {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
},
makePlayer() {
return {
id: this.makeId("player"),
name: "",
inHand: false,
folded: false,
cards: ["", ""],
chipCounts: {},
amountOverride: null,
customFields: [],
};
},
createDefault() {
const seatCount = this.DEFAULT_SEAT_COUNT;
return {
seatCount,
direction: "clockwise",
basePotAmount: null,
dealerSeatIndex: 0,
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
seats: Array.from({ length: seatCount }, () => this.makePlayer()),
notes: "",
};
},
load() {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
return parsed;
} catch {
return this.createDefault();
}
},
save(state) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage full/unavailable (private browsing, etc.) — the table still works
// for the rest of the session, it just won't survive a refresh.
}
},
clear() {
try {
localStorage.removeItem(this.STORAGE_KEY);
} catch {
// ignore
}
},
resizeSeats(state, newCount) {
const count = Math.max(2, Math.min(24, Math.floor(newCount) || this.DEFAULT_SEAT_COUNT));
const seats = state.seats.slice(0, count);
while (seats.length < count) seats.push(this.makePlayer());
state.seats = seats;
state.seatCount = count;
if (state.dealerSeatIndex >= count) state.dealerSeatIndex = 0;
return state;
},
// Contribution the table calculator will treat this player as having put in.
computeContribution(player, chipLegend, basePotAmount) {
if (!player.inHand) return 0;
const override = player.amountOverride;
if (override !== null && override !== undefined && String(override).trim() !== "") {
const parsed = Number(override);
if (!Number.isNaN(parsed)) return Math.max(0, parsed);
}
const chipTotal = chipLegend.reduce((sum, chip) => {
const count = Number(player.chipCounts[chip.id]) || 0;
return sum + count * Number(chip.value || 0);
}, 0);
if (chipTotal > 0) return chipTotal;
const base = Number(basePotAmount);
return Number.isNaN(base) ? 0 : Math.max(0, base);
},
totalPot(state) {
return state.seats.reduce(
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
0
);
},
// Next occupied-by-index seat in the current blind direction. Every seat index
// counts (there's no separate "empty chair" concept) — a dealer running fewer
// players just keeps the seat count matched to who's actually sitting down.
nextSeatIndex(state, fromIndex, step = 1) {
const count = state.seatCount;
const delta = state.direction === "counterclockwise" ? -step : step;
return ((fromIndex + delta) % count + count) % count;
},
advanceButton(state) {
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
},
getBlindSeats(state) {
const sbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
const bbIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 2);
return { dealerIndex: state.dealerSeatIndex, sbIndex, bbIndex };
},
startNextHand(state) {
this.advanceButton(state);
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
p.chipCounts = {};
p.amountOverride = null;
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HoldemState;
}