Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a488bbf832 | ||
|
|
9ee4816fa4 | ||
|
|
db208a1a86 |
@@ -138,7 +138,7 @@
|
|||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Table Notes</summary>
|
<summary>Table Notes</summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -198,8 +198,8 @@
|
|||||||
|
|
||||||
<template id="chipLegendRowTemplate">
|
<template id="chipLegendRowTemplate">
|
||||||
<div class="chipLegendRow">
|
<div class="chipLegendRow">
|
||||||
<input type="color" class="chipColorInput" />
|
<input type="color" class="chipColorInput" aria-label="Chip colour" />
|
||||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
|
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
|
||||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="addChipsActions">
|
<div class="addChipsActions">
|
||||||
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
|
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
|
||||||
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
<div class="rulingsHeader">
|
<div class="rulingsHeader">
|
||||||
<h2 id="rulingsTitle">Common rulings</h2>
|
<h2 id="rulingsTitle">Common rulings</h2>
|
||||||
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="rulingsIntro">
|
<p class="rulingsIntro">
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
<span id="seatMenuSubtitle"></span>
|
<span id="seatMenuSubtitle"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="seatMenuActions"></div>
|
<div id="seatMenuActions"></div>
|
||||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
<div class="cardPickerHeader">
|
<div class="cardPickerHeader">
|
||||||
<h2 id="cardPickerTitle"></h2>
|
<h2 id="cardPickerTitle"></h2>
|
||||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||||
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="cardPickerSlots"></div>
|
<div id="cardPickerSlots"></div>
|
||||||
<div id="cardPickerGrid"></div>
|
<div id="cardPickerGrid"></div>
|
||||||
@@ -340,17 +340,17 @@
|
|||||||
<h2 id="confirmTitle"></h2>
|
<h2 id="confirmTitle"></h2>
|
||||||
<p id="confirmMessage"></p>
|
<p id="confirmMessage"></p>
|
||||||
<div class="confirmActions">
|
<div class="confirmActions">
|
||||||
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="potWizardOverlay" hidden>
|
<div id="potWizardOverlay" hidden>
|
||||||
<div id="potWizardModal">
|
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||||
<div class="potWizardHeader">
|
<div class="potWizardHeader">
|
||||||
<h2>Resolve the Pot</h2>
|
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||||
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
|
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="potWizardBody"></div>
|
<div id="potWizardBody"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,5 +374,6 @@
|
|||||||
<script src="~/js/casino/holdem/shortcuts.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/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ button,
|
|||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
/* Named rather than `all`, so the focus ring isn't animated. With `all` the
|
||||||
|
outline faded in over 150ms, which on keyboard navigation reads as the ring
|
||||||
|
lagging a step behind the Tab key. Nothing else here changes. */
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
|
||||||
|
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1923,6 +1927,24 @@ body.hasActionBar #holdemSidebar {
|
|||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
|
||||||
|
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
|
||||||
|
the seat next to it. These step the size down so every digit stays on its own
|
||||||
|
puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
|
||||||
|
comfortably, but a dealer reading a rounded stack off the felt is a worse failure
|
||||||
|
than a small one, and the exact figure is what the tool is for. */
|
||||||
|
.seatPuckStack.puckAmountLong,
|
||||||
|
.seatPuckTotal.puckAmountLong {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatPuckStack.puckAmountHuge,
|
||||||
|
.seatPuckTotal.puckAmountHuge {
|
||||||
|
font-size: 0.44rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.blindsRow {
|
.blindsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2672,3 +2694,41 @@ body.hasActionBar #toastHost {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Keyboard focus ----------
|
||||||
|
Three focus rules existed for the whole tool, so tabbing through it relied on
|
||||||
|
whatever ring the browser draws by default — which on these dark, custom-painted
|
||||||
|
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
|
||||||
|
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
|
||||||
|
.holdemBtn:focus-visible,
|
||||||
|
.linkBtn:focus-visible,
|
||||||
|
.seatMenuBtn:focus-visible,
|
||||||
|
.seatSheetClose:focus-visible,
|
||||||
|
.pickerCard:focus-visible,
|
||||||
|
.actionBtn:focus-visible,
|
||||||
|
.raiseQuickBtn:focus-visible,
|
||||||
|
.actionUndo:focus-visible,
|
||||||
|
.toastAction:focus-visible,
|
||||||
|
.toastClose:focus-visible,
|
||||||
|
.potWizardChoice:focus-visible,
|
||||||
|
.cardSlot:focus-visible,
|
||||||
|
.subPanel summary:focus-visible,
|
||||||
|
.seat:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
[role="button"]:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid #7fc4ff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
/* The ring has to sit above the neighbouring seat that overlaps this one. */
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
|
||||||
|
That shouldn't paint a ring around the whole panel. */
|
||||||
|
[role="dialog"]:focus,
|
||||||
|
[role="alertdialog"]:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ const Betting = {
|
|||||||
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Never below zero. A seat can't have negative chips in front of it, and a typo in
|
||||||
|
// the stack field used to come back as-is — which then flowed into putIn(), where
|
||||||
|
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
|
||||||
stackOf(state, player) {
|
stackOf(state, player) {
|
||||||
const own = player.stack;
|
const own = player.stack;
|
||||||
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
|
if (own !== null && own !== undefined && String(own).trim() !== "") {
|
||||||
|
return Math.max(0, this.round(own));
|
||||||
|
}
|
||||||
const fallback = Number(state.defaultStack);
|
const fallback = Number(state.defaultStack);
|
||||||
return Number.isNaN(fallback) ? 0 : this.round(fallback);
|
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
|
||||||
},
|
},
|
||||||
|
|
||||||
toCall(state, player) {
|
toCall(state, player) {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const HandHistoryExport = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
|
||||||
|
// text, so the one thing anybody opens this file to do — total a column and see
|
||||||
|
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||||
|
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||||
toCsv(handHistory, standings = []) {
|
toCsv(handHistory, standings = []) {
|
||||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||||
@@ -25,7 +29,7 @@ const HandHistoryExport = {
|
|||||||
rows.push([
|
rows.push([
|
||||||
entry.handNumber,
|
entry.handNumber,
|
||||||
this.formatTimestamp(entry.timestamp),
|
this.formatTimestamp(entry.timestamp),
|
||||||
Money.format(entry.pot),
|
Money.forSheet(entry.pot),
|
||||||
payouts,
|
payouts,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -41,12 +45,12 @@ const HandHistoryExport = {
|
|||||||
standings.forEach(row => {
|
standings.forEach(row => {
|
||||||
rows.push([
|
rows.push([
|
||||||
row.name,
|
row.name,
|
||||||
Money.format(row.in),
|
Money.forSheet(row.in),
|
||||||
Money.format(row.won),
|
Money.forSheet(row.won),
|
||||||
Money.formatSigned(row.net),
|
Money.forSheet(row.net),
|
||||||
Money.format(row.boughtIn || 0),
|
Money.forSheet(row.boughtIn || 0),
|
||||||
Money.format(row.chipsNow || 0),
|
Money.forSheet(row.chipsNow || 0),
|
||||||
Money.formatSigned(row.cashPosition || 0),
|
Money.forSheet(row.cashPosition || 0),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const state = HoldemState.load();
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
||||||
|
// `hidden` attribute they all already toggle.
|
||||||
|
Modal.mount();
|
||||||
|
|
||||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||||
// by reference to every module at mount time, so it has to be refilled in place —
|
// by reference to every module at mount time, so it has to be refilled in place —
|
||||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||||
@@ -369,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (e.key === "Enter") AddChips.confirm();
|
if (e.key === "Enter") AddChips.confirm();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
||||||
@@ -376,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
rulingsOverlay.addEventListener("click", e => {
|
rulingsOverlay.addEventListener("click", e => {
|
||||||
if (e.target === rulingsOverlay) closeRulings();
|
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("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
|
||||||
|
//
|
||||||
|
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
|
||||||
|
// confirm — opened and closed by five different modules, and each one was missing
|
||||||
|
// the same three things: focus never moved into the dialog, Tab walked straight out
|
||||||
|
// of it into the page behind the scrim, and Escape did nothing (only the rulings
|
||||||
|
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
|
||||||
|
// 1 of 5 closed on Escape.
|
||||||
|
//
|
||||||
|
// Rather than patch that into five modules, this watches the `hidden` attribute every
|
||||||
|
// overlay already toggles. Nothing else had to change, and an overlay added later
|
||||||
|
// gets the same behaviour without anyone remembering to wire it up — which is the
|
||||||
|
// failure this is fixing in the first place.
|
||||||
|
const Modal = {
|
||||||
|
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||||
|
// must not be a Tab stop.
|
||||||
|
FOCUSABLE: [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
'input:not([disabled]):not([type="hidden"])',
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(","),
|
||||||
|
|
||||||
|
open: [],
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
|
||||||
|
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
|
||||||
|
// table-level shortcut sees them.
|
||||||
|
document.addEventListener("keydown", e => this.onKeyDown(e), true);
|
||||||
|
},
|
||||||
|
|
||||||
|
watch(overlay) {
|
||||||
|
const sync = () => {
|
||||||
|
const isOpen = !overlay.hidden;
|
||||||
|
const known = this.open.some(entry => entry.overlay === overlay);
|
||||||
|
if (isOpen && !known) this.onOpened(overlay);
|
||||||
|
else if (!isOpen && known) this.onClosed(overlay);
|
||||||
|
};
|
||||||
|
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
|
||||||
|
sheetOf(overlay) {
|
||||||
|
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
|
||||||
|
},
|
||||||
|
|
||||||
|
focusableIn(overlay) {
|
||||||
|
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
|
||||||
|
if (el.closest("[hidden]")) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpened(overlay) {
|
||||||
|
const sheet = this.sheetOf(overlay);
|
||||||
|
// aria-modal is only true while the thing is actually open, so it's set here
|
||||||
|
// rather than baked into the markup.
|
||||||
|
if (sheet) sheet.setAttribute("aria-modal", "true");
|
||||||
|
|
||||||
|
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||||
|
|
||||||
|
// Focus the first real control. A dialog that leaves focus on the button
|
||||||
|
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||||
|
// Content-only sheets get focus on the sheet so the title is announced.
|
||||||
|
const focusable = this.focusableIn(overlay);
|
||||||
|
const target = focusable[0];
|
||||||
|
if (target) {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
} else if (sheet) {
|
||||||
|
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
|
||||||
|
sheet.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onClosed(overlay) {
|
||||||
|
const index = this.open.findIndex(entry => entry.overlay === overlay);
|
||||||
|
if (index === -1) return;
|
||||||
|
const [entry] = this.open.splice(index, 1);
|
||||||
|
|
||||||
|
const sheet = this.sheetOf(overlay);
|
||||||
|
if (sheet) sheet.removeAttribute("aria-modal");
|
||||||
|
|
||||||
|
// Put the dealer back where they were. Without this, closing the pot wizard
|
||||||
|
// drops focus to the top of the document and the next Tab starts from the
|
||||||
|
// page header.
|
||||||
|
const back = entry.returnFocus;
|
||||||
|
if (back && back.isConnected && typeof back.focus === "function") {
|
||||||
|
back.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
topmost() {
|
||||||
|
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Escape presses the dialog's own cancel/close button rather than just hiding the
|
||||||
|
// overlay, so whatever that button does — resolving the confirm promise, clearing
|
||||||
|
// the add-chips draft — still happens. Only if there isn't one does it hide the
|
||||||
|
// overlay directly.
|
||||||
|
dismiss(overlay) {
|
||||||
|
const closer = overlay.querySelector("[data-modal-close]");
|
||||||
|
if (closer) closer.click();
|
||||||
|
else overlay.hidden = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
const top = this.topmost();
|
||||||
|
if (!top) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.dismiss(top.overlay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
|
||||||
|
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
|
||||||
|
// mouse, so letting the keyboard wander back there is a trap with no way out.
|
||||||
|
const focusable = this.focusableIn(top.overlay);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus({ preventScroll: true });
|
||||||
|
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Modal;
|
||||||
|
}
|
||||||
@@ -25,9 +25,27 @@ const Money = {
|
|||||||
return this.toCents(amount) / 100;
|
return this.toCents(amount) / 100;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Cents only when there are cents.
|
||||||
|
//
|
||||||
|
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
|
||||||
|
// is two characters that say nothing — measured at ~22px, against the 64px a
|
||||||
|
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
|
||||||
|
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
|
||||||
|
// 25c-blind game reads exactly as it did.
|
||||||
format(amount) {
|
format(amount) {
|
||||||
const value = Number(amount) || 0;
|
const value = Number(amount) || 0;
|
||||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
const hasCents = this.toCents(value) % 100 !== 0;
|
||||||
|
return `$${value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: hasCents ? 2 : 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same amount for a spreadsheet rather than for a person: no currency symbol
|
||||||
|
// and no thousands separators, because those are what make a column of money
|
||||||
|
// import as text and refuse to add up.
|
||||||
|
forSheet(amount) {
|
||||||
|
return (this.toCents(amount) / 100).toFixed(2);
|
||||||
},
|
},
|
||||||
|
|
||||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const Render = {
|
|||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
this.hooks = hooks;
|
||||||
this.selectedSeatIndex = null;
|
this.selectedSeatIndex = null;
|
||||||
|
// A reset hands over a different table entirely, so nothing cached about the
|
||||||
|
// last one still applies.
|
||||||
|
this.lastHistorySignature = null;
|
||||||
|
this.handTimeCache.clear();
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -261,11 +265,14 @@ const Render = {
|
|||||||
const total = handTotal > 0
|
const total = handTotal > 0
|
||||||
? handTotal
|
? handTotal
|
||||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
const totalEl = node.querySelector(".seatPuckTotal");
|
||||||
|
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||||
|
this.fitAmount(totalEl);
|
||||||
|
|
||||||
const stackEl = node.querySelector(".seatPuckStack");
|
const stackEl = node.querySelector(".seatPuckStack");
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||||
|
this.fitAmount(stackEl);
|
||||||
|
|
||||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||||
node.classList.toggle("seatEmpty", empty);
|
node.classList.toggle("seatEmpty", empty);
|
||||||
@@ -274,6 +281,17 @@ const Render = {
|
|||||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
|
||||||
|
// because the number has no spaces to break on, it doesn't wrap or ellipsise
|
||||||
|
// either — it just draws straight out over the neighbouring seat. Stepping the
|
||||||
|
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||||
|
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||||
|
fitAmount(el) {
|
||||||
|
const length = (el.textContent || "").length;
|
||||||
|
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||||
|
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||||
|
},
|
||||||
|
|
||||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||||
// (which live in a separate panel) never lose focus.
|
// (which live in a separate panel) never lose focus.
|
||||||
refreshPuck(index) {
|
refreshPuck(index) {
|
||||||
@@ -689,24 +707,57 @@ const Render = {
|
|||||||
|
|
||||||
// ---- Hand counter + history ----
|
// ---- Hand counter + history ----
|
||||||
|
|
||||||
|
// toLocaleTimeString is the single most expensive call in the history panel, and a
|
||||||
|
// logged hand's timestamp never changes, so the answer is worth keeping.
|
||||||
|
handTimeCache: new Map(),
|
||||||
|
|
||||||
formatHandTime(timestamp) {
|
formatHandTime(timestamp) {
|
||||||
|
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
|
||||||
|
let formatted = "";
|
||||||
try {
|
try {
|
||||||
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
formatted = "";
|
||||||
}
|
}
|
||||||
|
this.handTimeCache.set(timestamp, formatted);
|
||||||
|
return formatted;
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHandHistory() {
|
// Cheap stand-in for "has the log changed". Entries are immutable once written and
|
||||||
|
// only ever added at the front, so the length, the counter and the newest row
|
||||||
|
// between them catch a recorded hand, a cleared history and an undo.
|
||||||
|
historySignature() {
|
||||||
|
const history = this.state.handHistory || [];
|
||||||
|
const newest = history[0];
|
||||||
|
return [
|
||||||
|
history.length,
|
||||||
|
this.state.handNumber || 1,
|
||||||
|
newest ? newest.handNumber : "",
|
||||||
|
newest ? newest.timestamp : "",
|
||||||
|
newest ? newest.pot : "",
|
||||||
|
].join("|");
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
|
||||||
|
// full re-render runs after every fold, call and raise. The log only changes when a
|
||||||
|
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||||
|
// is skipped unless it actually did change. Everything else on the table reflects
|
||||||
|
// the current hand and still redraws every time.
|
||||||
|
renderHandHistory(force = false) {
|
||||||
|
const signature = this.historySignature();
|
||||||
|
if (!force && signature === this.lastHistorySignature) return;
|
||||||
|
this.lastHistorySignature = signature;
|
||||||
|
|
||||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||||
|
|
||||||
const list = document.getElementById("handHistoryList");
|
const list = document.getElementById("handHistoryList");
|
||||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||||
const history = this.state.handHistory || [];
|
const history = this.state.handHistory || [];
|
||||||
list.innerHTML = "";
|
|
||||||
emptyHint.hidden = history.length > 0;
|
emptyHint.hidden = history.length > 0;
|
||||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||||
|
|
||||||
|
// Built off-document so the panel is laid out once instead of once per row.
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
const template = document.getElementById("handHistoryRowTemplate");
|
const template = document.getElementById("handHistoryRowTemplate");
|
||||||
history.forEach(entry => {
|
history.forEach(entry => {
|
||||||
const row = template.content.firstElementChild.cloneNode(true);
|
const row = template.content.firstElementChild.cloneNode(true);
|
||||||
@@ -717,8 +768,10 @@ const Render = {
|
|||||||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||||||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||||
|
|
||||||
list.appendChild(row);
|
fragment.appendChild(row);
|
||||||
});
|
});
|
||||||
|
list.innerHTML = "";
|
||||||
|
list.appendChild(fragment);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ const Shortcuts = {
|
|||||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||||
},
|
},
|
||||||
|
|
||||||
// An overlay owns the keyboard while it's up: the confirm dialog already binds
|
// An overlay owns the keyboard while it's up. Asking the document which overlays
|
||||||
// Enter and Escape, and the card picker and seat menu are modal.
|
// are open, rather than listing them, means a sheet added later can't quietly
|
||||||
|
// start letting "f" fold the table behind it — the same reason Modal watches for
|
||||||
|
// them instead of being told about each one.
|
||||||
overlayOpen() {
|
overlayOpen() {
|
||||||
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
|
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||||
.some(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
return el && !el.hidden;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onKey(e) {
|
onKey(e) {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// The hand-history panel is only rebuilt when its signature changes, which takes a
|
||||||
|
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
|
||||||
|
// noticing every way the log can actually change — a stale panel would have the
|
||||||
|
// dealer reading last hand's payouts while settling this one.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Render, HoldemState, makeTable } = require("./load");
|
||||||
|
|
||||||
|
// historySignature() reads Render.state, so point it at a table for each case.
|
||||||
|
const signatureOf = state => {
|
||||||
|
Render.state = state;
|
||||||
|
return Render.historySignature();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableWithHands = (count) => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
state.seats[0].amountOverride = 2;
|
||||||
|
state.seats[1].amountOverride = 2;
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("the history panel knows when to redraw", () => {
|
||||||
|
test("nothing changes while a hand is being played", () => {
|
||||||
|
const state = tableWithHands(3);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
|
||||||
|
// A whole street of betting touches no part of the log.
|
||||||
|
state.seats[0].committed = 5;
|
||||||
|
state.seats[1].folded = true;
|
||||||
|
state.actingSeatIndex = 2;
|
||||||
|
state.street = "flop";
|
||||||
|
|
||||||
|
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settling a hand changes it", () => {
|
||||||
|
const state = tableWithHands(3);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
|
||||||
|
assert.notEqual(signatureOf(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing the history changes it", () => {
|
||||||
|
const state = tableWithHands(5);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
HoldemState.clearHistory(state);
|
||||||
|
assert.notEqual(signatureOf(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undoing back to a shorter log changes it", () => {
|
||||||
|
const state = tableWithHands(2);
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(state));
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
|
||||||
|
const afterRecord = signatureOf(state);
|
||||||
|
|
||||||
|
// The undo path swaps the state's contents in place.
|
||||||
|
Object.keys(state).forEach(k => delete state[k]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
|
||||||
|
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two hands that differ only in payout are told apart", () => {
|
||||||
|
const a = tableWithHands(1);
|
||||||
|
const b = tableWithHands(1);
|
||||||
|
// Same count and hand number; different pot.
|
||||||
|
b.handHistory[0].pot = a.handHistory[0].pot + 25;
|
||||||
|
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the hand counter moving on changes it", () => {
|
||||||
|
// The counter badge is drawn by the same function, so a new hand number has
|
||||||
|
// to invalidate even when the log itself is untouched.
|
||||||
|
const state = tableWithHands(2);
|
||||||
|
const before = signatureOf(state);
|
||||||
|
state.handNumber += 1;
|
||||||
|
assert.notEqual(signatureOf(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty log has a stable signature of its own", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1] });
|
||||||
|
const first = signatureOf(state);
|
||||||
|
assert.equal(signatureOf(state), first);
|
||||||
|
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||||
|
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the hand-time cache", () => {
|
||||||
|
test("the same timestamp is only formatted once, and formats the same", () => {
|
||||||
|
Render.handTimeCache.clear();
|
||||||
|
const stamp = Date.UTC(2026, 8, 1, 15, 4);
|
||||||
|
const first = Render.formatHandTime(stamp);
|
||||||
|
assert.equal(Render.handTimeCache.size, 1);
|
||||||
|
|
||||||
|
const second = Render.formatHandTime(stamp);
|
||||||
|
assert.equal(second, first);
|
||||||
|
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different timestamps still format differently", () => {
|
||||||
|
Render.handTimeCache.clear();
|
||||||
|
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
|
||||||
|
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
assert.equal(Render.handTimeCache.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a nonsense timestamp doesn't throw", () => {
|
||||||
|
Render.handTimeCache.clear();
|
||||||
|
assert.doesNotThrow(() => Render.formatHandTime(undefined));
|
||||||
|
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,11 @@ const Money = load("money");
|
|||||||
const PotCalculator = load("potCalculator");
|
const PotCalculator = load("potCalculator");
|
||||||
const SplitCalculator = load("splitCalculator");
|
const SplitCalculator = load("splitCalculator");
|
||||||
const Betting = load("betting");
|
const Betting = load("betting");
|
||||||
|
const HandHistoryExport = load("handHistoryExport");
|
||||||
|
|
||||||
|
// render.js exports { Render } rather than the namespace directly.
|
||||||
|
const Render = require(path.join(SRC, "render.js")).Render;
|
||||||
|
globalThis.Render = Render;
|
||||||
|
|
||||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||||
const HoldemState = require(path.join(SRC, "state.js"));
|
const HoldemState = require(path.join(SRC, "state.js"));
|
||||||
@@ -68,6 +73,8 @@ function contributions(state) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Money,
|
Money,
|
||||||
|
Render,
|
||||||
|
HandHistoryExport,
|
||||||
PotCalculator,
|
PotCalculator,
|
||||||
SplitCalculator,
|
SplitCalculator,
|
||||||
Betting,
|
Betting,
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Money, HandHistoryExport } = require("./load");
|
||||||
|
|
||||||
|
describe("Money.format", () => {
|
||||||
|
test("whole amounts drop the pointless cents", () => {
|
||||||
|
assert.equal(Money.format(12), "$12");
|
||||||
|
assert.equal(Money.format(450), "$450");
|
||||||
|
assert.equal(Money.format(1250000), "$1,250,000");
|
||||||
|
assert.equal(Money.format(0), "$0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("amounts that carry cents still show them", () => {
|
||||||
|
assert.equal(Money.format(12.5), "$12.50");
|
||||||
|
assert.equal(Money.format(0.25), "$0.25");
|
||||||
|
assert.equal(Money.format(3.01), "$3.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a quarter-blind game reads exactly as it did", () => {
|
||||||
|
// The case that would be ruined by dropping cents unconditionally.
|
||||||
|
[0.25, 0.5, 1.75, 12.05].forEach(v => {
|
||||||
|
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("floating-point dust doesn't invent cents", () => {
|
||||||
|
assert.equal(Money.format(0.1 + 0.2), "$0.30");
|
||||||
|
assert.equal(Money.format(1.0000000001), "$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed amounts put the sign out front, both scales", () => {
|
||||||
|
assert.equal(Money.formatSigned(87500), "+$87,500");
|
||||||
|
assert.equal(Money.formatSigned(-87500), "-$87,500");
|
||||||
|
assert.equal(Money.formatSigned(-12.5), "-$12.50");
|
||||||
|
assert.equal(Money.formatSigned(0), "$0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rubbish in doesn't produce rubbish out", () => {
|
||||||
|
assert.equal(Money.format(null), "$0");
|
||||||
|
assert.equal(Money.format(undefined), "$0");
|
||||||
|
assert.equal(Money.format("nonsense"), "$0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Money.forSheet", () => {
|
||||||
|
test("a bare number a spreadsheet will add up", () => {
|
||||||
|
assert.equal(Money.forSheet(1250000), "1250000.00");
|
||||||
|
assert.equal(Money.forSheet(12.5), "12.50");
|
||||||
|
assert.equal(Money.forSheet(-87500), "-87500.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no currency symbol and no thousands separators", () => {
|
||||||
|
const written = Money.forSheet(1234567.89);
|
||||||
|
assert.ok(!written.includes("$"), "a $ makes the cell text");
|
||||||
|
assert.ok(!written.includes(","), "a comma makes the cell text");
|
||||||
|
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the exported CSV", () => {
|
||||||
|
const history = [
|
||||||
|
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
|
||||||
|
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
|
||||||
|
];
|
||||||
|
const standings = [
|
||||||
|
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
|
||||||
|
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Splitting on commas isn't good enough: the timestamp column legitimately holds
|
||||||
|
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
|
||||||
|
const splitRow = line => {
|
||||||
|
const fields = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
|
||||||
|
else if (c === '"') quoted = false;
|
||||||
|
else field += c;
|
||||||
|
} else if (c === '"') quoted = true;
|
||||||
|
else if (c === ",") { fields.push(field); field = ""; }
|
||||||
|
else field += c;
|
||||||
|
}
|
||||||
|
fields.push(field);
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("every money cell parses as a number", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, standings);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
|
||||||
|
lines.slice(1, 3).forEach(line => {
|
||||||
|
const cell = splitRow(line)[2]; // Pot
|
||||||
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Standings rows: every column after the name has to be numeric.
|
||||||
|
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||||
|
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||||
|
splitRow(line).slice(1).forEach(cell => {
|
||||||
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a million-dollar pot doesn't need quoting any more", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, []);
|
||||||
|
assert.ok(csv.includes("1250000.00"), "written plainly");
|
||||||
|
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names and payout descriptions stay human", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv(history, standings);
|
||||||
|
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
|
||||||
|
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name with a comma is still quoted properly", () => {
|
||||||
|
const csv = HandHistoryExport.toCsv([], [
|
||||||
|
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
|
||||||
|
]);
|
||||||
|
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Values a dealer can actually produce with a typo, and the ones a corrupted save
|
||||||
|
// can produce on its own. None of these should be able to move chips.
|
||||||
|
const { test, describe } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
describe("a stack can never go negative", () => {
|
||||||
|
test("a mistyped negative stack reads as empty, not as owing chips", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats[0].stack = -50;
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative stack can't be bet with", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats[0].stack = -50;
|
||||||
|
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
|
||||||
|
|
||||||
|
const moved = Betting.putIn(state, 0, 25);
|
||||||
|
|
||||||
|
assert.equal(moved, 0, "nothing moves out of an empty stack");
|
||||||
|
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
|
||||||
|
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
|
||||||
|
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative table default doesn't hand everyone a negative stack", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats.forEach(p => { p.stack = null; });
|
||||||
|
state.defaultStack = -100;
|
||||||
|
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative stack doesn't make a player look like they can still bet", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.seats[0].stack = -50;
|
||||||
|
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blinds that can't be posted", () => {
|
||||||
|
test("negative blinds post nothing rather than paying players to sit down", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.smallBlind = -5;
|
||||||
|
state.bigBlind = -10;
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||||
|
assert.equal(chipsOnTable(state), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unreadable blind doesn't wedge the hand", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||||
|
state.bigBlind = NaN;
|
||||||
|
state.smallBlind = 1;
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
|
||||||
|
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blinds nobody can cover put those players all-in and move on", () => {
|
||||||
|
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
|
||||||
|
state.smallBlind = 500;
|
||||||
|
state.bigBlind = 1000;
|
||||||
|
const before = chipsOnTable(state);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
|
||||||
|
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
|
||||||
|
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a saved table that comes back damaged", () => {
|
||||||
|
test("unreadable storage falls back to a fresh table", () => {
|
||||||
|
// load() has to survive whatever is in localStorage, because the alternative
|
||||||
|
// is a dealer opening the tool mid-session to a blank screen.
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => "{not json at all",
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
|
||||||
|
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
|
||||||
|
assert.equal(state.handNumber, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a save missing its seats is treated as no save at all", () => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a save from before a field existed gets the field, not undefined", () => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => JSON.stringify({
|
||||||
|
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
|
||||||
|
handNumber: 4,
|
||||||
|
}),
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.load();
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
|
||||||
|
assert.equal(state.blindOverride, null);
|
||||||
|
assert.equal(state.street, "preflop");
|
||||||
|
state.seats.forEach(p => {
|
||||||
|
assert.equal(p.committed, 0);
|
||||||
|
assert.equal(p.hasActed, false);
|
||||||
|
assert.equal(p.buyIns, 0);
|
||||||
|
});
|
||||||
|
// And it has to be playable, not just parseable.
|
||||||
|
assert.doesNotThrow(() => Betting.beginHand(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storage that refuses to be written doesn't take the table down", () => {
|
||||||
|
const original = globalThis.localStorage;
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => { throw new Error("QuotaExceededError"); },
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
|
||||||
|
globalThis.localStorage = original;
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user