Compare commits

..
Author SHA1 Message Date
jheaps 24a5fd8b35 Merge pull request 'Announce the side-pot breakdown before the showdown' (#24) from feature/casino-holdem-side-pot-breakdown into feature/casino-holdem-gestures
Reviewed-on: #24
2026-08-31 23:34:25 -06:00
jheapsandClaude Sonnet 5 ee25e2a4fa Show the side-pot breakdown before the showdown, not after
Dealing guides are consistent that the most common side-pot error isn't
miscounting the chips, it's failing to announce the breakdown clearly before
the showdown. The tool had the arithmetic right but only revealed it after the
hand, one layer at a time, as the wizard asked who won each.

The breakdown is now visible while the hand is still running: as soon as an
all-in splits the money, the action bar lists the main pot and each side pot
with the amount and who can win it, and the wizard repeats it under "Announce
before the showdown" before asking for a winner. A hand with a single
undivided pot shows nothing, so the line means something when it appears.

Chips nobody could call are called what they are. Money above the largest live
contribution was never contested — usually an overbet by someone who then
folded — so it's listed as "Uncalled, back to X" rather than padding a side
pot nobody can win. The wizard's leftover warning says the same thing instead
of telling the dealer to un-fold someone to win it, which was the wrong advice
for chips that should simply be pushed back.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:10:19 -06:00
jheapsandClaude Sonnet 5 dabf1100c9 Add double-tap and press-and-hold shortcuts to the seats
Folding someone meant opening their editor, finding the Fold box, ticking it,
and dismissing the sheet — four interactions for the thing that happens most
often in a hand.

Double-tap a seat to fold, and again to put them back. Press and hold for a
quick menu of everything else you do to one player: fold, all-in, sit out or
deal in, hand them the button, or open the full editor. Both are undoable the
same way the header buttons are, because a gesture is easier to fire by
accident than a button.

The single tap now waits out the double-tap window before opening the editor —
a fifth of a second of latency on opening a form, in exchange for a fold that
doesn't also fling a sheet up over the table. A press that moves is a drag, so
the hold is cancelled and swapping seats still works, and the puck shrinks
slightly while held so a press that's being counted looks different from one
that's being ignored.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:54:56 -06:00
8 changed files with 594 additions and 21 deletions
@@ -230,6 +230,18 @@
</div>
</template>
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeader">
<h2 id="seatMenuTitle"></h2>
<span id="seatMenuSubtitle"></span>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
<div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@@ -279,6 +291,7 @@
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
+160 -1
View File
@@ -238,7 +238,12 @@ body {
cursor: grab;
z-index: 1;
text-align: center;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
/* A long press on a phone otherwise selects the player's name or raises the
system callout menu instead of ours. */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.seat.dragging {
@@ -518,6 +523,99 @@ body {
font-weight: 400;
}
/* ---------- Seat long-press menu ---------- */
/* Held down, before the menu opens — the only feedback that a press is being
counted rather than ignored. */
.seat.seatPressing {
transform: translate(-50%, -50%) scale(0.94);
}
#seatMenuOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 96;
}
#seatMenuOverlay[hidden] {
display: none;
}
#seatMenuSheet {
width: 100%;
max-width: 420px;
background: linear-gradient(180deg, #242424, #1a1a1a);
border: 1px solid #3a3a3a;
border-bottom: none;
border-radius: 16px 16px 0 0;
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
.seatMenuHeader {
margin-bottom: 0.8rem;
}
.seatMenuHeader h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 500;
color: #f5f5f5;
}
#seatMenuSubtitle {
color: #9a9a9a;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
#seatMenuActions {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 0.6rem;
}
.seatMenuBtn {
width: 100%;
min-height: 52px;
border-radius: 11px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #eee;
font-family: inherit;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.12s ease;
}
.seatMenuBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
.seatMenuBtn.menuDanger {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.seatMenuBtn.menuAccent {
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #a9782f;
color: #f0d9a8;
}
#seatMenuCancelBtn {
width: 100%;
justify-content: center;
min-height: 48px;
}
/* ---------- Card picker ---------- */
#cardPickerOverlay {
@@ -1004,6 +1102,67 @@ body.hasActionBar #holdemSidebar {
white-space: nowrap;
}
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.55rem;
padding: 0.4rem 0.55rem;
background: rgba(216, 180, 106, 0.07);
border: 1px solid rgba(216, 180, 106, 0.25);
border-radius: 8px;
}
.potBreakdownHeading {
margin: 0 0 0.35rem;
color: #d8b46a;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.potLayer {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.76rem;
}
.potLayerLabel {
color: #d8b46a;
font-weight: 600;
flex: none;
}
.potLayerAmount {
color: #f2f2f2;
font-variant-numeric: tabular-nums;
font-weight: 600;
flex: none;
}
.potLayerWho {
color: #9a9a9a;
flex: 1;
min-width: 0;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.potBreakdownWizard {
margin-bottom: 1rem;
}
.potLayerReturn .potLayerLabel,
.potLayerReturn .potLayerAmount {
color: #9a9a9a;
font-weight: 500;
}
.actionButtons {
display: flex;
gap: 0.5rem;
@@ -34,6 +34,9 @@ const ActionBar = {
host.appendChild(this.renderStatus());
const breakdown = this.renderPotBreakdown();
if (breakdown) host.appendChild(breakdown);
if (Betting.isHandOver(this.state)) {
host.appendChild(this.renderHandOver());
} else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) {
@@ -71,6 +74,28 @@ const ActionBar = {
return row;
},
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
renderPotBreakdown() {
const contributions = this.state.seats.map((player, index) => ({
id: player.id,
index,
contribution: Betting.handTotal(player),
folded: player.folded,
}));
const nameById = new Map(
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
);
return Render.buildPotBreakdown(
PotCalculator.layers(contributions),
PotCalculator.uncalledReturns(contributions),
id => nameById.get(id)
);
},
button(label, className, onClick, sublabel) {
const btn = document.createElement("button");
btn.type = "button";
@@ -1,9 +1,62 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
// A gesture on a seat is a shortcut for something the dealer could do the long
// way, so each one is undoable the same way the header buttons are.
const seatAction = (index, message, mutate) => {
const snapshot = JSON.parse(JSON.stringify(state));
mutate(state.seats[index], index);
HoldemState.save(state);
Render.renderAll();
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
HoldemState.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
duration: 6000,
});
};
const nameOf = index => HoldemState.displayName(state.seats[index], index);
const hooks = {
save: s => HoldemState.save(s),
rerender: () => Render.renderAll(),
onSeatDoubleTap: index => {
const player = state.seats[index];
if (!player.inHand) return;
if (player.folded) {
seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
return;
}
seatAction(index, `${nameOf(index)} folds.`, () => {
if (state.actingSeatIndex === index) Betting.fold(state, index);
else player.folded = true;
});
},
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
};
const seatMenuHooks = {
fold: index => hooks.onSeatDoubleTap(index),
unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
toggleDealtIn: index => {
const dealtIn = state.seats[index].inHand;
seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
p.inHand = !dealtIn;
if (!p.inHand) p.folded = false;
});
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
editSeat: index => Render.toggleSeatEditor(index),
};
Render.mount(state, hooks);
@@ -144,6 +197,11 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
});
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
@@ -24,6 +24,79 @@ const PotCalculator = {
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
},
// Splits the money on the table into the main pot and any side pots, with who can
// win each. Read-only — this is what a dealer says out loud before the showdown
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
// announcement that goes missing far more often than the arithmetic goes wrong.
//
// Every distinct all-in amount opens a new layer: each player pays into a layer up
// to their own contribution, and only players who reached that height can win it.
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
layers(players) {
const live = players.filter(p => p.contribution > this.EPSILON);
if (live.length === 0) return [];
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
const layers = [];
let floor = 0;
heights.forEach(height => {
const band = height - floor;
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
const amount = band * payers.length;
const eligible = payers.filter(p => !p.folded).map(p => p.id);
// A band nobody can win isn't a pot — it's an overbet that never got
// called, and it goes back to whoever put it in. Announcing it as a side
// pot would overstate what's actually being played for.
if (amount > 0 && eligible.length > 0) {
layers.push({ amount: amount / 100, eligible });
}
floor = height;
});
// Consecutive layers with the same set of eligible players are one pot to a
// dealer — nobody announces two side pots between the same two people.
const merged = [];
layers.forEach(layer => {
const previous = merged[merged.length - 1];
const sameEligibility =
previous &&
previous.eligible.length === layer.eligible.length &&
previous.eligible.every(id => layer.eligible.includes(id));
if (sameEligibility) {
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
} else {
merged.push({ ...layer });
}
});
return merged.map((layer, index) => ({
...layer,
label: index === 0 ? "Main pot" : `Side pot ${index}`,
}));
},
// Chips nobody could call, which the dealer pushes back rather than into the pot.
// Anything above the biggest live contribution was never contested — most often an
// overbet by someone who then folded.
uncalledReturns(players) {
const contributing = players.filter(p => p.contribution > this.EPSILON);
const liveCents = contributing.filter(p => !p.folded).map(p => Math.round(p.contribution * 100));
// Nobody live at all means the fold flags are wrong, not that every chip on
// the table was uncalled. That's a correction for the dealer to make, so say
// nothing here and let the wizard ask them to fix it.
if (liveCents.length === 0) return [];
const cap = Math.max(...liveCents);
return contributing
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
.filter(r => r.amount > 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
@@ -18,13 +18,16 @@ const PotWizard = {
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,
}))
);
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded,
}));
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();
@@ -58,6 +61,7 @@ const PotWizard = {
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));
@@ -65,7 +69,17 @@ const PotWizard = {
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.`;
// 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;
@@ -131,6 +145,20 @@ const PotWizard = {
});
},
// 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";
+152 -12
View File
@@ -47,9 +47,9 @@ const Render = {
renderTableHint() {
const hint = document.getElementById("tableHint");
hint.textContent = this.state.dedicatedDealer
? "Drag a seat onto another seat to swap players."
: "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
},
renderNotes() {
@@ -113,15 +113,7 @@ const Render = {
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
node.addEventListener("click", () => {
// Tapping the open seat again closes it — on a phone the editor is a
// sheet over the table, and the seat you just tapped is the closest
// thing to hand for dismissing it.
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
this.renderSeats();
this.renderSeatEditor();
});
this.wireSeatGestures(node, index);
this.wireDragAndDrop(node, index);
layer.appendChild(node);
@@ -202,6 +194,109 @@ const Render = {
if (node) this.updatePuckContent(node, this.state.seats[index], index);
},
// ---- Seat gestures ----
//
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
// the quick menu. The single tap waits out the double-tap window before it
// fires — a fifth of a second of latency on opening a form, in exchange for a
// fold that doesn't also fling a sheet up over the table.
LONG_PRESS_MS: 500,
DOUBLE_TAP_MS: 220,
DRAG_SLOP_PX: 8,
pendingTap: null,
wireSeatGestures(node, index) {
let longPressTimer = null;
let longPressFired = false;
let startX = 0;
let startY = 0;
const cancelLongPress = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
node.addEventListener("pointerdown", e => {
longPressFired = false;
startX = e.clientX;
startY = e.clientY;
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
longPressFired = true;
this.cancelPendingTap();
node.classList.remove("seatPressing");
this.hooks.onSeatLongPress(index);
}, this.LONG_PRESS_MS);
node.classList.add("seatPressing");
});
// Moving means they're dragging the seat, not holding it.
node.addEventListener("pointermove", e => {
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
cancelLongPress();
node.classList.remove("seatPressing");
}
});
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
node.addEventListener(type, () => {
cancelLongPress();
node.classList.remove("seatPressing");
});
});
node.addEventListener("dragstart", () => {
cancelLongPress();
this.cancelPendingTap();
node.classList.remove("seatPressing");
});
node.addEventListener("click", e => {
// The long press already did something; don't also treat the release as a tap.
if (longPressFired) {
longPressFired = false;
e.preventDefault();
e.stopPropagation();
return;
}
this.handleSeatTap(index);
});
},
cancelPendingTap() {
if (this.pendingTap) {
clearTimeout(this.pendingTap.timer);
this.pendingTap = null;
}
},
handleSeatTap(index) {
if (this.pendingTap && this.pendingTap.index === index) {
this.cancelPendingTap();
this.hooks.onSeatDoubleTap(index);
return;
}
this.cancelPendingTap();
const timer = setTimeout(() => {
this.pendingTap = null;
this.toggleSeatEditor(index);
}, this.DOUBLE_TAP_MS);
this.pendingTap = { index, timer };
},
// Tapping the open seat again closes it — on a phone the editor is a sheet over
// the table, and the seat you just tapped is the closest thing to hand for
// dismissing it.
toggleSeatEditor(index) {
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
this.renderSeats();
this.renderSeatEditor();
},
wireDragAndDrop(node, index) {
node.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", String(index));
@@ -425,6 +520,51 @@ const Render = {
});
},
// ---- Pot breakdown ----
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
// picked), because both are showing the dealer the same announcement.
buildPotBreakdown(pots, returns, nameFor, heading) {
if (pots.length < 2 && returns.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "potBreakdown";
if (heading) {
const title = document.createElement("p");
title.className = "potBreakdownHeading";
title.textContent = heading;
wrap.appendChild(title);
}
const addRow = (label, amount, who, className) => {
const row = document.createElement("div");
row.className = `potLayer ${className || ""}`;
const labelEl = document.createElement("span");
labelEl.className = "potLayerLabel";
labelEl.textContent = label;
const amountEl = document.createElement("span");
amountEl.className = "potLayerAmount";
amountEl.textContent = Money.format(amount);
const whoEl = document.createElement("span");
whoEl.className = "potLayerWho";
whoEl.textContent = who;
row.appendChild(labelEl);
row.appendChild(amountEl);
row.appendChild(whoEl);
wrap.appendChild(row);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
return wrap;
},
// ---- Session standings ----
renderStandings() {
@@ -0,0 +1,77 @@
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
// one press and one tap away, without opening the full editor and without hunting
// for whose turn it is in the action bar.
const SeatMenu = {
state: null,
hooks: null,
seatIndex: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
close() {
document.getElementById("seatMenuOverlay").hidden = true;
},
// Only the actions that make sense for this seat right now — a folded player has
// nothing to go all-in with, and an empty chair has nothing to fold.
actionsFor(player, index) {
const actions = [];
const dealtIn = player.inHand;
const live = dealtIn && !player.folded;
if (live) {
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
} else if (dealtIn && player.folded) {
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
}
actions.push({
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(index),
});
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
this.close();
action.run();
});
list.appendChild(btn);
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SeatMenu;
}