Compare commits

..
10 changed files with 49 additions and 885 deletions
+29 -107
View File
@@ -31,13 +31,9 @@
<span id="potBadgeAmount">$0</span>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
<div id="seatsLayer"></div>
</div>
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
</p>
</section>
<aside id="holdemSidebar">
@@ -85,25 +81,34 @@
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
</details>
<p class="fieldHint">Blinds post themselves each hand, and every seat without its own stack starts here. Set a seat's own stack in its editor.</p>
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">Every player who didn't go all-in short is assumed to have put in this amount. Enter a smaller amount on a seat only if that player is all-in for less.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Session</summary>
<summary>Chip Legend</summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a dollar value, then count chips per seat instead of typing a total.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
<details class="sidebarPanel">
<summary>Session Standings</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
@@ -114,33 +119,20 @@
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
</div>
</details>
<h3 class="panelSubhead">Hands</h3>
<details class="sidebarPanel">
<summary>Hand History</summary>
<div class="panelBody">
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
</aside>
</div>
</div>
@@ -182,7 +174,6 @@
<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">
@@ -239,73 +230,6 @@
</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>
<div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<p class="rulingsIntro">
The conventions most rooms follow, for the calls that come up mid-hand.
Your house rules always win — and settle a dispute before the next hand,
not during it.
</p>
<section class="ruling">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</section>
<section class="ruling">
<h3>A board card turned too early</h3>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</section>
<section class="ruling">
<h3>Acting out of turn</h3>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
</p>
</div>
</div>
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@@ -368,10 +292,8 @@
<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>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
+2 -390
View File
@@ -223,68 +223,6 @@ body {
inset: 0;
}
/* ---------- Bets on the felt ---------- */
/* Shares the seat ring's box exactly, so a bet always lands on the spoke between its
player and the pot. When the seat ring gets inset on a phone, this moves with it —
measured the alternative, and a bets ring on its own geometry ends up either behind
the pucks or on top of the pot badge. */
#betsLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.betChip {
position: absolute;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 0.28rem;
padding: 0.1rem 0.42rem 0.1rem 0.2rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
/* Reads as a chip seen from above: a lighter top-left, a rim, and the notches a
casino chip has around its edge. */
.betChipDisc {
width: 13px;
height: 13px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.45),
inset 0 -1px 2px rgba(0, 0, 0, 0.45);
}
.betChipAmount {
font-size: 0.62rem;
font-weight: 700;
color: #f0f0f0;
font-variant-numeric: tabular-nums;
}
/* Clinging to a puck rather than sitting on its own ring — the phone layout. */
.betChipAnchored {
left: 50%;
z-index: 3;
}
.betChipAbove {
top: -11px;
transform: translate(-50%, -50%);
}
.betChipBelow {
top: auto;
bottom: -11px;
transform: translate(-50%, 50%);
}
/* ---------- Seats (compact pucks on the table) ---------- */
.seat {
@@ -585,260 +523,6 @@ 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 {
margin: 0.35rem 0 0;
text-align: center;
}
.linkBtn {
background: none;
border: none;
padding: 0;
color: #a9782f;
font-family: inherit;
font-size: 0.78rem;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.linkBtn:hover {
color: #d8b46a;
}
#rulingsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 97;
}
#rulingsOverlay[hidden] {
display: none;
}
#rulingsSheet {
width: 100%;
max-width: 460px;
max-height: 86vh;
overflow-y: auto;
background: linear-gradient(180deg, #242424, #1a1a1a);
border: 1px solid #3a3a3a;
border-bottom: none;
border-radius: 16px 16px 0 0;
padding: 0.35rem 1.1rem calc(1.2rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
.rulingsHeader {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.rulingsHeader h2 {
flex: 1;
margin: 0;
font-size: 1.05rem;
font-weight: 500;
color: #f5f5f5;
}
.rulingsIntro {
margin: 0 0 1rem;
color: #9a9a9a;
font-size: 0.8rem;
line-height: 1.5;
}
.ruling {
margin-bottom: 1rem;
padding-left: 0.7rem;
border-left: 2px solid rgba(216, 180, 106, 0.4);
}
.ruling h3 {
margin: 0 0 0.35rem;
color: #d8b46a;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.03em;
}
.ruling p,
.ruling li {
color: #c4c4c4;
font-size: 0.82rem;
line-height: 1.5;
}
.ruling p {
margin: 0;
}
.ruling ul {
margin: 0;
padding-left: 1.1rem;
}
.ruling li + li {
margin-top: 0.25rem;
}
.rulingsSource {
margin: 0;
padding-top: 0.6rem;
border-top: 1px solid #2c2c2c;
color: #7a7a7a;
font-size: 0.72rem;
line-height: 1.45;
}
/* ---------- Seat long-press menu ---------- */
/* Held down, before the menu opens — the only feedback that a press is being
@@ -1188,46 +872,6 @@ body {
gap: 0.7rem;
}
/* A heading inside a panel, so one panel can hold two related things — the
standings and the hand log are two views of the same session — without needing
two identical-looking accordions to say so. */
.panelSubhead {
margin: 0.2rem 0 0;
color: #d8b46a;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.panelSubhead + * {
margin-top: -0.2rem;
}
/* Nested disclosure for the things a dealer sets once and then forgets, kept out
of the way of the settings they actually touch. */
.subPanel {
border-top: 1px solid #2c2c2c;
padding-top: 0.5rem;
}
.subPanel summary {
cursor: pointer;
color: #8f8f8f;
font-size: 0.78rem;
}
.subPanel summary:hover {
color: #c5c5c5;
}
.subPanelBody {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.6rem;
}
.fieldRow {
display: flex;
align-items: center;
@@ -1575,7 +1219,6 @@ body.hasActionBar #holdemSidebar {
}
.actionBtn {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
@@ -1613,29 +1256,6 @@ body.hasActionBar #holdemSidebar {
font-variant-numeric: tabular-nums;
}
.actionKey {
position: absolute;
top: 5px;
right: 7px;
padding: 0 0.25rem;
border-radius: 4px;
border: 1px solid currentColor;
opacity: 0.4;
font-family: inherit;
font-size: 0.6rem;
line-height: 1.4;
text-transform: uppercase;
pointer-events: none;
}
/* No keyboard, no key badges — on a phone they'd just be clutter on the buttons
the dealer is already tapping. */
@media (hover: none) {
.actionKey {
display: none;
}
}
.actionFold {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
@@ -1746,18 +1366,11 @@ body.hasActionBar #holdemSidebar {
.blindsRow .fieldRow {
flex: 1 1 120px;
min-width: 0;
gap: 0.4rem;
}
/* "Small blind" broke onto two lines beside its input, which made the pair look
like a mistake rather than a pair. */
.blindsRow .fieldRow > span {
white-space: nowrap;
}
.blindsRow input {
min-width: 0;
width: 3.4rem;
width: 5rem;
}
/* ---------- Toasts ---------- */
@@ -2050,8 +1663,7 @@ body.hasActionBar #toastHost {
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
#seatsLayer,
#betsLayer {
#seatsLayer {
inset: 26px 44px;
}
@@ -194,19 +194,6 @@ const ActionBar = {
);
},
// Which key presses each button. Shown on the button itself and hidden on touch
// devices — a shortcut nobody can discover may as well not exist, and a key badge
// on a phone is just clutter.
KEY_HINTS: {
actionFold: "F",
actionCheck: "C",
actionCall: "C",
actionRaise: "R",
actionAdvance: "↵",
raiseConfirm: "↵",
raiseCancel: "esc",
},
button(label, className, onClick, sublabel) {
const btn = document.createElement("button");
btn.type = "button";
@@ -217,14 +204,6 @@ const ActionBar = {
main.textContent = label;
btn.appendChild(main);
const hint = this.KEY_HINTS[className];
if (hint) {
const kbd = document.createElement("kbd");
kbd.className = "actionKey";
kbd.textContent = hint;
btn.appendChild(kbd);
}
if (sublabel) {
const sub = document.createElement("span");
sub.className = "actionBtnSub";
@@ -1,92 +0,0 @@
// "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,19 +35,9 @@ const HandHistoryExport = {
if (standings.length > 0) {
rows.push([]);
rows.push(["Session Standings"]);
// 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"]);
rows.push(["Player", "Paid In", "Won", "Net"]);
standings.forEach(row => {
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),
]);
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
});
}
@@ -57,15 +57,6 @@ 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);
@@ -125,38 +116,6 @@ document.addEventListener("DOMContentLoaded", () => {
// snapshots refer to a hand that no longer exists.
const clearActionHistory = () => { actionHistory.length = 0; };
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
// calling the betting engine themselves — there's no second code path to drift out
// of step with what the bar is actually offering right now.
const pressInBar = selector => {
const btn = document.querySelector(`#actionBar ${selector}`);
if (!btn || btn.disabled) return false;
btn.click();
return true;
};
const quickRaiseButton = label =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
Shortcuts.mount({
fold: () => pressInBar(".actionFold"),
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
raise: () => pressInBar(".actionRaise"),
allIn: () => {
// Open the raise pad first if it isn't already, then take its All-in amount.
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
const quick = quickRaiseButton("All-in");
if (!quick) return false;
quick.click();
return true;
},
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"),
undo: () => pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"),
});
// Moving to the next hand used to ask "are you sure?" every single time —
// the most-pressed button in a session, gated behind a system dialog. It now
// just happens, and the toast offers the way back for the few seconds anyone
@@ -269,26 +228,6 @@ 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; });
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
});
document.addEventListener("keydown", e => {
if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
@@ -69,85 +69,13 @@ const Render = {
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count) {
return this.positionOnRing(index, count, 44, 42);
},
// Bets sit on the felt between their player and the pot, the way chips actually
// do — closer in than the seats, on the same spoke. The bets layer is inset to
// match the seat ring (see the CSS), so this one radius holds at every size.
betPosition(index, count) {
return this.positionOnRing(index, count, 27, 25);
},
positionOnRing(index, count, rx, ry) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
return {
left: 50 + rx * Math.cos(radians),
top: 50 + ry * Math.sin(radians),
};
},
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
// gets bets that look like the chips actually being pushed out.
chipColorFor(amount) {
const legend = [...(this.state.chipLegend || [])]
.filter(chip => Number(chip.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
const match = legend.find(chip => amount >= Number(chip.value));
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
},
// ---- Bets on the felt ----
makeBetChip(bet) {
const chip = document.createElement("div");
chip.className = "betChip";
const disc = document.createElement("span");
disc.className = "betChipDisc";
disc.style.background = this.chipColorFor(bet);
const amount = document.createElement("span");
amount.className = "betChipAmount";
amount.textContent = Money.format(bet);
chip.appendChild(disc);
chip.appendChild(amount);
return chip;
},
// A phone's felt has no room for a third ring: measured every radius between the
// seats and the pot badge and each one collides with one or the other. So on a
// narrow screen the bet clings to its own puck instead — same chip, no ring —
// sitting on whichever side faces the middle of the table.
renderBets() {
const layer = document.getElementById("betsLayer");
if (!layer) return;
layer.innerHTML = "";
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
const chip = this.makeBetChip(bet);
const pos = this.betPosition(index, this.state.seatCount);
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
const seatPos = this.seatPosition(index, this.state.seatCount);
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
seat.appendChild(chip);
return;
}
chip.style.left = `${pos.left}%`;
chip.style.top = `${pos.top}%`;
layer.appendChild(chip);
});
const rx = 44;
const ry = 42;
const left = 50 + rx * Math.cos(radians);
const top = 50 + ry * Math.sin(radians);
return { left, top };
},
puckLabel(player, seatIndex) {
@@ -190,8 +118,6 @@ const Render = {
layer.appendChild(node);
});
this.renderBets();
},
isRedSuit(card) {
@@ -242,13 +168,11 @@ const Render = {
span.textContent = this.formatCard(card);
cardsEl.appendChild(span);
});
// Always this player's total for the hand. What they've just pushed out on this
// street is shown as a chip on the felt instead — the puck used to flip between
// the two meanings depending on the moment, which is a bad thing for a number
// sitting next to a stack size to do.
const handTotal = Betting.handTotal(player);
const total = handTotal > 0
? handTotal
// What's in front of them this street reads as the live number during betting;
// between hands it falls back to their total for the hand.
const streetBet = Betting.committed(player);
const total = streetBet > 0
? streetBet
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
@@ -491,13 +415,6 @@ 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,10 +32,6 @@ 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),
@@ -1,64 +0,0 @@
// Keyboard control for a dealer running this on a laptop.
//
// The phone has gestures; the desktop had a mouse and nothing else. These are the
// same actions the bar already offers, reachable without moving a hand to the
// trackpad between every decision — which is the whole point when the bar is being
// tapped thirty times a hand.
const Shortcuts = {
// Deliberately single letters with no modifier: a dealer's spare hand rests on
// the keyboard, and a chord is slower than reaching for the button would be.
KEYS: {
f: "fold",
c: "checkOrCall",
r: "raise",
a: "allIn",
" ": "advance",
enter: "advance",
u: "undo",
escape: "cancel",
},
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
},
// Nothing fires while the dealer is typing a name, an amount, or a note — the
// "c" in "Cass" must not fold the table.
isTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
},
// 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", "addChipsOverlay"]
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
});
},
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
const action = this.KEYS[key];
if (!action) return;
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
if (this.overlayOpen()) return;
const handled = this.handlers[action] && this.handlers[action]();
if (handled) e.preventDefault();
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Shortcuts;
}
@@ -31,8 +31,6 @@ const HoldemState = {
stack: null,
committed: 0,
hasActed: false,
// Cash handed over tonight, which only moves when they buy chips.
buyIns: 0,
};
},
@@ -81,7 +79,6 @@ 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;
@@ -159,20 +156,7 @@ const HoldemState = {
return ((fromIndex + delta) % count + count) % count;
},
// The button passes to the next player, not the next chair. Moving it one seat at
// a time parks it on empty chairs — with four players spread around ten seats it
// sits on nobody most hands — and that isn't only untidy: position and blinds are
// supposed to come round to each player at the same rate, and they don't if the
// button is rotating past chairs nobody is sitting in.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) {
state.dealerSeatIndex = index;
return;
}
}
// Nobody is dealt in; move it along anyway so it doesn't look stuck.
state.dealerSeatIndex = this.nextSeatIndex(state, state.dealerSeatIndex, 1);
},
@@ -252,32 +236,13 @@ 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 => {
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,
};
})
.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,
}))
.sort((a, b) => b.net - a.net);
},
};