Add chips to a seat without leaving the table

Somebody rebuying is the most common interruption in a cash game, and the only
way to record it was to open the seat editor and retype a stack from memory.

Press and hold a seat and there's now "Add chips…": quick amounts off the
table's starting stack — half a buy-in, a buy-in, two — with a keypad for the
times it isn't one of those. It's undoable like every other seat action, and it
records cash handed over as well as chips added, because those answer different
questions. The stack moves every hand; what a player has paid in only moves
when they buy.

The export gains bought in, chips now, and up/down, so settling up at the end of
the night is reading a row rather than reconstructing it.

Getting that last column right needed care. Chips in front of a player disagree
with the hand log for as long as a hand is open — and since blinds post the
instant the previous hand settles, that window is almost always. Counting what a
player already has in the current pot as still theirs makes the cash position
agree with the hand log at any moment, including across a rebuy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-08-31 23:53:15 -06:00
co-authored by Claude Sonnet 5
parent 9a5b974fda
commit 552ec1a542
9 changed files with 322 additions and 9 deletions
@@ -182,6 +182,7 @@
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
@@ -238,6 +239,25 @@
</div>
</template>
<div id="addChipsOverlay" hidden>
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle"></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
<div class="addChipsEntry">
<span class="addChipsCurrency">$</span>
<input type="text" id="addChipsInput" inputmode="decimal" autocomplete="off" aria-label="Amount to add" />
</div>
<div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
</div>
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@@ -348,6 +368,7 @@
<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/addChips.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/shortcuts.js?v=@ViewData["cssVersion"]"></script>
+139
View File
@@ -585,6 +585,145 @@ body {
font-weight: 400;
}
/* ---------- Add chips ---------- */
.seatBuyIns {
margin-top: -0.3rem;
color: #9a9a9a;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
#addChipsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 98;
}
#addChipsOverlay[hidden] {
display: none;
}
#addChipsSheet {
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 1rem calc(1.2rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
.addChipsHeader {
margin-bottom: 0.8rem;
}
.addChipsHeader h2 {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: #f5f5f5;
}
#addChipsSubtitle {
color: #9a9a9a;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
#addChipsQuick {
display: flex;
gap: 0.45rem;
margin-bottom: 0.7rem;
}
.addChipsQuickBtn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
min-height: 52px;
border-radius: 11px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-family: inherit;
cursor: pointer;
}
.addChipsQuickBtn.selected {
border-color: #d8b46a;
background: linear-gradient(180deg, #3a2f18, #241d0f);
color: #f0d9a8;
}
.addChipsQuickLabel {
font-size: 0.78rem;
font-weight: 600;
}
.addChipsQuickAmount {
font-size: 0.7rem;
color: #a8a8a8;
font-variant-numeric: tabular-nums;
}
.addChipsQuickBtn.selected .addChipsQuickAmount {
color: #d8b46a;
}
.addChipsEntry {
display: flex;
align-items: center;
gap: 0.4rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 11px;
padding: 0.2rem 0.8rem;
margin-bottom: 0.8rem;
}
.addChipsEntry:focus-within {
border-color: #d8b46a;
}
.addChipsCurrency {
color: #777;
font-size: 1.3rem;
}
#addChipsInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-family: inherit;
font-size: 1.5rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
padding: 0.4rem 0;
}
.addChipsActions {
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
}
.addChipsActions .holdemBtn {
width: 100%;
justify-content: center;
min-height: 48px;
}
/* ---------- Rulings reference ---------- */
.tableHintActions {
@@ -0,0 +1,92 @@
// "Add chips" — a rebuy, a top-up, or the first buy-in of the night.
//
// Quick amounts come off the table's starting stack, because a rebuy is almost
// always one buy-in, sometimes half or two. The keypad is there for the times it
// isn't. Recorded as cash in as well as chips, so at the end of the night the
// dealer can say what each player actually handed over.
const AddChips = {
state: null,
onConfirm: null,
seatIndex: null,
draft: "",
open(state, seatIndex, onConfirm) {
this.state = state;
this.seatIndex = seatIndex;
this.onConfirm = onConfirm;
this.draft = String(this.baseAmount());
this.render();
document.getElementById("addChipsOverlay").hidden = false;
},
close() {
document.getElementById("addChipsOverlay").hidden = true;
},
// The table's starting stack if there is one, otherwise something sane to nudge
// rather than an empty box.
baseAmount() {
const configured = Number(this.state.defaultStack);
if (!Number.isNaN(configured) && configured > 0) return configured;
const bb = Number(this.state.bigBlind) || 0;
return bb > 0 ? bb * 100 : 100;
},
quickAmounts() {
const base = this.baseAmount();
return [
{ label: "½ buy-in", value: Math.round(base / 2 * 100) / 100 },
{ label: "Buy-in", value: base },
{ label: "2 buy-ins", value: base * 2 },
];
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("addChipsTitle").textContent =
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
const stack = Betting.stackOf(this.state, player);
const boughtIn = Number(player.buyIns) || 0;
document.getElementById("addChipsSubtitle").textContent = boughtIn > 0
? `${Money.format(stack)} in front · ${Money.format(boughtIn)} in so far`
: `${Money.format(stack)} in front`;
const quick = document.getElementById("addChipsQuick");
quick.innerHTML = "";
this.quickAmounts().forEach(option => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "addChipsQuickBtn";
btn.classList.toggle("selected", Number(this.draft) === option.value);
const label = document.createElement("span");
label.className = "addChipsQuickLabel";
label.textContent = option.label;
const amount = document.createElement("span");
amount.className = "addChipsQuickAmount";
amount.textContent = Money.format(option.value);
btn.appendChild(label);
btn.appendChild(amount);
btn.addEventListener("click", () => {
this.draft = String(option.value);
this.render();
});
quick.appendChild(btn);
});
const input = document.getElementById("addChipsInput");
input.value = this.draft;
},
confirm() {
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
this.close();
if (amount > 0) this.onConfirm(this.seatIndex, Math.round(amount * 100) / 100);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = AddChips;
}
@@ -35,9 +35,19 @@ const HandHistoryExport = {
if (standings.length > 0) {
rows.push([]);
rows.push(["Session Standings"]);
rows.push(["Player", "Paid In", "Won", "Net"]);
// Bought in and chips now are what a cash-out is settled from; paid in and
// won describe the hands. Both belong in the file the dealer hands over.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
standings.forEach(row => {
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
rows.push([
row.name,
Money.format(row.in),
Money.format(row.won),
Money.formatSigned(row.net),
Money.format(row.boughtIn || 0),
Money.format(row.chipsNow || 0),
Money.formatSigned(row.cashPosition || 0),
]);
});
}
@@ -57,6 +57,15 @@ document.addEventListener("DOMContentLoaded", () => {
},
makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
// Chips in front and cash in are different questions — the stack moves
// every hand, what they've handed over only moves when they buy.
player.stack = Math.round((Betting.stackOf(state, player) + amount) * 100) / 100;
player.buyIns = Math.round(((Number(player.buyIns) || 0) + amount) * 100) / 100;
});
}),
};
Render.mount(state, hooks);
@@ -260,6 +269,15 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
document.getElementById("addChipsOverlay").addEventListener("click", e => {
if (e.target.id === "addChipsOverlay") AddChips.close();
});
document.getElementById("addChipsInput").addEventListener("keydown", e => {
if (e.key === "Enter") AddChips.confirm();
});
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
@@ -491,6 +491,13 @@ const Render = {
});
});
// What they've handed over tonight, shown next to what's in front of them so a
// cash-out is one subtraction rather than a memory test.
const boughtIn = Number(player.buyIns) || 0;
const boughtInEl = form.querySelector(".seatBuyIns");
boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : "";
boughtInEl.hidden = boughtIn <= 0;
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
@@ -32,6 +32,10 @@ const SeatMenu = {
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(index),
@@ -35,7 +35,7 @@ const Shortcuts = {
// An overlay owns the keyboard while it's up: the confirm dialog already binds
// Enter and Escape, and the card picker and seat menu are modal.
overlayOpen() {
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay"]
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
@@ -31,6 +31,8 @@ const HoldemState = {
stack: null,
committed: 0,
hasActed: false,
// Cash handed over tonight, which only moves when they buy chips.
buyIns: 0,
};
},
@@ -79,6 +81,7 @@ const HoldemState = {
if (player.stack === undefined) player.stack = null;
if (player.committed === undefined) player.committed = 0;
if (player.hasActed === undefined) player.hasActed = false;
if (player.buyIns === undefined) player.buyIns = 0;
});
return parsed;
@@ -236,13 +239,32 @@ const HoldemState = {
});
});
// Cash bought in and chips currently in front don't come from the hand log at
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
const seatById = new Map(state.seats.map(p => [p.id, p]));
return Array.from(totals.values())
.map(t => ({
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
}))
.map(t => {
const seat = seatById.get(t.id);
const boughtIn = seat ? Math.round((Number(seat.buyIns) || 0) * 100) / 100 : 0;
const chipsNow = seat ? Math.round((Number(seat.stack) || 0) * 100) / 100 : 0;
// Chips already pushed into the hand in progress are still this
// player's money until it's won. Leaving them out makes the cash
// position disagree with the hand log for as long as a hand is open —
// and blinds are posted the instant the previous one settles, so that
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
return {
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
boughtIn,
chipsNow,
cashPosition: Math.round((chipsNow + inFrontOfThem - boughtIn) * 100) / 100,
};
})
.sort((a, b) => b.net - a.net);
},
};