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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user