Compare commits

..
49 changed files with 236 additions and 7451 deletions
+12 -138
View File
@@ -25,6 +25,7 @@
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
@@ -86,32 +87,6 @@
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
</label>
</div>
<label class="fieldRow">
<span>No flop, no drop</span>
<input type="checkbox" id="rakeNoFlopInput" />
</label>
<p class="fieldHint">
Taken from the pot when it's awarded, and shown before anything is pushed.
Rounded down to a chip the table has, so the players keep what the house
can't make. Leave the percentage at zero for a game that doesn't rake.
</p>
<p class="fieldHint" id="rakeTakenHint" hidden></p>
</div>
</details>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
@@ -128,7 +103,6 @@
<details class="sidebarPanel">
<summary>Session</summary>
<div class="panelBody">
<p id="storageWarning" class="storageWarning" hidden></p>
<h3 class="panelSubhead">Standings</h3>
<div class="standingsTableWrap">
<div id="standingsTable">
@@ -136,9 +110,8 @@
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span class="standingsNetHead">Net</span>
<span class="standingsChipsHead" title="What they are holding — the cash to count out if they leave now">Chips</span>
<span title="Those chips, less the cash they handed over">Up/down</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
</div>
<div id="standingsList"></div>
</div>
@@ -154,48 +127,17 @@
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<div class="bankReconcileRow" id="bankRakedRow" hidden>
<span>House take</span>
<span id="bankRaked"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
<div id="settleUp" hidden>
<h3 class="panelSubhead">Settle up</h3>
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
<h3 class="panelSubhead">Hands</h3>
<div class="handHistoryFilterRow">
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
</div>
<div id="handHistoryList"></div>
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, 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>
<h3 class="panelSubhead">The table itself</h3>
<p class="fieldHint">
The night lives in this browser and nowhere else. Save it to a file to carry it
to another device, or to have something to come back to if this one dies.
</p>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
</div>
</div>
</details>
@@ -205,20 +147,6 @@
<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 id="colourUp" hidden>
<h3 class="panelSubhead">Stacks in chips</h3>
<dl id="colourUpList" class="colourUpList"></dl>
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
</div>
</div>
</details>
<details class="sidebarPanel" id="keyboardPanel">
<summary>Keyboard</summary>
<div class="panelBody">
<dl id="shortcutLegend" class="shortcutLegend"></dl>
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
</div>
</details>
@@ -235,10 +163,9 @@
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
@@ -313,7 +240,6 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsChips"></span>
<span class="standingsCashOut"></span>
</div>
</template>
@@ -362,9 +288,7 @@
not during it.
</p>
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
<section class="ruling" data-short="Misdeal">
<section class="ruling">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
@@ -374,7 +298,7 @@
</ul>
</section>
<section class="ruling" data-short="Board card early">
<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,
@@ -383,7 +307,7 @@
</p>
</section>
<section class="ruling" data-short="Out of turn">
<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.
@@ -391,24 +315,7 @@
</p>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</section>
<section class="ruling" data-short="Short all-in">
<section class="ruling">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
Somebody shoving in less than a full raise — say $3 more when the last
@@ -423,7 +330,7 @@
</p>
</section>
<section class="ruling" data-short="Uncalled bet">
<section class="ruling">
<h3>Chips nobody called come back</h3>
<p>
Bet more than anyone was able to call and the excess is returned to you
@@ -433,7 +340,7 @@
</p>
</section>
<section class="ruling" data-short="Oversized chip">
<section class="ruling">
<h3>One big chip, nothing said</h3>
<p>
The most common argument at a home table. A player facing a bet who
@@ -449,7 +356,7 @@
</p>
</section>
<section class="ruling" data-short="Minimum raise">
<section class="ruling">
<h3>A raise has to be a full raise</h3>
<p>
At least as much again as the last raise, or the big blind if there
@@ -460,23 +367,7 @@
</p>
</section>
<section class="ruling" data-short="Who shows first">
<h3>Who turns their hand over first</h3>
<p>
If there was a bet on the river, the last player to bet or raise shows
first. If the river was checked all the way round, it's the first live
player left of the button. Nobody has to be asked twice — a player may
always turn their hand over early if they want to.
</p>
<p>
When everyone is all-in before the river there was no final round to
lead, so it falls to whoever last put money in, on whichever street that
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
</p>
</section>
<section class="ruling" data-short="Odd chip">
<section class="ruling">
<h3>The odd chip in a split pot</h3>
<p>
When a chopped pot won't divide evenly, the odd chip goes to the first
@@ -486,22 +377,6 @@
</p>
</section>
<section class="ruling" data-short="String bet">
<h3>Betting in one motion — the string bet</h3>
<p>
A bet is whatever goes in on the first trip to the stack, unless the
amount was said out loud first. Reaching back for more chips without
having said "raise" is a string bet, and only the first motion stands.
Saying the number is always enough on its own: "raise to sixty" binds
whatever order the chips arrive in.
</p>
<p>
It's the rule home games invoke most and enforce least, and both are
fine — the point is that a player can't watch the table react and then
decide how much to bet. Say it, or push it all at once.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
@@ -568,7 +443,6 @@
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
File diff suppressed because it is too large Load Diff
@@ -143,18 +143,12 @@ button,
}
.unevenCurrency {
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
against every amount a dealer types, so it is read constantly. */
color: #8f8f8f;
color: #777;
}
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
44px everywhere else since the round that set it; this page never got measured for
it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
min-height: 46px;
background: none;
border: none;
outline: none;
@@ -184,17 +178,13 @@ button,
color: #ffd6d6;
}
/* 30px across, and it deletes a player's entry — the one control on the row where a
near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
width: 46px;
height: 46px;
width: 30px;
height: 44px;
border: none;
background: none;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
player's entry. A destructive action should not be the faintest thing in view. */
color: #9a9a9a;
color: #6a6a6a;
font-size: 0.85rem;
cursor: pointer;
}
@@ -219,86 +209,11 @@ button,
.unevenPot {
display: flex;
align-items: baseline;
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
eligible-players list and pushing it off the row. */
flex-wrap: wrap;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.unevenChopToggle {
flex: none;
order: 3;
/* 44px on touch: it moves money, like everything else that does. */
min-height: 32px;
padding: 0.2rem 0.6rem;
border: 1px solid rgba(216, 180, 106, 0.45);
border-radius: 999px;
background: rgba(216, 180, 106, 0.1);
color: #e3c887;
font: inherit;
font-size: 0.74rem;
cursor: pointer;
}
.unevenChopToggle:hover {
background: rgba(216, 180, 106, 0.18);
}
.unevenChopToggle:focus-visible {
outline: 2px solid #d8b46a;
outline-offset: 2px;
}
.unevenChopDetail {
order: 4;
flex: 0 0 100%;
/* Or the padding is added on top of the 100% basis and the panel sits wider than
the row it belongs to — measured 328px inside a 309px row on a phone. */
box-sizing: border-box;
margin: 0.15rem 0 0.2rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
}
.unevenChopWays {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.unevenChopWaysLabel {
color: #cfcfcf;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.unevenChopEach {
margin: 0;
color: #f2f2f2;
font-weight: 600;
}
.unevenChopNote {
margin: 0.2rem 0 0;
color: #9a9a9a;
font-size: 0.76rem;
}
.unevenChopDust {
color: #e0b169;
}
@media (max-width: 700px), (pointer: coarse) {
.unevenChopToggle {
min-height: 44px;
padding: 0.4rem 0.9rem;
}
}
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@@ -595,9 +510,7 @@ button,
.splitFootnote {
margin-top: 1.5rem;
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
list, which is not a decorative line. */
color: #8d8d8d;
color: #6f6f6f;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
@@ -11,11 +11,6 @@ const ActionBar = {
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
this.tankUnknownFor = state.bettingOpen &&
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
? state.actingSeatIndex
: null;
this.watchHeight();
this.render();
},
@@ -34,7 +29,6 @@ const ActionBar = {
const visible = !host.hidden;
const height = visible ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
this.publishFeltRoom(height);
};
if (typeof ResizeObserver === "function") {
@@ -46,66 +40,11 @@ const ActionBar = {
publish();
},
// Half a seat, the gap left under the lowest one, and the smallest felt worth
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
// table-sized — so the overhang is a constant rather than something read back off
// the layout this is about to change. Measuring it would mean shrinking the felt,
// moving the seats, and getting a different answer on the next pass.
SEAT_OVERHANG_PX: 37,
FELT_GAP_PX: 8,
// The floor is a guard against a measurement that has gone wrong — not a size the
// window has agreed to hand over. It used to be 220px, which a phone on its side
// hasn't got: at 844x390 there are 99px between the header and the bar, and holding
// out for 220 is how the bottom row of seats came to sit 26px inside the Fold
// button. A floor that puts seats behind the bar defeats the one thing the floor is
// for, so it drops to the smallest felt still worth drawing and the measurement
// wins above it.
FELT_FLOOR_PX: 96,
// How tall the felt can be and still leave the whole ring of seats above the bar.
//
// Publishing the bar's height was only half the job. The wide layout never used it:
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
// 117px to 172px the moment there's a side pot to show, which is exactly when the
// dealer most needs to see who is still in. Measured with two side pots, the bottom
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
//
// The wrap's offset is taken in document space rather than viewport space, so
// scrolling the page doesn't resize the table under the dealer's hands.
feltRoomFor(windowHeight, barHeight, topInDocument) {
const room = windowHeight - barHeight - topInDocument -
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
return Math.max(this.FELT_FLOOR_PX, Math.round(room));
},
publishFeltRoom(barHeight) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return;
// Taking the top before the height is set matters: the wrap sits below the
// header in normal flow, so its own height never moves its top and this can't
// chase itself around the screen.
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
document.documentElement.style.setProperty(
"--feltRoomPx",
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
// still holds during setup, because a seated player is a live seat whether or not
// the betting has been opened. An empty table stays uncluttered.
//
// A *named* table is not an empty one, though, and that was the hole: name six
// people without dealing any of them in and the bar hid itself, leaving a screen
// with six players on it, no way forward and nothing saying why. Anyone with a
// name on the felt is somebody the dealer has to be told about.
isRunning() {
return Betting.liveSeats(this.state).length > 0 ||
this.state.seats.some(p => (p.name || "").trim());
return Betting.liveSeats(this.state).length > 0;
},
render() {
@@ -159,12 +98,8 @@ const ActionBar = {
row.appendChild(pot);
// How many are still in the hand — part of the standard per-street
// announcement, and the number a dealer is asked for most often. Before the
// deal it counts the table as it will be dealt, so a busted player waiting on a
// rebuy isn't announced as one of the players. Read-only: the sitting-out
// itself happens when the hand is dealt, not while the bar is being drawn.
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
const remaining = Betting.liveSeats(counted).length;
// announcement, and the number a dealer is asked for most often.
const remaining = Betting.liveSeats(this.state).length;
const players = document.createElement("span");
players.className = "actionPlayers";
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
@@ -206,17 +141,6 @@ const ActionBar = {
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
undo.textContent = `↶ ${undoLabel}`;
// U undoes, and nothing said so — this control was the only place a dealer
// could have learned it, and it was the one action bar control without its
// key printed on it.
const key = document.createElement("kbd");
// Its own class, not .actionKey: that one pins itself to the top-right
// corner of a big action button, and this control is a 30px pill with
// overflow hidden and no positioning context — the cap escaped it and
// floated above the bar. Here the key belongs inline, after the label.
key.className = "actionUndoKey";
key.textContent = this.KEY_HINTS.actionUndo;
undo.appendChild(key);
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
}
@@ -237,14 +161,6 @@ const ActionBar = {
tankStartedAt: null,
tankInterval: null,
// The seat that was already being asked to act when this page loaded, if there was
// one. Its clock is unknowable: the action moved to them before this session
// started — maybe two minutes ago, maybe last night — and starting from zero would
// have the bar say they had just begun thinking. Which it did, every time a dealer
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
// clock is measuring something it actually saw.
tankUnknownFor: null,
// Restarts only when the action actually moves — a re-render for any other reason
// (a chip edit, a name change) must not put a thinking player back to zero.
syncTankTimer() {
@@ -258,8 +174,6 @@ const ActionBar = {
}
if (this.tankSeatIndex !== index) {
// Once the action has moved under our own eyes, nothing is unknown any more.
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
this.tankSeatIndex = index;
this.tankStartedAt = Date.now();
}
@@ -283,33 +197,13 @@ const ActionBar = {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
el.classList.remove("tankNudge", "tankSlow");
return;
}
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
el.title = "How long this player has been thinking";
el.textContent = this.formatTank(seconds);
const mins = Math.floor(seconds / 60);
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
},
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
// open on a hand does reach that, and sixty-five minutes written as though it were
// a minutes-and-seconds reading is a number the eye gets wrong.
formatTank(seconds) {
const pad = value => String(value).padStart(2, "0");
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = seconds % 60;
return hours > 0
? `${hours}:${pad(minutes)}:${pad(rest)}`
: `${minutes}:${pad(rest)}`;
},
// Shown only once someone is all-in and the money has actually split — a single
// undivided pot needs no announcement, and a line that's always there stops being
// read.
@@ -352,8 +246,6 @@ const ActionBar = {
actionAdvance: "↵",
raiseConfirm: "↵",
raiseCancel: "esc",
raiseAllIn: "A",
actionUndo: "U",
},
button(label, className, onClick, sublabel) {
@@ -366,10 +258,7 @@ const ActionBar = {
main.textContent = label;
btn.appendChild(main);
// Looked up by any of the button's classes, so a button can carry a shared
// class for its looks and a specific one for its key — the raise pad's four
// quick amounts are all raiseQuickBtn, but only All-in has a key.
const hint = className.split(/\s+/).map(name => this.KEY_HINTS[name]).find(Boolean);
const hint = this.KEY_HINTS[className];
if (hint) {
const kbd = document.createElement("kbd");
kbd.className = "actionKey";
@@ -421,10 +310,7 @@ const ActionBar = {
}
if (stack > owed) {
// Not "does this player owe anything" — the big blind owes nothing and is
// still raising. It's whether there is a live bet to raise.
const raising = Betting.wagerVerb(this.state) === "raise";
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
this.raiseOpen = true;
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
this.render();
@@ -449,6 +335,7 @@ const ActionBar = {
const player = this.state.seats[index];
const stack = Betting.stackOf(this.state, player);
const maxTo = Betting.round(Betting.committed(player) + stack);
const pot = Betting.potSoFar(this.state);
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
// what this player can actually put in, which is what the Min button should
// offer. `legalMin` is what the rules require — for a short stack the two are
@@ -456,7 +343,6 @@ const ActionBar = {
// or a player whose whole stack is under a full raise never gets told that.
const legalMin = Betting.minRaiseTo(this.state);
const minTo = Math.min(legalMin, maxTo);
const raising = Betting.wagerVerb(this.state) === "raise";
const wrap = document.createElement("div");
wrap.className = "raisePad";
@@ -464,21 +350,15 @@ const ActionBar = {
const quick = document.createElement("div");
quick.className = "raiseQuick";
// The pot amounts come from Betting, which knows that a pot raise is measured
// off the pot as it stands *after* the caller's own call goes in. The pad used
// to work it out here as "the bet plus the pot", which left the call out and
// made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot
// raise is $7, and a half-pot that clamped up to the minimum and sat next to
// the Min button showing the same $4 twice.
const options = [
{ label: "Min", value: minTo },
{ label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
{ label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
{ label: "All-in", value: maxTo },
];
options.forEach(option => {
const value = Math.max(minTo, Math.min(option.value, maxTo));
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
const btn = this.button(option.label, "raiseQuickBtn", () => {
this.raiseDraft = value;
this.render();
}, Money.format(value));
@@ -495,7 +375,7 @@ const ActionBar = {
input.inputMode = "decimal";
input.className = "raiseInput";
input.value = this.raiseDraft ?? minTo;
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
this.updateRaiseNote(wrap, legalMin, maxTo);
@@ -507,16 +387,11 @@ const ActionBar = {
this.render();
}));
// "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
// under it to raise past.
entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
const label = raising
? `${who}'s raise to ${Money.format(target)}`
: `${who}'s bet of ${Money.format(target)}`;
this.commit(label, () => Betting.raiseTo(this.state, index, target));
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
}));
wrap.appendChild(entry);
@@ -545,7 +420,6 @@ const ActionBar = {
const typed = Number(this.raiseDraft);
const current = this.state.currentBet || 0;
const raising = Betting.wagerVerb(this.state) === "raise";
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
@@ -554,20 +428,16 @@ const ActionBar = {
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
// The way out of an under-minimum differs: with a bet in front of you the
// alternative is calling it, and with an empty pot there's nothing to call.
note.textContent = raising
? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
: `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
note.textContent =
`${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
note.className = "raiseNote raiseNoteWarn";
note.hidden = false;
return;
}
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
note.textContent = raising
? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
: `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
note.textContent =
`All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
note.className = "raiseNote raiseNoteInfo";
note.hidden = false;
return;
@@ -596,161 +466,34 @@ const ActionBar = {
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
// name doesn't need the verb again, and the bar has no room for it.
straddleClause(preview) {
const straddles = preview.straddles || [];
if (straddles.length === 0) return "";
const name = i => HoldemState.displayName(this.state.seats[i], i);
const [first, ...rest] = straddles;
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
},
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
// the big blind to straddle from, so nothing is offered rather than a button that
// would do nothing.
straddleButtons(preview) {
const on = (preview.straddles || []).length;
const max = preview.maxStraddles || 0;
if (max <= 0) return [];
const buttons = [];
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
if (on < max) {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off",
() => { this.state.straddleCount = 0; })));
}
return buttons;
},
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
const preview = Betting.blindPreview(this.state);
const seated = Betting.liveSeats(this.state).length;
const broke = Betting.brokeSeats(this.state);
const name = i => HoldemState.displayName(this.state.seats[i], i);
const listNames = indexes => indexes.length === 1
? name(indexes[0])
: `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
const line = document.createElement("p");
line.className = "dealPromptLine";
if (!preview) {
// Two players is the least you can deal. Say what's missing rather than
// offering a button that would quietly do nothing — and say the *right*
// thing: "only one player is dealt in" is wrong when four are sitting there
// and three of them are just out of chips.
const withChips = seated - broke.length;
// "Nobody has chips left" is the wrong story for a table that never had
// any. A dealer who has just named six people and not yet said what a
// buy-in is gets told their players are busted, which sends them looking
// for a rebuy button instead of the one field that fixes it.
const nobodyHasEverBoughtIn = this.state.seats.every(p =>
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
if (broke.length > 0 && withChips < 2) {
line.textContent = withChips === 1
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
: nobodyHasEverBoughtIn
? "Nobody has bought in yet. Set what everyone starts with, or give each seat their chips."
: "Nobody at the table has chips left. Add a buy-in to deal.";
} else {
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
: "Only one player is dealt in — a hand needs two.";
}
// offering a button that would quietly do nothing.
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
: "Only one player is dealt in — a hand needs two.";
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
// A blocked prompt with nothing to press is a dead end, and this was the
// one a first-time dealer hits: six players named, a sentence saying they
// have no chips, and no button anywhere on the bar. The starting stack is a
// single field in the sidebar and the message never pointed at it.
if (nobodyHasEverBoughtIn && seated > 0) {
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Set starting stack", "actionDeal", () => {
const field = document.getElementById("defaultStackInput");
if (!field) return;
const panel = field.closest("details");
if (panel) panel.open = true;
field.scrollIntoView({ block: "center", behavior: "smooth" });
field.focus();
field.select();
}));
wrap.appendChild(row);
return wrap;
}
// Named seats that aren't in the hand. Naming a chair now seats whoever is
// in it, so reaching this takes something else — a table where everyone was
// sat out, or a save from before that was true — but the way out of it
// should still be one tap rather than a hunt through six seat editors.
const benched = this.state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
.map(({ index }) => index);
if (benched.length > 0) {
const aside = document.createElement("p");
aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
const text = document.createElement("span");
text.textContent = benched.length === 1
? `${listNames(benched)} is sitting out.`
: `${listNames(benched)} are sitting out.`;
aside.appendChild(text);
aside.appendChild(this.button(
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
"dealPromptDebtBtn",
() => this.commit("dealing everybody in", () => {
benched.forEach(index => {
this.state.seats[index].inHand = true;
this.state.seats[index].folded = false;
});
})
));
wrap.appendChild(aside);
}
return wrap;
}
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
const blinds = document.createElement("span");
blinds.textContent = (preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
this.straddleClause(preview) +
const name = i => HoldemState.displayName(this.state.seats[i], i);
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
line.appendChild(blinds);
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
// job, and both look like a bug until somebody says otherwise.
if (preview.deadButton || preview.deadSmallBlind) {
const dead = document.createElement("p");
dead.className = "dealPromptLine dealPromptAside";
dead.textContent = preview.deadButton
? `Dead button — seat ${preview.buttonIndex + 1} is empty. Its player left out of the blinds, so the button waits a hand rather than moving the big blind on twice.`
: `Dead small blind — the player due to post it left the table. The big blind still moves on one player, so nobody pays two in an orbit.`;
wrap.appendChild(dead);
}
// Dealing relocates a button sitting on an empty chair. That has to be said
// before it happens rather than discovered afterwards — a fresh table starts
// the button on seat 1 whether or not anyone is sitting there.
@@ -762,61 +505,8 @@ const ActionBar = {
wrap.appendChild(moved);
}
// Dealing sits out anyone with nothing behind. Said before it happens, for the
// same reason the button move is: a dealer shouldn't find out from the felt.
if (preview.satOut.length > 0) {
const out = document.createElement("p");
out.className = "dealPromptLine dealPromptAside";
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
wrap.appendChild(out);
}
// Somebody who has just sat down between the button and the big blind has had
// the blinds go past them, and dealing them in there is a free hand in late
// position. At a table the answer is that they post the big blind to come in
// now, or wait for it to reach them. The tool used to do neither and say
// nothing: it dealt them in for free, and the dealer had no way to know it had
// happened. Both answers are offered here; neither is forced, because a home
// game that doesn't care shouldn't have to argue with its own scoreboard.
// One line for however many have walked up, not one each. The bar is docked
// over the felt and the felt has already shrunk as far as it will go on a
// phone, so every line here is taken out of the ring of seats: measured at
// 390x844 with two newcomers, a line apiece put the bar over two fifths of the
// bottom seat. They post together or they wait together, which is what happens
// at a table anyway when two people sit down at once.
if (preview.debtors.length > 0) {
const ids = preview.debtors.map(i => this.state.seats[i].id);
const posting = ids.every(id => preview.posting.includes(id));
const who = listNames(preview.debtors);
const each = preview.debtors.length > 1 ? " each" : "";
const line = document.createElement("p");
line.className = "dealPromptLine dealPromptAside dealPromptDebt";
const text = document.createElement("span");
text.textContent = posting
? `${who} posting ${Money.format(preview.bigBlind)}${each} to come in.`
: `${who} sat down after the blinds — ${preview.debtors.length > 1 ? "free hands" : "a free hand"} unless they post.`;
line.appendChild(text);
line.appendChild(this.button(
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
"dealPromptDebtBtn",
() => this.commit(
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
() => { this.state.postingIn = posting ? [] : ids; })
));
wrap.appendChild(line);
}
const row = document.createElement("div");
row.className = "actionButtons dealPromptButtons";
// Beside the deal button rather than on a line of its own. Measured at 390x844:
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
// and this row is already there and already 44px tall. It reads in the order it
// happens, too — say the straddle, then deal.
this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
@@ -825,41 +515,13 @@ const ActionBar = {
return wrap;
},
// "Dee shows first — last to raise." The call the dealer has to make at every
// showdown and that nobody at the table volunteers for, said out loud with the
// reason attached so it can be repeated to whoever argues with it.
showdownCall() {
const call = Betting.showdownOrder(this.state);
if (!call) return null;
const who = HoldemState.displayName(this.state.seats[call.index], call.index);
const because = {
aggressor: "last to bet or raise",
// Semicolon, not a second dash: the sentence already spends its dash on
// "shows first —", and two of them in one line reads as a stutter.
allInAggressor: "last to put money in; nobody could bet the river",
leftOfButton: "checked round, first left of the button",
}[call.reason];
const line = document.createElement("p");
line.className = "showdownCall";
line.textContent = `${who} shows first — ${because}.`;
return line;
},
renderStreetDone() {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "actionButtons";
const isRiver = this.state.street === "river";
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
if (isRiver) {
const call = this.showdownCall();
if (call) wrap.appendChild(call);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button(label, "actionAdvance", () => {
if (isRiver) {
this.hooks.resolvePot();
@@ -867,33 +529,22 @@ const ActionBar = {
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
}));
wrap.appendChild(row);
return wrap;
return row;
},
renderHandOver() {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "actionButtons";
const live = Betting.liveSeats(this.state);
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
// Two or more still in with the betting finished is a showdown however it was
// arrived at — including everyone all-in on the flop, where the cards go on
// their backs and the order still decides who turns over first.
if (!winner) {
const call = this.showdownCall();
if (call) wrap.appendChild(call);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button(
winner ? `Award pot to ${winner}` : "Resolve pot",
"actionAdvance",
() => this.hooks.resolvePot()
));
wrap.appendChild(row);
return wrap;
return row;
},
};
+13 -316
View File
@@ -32,159 +32,28 @@ const Betting = {
// Who would post if the hand were dealt right now. Used to preview the blinds
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
//
// Not the same as all-in: an all-in player has money riding on the hand. This is
// somebody who busted and hasn't bought back in yet, which a home game produces
// about once an hour. A seat with no stack *recorded* isn't broke either —
// stackOf falls back to the table's starting stack for those — only an explicit
// zero counts.
brokeSeats(state) {
const broke = [];
state.seats.forEach((player, index) => {
if (!player.inHand) return;
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
broke.push(index);
});
return broke;
},
// The table as it will be once the seats that can't post are dealt around. A view,
// not a change: the preview has to ask the same question the deal will answer, and
// asking it of a different shape of table is how the two came apart last time.
tableAsDealt(state) {
const broke = this.brokeSeats(state);
if (broke.length === 0) return state;
const skip = new Set(broke);
return {
...state,
seats: state.seats.map((player, index) =>
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
};
},
blindPreview(state) {
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
// guessing this whole step exists to remove. Same reason the broke seats come
// out first: the preview once promised a small blind from a seat with no chips.
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
// guessing this whole step exists to remove.
const buttonIndex = HoldemState.buttonSeatForDeal(state);
const asDealt = buttonIndex === state.dealerSeatIndex
? state
: { ...state, dealerSeatIndex: buttonIndex };
const satOut = this.brokeSeats(state);
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return null;
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
// Asked of the table as it will be dealt, for the same reason the blinds
// are: someone sitting out for having no chips isn't owed a blind, and the
// big blind of the hand about to go out isn't either.
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
posting: (state.postingIn || []).slice(),
sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
// What the straddles will be if the hand is dealt as it stands, and how
// many more this table could take.
straddles: this.straddleSeats(state),
maxStraddles: this.maxStraddles(state),
};
},
// ---- Straddles ----
//
// A straddle is a blind raise put out before the cards, by the player left of the
// big blind, for twice it. It buys the last word before the flop: everyone else
// acts first, and the straddler still has the option when it comes back round even
// if nobody raised. Re-straddles carry on left, each doubling again.
//
// Home games run them constantly and a dealer has to get three things right at
// once — the amount, who is first to act, and what the minimum raise becomes. The
// tool used to do none of them, so a dealer's only way through was to deal, have
// the straddler "raise" to $4, and hand the action to the wrong player.
// How many straddles this table could take. Everyone but the two blinds can put
// one out; heads-up that is nobody, which is the right answer — the button is
// already the small blind and there is no seat left of the big blind to straddle
// from.
maxStraddles(state) {
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
},
// Who straddles and for how much, walking left from the big blind and doubling.
// Asked of the table as it will be dealt, so a seat about to be sat out for having
// no chips is never handed a straddle it can't post.
straddleSeats(state, count = state.straddleCount) {
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
if (wanted <= 0) return [];
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return [];
const straddles = [];
let index = bbIndex;
let amount = this.round((Number(state.bigBlind) || 0) * 2);
for (let n = 0; n < wanted; n++) {
index = this.nextToAct(asDealt, index);
if (index === null || index === bbIndex) break;
straddles.push({ index, amount });
amount = this.round(amount * 2);
}
return straddles;
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
// their chips and stayed sat out, silently skipped hand after hand until somebody
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
// read "Ben added $100".
//
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
// somebody out on purpose and is topping them up, and dealing them back in would be
// the tool guessing at something it wasn't told. Only a seat with no stack of its
// own: busted at zero, or a chair being bought into for the first time.
//
// Deliberately reads player.stack rather than stackOf(), which substitutes the
// table's starting stack for a seat that has none — the question here is what this
// seat was actually recorded as holding.
buyInDealsThemIn(player) {
if (!player || player.inHand) return false;
const own = player.stack;
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
return !recorded || Number(own) <= this.EPSILON_CHIPS;
},
// "bet" or "raise" — what putting more in is actually called right now.
//
// A wager into a pot nobody has wagered into is a bet; everything after that is a
// raise. The bar decided this from what the acting player still owes, which gets
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
// their own blind is a live bet and putting more in is a raise. It gets the other
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
// and wrote "Ann's raise to $10" into the undo control for it.
//
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet). This is only what it's called, which
// for a tool a dealer announces the action from is not a small thing.
wagerVerb(state) {
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
},
committed(player) {
return this.round(player.committed || 0);
},
@@ -248,20 +117,6 @@ const Betting = {
return null;
},
// A player still in the hand with chips in the pot and nothing left behind. This is
// the same stack test nextToAct uses to pass over them, which is the point: the
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
// act on again.
//
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
// anything in is a busted player who shouldn't have been dealt in, not a player
// who moved all-in, and calling them all-in on the felt would be a lie.
isAllIn(state, player) {
if (!player || !player.inHand || player.folded) return false;
return this.handTotal(player) > this.EPSILON_CHIPS &&
this.stackOf(state, player) <= this.EPSILON_CHIPS;
},
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
@@ -327,11 +182,6 @@ const Betting = {
if (newBet > previousBet) {
const increase = this.round(newBet - previousBet);
// Aggression, whether or not it was a full raise — a short all-in doesn't
// reopen the betting, but the player who made it is still the last one who
// put money in, and at the showdown they are still the one who shows first.
state.streetAggressorIndex = index;
state.handAggressorIndex = index;
// What a full raise would have had to be: at least as much again as the
// last one, or the big blind if there hasn't been a raise yet.
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
@@ -393,10 +243,6 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
// Whoever led this street stops being the answer the moment the next one opens.
// The hand's aggressor is deliberately left alone: it's the fallback for a
// river nobody could bet on.
state.streetAggressorIndex = null;
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
@@ -443,11 +289,6 @@ const Betting = {
// True when the hand is still live but nobody can bet again — the dealer is just
// running the board out.
isRunningOut(state) {
// Only while a hand is actually being played. Before the deal, a table of
// busted players who haven't rebought looks exactly like a table of all-ins to
// this test, and the bar was announcing a run-out over a felt with no cards and
// no chips on it.
if (!state.bettingOpen) return false;
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
},
@@ -484,26 +325,6 @@ const Betting = {
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// Posting a blind isn't aggression — a hand limped and checked all the way
// down has no aggressor at all, which is exactly the case the showdown rule
// sends to the seat left of the button.
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
// Deal around anyone with no chips, before anything else reads the table.
//
// A busted player who hasn't rebought used to be dealt in anyway, and the
// result was a hand nobody could finish: the table promised a small blind from
// a seat with nothing to post it with, took it from nobody, put the action on
// the big blind with only their own money in front of them, and announced
// "3 players — all-in, running out the board" before a card was dealt.
//
// Sitting them out is what a dealer does. Seat, name and buy-in record are
// untouched, so dealing them back in is one tap when they buy chips.
this.brokeSeats(state).forEach(index => {
state.seats[index].inHand = false;
state.seats[index].folded = false;
});
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
@@ -519,148 +340,24 @@ const Betting = {
const bb = Number(state.bigBlind) || 0;
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
// A null big blind means there aren't enough players to post at all. A null
// small blind with a big blind behind it is the dead small blind, which is a
// hand that gets dealt like any other — it just has one blind in it.
if (bbIndex === null) {
if (sbIndex === null || bbIndex === null) {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
state.postedStraddles = [];
return;
}
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
if (sb > 0) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
// Anyone the dealer said would post to come in past the blinds. It's a big
// blind like any other — it buys them a hand they'd otherwise have to wait for
// — so it goes in the middle the same way, before the count below reads it.
const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
if (bb > 0) {
state.seats.forEach((player, index) => {
if (index === sbIndex || index === bbIndex) return;
if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
});
}
// Deliberately not cleared here. repostBlinds takes every posted chip back and
// deals again through this same function, so a post that cleared itself on the
// way in would be refunded and never retaken — the dealer moves the button and
// somebody's post quietly vanishes. startNextHand is what ends it.
// What the next hand's rotation is worked out from. Recorded here, where the
// blinds are actually posted, because by the time it's needed the seats have
// moved on and the answer can no longer be read off the table.
state.postedBlinds = { sbIndex, bbIndex };
// Who got cards. Read next hand to tell a player who has been here all along
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
// Straddles go out after the blinds and before the cards, each one left of the
// last. Posted here rather than left to the dealer to fake as a raise, because
// a raise from that seat would hand the action to the wrong player.
const straddles = this.straddleSeats(state);
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
state.postedStraddles = straddles.map(({ index }) => index);
state.currentBet = this.round(state.seats.reduce(
(most, player) => Math.max(most, this.committed(player)), 0));
// A straddle is a blind raise, so what has to be matched to raise again is the
// straddle itself, not the big blind underneath it: $2 blinds with a $4
// straddle make the next raise $8, the same way the $2 blind makes it $4.
// Left alone, the minimum would come out at $6 and the dealer would be talked
// out of it by every player at the table.
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
//
// A straddle moves that seat along: first to act is left of the last straddler,
// and the straddler keeps the option, which falls out of nobody having acted
// yet.
const lastPosted = straddles.length > 0
? straddles[straddles.length - 1].index
: bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
//
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
// is in the pot before the raise is measured off it, so the pot being matched is
// the one that exists after the call:
//
// raise to = the current bet + (pot + what they owe) × fraction
//
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
// raise $5. Leaving the call out understates every one of these, and players say
// "pot" out loud meaning a definite number.
//
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
// zero, so it comes out as the pot, which is the right answer there too.
// Rounded up to something the table's chips can actually build. Half the pot is
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
// chip in the default legend is a dollar, so the button offered a number the
// dealer would have to round themselves every single time.
//
// Only when the legend describes this game. A smallest chip *larger* than the big
// blind means the denominations were never set for these stakes, and rounding a
// 5c/10c table up to whole dollars would be the tool doing real damage on the
// strength of a field somebody forgot to fill in. Left exact there, and the wrong
// legend stays visible rather than being quietly applied.
potRaiseTo(state, index, fraction = 1) {
const player = state.seats[index];
if (!player) return 0;
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
const bigBlind = Number(state.bigBlind) || 0;
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
return Money.upToChip(exact, state.chipLegend);
},
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
// the table volunteers for — everyone would rather see a hand than show one. The
// tool knew who last raised and who sits left of the button and said neither, so
// the dealer was working it out from memory while five people waited.
//
// Three cases, in the order the rule asks them:
// * somebody bet or raised on the final round — they show first;
// * the round was checked through — first live player left of the button;
// * nobody could bet, because the chips were already in — the last player who
// did put money in, on whichever round that was.
//
// Returns null when there's nothing to call: one player left, or none.
showdownOrder(state) {
const live = this.liveSeats(state);
if (live.length < 2) return null;
const seatOf = index =>
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
const streetAggressor = seatOf(state.streetAggressorIndex);
if (streetAggressor !== null) {
return { index: streetAggressor, reason: "aggressor" };
}
// Nobody could have bet this round, so "no bet on the final round" isn't a
// choice anybody made and the seat left of the button hasn't earned the
// obligation. It falls to whoever last put money in.
if (!this.canStillBet(state)) {
const handAggressor = seatOf(state.handAggressorIndex);
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
}
const order = HoldemState.liveOrderFromButton(state)
.filter(index => live.some(s => s.index === index));
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
: this.nextToAct(state, bbIndex);
},
// Winners get their share put back in front of them.
@@ -19,12 +19,8 @@ const HandHistoryExport = {
// 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 = [], { notes = "" } = {}) {
// The rake gets its own column rather than being folded into the pot: the two
// numbers answer different questions — what was in the middle, and what the
// house took out of it — and a night's worth of the second one is what the box
// is counted against.
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -34,7 +30,6 @@ const HandHistoryExport = {
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.forSheet(entry.pot),
Money.forSheet(entry.rake || 0),
payouts,
]);
});
@@ -46,12 +41,7 @@ const HandHistoryExport = {
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.
//
// Notes go last so the money columns keep the shape a spreadsheet expects.
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
// somebody settles up from, and it was only ever visible inside that seat's
// own editor.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
standings.forEach(row => {
rows.push([
row.name,
@@ -61,41 +51,8 @@ const HandHistoryExport = {
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
row.notes || "",
]);
});
// And the answer the standings are read to get to. Whoever opens this file
// wants the payment list, not to redo the arithmetic in a spreadsheet.
const plan = SettleUp.plan(standings);
if (plan.payments.length > 0) {
rows.push([]);
rows.push(["Settle Up"]);
rows.push(["Pays", "Receives", "Amount"]);
plan.payments.forEach(payment => {
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
});
if (!plan.balanced) {
// The file is the paper trail, so the caveat travels with it rather
// than living only in a sidebar nobody exported.
rows.push([
plan.shortBy > 0
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
]);
}
}
}
// The table's own notes — side bets, IOUs, the promo running until midnight.
// The dealer typed them because they matter to the night, and the export is the
// thing that outlives the browser tab; leaving them behind meant the one place
// the context was written down was the one place it couldn't be handed over.
// csvField quotes the line breaks, so a multi-line note survives as one cell.
if (notes && notes.trim()) {
rows.push([]);
rows.push(["Table Notes"]);
rows.push([notes.trim()]);
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
@@ -108,8 +65,8 @@ const HandHistoryExport = {
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = [], options = {}) {
const csv = this.toCsv(handHistory, standings, options);
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
+14 -285
View File
@@ -96,19 +96,6 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
const movedName = nameOf(from);
const intoName = nameOf(to);
const wasEmpty = Render.isEmptySeat(state.seats[to]);
runUndoable(
wasEmpty
? `${movedName} moved to seat ${to + 1}.`
: `${movedName} and ${intoName} changed seats.`,
() => HoldemState.swapSeats(state, from, to));
},
makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
@@ -121,11 +108,6 @@ document.addEventListener("DOMContentLoaded", () => {
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
: `Button on ${nameOf(index)}.`, () => {
state.dealerSeatIndex = index;
// Putting the button somewhere by hand is the dealer overruling the
// rotation, dead positions included. Leaving them set would hold a
// small blind dead at a table whose button has moved out from under it.
state.deadButton = false;
state.deadSmallBlind = false;
if (willRepost) Betting.repostBlinds(state);
});
},
@@ -158,27 +140,11 @@ document.addEventListener("DOMContentLoaded", () => {
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
(dealBackIn
? handRunning ? " — in from the next hand." : " — dealt in."
: ".");
seatAction(seatIndex, message, p => {
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.
p.stack = Money.round(Betting.stackOf(state, p) + amount);
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
if (dealBackIn) {
p.inHand = true;
// Same rule "Deal in" follows: you can't buy into a pot you haven't
// been paying for, and `folded` is exactly that state for one hand.
p.folded = handRunning;
}
player.stack = Money.round(Betting.stackOf(state, player) + amount);
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
});
}),
};
@@ -192,15 +158,11 @@ document.addEventListener("DOMContentLoaded", () => {
hooks.save(state);
Render.renderAll();
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
PotWizard.open(state, payouts => {
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
const withRake = rake > 0 ? `${paid}, house ${Money.format(rake)}` : paid;
runUndoable(withRake ? `Paid ${withRake}.` : "Hand closed with nothing to pay out.", () => {
if (rake > 0) {
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
}
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
if (payouts.length > 0) {
HoldemState.recordHand(state, payouts, rake);
HoldemState.recordHand(state, payouts);
// Winnings go straight back in front of the player, so the next
// hand's stacks are already right.
Betting.creditWinnings(state, payouts);
@@ -385,30 +347,17 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
}
// Say where the button landed so the dealer can confirm the rotation without
// hunting for the badge. Asked of the rotation itself rather than guessed at as
// "the next chair along" — which was only ever right at a table with nobody
// missing, and is the exact thing the dead button makes untrue.
const rotation = HoldemState.nextButtonRotation(state);
const nextDealer = rotation.buttonIndex !== null
? rotation.buttonIndex
: HoldemState.buttonSeatForDeal({ ...state, dealerSeatIndex: HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1) });
const where = rotation.deadButton
? `dead button on seat ${nextDealer + 1}`
: state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
// startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
const discarded = unresolved > Betting.EPSILON_CHIPS
? ` ${Money.format(unresolved)} taken off the table.`
: "";
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
// Written down as well as announced. The toast is a moment; the bank check
// is asked at cash-out, and until now it saw only that the chips were gone
// and reported them as missing.
if (unresolved > Betting.EPSILON_CHIPS) {
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
}
HoldemState.startNextHand(state);
});
});
@@ -429,29 +378,6 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Table reset.", { duration: 2500 });
});
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
//
// Delegated from the document rather than wired per field: the raise pad, the
// add-chips sheet and the seat editor all build their inputs fresh every render,
// and a box added later should not have to remember to ask for this.
document.addEventListener("focusin", event => {
const field = event.target;
if (!Money.selectsOnFocus(field)) return;
if (!field.value) return;
// After the browser has finished placing its own caret, or it wins the race
// and the selection collapses again the moment the tap completes.
setTimeout(() => {
if (document.activeElement !== field) return;
try {
field.select();
} catch {
// A number input refuses setSelectionRange in some browsers; select()
// is the one that works on both, and if neither does the field is
// simply left as it was.
}
}, 0);
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
@@ -466,25 +392,6 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
// The house's cut. Kept as a group rather than three loose fields, because
// migrate() guarantees the shape and half a rake object would be a table nobody
// could read back.
const readRake = () => {
const percent = Number(document.getElementById("rakePercentInput").value);
const cap = Number(document.getElementById("rakeCapInput").value);
state.rake = {
percent: Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0,
cap: Number.isFinite(cap) && cap > 0 ? Money.round(cap) : null,
noFlopNoDrop: document.getElementById("rakeNoFlopInput").checked,
};
hooks.save(state);
Render.renderAll();
};
["rakePercentInput", "rakeCapInput"].forEach(id => {
document.getElementById(id).addEventListener("input", readRake);
});
document.getElementById("rakeNoFlopInput").addEventListener("change", readRake);
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
@@ -499,90 +406,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
// The one time the browser refuses to save, say so out loud as well as leaving the
// standing warning in the Session panel. Set before the first render so a failure
// during start-up is caught too.
// Another tab writing to the same table. Said once, with the way out attached —
// reloading here adopts whatever that tab has done, which is the only resolution
// that doesn't throw somebody's work away.
HoldemState.onOtherTab = () => {
Toast.show(
"This table is open in another tab. Whichever acts last wins.",
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
Render.renderStorageWarning();
};
HoldemState.watchOtherTabs();
HoldemState.onSaveFailed = () => {
Toast.show(
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
{ duration: 12000 });
Render.renderStorageWarning();
};
// The shortcut legend, built from the same list the handlers are keyed off, so a
// shortcut can't exist without a line here saying what it does.
const legend = document.getElementById("shortcutLegend");
if (legend) {
Shortcuts.LEGEND.forEach(entry => {
const term = document.createElement("dt");
entry.keys.forEach(key => {
const kbd = document.createElement("kbd");
kbd.className = "shortcutKey";
kbd.textContent = key;
term.appendChild(kbd);
});
const detail = document.createElement("dd");
detail.textContent = entry.what;
legend.appendChild(term);
legend.appendChild(detail);
});
}
// An index across the top of the rulings sheet, one chip per ruling.
//
// This is opened mid-argument with players waiting, and it was nine rulings of
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
// the odd-chip rule had to read past seven others to find it.
//
// Built from the sections rather than written out beside them, so a ruling added
// later turns up in the index without anyone having to remember to add it twice.
const buildRulingsIndex = () => {
const index = document.getElementById("rulingsIndex");
if (!index || index.childElementCount > 0) return;
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
const heading = section.querySelector("h3");
if (!heading) return;
if (!section.id) section.id = `ruling${i + 1}`;
const jump = document.createElement("button");
jump.type = "button";
jump.className = "rulingsIndexBtn";
// A short label where the ruling gives one. The headings are sentences —
// "A short all-in doesn't reopen the betting" — and nine of those stacked
// one per row made the index 342px tall on a phone, which is most of the
// sheet before the first ruling. "Short all-in" says the same thing to
// somebody who is looking for it, and three fit on a line.
jump.textContent = section.dataset.short || heading.textContent;
// The full heading is still what it announces, so the short label never
// costs a screen reader the sentence.
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => {
section.scrollIntoView({ block: "start", behavior: "smooth" });
// The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
});
index.appendChild(jump);
});
};
document.getElementById("openRulingsBtn").addEventListener("click", () => {
buildRulingsIndex();
rulingsOverlay.hidden = false;
});
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
@@ -614,108 +438,13 @@ document.addEventListener("DOMContentLoaded", () => {
if (!ok) return;
HoldemState.clearHistory(state);
Render.historyFilter = "";
document.getElementById("handHistoryFilter").value = "";
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
document.getElementById("saveTableBtn").addEventListener("click", () => {
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = HoldemState.backupFileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
});
document.getElementById("loadTableBtn").addEventListener("click", () => {
document.getElementById("loadTableInput").click();
});
document.getElementById("loadTableInput").addEventListener("change", async e => {
const file = e.target.files && e.target.files[0];
// Cleared straight away so picking the same file twice still fires a change.
e.target.value = "";
if (!file) return;
let text;
try {
text = await file.text();
} catch {
Toast.show("That file couldn't be read.", { duration: 4000 });
return;
}
const result = HoldemState.fromBackup(text);
if (result.error) {
Toast.show(result.error, { duration: 5000 });
return;
}
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
// what is about to be lost is the difference between a dealer choosing that
// and discovering it — and the current table might be the live one.
const here = HoldemState.sessionStandings(state).length;
const incoming = HoldemState.sessionStandings(result.table).length;
const when = result.savedAt ? new Date(result.savedAt) : null;
const saidWhen = when && !Number.isNaN(when.getTime())
? ` It was saved ${when.toLocaleString()}.`
: "";
const ok = await Dialog.confirm({
title: "Replace this table?",
message:
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
`Undo will bring it back for a few seconds; after that it is gone.`,
confirmLabel: "Load it",
danger: true,
});
if (!ok) return;
runUndoable("loading a saved table", () => {
// Replace the contents rather than the reference: everything on the page
// holds a pointer to this object.
Object.keys(state).forEach(key => { delete state[key]; });
Object.assign(state, result.table);
});
});
// The filter is how the dealer looks at the log, not something about the table, so
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
// tab. Clearing the history clears it too, since there is nothing left to filter.
document.getElementById("handHistoryFilter").addEventListener("input", e => {
Render.historyFilter = e.target.value;
Render.renderHandHistory();
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
// Always the whole night. A filter is for reading the panel; an export that
// quietly dropped the hands scrolled past would be a record with holes in it,
// and nothing on the CSV would say so.
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
{ notes: state.notes });
if (Render.historyFilter) {
Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${
state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`,
{ duration: 3500 });
}
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
});
// The load happens on the first line of this file, long before there's a Toast to
// show it in, so this is checked here rather than wired as a callback. The standing
// warning in the Session panel doesn't need it — that reads the flag every render —
// but an empty table is a thing a dealer needs told, not left to notice.
if (HoldemState.loadFailed) {
Toast.show(
"The saved table couldn't be read, so this one started empty.",
{ duration: 12000 });
}
});
@@ -48,114 +48,6 @@ const Money = {
return (this.toCents(amount) / 100).toFixed(2);
},
// The chips to actually push, for an amount.
//
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
// still have to work out that $132 is two blacks, a green, a red and two whites
// while six people watch. The chip legend already knows the denominations.
//
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
// values — but poker chips are not arbitrary. Every real set is canonical (each
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
// use one chip more than it had to.
//
// Anything the smallest chip can't make comes back as `remainder` rather than being
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
// problem for the dealer to solve, not a rounding error to hide.
// The smallest chip the table has said it plays with, in cents. Zero when no
// denominations are set, which means the tool has been told nothing about what
// chips exist and shouldn't pretend otherwise.
chipStepCents(legend) {
const values = (legend || [])
.map(chip => this.toCents(chip.value))
.filter(cents => cents > 0);
return values.length > 0 ? Math.min(...values) : 0;
},
// Rounds an amount up to something the table can actually build out of its chips.
//
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
// amount below a legal minimum, which rounding the other way can.
upToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return this.round(amount);
return this.fromCents(Math.ceil(cents / step) * step);
},
// And down, for money coming *out* of a pot rather than going into it.
//
// The rake is the one amount in this tool that leaves the table, so the direction
// of the rounding is a decision about whose money it is. Down: a house that can't
// make 40c out of its chips takes nothing rather than a dollar, and the players
// keep the difference. Being wrong in the players' favour is the only way to be
// wrong about this that nobody has to argue about.
downToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return 0;
return this.fromCents(Math.floor(cents / step) * step);
},
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
const values = (legend || [])
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
.filter(entry => entry.cents > 0)
.sort((a, b) => b.cents - a.cents);
const chips = [];
values.forEach(entry => {
const count = Math.floor(cents / entry.cents);
if (count <= 0) return;
cents -= count * entry.cents;
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
});
return { chips, remainder: this.fromCents(cents) };
},
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
//
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
// hasn't told us its denominations, so there is no advice to give and the amount
// stands on its own. A legend that exists but can't reach the amount is different,
// and does say so.
formatChipBreakdown(amount, legend) {
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
if (!hasDenominations) return "";
const { chips, remainder } = this.chipBreakdown(amount, legend);
if (chips.length === 0 && remainder === 0) return "";
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
return parts.join(", ");
},
// Should focusing this field select what's already in it?
//
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
// it already has. A dealer taps the box because they want a *different* number,
// and a tap puts the caret after the suggestion instead of over it. Measured on a
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
// showing 200, tap, type "50" — 20050.
//
// Only number boxes. A name or a note is a thing you edit, and selecting all of
// it on focus would be the same mistake pointing the other way.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
@@ -232,24 +232,6 @@ const PotWizard = {
return wrap;
},
// What the house takes out of this pot, and what is left to push.
//
// Worked out here rather than at the end so the dealer sees it before anything
// moves: the summary is the last chance to disagree with the arithmetic, and a
// rake that only appeared in the hand log afterwards would be the tool taking
// money quietly.
rakeSplit() {
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
const sawFlop = (this.state.street || "preflop") !== "preflop";
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -258,43 +240,16 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
const split = this.rakeSplit();
const rakeSet = Number((this.state.rake || {}).percent) > 0;
if (rakeSet && this.totals.size > 0) {
const line = document.createElement("p");
line.className = "potWizardRake";
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
line.textContent = split.rake > 0
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
: split.sawFlop
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
wrap.appendChild(line);
}
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
wrap.appendChild(empty);
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const { id, amount } of split.payouts) {
for (const [id, amount] of this.totals.entries()) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
// The amount is the answer to the arithmetic; the chips are the answer
// to the dealer's job. Only when the legend has been filled in — a
// table that never set up chip colours gets the number on its own,
// which is what it had before.
const chips = Money.formatChipBreakdown(amount, legend);
if (chips) {
const note = document.createElement("span");
note.className = "payoutChips";
note.textContent = chips;
li.appendChild(note);
}
list.appendChild(li);
}
wrap.appendChild(list);
@@ -307,12 +262,13 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
// Recomputed rather than reused from the render: the same numbers, but
// taken at the moment the dealer commits rather than when they last
// looked at the screen.
const paid = this.rakeSplit();
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
this.close();
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
this.onFinished(payouts);
});
actions.appendChild(applyBtn);
@@ -197,80 +197,6 @@ const QuickSplit = {
});
},
// Chopping one of the pots listed above, without leaving the screen that worked it
// out. Starts closed and at as many ways as there are players who can win the pot,
// which is the common case; the stepper covers a chop between fewer of them, which
// is the other one.
//
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
// the main pot doesn't change what the side pot below it is saying.
addChopControl(row, amount, contenderCount) {
let ways = contenderCount;
let open = false;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "unevenChopToggle";
const detail = document.createElement("div");
detail.className = "unevenChopDetail";
detail.hidden = true;
const draw = () => {
toggle.textContent = open ? "Hide chop" : "Chop";
toggle.setAttribute("aria-expanded", String(open));
detail.hidden = !open;
if (!open) return;
const result = SplitCalculator.split(amount, ways, this.chip);
detail.innerHTML = "";
const stepper = document.createElement("div");
stepper.className = "unevenChopWays";
stepper.appendChild(this.makeStepButton("−", () => {
if (ways > 2) { ways -= 1; draw(); }
}));
const label = document.createElement("span");
label.className = "unevenChopWaysLabel";
label.textContent = `${ways} ways`;
stepper.appendChild(label);
stepper.appendChild(this.makeStepButton("+", () => {
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
}));
detail.appendChild(stepper);
const each = document.createElement("p");
each.className = "unevenChopEach";
each.textContent = `${Money.format(result.each)} each`;
detail.appendChild(each);
// Same wording as the main split, because it is the same rule and a dealer
// shouldn't have to notice they're reading a different screen.
if (result.valid && result.oddChips > 0) {
const odd = document.createElement("p");
odd.className = "unevenChopNote";
odd.textContent = result.oddChips === 1
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
`first ${result.oddChips} players left of the button`;
detail.appendChild(odd);
}
if (result.valid && result.dust > 0) {
const dust = document.createElement("p");
dust.className = "unevenChopNote unevenChopDust";
dust.textContent =
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
`pick a smaller denomination.`;
detail.appendChild(dust);
}
};
toggle.addEventListener("click", () => { open = !open; draw(); });
draw();
row.appendChild(toggle);
row.appendChild(detail);
},
updateUneven() {
const players = this.entries
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
@@ -292,7 +218,7 @@ const QuickSplit = {
}
empty.hidden = true;
const addRow = (label, amount, who, className, chopWays) => {
const addRow = (label, amount, who, className) => {
const row = document.createElement("div");
row.className = `unevenPot ${className || ""}`;
const name = document.createElement("span");
@@ -308,19 +234,9 @@ const QuickSplit = {
row.appendChild(value);
row.appendChild(eligible);
host.appendChild(row);
// Two players tying for a side pot is ordinary, and working out each one's
// share meant copying the amount into the other mode by hand — retyping a
// number the screen had just worked out, which is a transcription error
// waiting to happen with money on it. The chop happens here instead.
//
// Only offered where it means something: a pot with at least two people
// who can win it. Not the uncalled row, which goes back to one player, and
// not the total.
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
const total = players.reduce((sum, p) => sum + p.contribution, 0);
+19 -526
View File
@@ -47,36 +47,13 @@ const Render = {
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
// wants to see the running total right there.
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
const hint = document.getElementById("rakeTakenHint");
hint.hidden = taken <= 0;
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
// written for. Touch gets told about the menu route instead, which works.
canDragSeats() {
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
},
renderTableHint() {
const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = this.canDragSeats()
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
},
renderNotes() {
@@ -95,207 +72,26 @@ const Render = {
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count, aspect = this.feltAspect()) {
return this.positionOnRing(index, count, 44, 42, aspect);
},
// How many times wider than tall the felt actually is, which is the one thing the
// ring needs from the layout and can't work out from percentages. Falls back to a
// circle when there's nothing to measure — the same answer the ring gave before it
// asked.
feltAspect() {
const box = this.ringBox();
return box ? box.width / box.height : 1;
},
// The box a seat's left/top percentages actually resolve against. Not the felt: on
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
// off the screen, and measuring the felt instead put the ring's shape out by that
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
// which is the difference between "fine" and "overlapping".
ringBox() {
if (typeof document === "undefined") return null;
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
if (!layer) return null;
const box = layer.getBoundingClientRect();
return box.width && box.height ? box : null;
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.
//
// The angle is the seat's own rather than one walked again round a tighter ellipse.
// The two answers are within half a degree of each other, so this is not a fix for
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
betPosition(index, count, aspect = this.feltAspect()) {
const seat = this.seatPosition(index, count, aspect);
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 + (seat.left - 50) * (27 / 44),
top: 50 + (seat.top - 50) * (25 / 42),
left: 50 + rx * Math.cos(radians),
top: 50 + ry * Math.sin(radians),
};
},
// Seats spaced evenly around the rim — by the distance between them, not by the
// angle they sit at.
//
// Equal angles are only equal spacing on a circle. The felt is an ellipse, and a
// phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36°
// apart then put four of them down each steep side, where 36° of turn is barely any
// distance — seats 3 and 4 came out at the same x and 20px apart vertically, each
// puck 72px tall. Their centres landed on each other, so tapping a player got their
// neighbour, and the layout audit had been calling it at 640x360 and 667x375.
//
// Walking the perimeter instead puts the seats where a real table has them: along
// the two long rails, one at each end. The arc length is summed numerically because
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
// few hundred pixels long, and it runs once per render.
positionOnRing(index, count, rx, ry, aspect = 1) {
return this.ringPositions(count, rx, ry, aspect)[index];
},
// Past this much wider than tall, the rim stops being an ellipse and becomes a
// stadium — two straight rails with a semicircular cap at each end.
//
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
// an end holds one seat and the rest of the table is rail — which is how a real
// poker table is built, and why it is shaped that way.
//
// Only where it is needed. Anything rounder than this keeps the ellipse it has
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
// upright), so no currently-working table changes shape.
STADIUM_RATIO: 2.5,
// Seats spaced evenly around whichever rim this table has, as percentages.
ringPositions(count, rx, ry, aspect = 1) {
if (count <= 0) return [];
const halfWidth = rx * aspect;
const halfHeight = ry;
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
left: 50 + rx * Math.cos(angle),
top: 50 + ry * Math.sin(angle),
}));
}
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
},
// The same walk, around a stadium instead. Its perimeter has a closed form, so
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
stadiumPositions(count, halfWidth, halfHeight, aspect) {
const rail = halfWidth - halfHeight;
const cap = Math.PI * halfHeight;
const perimeter = 4 * rail + 2 * cap;
return Array.from({ length: count }, (_, seat) => {
// Clockwise from the top centre, so seat one sits where it always has.
let walked = (perimeter * seat) / count;
let x;
let y;
if (walked <= rail) { // top rail, going right
x = walked;
y = -halfHeight;
} else if (walked <= rail + cap) { // right cap
const turned = (walked - rail) / halfHeight;
x = rail + halfHeight * Math.sin(turned);
y = -halfHeight * Math.cos(turned);
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
x = rail - (walked - rail - cap);
y = halfHeight;
} else if (walked <= 3 * rail + 2 * cap) { // left cap
const turned = (walked - 3 * rail - cap) / halfHeight;
x = -rail - halfHeight * Math.sin(turned);
y = halfHeight * Math.cos(turned);
} else { // top rail, back to the start
x = -rail + (walked - 3 * rail - 2 * cap);
y = -halfHeight;
}
return { left: 50 + x / aspect, top: 50 + y };
});
},
RING_STEPS: 720,
ringCache: {},
// The closest two neighbouring seats come to each other, straight line, in pixels.
//
// Rim length divided by seat count was the first answer and it is the wrong one:
// it measures the walk between two seats rather than the gap between them, and on
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
// and two seats a long walk apart are stacked one above the other.
closestSeatGapPx(count, box = this.ringBox()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
return points.reduce((closest, point, i) => {
const next = points[(i + 1) % points.length];
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
},
// A puck is 76px across, so neighbours whose centres are closer than that overlap
// by definition; a few pixels more keeps them from touching. Under it the pucks
// come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 84,
// Called on every render and again whenever the felt is resized — the action bar
// growing mid-hand shortens the table, and the seats have to answer that without
// waiting for the next render to come along.
updateSeatDensity() {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
// Every seat on a ring wants the same answer, so it is worked out once per shape
// and kept — the ring only changes when the table is resized or the window is.
ringAngles(count, rx, ry, aspect) {
if (count <= 0) return [];
const key = `${count}:${rx}:${ry}:${aspect.toFixed(3)}`;
if (this.ringCache[key]) return this.ringCache[key];
const start = -Math.PI / 2;
// Percentages of the width and of the height are not the same distance, so the
// horizontal radius is scaled into the vertical one's units before measuring.
const a = rx * aspect;
const b = ry;
const step = (2 * Math.PI) / this.RING_STEPS;
const cumulative = [0];
for (let i = 1; i <= this.RING_STEPS; i++) {
const from = start + (i - 1) * step;
const to = start + i * step;
const dx = a * (Math.cos(to) - Math.cos(from));
const dy = b * (Math.sin(to) - Math.sin(from));
cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy));
}
const perimeter = cumulative[this.RING_STEPS];
const angles = [];
let walked = 0;
for (let seat = 0; seat < count; seat++) {
const wanted = (perimeter * seat) / count;
while (walked < this.RING_STEPS && cumulative[walked + 1] < wanted) walked++;
// Interpolate inside the step the seat falls in, so the answer doesn't
// jump in 0.5° increments.
const spanned = cumulative[walked + 1] - cumulative[walked];
const into = spanned > 0 ? (wanted - cumulative[walked]) / spanned : 0;
angles.push(start + (walked + into) * step);
}
this.ringCache[key] = angles;
return angles;
},
// 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) {
@@ -365,15 +161,8 @@ const Render = {
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
this.updateSeatDensity();
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
// fixing. Once it's the dead button it means the opposite — it's exactly where
// it belongs — so it gets marked rather than looking like the thing to correct.
const deadButtonHere = this.state.deadButton &&
HoldemState.deadPositionsApply(this.state) &&
!this.state.seats[dealerIndex].inHand;
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -382,34 +171,11 @@ const Render = {
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
// Which side of the seat the pot is on, which is the side its bet chip
// arrives from — the chip sits on the same spoke, closer in. For a seat on
// the top of the ring that's from below; on the bottom, from above.
//
// The blind badge has to be on the other side or the chip lands on top of
// it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
// the badge's 288 square pixels, and it sits a layer higher, so the marker
// telling the dealer who the big blind is was simply gone.
node.classList.toggle("seatBelowCentre", pos.top > 50);
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
const chipLabel = hasButton
? (deadButtonHere
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
: `${who} has the dealer button`)
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
// Tapping the puck is the same act as "Give the button" in the seat menu,
// so it goes through the same hook — otherwise this one path would move
// the button without moving the blinds that follow it.
@@ -431,14 +197,6 @@ const Render = {
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
// A straddle is a blind too, and the seat wearing it is the one the action
// comes back to. Marked from what the deal actually posted rather than from
// the count, so the badge can't outlive the chips it stands for — before
// the cards there is nothing out there yet.
const straddleBadge = node.querySelector(".strBadge");
const straddled = (this.state.postedStraddles || []).includes(index);
straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
@@ -511,18 +269,11 @@ const Render = {
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
// A player with nothing behind used to leave this line blank, so the felt drew
// an all-in exactly like a seat whose stack nobody had entered. On a table with
// side pots that's the one distinction the dealer is working from — who can
// still be bet at, and who is already committed for everything they have.
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
const allIn = Betting.isAllIn(this.state, player);
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
stackEl.classList.toggle("seatPuckAllIn", allIn);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
this.fitAmount(stackEl);
node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
@@ -727,41 +478,13 @@ const Render = {
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
// The editor deliberately refreshes narrowly rather than redrawing everything —
// a full render on every keystroke would take the name field out from under the
// dealer mid-word. But the action bar answers "is there a table here at all",
// and seating somebody is exactly what changes that answer. Without this it
// stayed hidden until something else happened to redraw: two players seated, a
// dealable hand, and a blank bar until the page was reloaded.
//
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
// anyway, and nothing inside it can hold focus while the sidebar has it.
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
// Putting a name on an empty chair is somebody sitting down, so they get
// dealt in. It used to be two separate acts, and the second one was easy to
// miss: a dealer setting up typed six names, got six seats that still
// weren't in the hand, and the action bar hid itself because by its reckoning
// nobody was playing. Six people at the table and a blank screen.
//
// Only for a chair that was empty — no name and not dealt in. Renaming
// somebody who is sitting out is a correction, not an arrival, and dealing
// them back in over a typo would be the tool overruling the dealer.
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = nameInput.value;
if (wasEmpty && player.name.trim()) {
player.inHand = true;
player.folded = false;
const inHandBox = form.querySelector(".inHandCheckbox");
if (inHandBox) inHandBox.checked = true;
const foldBox = form.querySelector(".foldCheckbox");
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
}
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
@@ -907,49 +630,6 @@ const Render = {
list.appendChild(row);
});
this.renderColourUp();
},
// Each stack as the chips that make it.
//
// The legend has only ever read chips *into* an amount — counting what's in front
// of somebody. Colouring up at the end of a level is the same question backwards,
// and it's the one the dealer has to answer with a rack in one hand: $205 is four
// fifties and a five. So is counting a stack out for a player who's leaving.
//
// Only when there's a legend to work from and a stack to break down; a table that
// never set its chip colours up gets nothing, which is what it had before.
renderColourUp() {
const host = document.getElementById("colourUp");
const list = document.getElementById("colourUpList");
if (!host || !list) return;
const legend = this.state.chipLegend || [];
const rows = this.state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
.map(({ player, index }) => ({
name: HoldemState.displayName(player, index),
stack: Betting.round(Number(player.stack) || 0),
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
}))
.filter(row => row.chips);
host.hidden = rows.length === 0;
if (host.hidden) return;
list.innerHTML = "";
rows.forEach(row => {
const term = document.createElement("dt");
term.className = "colourUpWho";
term.textContent = `${row.name} — ${Money.format(row.stack)}`;
const detail = document.createElement("dd");
detail.className = "colourUpChips";
detail.textContent = row.chips;
list.appendChild(term);
list.appendChild(detail);
});
},
// ---- Pot breakdown ----
@@ -1021,15 +701,9 @@ const Render = {
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
// The cash to count out, which is a plain amount and never signed — this
// is the one the banker's hands follow. It sat only in the CSV: on screen
// the column headed "Cash out" held the difference against their buy-in
// instead, so a player up $140 on a $340 stack read as "$140" at the exact
// moment somebody was counting money onto the table.
node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
// And whether that leaves them up or down, which is the question the rest
// of the table asks and the one the old column was actually answering.
// What they'd actually be handed if the night ended now: chips in front of
// them less the cash they gave the banker. This was computed and written to
// the CSV all along, but never shown to the person doing the paying out.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
@@ -1039,98 +713,6 @@ const Render = {
});
this.renderBankReconciliation();
this.renderSettleUp(standings);
this.renderStorageWarning();
},
// A toast is a moment; this table not being saved is a fact about the rest of the
// night. It sits at the top of the panel a dealer opens to check the money, and it
// stays there, because the raise pad dismisses toasts and the one time this warning
// matters is the one time it must not have been missed.
renderStorageWarning() {
const host = document.getElementById("storageWarning");
if (!host) return;
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
if (host.hidden) return;
// Any of these can be true at once — a browser having trouble with storage
// tends to have it in both directions — and the one that explains an empty
// table goes first, because that's the question the dealer is standing there
// asking.
const lines = [];
if (HoldemState.otherTabActive) {
lines.push(
"This table is open in another tab, and both are writing to it — " +
"whichever acts last wins, so work done in the other one can vanish. " +
"Close one, then reload this tab to pick up the table as it stands.");
}
if (HoldemState.loadFailed) {
lines.push(
"The saved table couldn't be read, so this one started empty. " +
"The unreadable copy has been kept rather than written over.");
}
if (HoldemState.saveFailed) {
lines.push(
"This table isn't being saved — the browser refused. It'll keep working, " +
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
}
host.textContent = lines.join(" ");
},
// The last step the standings stopped short of. Six signed numbers at 2am is not
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
// and it's the part of the night people actually get wrong.
renderSettleUp(standings) {
const host = document.getElementById("settleUp");
if (!host) return;
const plan = SettleUp.plan(standings);
const note = document.getElementById("settleUpNote");
const list = document.getElementById("settleUpList");
// Nothing to show before anyone is up or down. A panel that says "no payments"
// to a table that hasn't played a hand is just noise in the sidebar.
host.hidden = plan.payments.length === 0 && plan.balanced;
if (host.hidden) return;
list.innerHTML = "";
plan.payments.forEach(payment => {
const row = document.createElement("li");
row.className = "settleUpRow";
const who = document.createElement("span");
who.className = "settleUpWho";
// An arrow rather than "pays": the direction is the whole content of the
// line, and it survives being read at a glance from across the table.
who.textContent = `${payment.from} → ${payment.to}`;
row.appendChild(who);
const amount = document.createElement("span");
amount.className = "settleUpAmount";
amount.textContent = Money.format(payment.amount);
row.appendChild(amount);
list.appendChild(row);
});
if (plan.balanced) {
const count = plan.payments.length;
note.textContent = count === 1
? "One payment and the night is square."
: `${count} payments and the night is square.`;
} else if (plan.shortBy > 0) {
note.textContent =
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
} else {
// If a pot was thrown away on purpose, that's where the gap went, and the
// dealer already answered a dialog about it. Telling them to go and fix it
// would be sending them after their own decision.
const discarded = Math.max(0, Number(this.state.discarded) || 0);
note.textContent = discarded > 0
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
}
note.classList.toggle("settleUpOff", !plan.balanced);
},
// Does the cash box cover the chips on the table? Nothing asked this before, and
@@ -1147,38 +729,9 @@ const Render = {
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
// An abandoned pot the dealer confirmed throwing away is money that left the
// table on purpose. It gets its own line so the two columns still add up in
// front of them, rather than looking like a shortfall they have to explain.
const discardedRow = document.getElementById("bankDiscardedRow");
if (discardedRow) {
discardedRow.hidden = !(bank.discarded > 0);
if (bank.discarded > 0) {
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
}
}
// Same idea for the house's cut, and the more important of the two: a raked
// game moves chips off the table every single hand, so without this line the
// check reads as a shortfall that grows all night.
const rakedRow = document.getElementById("bankRakedRow");
if (rakedRow) {
rakedRow.hidden = !(bank.raked > 0);
if (bank.raked > 0) {
document.getElementById("bankRaked").textContent = Money.format(bank.raked);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = accountedFor.length > 0
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
verdict.textContent = "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
@@ -1220,50 +773,9 @@ const Render = {
newest ? newest.handNumber : "",
newest ? newest.timestamp : "",
newest ? newest.pot : "",
// Typing in the filter changes the list without changing the history, and
// the redraw is skipped when the signature matches — leave this out and the
// box takes input and does nothing.
this.historyFilter || "",
].join("|");
},
// What the dealer opens the log for is one hand: the one somebody is arguing
// about. A night runs to sixty or more, five at a time in the panel, so finding it
// meant scrolling and reading every row. A name, an amount, or a hand number
// narrows it instead.
//
// Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
// "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
// remembering an amount shouldn't have to know which of those they mean; "#12"
// pins it to the hand number, for when they do.
handMatchesFilter(entry, query) {
if (!query) return true;
const term = query.trim().toLowerCase();
if (!term) return true;
const handNumber = String(entry.handNumber || "");
if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
// Only what the row itself shows. Matching the hand's full ledger as well was
// the first attempt, on the reasoning that a dealer might hunt for the hand
// that broke somebody by the loser's name — but the ledger holds everyone who
// put a chip in, so at a table where nobody folds it matched every hand.
// Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
// panel doesn't display is a filter that looks broken even when it's right.
const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
if (labels.includes(term)) return true;
// Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
const digits = term.replace(/[^0-9.]/g, "");
if (digits) {
const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
.map(a => String(Money.round(Number(a) || 0)));
if (amounts.some(a => a.startsWith(digits))) return true;
if (handNumber === digits) return true;
}
return false;
},
// 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
@@ -1282,29 +794,10 @@ const Render = {
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
// How much of the night is being hidden. Without it a filter that matches one
// hand looks exactly like a table that only played one, which is the sort of
// thing a dealer reads wrong at 2am with somebody leaning over the rail.
const count = document.getElementById("handHistoryCount");
if (count) {
count.hidden = !query || history.length === 0;
count.textContent = `${shown.length} of ${history.length}`;
}
const noMatch = document.getElementById("handHistoryNoMatch");
if (noMatch) {
noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
}
const filterBox = document.getElementById("handHistoryFilter");
if (filterBox) filterBox.hidden = 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");
shown.forEach(entry => {
history.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
@@ -6,16 +6,10 @@ const SeatMenu = {
hooks: null,
seatIndex: null,
mode: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
// Always the action list, never whatever the sheet was showing when it last
// closed — a seat menu that opens on a list of chairs is a menu that lost the
// dealer's place.
this.mode = null;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
@@ -69,81 +63,29 @@ const SeatMenu = {
}
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
// The chairs this player could move to. Every other chair: swapping into an empty
// one is how a player moves, and swapping with an occupied one is two players
// changing places, which is the other half of the same job.
destinationsFor(index) {
return this.state.seats
.map((player, seat) => ({ player, seat }))
.filter(entry => entry.seat !== index);
},
openMovePicker() {
this.mode = "move";
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
if (this.mode === "move") {
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "seatMenuBtn";
// displayName falls back to "Seat 5" for a chair with nobody in it,
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
const named = (other.name || "").trim();
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
btn.addEventListener("click", () => {
const from = this.seatIndex;
this.close();
this.hooks.swapSeats(from, seat);
});
list.appendChild(btn);
});
return;
}
title.textContent = HoldemState.displayName(player, this.seatIndex);
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
subtitle.textContent = bits.join(" · ");
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
// "Move to…" stays on the sheet and swaps it for the seat list; every
// other action is the end of the interaction.
if (action.label === "Move to…") {
action.run();
return;
}
this.close();
action.run();
});
@@ -1,66 +0,0 @@
// Who hands cash to whom at the end of the night.
//
// The standings panel already works out what everyone is up or down. It then stopped,
// which leaves the dealer with six signed numbers at 2am and the actual job still to
// do: turning them into payments. That's the step people get wrong — not the poker.
//
// Everything is done in cents. A settlement that has to come out exactly right is the
// last place to let a float land on 41.99999999.
const SettleUp = {
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
// and for this problem that's the right trade: finding the true minimum number of
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
// than there are people, and each payment it does emit clears somebody out of the
// list entirely. A dealer wants a short list they can read down, not a proof.
//
// Payers are the outer loop so that everything one person owes is printed together
// — the list is read out loud, one player at a time.
plan(standings) {
const owed = [];
const owing = [];
(standings || []).forEach(row => {
const cents = Money.toCents(row.cashPosition);
if (cents > 0) owed.push({ name: row.name, cents });
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
});
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
const payments = [];
let creditor = 0;
owing.forEach(payer => {
while (payer.cents > 0 && creditor < owed.length) {
const taker = owed[creditor];
const amount = Math.min(payer.cents, taker.cents);
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
payer.cents -= amount;
taker.cents -= amount;
if (taker.cents === 0) creditor++;
}
});
// What's left over when the chips on the table don't match the cash that came
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
// is always the thing the bank check is already warning about: a rebuy nobody
// wrote down, or a miscount. Reported rather than quietly spread across the
// payments, because inventing a transfer to make the columns agree is how a
// real person ends up short.
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
return {
payments,
// Only one of these can be non-zero: either there are more chips than cash
// or less, never both.
shortBy: unpaid,
overBy: unspent,
balanced: unpaid === 0 && unspent === 0,
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SettleUp;
}
@@ -18,23 +18,6 @@ const Shortcuts = {
escape: "cancel",
},
// What each one does, in a dealer's words, for the legend in the sidebar.
//
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
// buttons they drive, but A and U were on nothing at all — a dealer who learned
// the first three from the bar had no way to find out the other two existed. A
// shortcut with no line here is a shortcut nobody can find, and there's a test
// that says so.
LEGEND: [
{ keys: ["F"], action: "fold", what: "Fold" },
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
{ keys: ["U"], action: "undo", what: "Take back the last action" },
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
],
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
@@ -57,22 +40,6 @@ const Shortcuts = {
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
// Enter and Space are how a keyboard presses the control it is already sitting on.
// Claiming them for "advance" didn't just block that press — it ran a different
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
// the next hand. Space on the same button worked, because "advance" happened to
// decline that time and the browser's own activation got through. A control that
// does something else every other press is worse than one that does nothing.
//
// Only these two keys are handed back. The letter shortcuts stay live with a
// button focused, because a button does nothing with "f" and the dealer's hand
// shouldn't have to leave them.
focusedControlOwnsKey(key) {
if (key !== "enter" && key !== " ") return false;
const el = document.activeElement;
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
},
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -80,8 +47,6 @@ const Shortcuts = {
const action = this.KEYS[key];
if (!action) return;
if (this.focusedControlOwnsKey(key)) 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;
+15 -451
View File
@@ -51,9 +51,6 @@ const HoldemState = {
notes: "",
handNumber: 1,
handHistory: [],
// Chips taken off the table on purpose, so the bank check can tell them
// apart from chips that have gone missing.
discarded: 0,
smallBlind: 1,
bigBlind: 2,
defaultStack: null,
@@ -81,155 +78,27 @@ const HoldemState = {
// button order is the wrong answer often enough that it has to be
// overridable. Cleared at the start of every hand.
blindOverride: null,
// Who actually posted, the last time cards went out — { sbIndex, bbIndex }.
// The next hand's rotation is worked out from this rather than from the
// seating as it stands, because by then the people it needs to know about
// are exactly the ones who have got up and gone.
postedBlinds: null,
// Who last bet or raised, on this street and anywhere in the hand. Kept
// apart because the showdown rule needs both: normally the aggressor of the
// final betting round shows first, but a round nobody could bet in — chips
// already all-in — falls back to the last one who could.
streetAggressorIndex: null,
handAggressorIndex: null,
// Player ids that were dealt in the last hand actually dealt. What makes a
// seat recognisable as having *just* joined — new to the table, or back
// from sitting out — which is the whole question behind owing a blind.
dealtLastHand: [],
// Ids the dealer has said will buy in past the blinds this hand, by posting
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
// How many straddles are on for the hand about to be dealt. A straddle is
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
// What the house takes, if it takes anything. Percent zero is a game that
// doesn't rake, which is nearly all of them and the default.
rake: { percent: 0, cap: null, noFlopNoDrop: true },
// What it has taken so far tonight. A session total, not a per-hand one:
// it is the number the box is checked against at the end.
rakeTaken: 0,
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
postedStraddles: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
deadButton: false,
deadSmallBlind: false,
};
},
// Where an unreadable save is kept instead of being written over.
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
// True once another tab has written to this table. Two tabs each hold their own
// copy in memory and each save writes the whole thing, so the second one to act
// silently throws the first one's work away — measured: tab A deals and takes a
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
// nothing said in either tab.
//
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
// would be its own hazard; knowing there are two of them is what they need.
otherTabActive: false,
onOtherTab: null,
// The browser fires this in every *other* tab, never the one that wrote, so our
// own saves can't trip it.
watchOtherTabs() {
if (typeof window === "undefined" || this.tabWatcher) return;
this.tabWatcher = event => {
if (event.key !== this.STORAGE_KEY) return;
if (this.otherTabActive) return;
this.otherTabActive = true;
if (typeof this.onOtherTab === "function") this.onOtherTab();
};
window.addEventListener("storage", this.tabWatcher);
},
// True when there was a saved table and it couldn't be read. Same pair as
// saveFailed above, for the same reason: the UI has to be able to say so.
loadFailed: false,
onLoadFailed: null,
// A saved table that won't parse becomes an empty table, which is the only thing
// it can become — but it used to become one silently, and then the first edit
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
// a person might have picked out of the raw JSON was gone a tap later.
//
// So the unreadable value is moved aside first. It is not offered as a restore —
// half a JSON document isn't a table, and pretending otherwise would be worse than
// an empty one — but it stops being destroyed, and somebody who wants the
// standings out of it still can.
failedLoad(raw) {
if (!this.loadFailed) {
this.loadFailed = true;
try {
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
} catch {
// No room to keep it. Nothing more to try, and the warning below is
// the part that actually matters.
}
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
}
return this.createDefault();
},
// Everything a table has to be put through before it can be trusted, whichever
// direction it arrived from.
//
// Pulled out of load() so a table read from a file goes through exactly the same
// checks as one read from storage. A second, looser path for imports is how a
// format grows two meanings of the same field.
//
// Returns null for anything that isn't a table at all; every other repair is
// silent and forward, the way a save from an older version is treated.
migrate(parsed) {
if (!parsed || !Array.isArray(parsed.seats)) return null;
{
load() {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
// Tables saved before live betting existed are missing its fields; fill
// them in rather than making the dealer reset a table mid-session.
const defaults = this.createDefault();
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
"actingSeatIndex", "blindOverride", "postedBlinds",
"streetAggressorIndex", "handAggressorIndex"]
"actingSeatIndex", "blindOverride"]
.forEach(key => {
if (parsed[key] === undefined) parsed[key] = defaults[key];
});
// A table saved before the dead button existed has neither flag. False is
// the right guess for both: it means the rotation carries on the ordinary
// way, which is what that table was already doing.
parsed.deadButton = parsed.deadButton === true;
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
// An old save has no record of who was dealt last hand. Empty is the
// honest answer — it reads as "this table hasn't played yet", so the first
// hand after reopening treats nobody as having just walked up, rather than
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// None on is the right reading of a save from before straddles existed:
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
// A table saved before the house took anything takes nothing, which is
// also the right answer for one that never will.
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
parsed.rake = {
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
noFlopNoDrop: rake.noFlopNoDrop !== false,
};
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
// progress. Anything already committed, or anyone already on the clock,
@@ -248,111 +117,17 @@ const HoldemState = {
});
return parsed;
}
},
load() {
let raw = null;
try {
raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return this.createDefault();
const migrated = this.migrate(JSON.parse(raw));
if (!migrated) return this.failedLoad(raw);
return migrated;
} catch {
// Malformed JSON — a truncated write, another tab, a browser hiccup.
return this.failedLoad(raw);
return this.createDefault();
}
},
// ---- Taking the table off this device ------------------------------------
//
// A night lives in one browser's localStorage and nowhere else. A dealer whose
// phone dies, or who wants to carry on from the tablet after the break, loses the
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
// export can't bring any of that back, because it records what happened rather
// than where the table stands.
//
// So the table can be written out as a file and read back in. Same shape that goes
// into storage, wrapped in enough to recognise it later.
BACKUP_KIND: "casino.holdem.table",
BACKUP_VERSION: 1,
toBackup(state) {
return JSON.stringify({
kind: this.BACKUP_KIND,
version: this.BACKUP_VERSION,
savedAt: new Date().toISOString(),
table: state,
}, null, 2);
},
// Reads a file back. Returns { table } on success, or { error } with something a
// dealer can act on — never a half-loaded table, because the failure mode there is
// a night that looks restored and isn't.
fromBackup(text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
}
if (!parsed || typeof parsed !== "object") {
return { error: "That file isn't a saved table." };
}
// A bare table, from someone who copied the storage value out by hand, is
// still a table. The envelope is a convenience, not a password.
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
return { error: "That file is a save from something else, not a Hold'em table." };
}
const migrated = this.migrate(table);
if (!migrated) {
return { error: "That file doesn't have a table in it — no seats were found." };
}
if (parsed.version > this.BACKUP_VERSION) {
// Newer than this build understands. Loading it anyway risks reading a
// field that has changed meaning, and the dealer would find out from the
// money, so say so instead.
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
}
return { table: migrated, savedAt: parsed.savedAt || null };
},
// What the file is called. Dated, because a dealer who saves twice in a night
// should not have to guess which is which.
backupFileName(now = new Date()) {
const pad = n => String(n).padStart(2, "0");
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
},
// True once a save has failed. The UI reads this to keep saying so, because a
// toast is a moment and this is a fact about the rest of the night.
saveFailed: false,
// Called the first time a save fails. Set by the UI.
onSaveFailed: null,
save(state) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
return true;
} catch (error) {
// Storage full, or private browsing. The table still works for the rest of
// the session — it just won't survive a refresh, and nothing used to say
// so. That is the same shape of fault the bank check exists for: silent
// until it is too late to do anything about it. A dealer who finds out by
// refreshing has lost the night's standings, and the only warning they got
// was the table being empty.
//
// Announced once. A dealer tapping a bar thirty times a hand does not need
// thirty of these.
if (!this.saveFailed) {
this.saveFailed = true;
if (typeof this.onSaveFailed === "function") this.onSaveFailed(error);
}
return false;
} catch {
// Storage full/unavailable (private browsing, etc.) — the table still works
// for the rest of the session, it just won't survive a refresh.
}
},
@@ -470,10 +245,6 @@ const HoldemState = {
// 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.
//
// The fallback, now. nextButtonRotation is what a hand that was actually dealt
// rotates by; this is for the cases it has nothing to work from — a fresh table,
// a hand never dealt, a dealer pressing Next Hand twice to pass the button on.
advanceButton(state) {
for (let step = 1; step <= state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
@@ -512,10 +283,6 @@ const HoldemState = {
buttonSeatForDeal(state) {
const seated = state.seats[state.dealerSeatIndex];
if (seated && seated.inHand) return state.dealerSeatIndex;
// A button left on an empty chair on purpose is the dead button, and moving it
// to a player is precisely what it's there to stop. Only a button that has been
// stranded by accident gets rescued.
if (state.deadButton && this.deadPositionsApply(state)) return state.dealerSeatIndex;
for (let step = 0; step < state.seatCount; step++) {
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
if (state.seats[index].inHand) return index;
@@ -538,13 +305,6 @@ const HoldemState = {
return o;
},
// A dead button and a dead small blind are three-handed-and-up ideas. Down to two
// players the button *is* the small blind and the blinds simply alternate, so
// whatever the last rotation left behind stops applying.
deadPositionsApply(state) {
return state.seats.filter(p => p.inHand).length >= 3;
},
// Who posts what. Heads-up is its own rule: with two players the button posts the
// small blind, which is the opposite of the usual order.
getBlindSeats(state) {
@@ -576,88 +336,11 @@ const HoldemState = {
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
}
// The small blind is dead: whoever would have moved into it has left the table,
// and the seat stays empty for a hand rather than dragging the big blind
// forward an extra place. One player gets a free hand out of position for it;
// that's the price of nobody being charged two big blinds in an orbit.
if (state.deadSmallBlind && this.deadPositionsApply(state)) {
return { dealerIndex, sbIndex: null, bbIndex: live[0], headsUp: false, deadSmallBlind: true };
}
return { dealerIndex, sbIndex: live[0], bbIndex: live[1], headsUp: false };
},
// Seats about to be dealt a hand they haven't paid for.
//
// Somebody who walks up and sits down between the button and the big blind has had
// the blinds go past them. Dealing them in there is a free hand in late position —
// which is exactly what posting is for, and it comes out of everybody else's night
// a couple of dollars at a time. At a real table they either post the big blind to
// come straight in, or they wait for it to reach them.
//
// The seat that *is* the big blind this hand isn't owed anything: they're paying.
// Neither is anyone at a table that hasn't dealt a hand yet, where everybody is new
// and nobody has been skipped.
blindDebtors(state, bbIndex) {
const played = Array.isArray(state.dealtLastHand) && state.dealtLastHand.length > 0;
if (!played) return [];
return state.seats
.map((player, index) => ({ player, index }))
.filter(({ player, index }) =>
player.inHand &&
index !== bbIndex &&
!state.dealtLastHand.includes(player.id))
.map(({ index }) => index);
},
// Where the button goes for the next hand, and whether anything is left dead.
//
// Ordinarily it follows the small blind round — the next player along, which is
// what it has always done. The case that needs care is somebody getting up out of
// the blinds. Passing the button to the next *player* then pulls the whole blind
// order forward a place: the player after the empty chair is never asked for a big
// blind at all, and the one after them is asked an orbit early. Holding the
// rotation to the seats instead is the dead button, and getting it wrong costs a
// real player a real big blind.
//
// Worked out from who actually posted last hand, not from the seating as it stands,
// because by now the people it needs to know about are the ones who have gone.
nextButtonRotation(state) {
const posted = state.postedBlinds;
const inRange = i => Number.isInteger(i) && i >= 0 && i < state.seatCount && state.seats[i];
const here = i => inRange(i) && state.seats[i].inHand;
// Nothing to hold to: a fresh table, a hand that was never dealt, or one where
// the blinds were named by hand. And heads-up has its own rule — with two
// players the button is the small blind, so there is no position to leave dead.
if (!posted || !inRange(posted.sbIndex) || !inRange(posted.bbIndex) || !this.deadPositionsApply(state)) {
return { buttonIndex: null, deadButton: false, deadSmallBlind: false };
}
// Both still sitting there is the ordinary case, and this comes out as the next
// player along — the same answer as passing the button, arrived at from the
// blinds so that the unusual case doesn't need a separate path.
return {
buttonIndex: posted.sbIndex,
deadButton: !here(posted.sbIndex),
deadSmallBlind: !here(posted.bbIndex),
};
},
startNextHand(state) {
const rotation = this.nextButtonRotation(state);
if (rotation.buttonIndex === null) {
this.advanceButton(state);
state.deadButton = false;
state.deadSmallBlind = false;
} else {
state.dealerSeatIndex = rotation.buttonIndex;
state.deadButton = rotation.deadButton;
state.deadSmallBlind = rotation.deadSmallBlind;
}
// Spent. Pressing Next Hand again without dealing is a dealer passing the
// button along, not a second rotation off the same hand.
state.postedBlinds = null;
this.advanceButton(state);
// Cards aren't out and nothing is posted until the dealer says so.
state.bettingOpen = false;
state.actingSeatIndex = null;
@@ -668,13 +351,6 @@ const HoldemState = {
// the next one would freeze the blinds on two players for the rest of the
// night, which is the opposite of what naming them once is for.
state.blindOverride = null;
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
// So is a straddle. It is announced hand by hand at a table, and a straddle
// that stayed on would take chips off somebody who never said the word.
state.straddleCount = 0;
state.postedStraddles = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
@@ -694,15 +370,11 @@ const HoldemState = {
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts, rake = 0) {
recordHand(state, payouts) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
// The pot as it was, house cut included — the number the table saw in the
// middle. What each player took is separately recorded and is net of it, so
// the two together say where every chip went.
const taken = Money.round(Math.max(0, Number(rake) || 0));
const pot = Money.round(payouts.reduce((sum, p) => sum + p.amount, 0) + taken);
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
@@ -717,7 +389,6 @@ const HoldemState = {
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
rake: taken,
payouts,
ledger,
});
@@ -783,14 +454,6 @@ const HoldemState = {
// window is almost always.
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
// "Venmo: @ann". It's carried here so the export can hand it over with
// the money it belongs to; the standings panel itself doesn't show it.
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
.filter(field => (field.label || "").trim() || (field.value || "").trim())
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
.join("; ");
return {
...t,
in: Money.round(t.in),
@@ -798,36 +461,12 @@ const HoldemState = {
net: Money.round(t.won - t.in),
boughtIn,
chipsNow,
// Everything still theirs: the stack in front of them plus whatever
// is out in the middle for the hand in progress. This is the number
// a banker counts out when somebody leaves, and it was the one
// figure the standings never showed — the column called "Cash out"
// held the difference against their buy-in, so a player up $140 on
// a $340 stack read as "$140" at the exact moment cash changed
// hands.
chipsInFront: Money.round(chipsNow + inFrontOfThem),
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
notes,
};
})
.sort((a, b) => b.net - a.net);
},
// Two players change chairs.
//
// The whole seat moves — name, stack, cards, what they have in the pot — because
// that is what changing chairs is. What stays behind is everything that belongs to
// the *position*: the button, the blinds and the acting index all name a chair, and
// a player who moves into the big blind's chair is the big blind. That is the same
// answer a card room gives.
swapSeats(state, a, b) {
const seats = state.seats || [];
if (a === b) return false;
if (!seats[a] || !seats[b]) return false;
[seats[a], seats[b]] = [seats[b], seats[a]];
return true;
},
// The one check a banker actually needs, and the one nothing here did.
//
// Every chip on the table is a promise to pay cash for it later. If the chips in
@@ -838,67 +477,6 @@ const HoldemState = {
//
// Chips in play counts everything a player could hand back: what's in front of
// them and what's in the middle waiting to be won.
// ---- The house's cut ----
//
// A game that takes a rake or a time charge has chips leaving the table every hand,
// and until now the bank check called them missing: it added up the buy-ins,
// added up the chips on the felt, and told the dealer somebody had been paid for
// chips they never got. The house's cut is the one thing on this table that is
// supposed to go and not come back, so it is counted rather than mourned.
//
// Percentage with a cap, which is what nearly every room and home game runs. Zero
// percent is a game that doesn't rake, which is the default and the common case.
// What the house takes from a pot of this size.
//
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
// flop pays nothing, because the players never got a hand out of it. It is on by
// default and can be turned off for a house that rakes every pot.
rakeOn(state, pot, sawFlop = true) {
const rake = state.rake || {};
const percent = Number(rake.percent) || 0;
const amount = Money.round(Number(pot) || 0);
if (percent <= 0 || amount <= 0) return 0;
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
const uncapped = (amount * percent) / 100;
const cap = Number(rake.cap);
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
// Never more than the pot, however the numbers were typed in.
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
},
// The same payouts with the house's cut taken out of them.
//
// Off the top of the whole pot, then shared out in proportion — which for the hand
// a dealer actually deals, with one winner, is just "winner gets the pot less the
// rake". Split pots take it proportionally, and whatever the rounding leaves over
// goes back to the winners in turn rather than to the house: the house asked for a
// number, and it gets that number, not that number plus dust.
applyRake(payouts, rake) {
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
const taken = Money.round(Math.min(Math.max(0, rake), total));
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
const kept = Money.toCents(total) - Money.toCents(taken);
const shares = payouts.map(p => {
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
return { payout: p, cents: Math.floor(exact) };
});
// Floor everybody, then hand the leftover cents out one at a time, biggest
// share first — the same shape as the odd-chip rule the split calculator uses.
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
[...shares]
.sort((a, b) => b.payout.amount - a.payout.amount)
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
return {
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
rake: taken,
};
},
bankReconciliation(state) {
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
const chipsInPlay = state.seats.reduce(
@@ -910,24 +488,10 @@ const HoldemState = {
0
);
// Chips the dealer deliberately took off the table — an abandoned pot they
// were asked about and confirmed. They are gone, so they can't be counted as
// in play, but they aren't missing either: the box knows exactly where they
// went. Without this the check blamed the tool's own confirmed action on
// "someone has paid for chips they haven't been given", which sends a dealer
// hunting for a miscount that never happened.
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
// Chips the house has taken. Off the table for good, like the discards, and
// for a better reason: the box is supposed to have them.
const raked = Math.max(0, Money.round(Number(state.rakeTaken) || 0));
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
const difference = Money.round(chipsInPlay - cashIn);
return {
cashIn: Money.round(cashIn),
chipsInPlay: Money.round(chipsInPlay),
discarded,
raked,
difference,
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
balanced: Math.abs(difference) <= Money.EPSILON,
+1 -16
View File
@@ -36,22 +36,7 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
phones on their side — each viewport twice, once with a mouse and once with a thumb.
That last part, and two of those checks, are newer than this paragraph claiming them.
`runAll()` did not include the contrast check at all, and touch targets sat behind an
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
eleven for a long run of rounds. Both were written and tested and simply never called.
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
where `hover: hover` says there is a pointer, and a headless desktop browser is a
pointer, so a single pass had never measured a touch target at any viewport. Between
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
button on Quick Split.
A check that decides it doesn't apply says so in the output rather than reporting no
offenders, which reads identically to a pass. The same goes for the two exemptions
that exist: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.
phones on their side.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
this one needs a browser and a running site. Run it after any change that adds or
-98
View File
@@ -1,98 +0,0 @@
// Who the felt marks "ALL IN".
//
// The mark's whole value is that a dealer can scan the ring and see who can still be
// bet at, so the test that matters most is the last one: the seats it marks are
// exactly the seats the betting round refuses to ask for another chip.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("a player who moved all-in is marked", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
assert.equal(state.seats[3].stack, 0);
});
test("a player with chips behind is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("a busted player who was dealt in but has put nothing in is not", () => {
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("a folded player with an empty stack is not", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[1].committed = 5;
state.seats[1].stack = 0;
state.seats[1].folded = true;
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("an empty chair is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
});
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
state.seats[2].committed = 10;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("the seats marked are exactly the ones the action passes over", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[1].stack = 8;
state.seats[3].stack = 30;
Betting.beginHand(state);
// Everyone in, two of them for less than the others can cover.
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
const marked = state.seats
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
.filter(index => index !== null);
const skipped = state.seats
.map((player, index) =>
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
.filter(index => index !== null);
assert.deepEqual(marked, skipped);
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
});
test("nobody is marked once the next hand starts", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
// The short stack got there by winning nothing; give them chips back the way a
// dealer would before the next deal.
HoldemState.startNextHand(state);
state.seats[2].stack = 50;
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
});
test("marking nobody doesn't move a chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
const before = chipsOnTable(state);
state.seats.forEach(player => Betting.isAllIn(state, player));
assert.equal(chipsOnTable(state), before);
});
-108
View File
@@ -1,108 +0,0 @@
// The sums the contrast check runs on, tested away from a browser.
//
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
// and the arithmetic is the part that decides whether a real fault is reported or
// waved through. Faded text used to be filed as "not checked" rather than measured;
// this is the compositing that replaced that.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const LayoutAudit = require("./uiAudit");
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
describe("the ratio itself", () => {
test("black on white is the full 21:1, either way round", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
});
test("a colour against itself is 1:1", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
});
});
describe("text on a box that is being faded", () => {
// opacity on a box fades the box and its text together onto whatever is behind
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
// faded text both landing on the page.
const fade = alpha => ({ alpha, behind: PAGE });
test("at full strength nothing is composited", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
test("fading moves both the text and its background, not just the text", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
assert.ok(painted.fg.r < WHITE.r, "the text dims");
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
});
test("which is what stops a fade being reported as a fault it isn't", () => {
// Dimming light text on a dark puck over a dark page costs contrast, but far
// less than measuring the faded text against the *unfaded* puck would claim.
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
});
test("a fade all the way out lands everything on the page behind", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
assert.deepEqual(painted.fg, PAGE);
assert.deepEqual(painted.bg, PAGE);
});
test("with nothing known to be behind it, the fade is left alone", () => {
// Better to measure the unfaded pair than to invent a backdrop.
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
});
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
// Deliberately not asserting a particular CSS colour here: the page's own greys are
// measured in the browser, against the real gradients, where they live. What this
// pins is the direction — that dimming a box costs its text contrast, which is why
// filing faded text as "not checked" hid faults rather than avoiding false ones.
test("the same grey scores lower once its box is dimmed", () => {
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
LayoutAudit.contrastRatio(solid.fg, solid.bg));
});
test("and a lighter grey scores higher at the same fade", () => {
const at = colour => {
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
};
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
});
});
describe("reading a colour off the page", () => {
test("rgb and rgba both come back as numbers", () => {
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
});
test("a gradient gives up every stop, because text could be over any of them", () => {
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
assert.equal(stops.length, 2);
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
});
test("no background image, no stops", () => {
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
});
});
-55
View File
@@ -167,58 +167,3 @@ describe("does the cash box cover the chips on the table", () => {
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
describe("the number the banker counts out", () => {
// The standings had two signed columns and neither was an amount to pay. A player
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
// exact moment somebody was counting money onto the table.
test("chips in front is what they are holding, not what they are up", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
state.seats[0].name = "Ann";
state.seats[0].buyIns = 200;
state.seats[1].buyIns = 200;
state.seats[1].stack = 60;
const rows = HoldemState.sessionStandings(state);
const ann = rows.find(r => r.name === "Ann");
assert.equal(ann.chipsInFront, 340, "the cash to count out");
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
});
test("a player who is down still has chips to be handed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
state.seats[0].name = "Cal";
state.seats.forEach(p => { p.buyIns = 200; });
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
});
test("chips still out in the middle are counted as theirs", () => {
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
// and a stack that drops by the size of a bet looks like money going missing.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats[0].name = "Dee";
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
Betting.raiseTo(state, 0, 50);
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
assert.equal(dee.cashPosition, 0, "so they are still level");
});
test("chips and up/down always differ by exactly the buy-in", () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
[340, 150, 60, 250].forEach((chips, i) => {
state.seats[i].stack = chips;
state.seats[i].buyIns = 200 + i * 50;
});
HoldemState.sessionStandings(state).forEach(row => {
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
`${row.name}'s two columns should tell one story`);
});
});
});
-148
View File
@@ -1,148 +0,0 @@
// Dealing around a player who busted and hasn't bought back in.
//
// A home game produces one of these about once an hour. The table used to deal them
// in anyway, promise a blind from a seat with nothing to post it with, take it from
// nobody, and then sit on a hand that could never finish.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const postedBy = state =>
state.seats
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
.filter(Boolean);
test("a seat with no chips and nothing in the pot is broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.deepEqual(Betting.brokeSeats(state), [2]);
});
test("an all-in player is not broke — they have money riding on the hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, 2);
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a chair nobody is sitting in isn't broke, it's empty", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].name = "Cal";
state.seats[2].buyIns = 100;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
assert.equal(state.seats[2].name, "Cal");
assert.equal(state.seats[2].buyIns, 100);
});
test("the blinds skip the broke seat and land on players who can post", () => {
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
Betting.beginHand(state);
assert.deepEqual(postedBy(state), [
{ seat: 3, amount: state.smallBlind },
{ seat: 4, amount: state.bigBlind },
]);
});
test("the preview promises what the deal produces", () => {
// The fault this whole step exists to prevent: the preview once read a table the
// deal was about to change underneath it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
state.bettingOpen = false;
const preview = Betting.blindPreview(state);
assert.deepEqual(preview.satOut, [1]);
Betting.beginHand(state);
const posted = postedBy(state);
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
});
test("asking for the preview doesn't sit anybody out on its own", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.blindPreview(state);
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
});
test("with only one player left holding chips there is nothing to preview", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
assert.equal(Betting.blindPreview(state), null);
});
test("no chips move when the broke seats are dealt around", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
test("the board isn't running out before the hand is dealt", () => {
// Two busted players and one with chips looks exactly like a table of all-ins to
// the run-out test, and the bar was announcing one over an empty felt.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
state.bettingOpen = false;
assert.equal(Betting.isRunningOut(state), false);
});
test("buying back in puts them in the next hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
// What the dealer does when they hand over cash: chips, and back in.
state.seats[2].stack = 100;
state.seats[2].inHand = true;
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.liveSeats(state).length, 3);
});
test("the table dealt around them counts the right number of players", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 0;
assert.equal(Betting.liveSeats(state).length, 4);
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
});
-76
View File
@@ -1,76 +0,0 @@
// Whether handing a seat chips also puts them back in the game.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
test("a busted player who has been sat out comes back in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.beginHand(state); // the deal sits them out
assert.equal(state.seats[1].inHand, false);
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
});
test("a chair being bought into for the first time comes in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("a seat sat out while holding chips stays out", () => {
// A dealer sat them out on purpose and is topping them up. Dealing them back in
// would be the tool guessing at something it wasn't told.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].inHand = false;
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("somebody already in the hand is left exactly where they are", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
// Including a player who is in but out of chips — they're all-in, not sat out.
state.seats[2].stack = 0;
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("a blank stack field reads as nothing recorded, not as a number", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[3].name = "Dee";
state.seats[3].stack = "";
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
});
test("it doesn't ask the table what a missing stack would have been", () => {
// stackOf substitutes the table's starting stack for a seat that has none, which
// would make a seat with nothing recorded look like it was holding chips.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.defaultStack = 500;
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("asking doesn't change anything", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].inHand = false;
const before = JSON.stringify(state);
Betting.buyInDealsThemIn(state.seats[2]);
assert.equal(JSON.stringify(state), before);
});
test("nothing to decide about a seat that isn't there", () => {
assert.equal(Betting.buyInDealsThemIn(null), false);
assert.equal(Betting.buyInDealsThemIn(undefined), false);
});
-82
View File
@@ -1,82 +0,0 @@
// The chips to actually push, for an amount.
const test = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// A standard house set.
const LEGEND = [
{ value: 1, color: "#fff" },
{ value: 5, color: "#c0392b" },
{ value: 25, color: "#27ae60" },
{ value: 100, color: "#2c3e50" },
{ value: 500, color: "#8e44ad" },
];
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
test("a payout comes back as chips, largest first", () => {
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
});
test("it uses the fewest chips on a normal set", () => {
const { chips } = Money.chipBreakdown(500, LEGEND);
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
});
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
assert.equal(say(7), "1×$5, 2×$1");
});
test("nothing to pay is nothing to say", () => {
assert.equal(say(0), "");
assert.equal(say(-5), "");
});
test("no legend means no advice, not a wrong answer", () => {
// A table that never set chip colours up gets the amount on its own.
assert.equal(say(132, []), "");
assert.equal(say(132, null), "");
});
test("a legend with no values in it yet is the same as none", () => {
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
});
test("what the smallest chip can't make is reported, not rounded away", () => {
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
// and they can only solve it if they're told.
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
assert.equal(remainder, 0.5);
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
});
test("cent chips make cent amounts exactly", () => {
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
});
test("the chips add up to the amount", () => {
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
}
});
test("a legend in any order still pays largest first", () => {
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
});
test("the colour comes along, so the felt can say it in chips", () => {
const { chips } = Money.chipBreakdown(25, LEGEND);
assert.equal(chips[0].color, "#27ae60");
});
test("a legend that exists but can't reach the amount does say so", () => {
// Different from having no legend at all: this table has told us its chips, and
// the answer is that they don't go that low.
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
});
-268
View File
@@ -1,268 +0,0 @@
// The button when somebody gets up out of the blinds.
//
// Passing the button to the next *player* is right until a chair between the button
// and the blinds empties. Then it pulls the whole blind order forward a place: the
// player after the empty chair is never asked for a big blind, and the one after them
// is asked an orbit early. The dead button rule holds the rotation to the seats for
// one hand so the big blind still advances exactly one player.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
// Four players in six chairs: Ann 0, Ben 1, Cal 2, Dee 3.
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
const table = (dealerSeatIndex = 0) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
return state;
};
const nameOf = (state, index) =>
index === null || index === undefined ? null : (state.seats[index].name || `empty seat ${index + 1}`);
const blinds = state => {
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
return { sb: nameOf(state, sbIndex), bb: nameOf(state, bbIndex) };
};
const leave = (state, index) => {
state.seats[index].name = "";
state.seats[index].inHand = false;
state.seats[index].stack = 0;
state.seats[index].buyIns = 0;
};
describe("somebody gets up out of the blinds", () => {
test("the small blind leaves: dead button, and the big blind still moves one player", () => {
const state = table(1); // button Ben, small Cal, big Dee
Betting.beginHand(state);
assert.deepEqual(blinds(state), { sb: "Cal", bb: "Dee" });
leave(state, 2); // Cal takes their chips and goes
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.dealerSeatIndex, 2, "the button holds Cal's empty chair");
assert.equal(state.seats[2].inHand, false, "which is the point — it's on nobody");
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ann" },
"Dee moves from big to small and Ann posts the big blind, one player along");
});
test("and Ann is not skipped, which is the money in it", () => {
// The old rotation passed the button to the next player, which made the blinds
// Ann/Ben — Ann never posting a big blind at all, and Ben posting one an orbit
// early. That is a real big blind out of a real player's stack.
const state = table(1);
Betting.beginHand(state);
leave(state, 2);
HoldemState.startNextHand(state);
assert.equal(blinds(state).bb, "Ann", "Ann owes the big blind and still owes it");
});
test("the big blind leaves: no small blind is posted this hand", () => {
const state = table(1); // button Ben, small Cal, big Dee
Betting.beginHand(state);
leave(state, 3); // Dee goes
HoldemState.startNextHand(state);
Betting.beginHand(state);
const { sbIndex, bbIndex, deadSmallBlind } = HoldemState.getBlindSeats(state);
assert.equal(state.dealerSeatIndex, 2, "the button reaches Cal as it was always going to");
assert.equal(sbIndex, null, "the small blind is dead");
assert.equal(deadSmallBlind, true, "and says so, rather than looking like a table with no blinds");
assert.equal(nameOf(state, bbIndex), "Ann", "the big blind still moves on one player");
});
test("a dead small blind still deals a hand, with one blind in the middle", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 3);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.bettingOpen, true, "the hand goes ahead");
const posted = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
assert.equal(posted, state.bigBlind, "just the big blind, and nothing conjured to stand in for the small");
assert.equal(state.currentBet, state.bigBlind);
assert.ok(state.actingSeatIndex !== null, "and somebody is on the clock");
});
test("somebody leaving from anywhere else changes nothing", () => {
const state = table(1); // button Ben, small Cal, big Dee
Betting.beginHand(state);
leave(state, 0); // Ann, who was under the gun
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 2, "the button passes to Cal exactly as before");
assert.equal(state.deadButton, false);
assert.equal(state.deadSmallBlind, false);
assert.deepEqual(blinds(state), { sb: "Dee", bb: "Ben" });
});
});
describe("the ordinary rotation is untouched", () => {
test("a full table passes the button one player at a time", () => {
const state = table(0);
const seen = [];
for (let hand = 0; hand < 8; hand++) {
Betting.beginHand(state);
seen.push(blinds(state).bb);
HoldemState.startNextHand(state);
}
assert.deepEqual(seen, ["Cal", "Dee", "Ann", "Ben", "Cal", "Dee", "Ann", "Ben"],
"two clean orbits, everybody paying one big blind per orbit");
assert.equal(state.deadButton, false);
assert.equal(state.deadSmallBlind, false);
});
test("players spread thinly around a big table don't create dead positions", () => {
// Three players in nine chairs. The chairs between them were never in the
// rotation, so holding to seats there would post blinds to nobody hand after
// hand — which is why the button follows players, not chairs, by default.
const state = makeTable({ seatCount: 9, seated: [0, 4, 7], stack: 200, dealerSeatIndex: 0 });
["Ann", "", "", "", "Ben", "", "", "Cal"].forEach((n, i) => { state.seats[i].name = n; });
const seen = [];
for (let hand = 0; hand < 6; hand++) {
Betting.beginHand(state);
const { sbIndex, bbIndex } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand, "a real player posts the small blind");
assert.ok(state.seats[bbIndex].inHand, "and a real player the big");
seen.push(nameOf(state, bbIndex));
HoldemState.startNextHand(state);
}
assert.deepEqual(seen, ["Cal", "Ann", "Ben", "Cal", "Ann", "Ben"], "one big blind each per orbit");
});
test("pressing next hand twice without dealing just passes the button along", () => {
const state = table(0);
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 1);
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 2, "and doesn't rotate off the same hand twice");
});
});
describe("the rule knows where it stops", () => {
test("down to two players, heads-up takes over and nothing is left dead", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 2);
leave(state, 3); // both blinds walk at once
HoldemState.startNextHand(state);
Betting.beginHand(state);
const { sbIndex, headsUp } = HoldemState.getBlindSeats(state);
assert.equal(headsUp, true);
assert.equal(sbIndex, state.dealerSeatIndex, "heads-up the button posts the small blind");
assert.ok(state.seats[state.dealerSeatIndex].inHand, "and the button is on a player again");
});
test("a button stranded by accident is still rescued", () => {
// Not the dead button: nobody left out of the blinds, the chair the button is
// on just emptied. That one does want putting back on a player.
const state = table(3);
state.seats[3].inHand = false;
HoldemState.ensureButtonOnPlayer(state);
assert.ok(state.seats[state.dealerSeatIndex].inHand);
});
test("moving the button by hand clears the dead positions", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 2);
HoldemState.startNextHand(state);
assert.equal(state.deadButton, true);
// What the seat menu's "Give the button" does.
state.dealerSeatIndex = 0;
state.deadButton = false;
state.deadSmallBlind = false;
Betting.beginHand(state);
const { sbIndex } = HoldemState.getBlindSeats(state);
assert.ok(state.seats[sbIndex].inHand, "a real small blind again, where the dealer put it");
});
test("naming a blind by hand overrules a dead one", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 3);
HoldemState.startNextHand(state);
assert.equal(state.deadSmallBlind, true);
state.blindOverride = { sbIndex: 1, bbIndex: 0 };
const { sbIndex, bbIndex, manual } = HoldemState.getBlindSeats(state);
assert.equal(manual, true);
assert.equal(sbIndex, 1, "the dealer's word beats the rule");
assert.equal(bbIndex, 0);
});
test("a table saved before any of this reads as nothing dead", () => {
const state = table(0);
const parsed = JSON.parse(JSON.stringify(state));
delete parsed.deadButton;
delete parsed.deadSmallBlind;
delete parsed.postedBlinds;
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, value),
removeItem: key => store.delete(key),
};
const loaded = HoldemState.load();
assert.equal(loaded.deadButton, false);
assert.equal(loaded.deadSmallBlind, false);
assert.equal(loaded.postedBlinds, null);
HoldemState.startNextHand(loaded);
assert.ok(loaded.seats[loaded.dealerSeatIndex].inHand, "and rotates the ordinary way");
delete globalThis.localStorage;
});
});
describe("chips are not invented or lost by any of it", () => {
test("posting one blind instead of two moves chips and doesn't make them", () => {
// Measured across the deal, which is the step that touches stacks. A hand with
// a dead small blind has one blind in the middle instead of two — the danger is
// a null small blind being treated as a zero-cost posting somewhere, or the big
// blind quietly covering for it.
const state = table(1);
const chips = s => s.seats.reduce(
(sum, p) => sum + Betting.stackOf(s, p) + Betting.handTotal(p), 0);
Betting.beginHand(state);
leave(state, 3); // the big blind goes, small blind dies
for (let hand = 0; hand < 4; hand++) {
HoldemState.startNextHand(state);
const before = chips(state);
Betting.beginHand(state);
assert.equal(chips(state), before, `the deal drifted the count on hand ${hand + 1}`);
}
});
test("a dead small blind takes exactly one blind out of one stack", () => {
const state = table(1);
Betting.beginHand(state);
leave(state, 3);
HoldemState.startNextHand(state);
const stacks = () => state.seats.map(p => Betting.stackOf(state, p));
const before = stacks();
Betting.beginHand(state);
const after = stacks();
const paid = before.map((v, i) => v - after[i]).filter(d => d !== 0);
assert.deepEqual(paid, [state.bigBlind], "one player, one big blind, nobody else touched");
});
});
-94
View File
@@ -1,94 +0,0 @@
// Chips the dealer took off the table on purpose.
//
// Starting the next hand with money still in the middle destroys it — the tool asks
// first and says so afterwards. What it didn't do was remember, so the bank check saw
// only that chips were gone and reported them as missing.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState, Money, makeTable } = require("./load");
const table = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
return state;
};
test("a table with nothing thrown away balances as before", () => {
const bank = HoldemState.bankReconciliation(table());
assert.equal(bank.balanced, true);
assert.equal(bank.discarded, 0);
});
test("chips that went missing are still reported", () => {
// The check exists for this: cash in the box with no chips to match it.
const state = table();
state.seats[0].stack = 150;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("an abandoned pot is accounted for rather than counted as missing", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 198;
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.chipsInPlay, 795);
assert.equal(bank.discarded, 5);
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
});
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, -50);
});
test("a nonsense discarded value can't move the answer", () => {
const state = table();
state.discarded = -100;
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
state.discarded = "not a number";
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
});
test("a table saved before this existed reads as nothing thrown away", () => {
const saved = JSON.stringify(table());
const parsed = JSON.parse(saved);
delete parsed.discarded;
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, value),
removeItem: key => store.delete(key),
};
const loaded = HoldemState.load();
assert.equal(loaded.discarded, 0);
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
delete globalThis.localStorage;
});
test("the amounts still add up the way the panel shows them", () => {
const state = table();
state.seats[1].stack = 199;
state.seats[2].stack = 198;
state.seats[3].stack = 198;
state.discarded = 5;
const bank = HoldemState.bankReconciliation(state);
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
});
-97
View File
@@ -1,97 +0,0 @@
// The things the dealer wrote down, in the file they hand over.
//
// The export already carried the hands, the standings and the settle-up. It didn't
// carry either of the two places a dealer types context — the per-seat fields and the
// table's own notes — so the one place an IOU was written down was the one place it
// couldn't leave.
const test = require("node:test");
const assert = require("node:assert");
const { HandHistoryExport, HoldemState, makeTable } = require("./load");
const tableWithNotes = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
state.seats[0].customFields = [
{ label: "Seat rent", value: "paid to 2am" },
{ label: "Venmo", value: "@ann" },
];
state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
return state;
};
test("a seat's own fields ride along with its standings row", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
assert.match(csv, /IOU: \$20 to Ann/);
});
test("the money columns keep their order, with notes on the end", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
});
test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
assert.ok(calRow, "Cal should have a row");
assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
assert.ok(!/undefined|null/.test(calRow));
});
test("a blank label or value doesn't produce a stray colon", () => {
const state = tableWithNotes();
state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
assert.match(calRow, /Note/);
assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
});
test("the table's notes are in the file", () => {
const state = tableWithNotes();
state.notes = "High-hand promo until midnight.";
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
assert.match(csv, /Table Notes/);
assert.match(csv, /High-hand promo until midnight\./);
});
test("a multi-line note survives as one cell", () => {
// csvField quotes anything with a line break in it, so the note doesn't turn into
// rows a spreadsheet reads as more players.
const state = tableWithNotes();
const notes = "Line one\nLine two, with a comma\nLine \"three\"";
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
});
test("no notes means no section, not an empty heading", () => {
const state = tableWithNotes();
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
assert.ok(!/Table Notes/.test(csv));
});
test("the export still works with no standings at all", () => {
assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
assert.match(csv, /Table Notes/);
});
test("asking for the standings doesn't change the seats", () => {
const state = tableWithNotes();
const before = JSON.stringify(state.seats);
HoldemState.sessionStandings(state);
assert.equal(JSON.stringify(state.seats), before);
});
-73
View File
@@ -1,73 +0,0 @@
// How tall the felt is allowed to be.
//
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
// has to be told what is left. Everything below is the arithmetic that decides it —
// the part that can be wrong without anyone noticing until a seat is behind the Fold
// button.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar } = require("./load");
// window height, bar height, and how far down the page the felt starts.
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
describe("the room left for the felt", () => {
test("is what the window has after the bar, the header and the bottom seat", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
});
test("shrinks when the bar grows mid-hand", () => {
// Side pots make the bar taller exactly when the dealer most needs to see who
// is still in.
const quiet = room(900, 117, 146);
const sidePots = room(900, 172, 146);
assert.equal(quiet - sidePots, 55);
});
test("leaves the bottom seat's overhang out of it", () => {
// The seat pucks hang below the felt's edge; the felt has to stop short by that
// much or the ring ends up in the bar even though the felt doesn't.
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
});
});
describe("a window too short to give what was asked", () => {
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
// everything above the felt. What's left is under 100.
const landscape = () => room(390, 127, 119);
test("gets the measurement, not a comfortable number", () => {
// The floor used to be 220px, which this window hasn't got. Holding out for it
// put the bottom row of seats 26px inside the action bar — the exact fault the
// measurement exists to prevent.
assert.ok(landscape() < 150, `${landscape()}px`);
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
});
test("still draws a table rather than a line", () => {
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
});
test("never returns something a browser can't lay out", () => {
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
const answer = ActionBar.feltRoomFor(h, bar, top);
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
});
});
});
describe("a window with room to spare", () => {
test("hands over everything that's left, floor or no floor", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
});
test("gives a whole number of pixels", () => {
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
});
});
-123
View File
@@ -1,123 +0,0 @@
// Finding one hand in a night of them.
//
// What the log is actually opened for is a single hand — the one somebody at the rail
// is arguing about. A night runs to sixty or more, five at a time in the panel, and
// there was no way to ask for one: the dealer scrolled and read every row.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
const hand = (handNumber, pot, payouts, ledger = []) => ({
handNumber, pot, timestamp: Date.now(), payouts, ledger,
});
// A night as the tool records it: labels carry the cards the wizard was shown.
const NIGHT = [
hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]),
hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]),
hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]),
hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]),
];
const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber);
describe("no filter", () => {
test("shows the whole night", () => {
assert.deepEqual(found(""), [1, 2, 12, 120]);
assert.deepEqual(found(" "), [1, 2, 12, 120]);
assert.deepEqual(found(null), [1, 2, 12, 120]);
});
});
describe("by name", () => {
test("finds the hand somebody won", () => {
assert.deepEqual(found("Fay"), [2]);
});
test("case doesn't matter — nobody types a capital at 2am", () => {
assert.deepEqual(found("fay"), [2]);
assert.deepEqual(found("FAY"), [2]);
});
test("a name inside a label with cards on it still matches", () => {
// The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring
// rather than the whole cell.
assert.deepEqual(found("ann"), [1]);
});
test("matches what the row shows, not everyone who was in the hand", () => {
// Gus is in hand 2's ledger — he paid for it — but the row names the winner,
// and searching text the panel doesn't display is a filter that looks broken
// even when it's right. Measured on a seven-handed night where nobody folded,
// matching the ledger returned 60 of 60 for every name at the table.
assert.deepEqual(found("gus"), []);
});
test("a split pot is found by either winner", () => {
assert.deepEqual(found("cal"), [120]);
assert.deepEqual(found("dee"), [120]);
});
test("a name nobody has finds nothing rather than everything", () => {
assert.deepEqual(found("zed"), []);
});
});
describe("by amount", () => {
test("finds a pot", () => {
assert.deepEqual(found("190"), [2]);
});
test("a dollar sign and commas are ignored", () => {
assert.deepEqual(found("$190"), [2]);
assert.deepEqual(found("$1,90"), [2], "typed badly, still found");
});
test("finds a payout that isn't the whole pot", () => {
assert.deepEqual(found("38"), [120], "half of a chopped $76");
});
test("a partial amount narrows rather than missing", () => {
// Half remembering "something around forty" should still get there.
assert.deepEqual(found("4"), [1], "$40");
assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one");
});
test("a bare number is an exact hand number, not a prefix of one", () => {
// Amounts match on their leading digits, because half a remembered figure is
// still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who
// knows which hand they want, and dragging 120 and 128 along with it is noise.
// "#12" is there for when they do want the prefix.
assert.ok(!found("12").includes(120));
});
});
describe("by hand number", () => {
test("a bare number matches the hand number as well as amounts", () => {
assert.ok(found("12").includes(12));
});
test("a hash pins it to the hand number and nothing else", () => {
assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12");
assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours");
});
test("a hash with a number nobody played finds nothing", () => {
assert.deepEqual(found("#7"), []);
});
});
describe("odd input can't break it", () => {
test("punctuation on its own doesn't match everything", () => {
assert.deepEqual(found("$"), [], "no digits and no letters left to match on");
assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all");
});
test("an entry with pieces missing doesn't throw", () => {
const broken = { handNumber: 5, pot: 10 };
assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann"));
assert.equal(Render.handMatchesFilter(broken, "ann"), false);
assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable");
});
});
-7
View File
@@ -18,7 +18,6 @@ const Money = load("money");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
const SettleUp = load("settleUp");
const HandHistoryExport = load("handHistoryExport");
// render.js exports { Render } rather than the namespace directly.
@@ -29,10 +28,6 @@ globalThis.Render = Render;
const HoldemState = require(path.join(SRC, "state.js"));
globalThis.HoldemState = HoldemState;
// actionBar.js after state, since its own module-level code reaches for neither but
// its methods reach for both. Loaded last so nothing it touches is still undefined.
const ActionBar = load("actionBar");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -82,10 +77,8 @@ module.exports = {
HandHistoryExport,
PotCalculator,
SplitCalculator,
SettleUp,
Betting,
HoldemState,
ActionBar,
makeTable,
chipsOnTable,
contributions,
-114
View File
@@ -1,114 +0,0 @@
// What happens when the saved table can't be read.
//
// An unreadable save can only become an empty table. What it must not do is become
// one silently and then get written over by the first edit.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState } = require("./load");
function withStored(value, { readOnly = false } = {}) {
const store = new Map();
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem(key, val) {
if (readOnly) throw new Error("QuotaExceededError");
store.set(key, val);
},
removeItem: key => store.delete(key),
};
return store;
}
test.beforeEach(() => {
HoldemState.loadFailed = false;
HoldemState.onLoadFailed = null;
});
test.after(() => {
delete globalThis.localStorage;
HoldemState.loadFailed = false;
HoldemState.onLoadFailed = null;
});
test("nothing stored is a new table, not a failure", () => {
withStored(undefined);
const state = HoldemState.load();
assert.equal(HoldemState.loadFailed, false);
assert.ok(Array.isArray(state.seats));
});
test("a good save loads and reports nothing", () => {
const store = withStored(undefined);
const original = HoldemState.createDefault();
original.handNumber = 34;
HoldemState.save(original);
const state = HoldemState.load();
assert.equal(HoldemState.loadFailed, false);
assert.equal(state.handNumber, 34);
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
});
test("a truncated save is reported rather than silently becoming an empty table", () => {
const good = JSON.stringify(HoldemState.createDefault());
withStored(good.slice(0, Math.floor(good.length * 0.7)));
const state = HoldemState.load();
assert.equal(HoldemState.loadFailed, true);
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
});
test("valid JSON of the wrong shape counts as unreadable too", () => {
withStored(JSON.stringify({ hello: "world" }));
HoldemState.load();
assert.equal(HoldemState.loadFailed, true);
});
test("the unreadable copy is kept instead of being written over", () => {
// This is the half that matters: whatever a person could have picked out of the
// raw JSON used to be gone the moment the dealer touched a seat.
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
const store = withStored(broken);
HoldemState.load();
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
});
test("a later save doesn't touch the copy that was set aside", () => {
const broken = '{"seats":[{"name":"Ann"';
const store = withStored(broken);
const state = HoldemState.load();
state.handNumber = 2;
HoldemState.save(state);
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
});
test("the listener fires once, not on every read", () => {
withStored("{not json");
let told = 0;
HoldemState.onLoadFailed = () => { told++; };
HoldemState.load();
HoldemState.load();
HoldemState.load();
assert.equal(told, 1);
});
test("no room to keep the copy is not a crash", () => {
// Storage that refuses writes is exactly the storage most likely to hand back a
// half-written value in the first place.
withStored("{not json", { readOnly: true });
let state;
assert.doesNotThrow(() => { state = HoldemState.load(); });
assert.ok(Array.isArray(state.seats));
assert.equal(HoldemState.loadFailed, true);
});
+3 -18
View File
@@ -96,26 +96,11 @@ describe("the exported CSV", () => {
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
});
// Standings rows: the money columns have to be numeric. Named rather than
// "everything after the first", which is what this used to say — the row now
// ends with a Notes column carrying whatever the dealer typed against the seat,
// and that is text by definition. The point of this test is that a column
// somebody totals adds up, so it checks the columns somebody totals.
// Standings rows: every column after the name has to be numeric.
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
const header = splitRow(lines[standingsStart]);
const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
.map(name => {
const index = header.indexOf(name);
assert.ok(index > 0, `the standings header should still have a ${name} column`);
return index;
});
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
const cells = splitRow(line);
moneyColumns.forEach(index => {
const cell = cells[index];
assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
`standings cell "${cell}" in column ${header[index]} isn't a number`);
splitRow(line).slice(1).forEach(cell => {
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
});
});
});
-100
View File
@@ -1,100 +0,0 @@
// Noticing that the same table is open twice.
//
// Two tabs each hold their own copy in memory and each save writes the whole thing,
// so the second one to act throws the first one's work away. This doesn't merge them
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
// notices, which is what lets them decide.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState } = require("./load");
function withWindow() {
const listeners = [];
globalThis.window = {
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
removeEventListener(type, fn) {
const i = listeners.indexOf(fn);
if (i >= 0) listeners.splice(i, 1);
},
};
return {
listeners,
fire(key) { listeners.forEach(fn => fn({ key })); },
};
}
test.beforeEach(() => {
HoldemState.otherTabActive = false;
HoldemState.onOtherTab = null;
HoldemState.tabWatcher = null;
});
test.after(() => {
delete globalThis.window;
HoldemState.otherTabActive = false;
HoldemState.onOtherTab = null;
HoldemState.tabWatcher = null;
});
test("a write to this table from elsewhere is noticed", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
win.fire(HoldemState.STORAGE_KEY);
assert.equal(HoldemState.otherTabActive, true);
assert.equal(told, 1);
});
test("somebody else's storage key is not our business", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
win.fire("some.other.app.state");
win.fire(HoldemState.RECOVERY_KEY);
assert.equal(HoldemState.otherTabActive, false);
assert.equal(told, 0);
});
test("told once, however many times the other tab acts", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
assert.equal(told, 1);
});
test("watching twice doesn't listen twice", () => {
const win = withWindow();
HoldemState.watchOtherTabs();
HoldemState.watchOtherTabs();
HoldemState.watchOtherTabs();
assert.equal(win.listeners.length, 1);
});
test("no listener set is not an error", () => {
const win = withWindow();
HoldemState.onOtherTab = null;
HoldemState.watchOtherTabs();
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
assert.equal(HoldemState.otherTabActive, true);
});
test("no window at all is not an error", () => {
// The same modules are loaded in Node by the test harness.
delete globalThis.window;
HoldemState.tabWatcher = null;
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
assert.equal(HoldemState.otherTabActive, false);
});
-238
View File
@@ -1,238 +0,0 @@
// Somebody who walks up and sits down after the blinds have gone past them.
//
// Dealing them straight in is a free hand in late position — which is what posting
// exists to stop, and it comes out of everybody else's night a couple of dollars at a
// time. The tool used to do it silently. It now says so, and takes the post if the
// dealer says to.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
const table = (dealerSeatIndex = 0) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
return state;
};
// Eve walks up and takes seat 5 while the table is running.
const seatEve = state => {
state.seats[4].name = "Eve";
state.seats[4].inHand = true;
state.seats[4].stack = 200;
state.seats[4].buyIns = 200;
return 4;
};
const debtors = state => {
const { bbIndex } = HoldemState.getBlindSeats(state);
return HoldemState.blindDebtors(state, bbIndex).map(i => state.seats[i].name);
};
describe("who is owed a blind", () => {
test("nobody, at a table that hasn't dealt a hand yet", () => {
// Everyone is new on the first hand. Asking the whole table to post would be
// the tool mistaking the start of the night for five people walking up late.
const state = table();
assert.deepEqual(debtors(state), []);
});
test("nobody, on an ordinary hand where the same people are still sitting there", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.deepEqual(debtors(state), []);
});
test("the player who has just sat down", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
seatEve(state);
assert.deepEqual(debtors(state), ["Eve"]);
});
test("not once they've been dealt a hand", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
seatEve(state);
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.deepEqual(debtors(state), [], "she's one of the regulars now");
});
test("not somebody who is the big blind this hand — they're paying already", () => {
const state = table(0); // blinds Ben and Cal
Betting.beginHand(state);
HoldemState.startNextHand(state); // blinds move to Cal and Dee
// Dee sits out for a hand and comes straight back into her own big blind.
state.seats[3].inHand = false;
Betting.beginHand(state);
HoldemState.startNextHand(state);
state.seats[3].inHand = true;
const { bbIndex } = HoldemState.getBlindSeats(state);
assert.ok(!HoldemState.blindDebtors(state, bbIndex).includes(bbIndex),
"the big blind is never asked to post on top of their blind");
});
test("somebody back from sitting out is owed one just like a newcomer", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
state.seats[3].inHand = false; // Dee stands up for a hand
Betting.beginHand(state);
HoldemState.startNextHand(state);
state.seats[3].inHand = true; // and sits back down
assert.ok(debtors(state).includes("Dee"), "the blinds went past her while she was up");
});
test("a seat sat out for having no chips isn't owed anything", () => {
// They aren't being dealt in, so there's no free hand to charge for.
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.seats[eve].stack = 0;
const preview = Betting.blindPreview(state);
assert.ok(!preview.debtors.includes(eve), "she's sitting out until she buys chips");
});
});
describe("posting to come in", () => {
test("the post goes in the middle and buys a hand", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
const before = Betting.stackOf(state, state.seats[eve]);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "she's posted a big blind");
assert.equal(Betting.stackOf(state, state.seats[eve]), before - state.bigBlind,
"out of her own stack, not conjured");
assert.equal(state.currentBet, state.bigBlind, "and it doesn't raise the hand");
});
test("not posting leaves her stack alone", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
const before = Betting.stackOf(state, state.seats[eve]);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[eve]), 0);
assert.equal(Betting.stackOf(state, state.seats[eve]), before);
});
test("the pot is what the blinds and the post add up to", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
Betting.beginHand(state);
const middle = state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
assert.equal(middle, state.smallBlind + state.bigBlind + state.bigBlind);
});
test("it's spent after the hand, not a standing charge", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
Betting.beginHand(state);
HoldemState.startNextHand(state);
assert.deepEqual(state.postingIn, [], "or she'd pay an extra blind every hand all night");
Betting.beginHand(state);
assert.ok(Betting.committed(state.seats[eve]) === 0 ||
state.seats[eve].id === state.seats[HoldemState.getBlindSeats(state).bbIndex]?.id,
"she pays nothing extra on the next hand");
});
test("moving the button after a post doesn't lose it", () => {
// repostBlinds takes every posted chip back and deals again. A post that
// cleared itself on the way in would be refunded and never retaken.
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
state.postingIn = [state.seats[eve].id];
Betting.beginHand(state);
const chips = () => state.seats.reduce(
(sum, p) => sum + Betting.stackOf(state, p) + Betting.handTotal(p), 0);
const before = chips();
state.dealerSeatIndex = 2;
Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[eve]), state.bigBlind, "still posted");
assert.equal(chips(), before, "and no chips made or lost in the shuffle");
});
test("a post from somebody who isn't owed one is not taken", () => {
// Only seats actually being dealt in can post, and only for their own chips.
const state = table();
state.postingIn = [state.seats[3].id];
state.seats[3].inHand = false;
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 0, "she isn't even in the hand");
});
});
describe("what the dealer is shown", () => {
test("the prompt has something to say about it, and something to press", () => {
const state = table();
Betting.beginHand(state);
HoldemState.startNextHand(state);
const eve = seatEve(state);
const preview = Betting.blindPreview(state);
assert.deepEqual(preview.debtors, [eve]);
assert.deepEqual(preview.posting, [], "not posting until the dealer says so");
state.postingIn = [state.seats[eve].id];
assert.deepEqual(Betting.blindPreview(state).posting, [state.seats[eve].id]);
});
test("an old save reads as a table nobody has just walked up to", () => {
const state = table();
Betting.beginHand(state);
const parsed = JSON.parse(JSON.stringify(state));
delete parsed.dealtLastHand;
delete parsed.postingIn;
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
globalThis.localStorage = {
getItem: key => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, value),
removeItem: key => store.delete(key),
};
const loaded = HoldemState.load();
assert.deepEqual(loaded.dealtLastHand, []);
assert.deepEqual(loaded.postingIn, []);
assert.deepEqual(debtors(loaded), [],
"reopening the tab doesn't ask the whole table to post");
delete globalThis.localStorage;
});
});
-180
View File
@@ -1,180 +0,0 @@
// What a pot-sized raise actually is.
//
// The raise pad worked it out as "the current bet plus the pot as it stands", which
// leaves out the fact that the raiser calls first and their call is in the pot before
// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
// definite number, and a dealer tapping the button that claims to be it should record
// what was actually said.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, Money, makeTable } = require("./load");
describe("pre-flop at 1/2", () => {
const dealt = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
Betting.beginHand(state);
return state;
};
test("a pot raise from the first player to act is $7", () => {
// Call the $2, which makes the pot $5, then raise $5.
const state = dealt();
assert.equal(Betting.potSoFar(state), 3, "the blinds");
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
});
test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
// same $4 on two buttons side by side. The honest half-pot is $4.50, and the
// smallest chip in the default legend is a dollar, so the button would have
// offered an amount the dealer had to round themselves every single time.
const state = dealt();
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
Betting.minRaiseTo(state), "the two quick buttons say different things");
});
test("the small blind owes less, so their pot raise is smaller", () => {
// They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
const state = dealt();
const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
});
test("the big blind owes nothing, so it is just the pot", () => {
const state = dealt();
const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
});
});
describe("after the flop", () => {
const flopWithBet = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
return state;
};
test("a pot raise facing a bet is three times the bet plus the dead money", () => {
const state = flopWithBet();
const potBefore = Betting.potSoFar(state); // $6 carried over
const bettor = state.actingSeatIndex;
Betting.raiseTo(state, bettor, 10);
const raiser = state.actingSeatIndex;
// Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
assert.equal(Betting.potRaiseTo(state, raiser), 36);
assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
});
test("half pot facing that bet is $23", () => {
const state = flopWithBet();
Betting.raiseTo(state, state.actingSeatIndex, 10);
// Call $10, pot becomes $26, raise $13 → $23.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
});
test("facing no bet at all it is a plain pot-sized bet", () => {
const state = flopWithBet();
assert.equal(state.currentBet, 0, "nobody has bet the flop");
assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
});
});
describe("it stays sane at the edges", () => {
test("a seat that doesn't exist gets zero rather than throwing", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.potRaiseTo(state, 99), 0);
});
test("the amount is always at least a call", () => {
// A raise-to below the current bet would be nonsense to offer. Every fraction
// from a tenth up lands at or above what the player owes.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
Betting.beginHand(state);
const index = state.actingSeatIndex;
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
`${fraction} of the pot should still cover the bet`);
});
});
test("cent-sized blinds don't drift", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
Betting.beginHand(state);
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
});
});
describe("the amounts are ones the table can actually build", () => {
const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
const table = (legend, smallBlind, bigBlind, stack = 200) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
state.chipLegend = legend;
Betting.beginHand(state);
return state;
};
test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
const state = table(DOLLARS, 1, 2);
// Honestly $4.50. No dollar chip makes fifty cents.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
});
test("up rather than down — a shaved pot bet is not a pot bet", () => {
const state = table(DOLLARS, 1, 2);
assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
});
test("an amount the chips already make is left exactly alone", () => {
const state = table(DOLLARS, 1, 2);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
});
test("a cent table keeps its cents", () => {
const state = table(CENTS, 0.05, 0.1);
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
});
test("a legend that plainly isn't for these stakes is not applied", () => {
// Dollar chips on a 5c/10c game means somebody never filled the legend in.
// Rounding those blinds up to whole dollars would be the tool doing real
// damage on the strength of a stale field, so the amounts stay exact and the
// wrong legend stays visible.
const state = table(DOLLARS, 0.05, 0.1);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
});
test("a table with no legend at all gets exact amounts", () => {
const state = table([], 1, 2);
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
});
test("every fraction comes out buildable, and never shaved", () => {
const state = table(DOLLARS, 1, 2);
const index = state.actingSeatIndex;
const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
const value = Betting.potRaiseTo(state, index, fraction);
const exact = state.currentBet + potAfterCall * fraction;
assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
`${fraction} of the pot should be buildable from the table's chips`);
});
});
});
-230
View File
@@ -1,230 +0,0 @@
// The house's cut.
//
// A game that rakes has chips leaving the table every hand, and the bank check used to
// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
// paid for chips they never got. The cut is the one thing here that is supposed to go
// and not come back.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
const raking = (rake, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, ...extra });
state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
return state;
};
describe("what the house takes", () => {
test("nothing, unless somebody says otherwise", () => {
const state = makeTable({ seatCount: 6 });
assert.equal(state.rake.percent, 0);
assert.equal(HoldemState.rakeOn(state, 100), 0);
});
test("a percentage of the pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
});
test("no more than the cap", () => {
const state = raking({ percent: 5, cap: 5 });
assert.equal(HoldemState.rakeOn(state, 100), 5);
assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
});
test("no flop, no drop", () => {
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
assert.equal(HoldemState.rakeOn(state, 100, true), 5);
});
test("unless the house rakes every pot", () => {
const state = raking({ percent: 5, noFlopNoDrop: false });
assert.equal(HoldemState.rakeOn(state, 100, false), 5);
});
test("nothing it can't make out of the table's chips, rounded the players' way", () => {
// 5% of $30 is $1.50, and this table's smallest chip is a dollar.
const state = raking({ percent: 5 });
assert.equal(HoldemState.rakeOn(state, 30), 1);
// 5% of $10 is 50c — under a chip, so the house takes nothing at all.
assert.equal(HoldemState.rakeOn(state, 10), 0);
});
test("never more than the pot, whatever gets typed in", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
});
test("nothing out of an empty pot", () => {
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
});
});
describe("taking it out of the pot", () => {
const winner = amount => [{ id: "a", label: "Ann", amount }];
test("one winner just gets the pot less the cut", () => {
const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
assert.equal(rake, 5);
assert.deepEqual(payouts.map(p => p.amount), [95]);
});
test("a split pot shares what's left, in proportion", () => {
const { payouts } = HoldemState.applyRake(
[{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
});
test("the rounding leftovers go back to the players, not to the house", () => {
// $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
// it has to land on somebody.
const { payouts, rake } = HoldemState.applyRake([
{ id: "a", label: "Ann", amount: 34 },
{ id: "b", label: "Ben", amount: 33 },
{ id: "c", label: "Cal", amount: 33 },
], 7);
assert.equal(rake, 7);
assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
});
test("what goes out plus what the house keeps is what was in the middle", () => {
[[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
const result = HoldemState.applyRake(winner(pot), rake);
assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
});
});
test("asking for more than the pot takes the pot, not more", () => {
const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
assert.equal(rake, 20);
assert.deepEqual(payouts.map(p => p.amount), [0]);
});
test("no rake, no change — the same payouts come back", () => {
const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
assert.equal(rake, 0);
assert.deepEqual(payouts.map(p => p.amount), [80]);
});
});
describe("what the box is checked against", () => {
test("raked chips are gone but not missing", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
// The house takes $10 out of the middle: somebody's stack is $10 lighter.
state.seats[0].stack = 190;
state.rakeTaken = 10;
const after = HoldemState.bankReconciliation(state);
assert.equal(after.raked, 10);
assert.equal(after.balanced, true, "the check still balances");
});
test("and are told apart from the ones that went astray", () => {
const state = raking({ percent: 5 });
state.seats.forEach(p => { p.buyIns = 200; });
state.seats[0].stack = 180;
state.rakeTaken = 10;
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 10);
assert.equal(check.difference, -10, "ten dollars really are missing");
assert.equal(check.balanced, false);
});
test("a table that has never raked reads exactly as it did before", () => {
const state = makeTable({ seatCount: 6 });
state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
const check = HoldemState.bankReconciliation(state);
assert.equal(check.raked, 0);
assert.equal(check.balanced, true);
});
});
describe("the record of it", () => {
test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
const [entry] = state.handHistory;
assert.equal(entry.pot, 100, "the pot in the middle");
assert.equal(entry.rake, 5);
assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
});
test("a hand with no rake records a zero rather than nothing", () => {
const state = makeTable({ seatCount: 6 });
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
assert.equal(state.handHistory[0].rake, 0);
assert.equal(state.handHistory[0].pot, 40);
});
test("a table saved before the house took anything takes nothing", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.rake;
delete parsed.rakeTaken;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.noFlopNoDrop, true);
assert.equal(migrated.rakeTaken, 0);
});
test("nonsense in a saved rake is cleaned up rather than trusted", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
parsed.rakeTaken = -20;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.rake.percent, 0);
assert.equal(migrated.rake.cap, null);
assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
assert.equal(migrated.rakeTaken, 0);
});
});
describe("the file the dealer hands over", () => {
const { HandHistoryExport } = require("./load");
test("has a column for what the house took", () => {
const state = raking({ percent: 5 });
HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
// Oldest hand first, and money as bare numbers so a spreadsheet can total it.
const csv = HandHistoryExport.toCsv(state.handHistory);
const [header, raked, unraked] = csv.split("\n");
assert.match(header, /Pot,House take,Payouts/);
assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
});
});
describe("a raked hand, end to end", () => {
test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
// 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
// At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
// nothing — which is the rule working, not a hand worth testing the bank on.
const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
// Everyone calls the big blind, then it is checked down.
state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
Betting.sweepToHandTotals(state);
const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
const rake = HoldemState.rakeOn(state, pot, true);
assert.equal(pot, 60);
assert.equal(rake, 3);
const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
state.rakeTaken = rake;
HoldemState.recordHand(state, payouts, rake);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
+36 -53
View File
@@ -68,74 +68,57 @@ async function main() {
}
const browser = await chromium.launch();
const page = await browser.newPage();
const failures = [];
const warnings = [];
let checks = 0;
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
// down where `hover: hover` says there's a pointer, and a headless desktop browser
// is a pointer — so a single pass here has never once measured a touch target,
// however many viewports it visited. The phone-sized ones are exactly the pass that
// needs it.
for (const touch of [false, true]) {
const context = await browser.newContext(touch
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
: {});
const page = await context.newPage();
const hand = touch ? "touch" : "mouse";
for (const [width, height, label] of VIEWPORTS) {
await page.setViewportSize({ width, height });
for (const [width, height, rawLabel] of VIEWPORTS) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.click("#nextHandBtn");
await page.waitForTimeout(300);
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
await page.addScriptTag({ path: AUDIT });
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
const table = await page.evaluate(() => LayoutAudit.runAll());
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
await context.close();
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
}
await browser.close();
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
-95
View File
@@ -1,95 +0,0 @@
// What happens when the browser refuses to save.
//
// The table keeps working — that was always true and is still the right behaviour.
// What was missing is anybody being told, which is the same shape of fault the bank
// check exists for: silent until it's too late to act on.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState, makeTable } = require("./load");
function withStorage(behaviour) {
const calls = [];
globalThis.localStorage = {
setItem(key, value) {
calls.push({ key, value });
if (behaviour === "throw") {
const error = new Error("QuotaExceededError");
error.name = "QuotaExceededError";
throw error;
}
},
getItem: () => null,
removeItem() {},
};
return calls;
}
test.beforeEach(() => {
HoldemState.saveFailed = false;
HoldemState.onSaveFailed = null;
});
test.after(() => {
delete globalThis.localStorage;
HoldemState.saveFailed = false;
HoldemState.onSaveFailed = null;
});
test("a save that works says so and leaves no mark", () => {
withStorage("ok");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(HoldemState.save(state), true);
assert.equal(HoldemState.saveFailed, false);
});
test("a save that fails says so rather than throwing", () => {
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(HoldemState.save(state), false);
assert.equal(HoldemState.saveFailed, true);
});
test("the table is told once, not on every action", () => {
// A dealer taps the bar thirty times a hand and doesn't need thirty warnings.
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
let told = 0;
HoldemState.onSaveFailed = () => { told++; };
for (let i = 0; i < 20; i++) HoldemState.save(state);
assert.equal(told, 1);
});
test("the failure is handed the error, so the reason can be reported later", () => {
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
let seen = null;
HoldemState.onSaveFailed = error => { seen = error; };
HoldemState.save(state);
assert.equal(seen && seen.name, "QuotaExceededError");
});
test("a failing save still doesn't stop the table", () => {
// The chips have already moved in memory; refusing to continue would be worse
// than not persisting.
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].stack = 175;
assert.doesNotThrow(() => HoldemState.save(state));
assert.equal(state.seats[0].stack, 175);
});
test("no listener is not an error", () => {
withStorage("throw");
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
HoldemState.onSaveFailed = null;
assert.doesNotThrow(() => HoldemState.save(state));
assert.equal(HoldemState.saveFailed, true);
});
-241
View File
@@ -1,241 +0,0 @@
// Where the seats sit on the rim.
//
// The ring used to space seats by angle, which is only the same as spacing them evenly
// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
// seat's centre belonged to its neighbour and tapping a player got the wrong one.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// The seats as a dealer sees them: pixels on a felt of a given size.
const ringPixels = (count, width, height, rx = 44, ry = 42) => {
const aspect = width / height;
return Array.from({ length: count }, (_, i) => {
const pos = Render.positionOnRing(i, count, rx, ry, aspect);
return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
});
};
const gaps = (points) => points.map((p, i) => {
const next = points[(i + 1) % points.length];
return Math.hypot(next.x - p.x, next.y - p.y);
});
// What the ring did before: one turn cut into equal angles.
const byAngle = (index, count, rx, ry) => {
const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
};
describe("a round table", () => {
test("still spaces seats by angle, because on a circle that is the same thing", () => {
for (let seat = 0; seat < 9; seat++) {
const now = Render.positionOnRing(seat, 9, 40, 40, 1);
const before = byAngle(seat, 9, 40, 40);
assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
}
});
test("seat one is at the top, whatever shape the felt is", () => {
[[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
const first = Render.positionOnRing(0, 10, 44, 42, w / h);
assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
assert.ok(first.top < 50, `${w}x${h} is above the middle`);
});
});
});
describe("a felt wider than it is tall", () => {
// 640x360 landscape: the size the audit was failing at.
const wide = () => ringPixels(10, 601, 126);
const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
const pos = byAngle(i, 10, 44, 42);
return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
});
// The two seats at the right-hand end, which is where they crowd.
const endPairApart = (seats) => {
const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
return Math.abs(rightmost[0].y - rightmost[1].y);
};
test("pulls the pair at each end apart", () => {
// They still share the end — ten seats on a rim this flat have nowhere else to
// be — but they stop sitting on top of each other.
const now = endPairApart(wide());
const before = endPairApart(byAnglePixels());
assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
});
test("nearly doubles the room between the closest two neighbours", () => {
// Not equal spacing in a straight line — at the ends the rim turns back on
// itself, so the same walk along it covers less ground across. Even there the
// gap goes from a third of a puck to most of one.
const now = Math.min(...gaps(wide()));
const before = Math.min(...gaps(byAnglePixels()));
assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
assert.ok(now > 55, `${now.toFixed(1)}px`);
});
test("never sits two seats on the same spot", () => {
const seats = wide();
seats.forEach((seat, i) => {
seats.slice(i + 1).forEach((other, j) => {
const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
});
});
});
});
describe("how close the closest two seats come", () => {
// What decides whether the pucks shrink. Rim length per seat was the first answer
// and it is the wrong question: it measures the walk between two seats, not the
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
// apart end up stacked one above the other.
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
test("a flat table no longer stacks two seats at each end", () => {
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
// is a stadium at this shape, so an end holds one seat.
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
});
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
// Same felt, forced through the ellipse the tool uses for rounder tables.
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
}));
const closest = asEllipse.reduce((least, point, i) => {
const next = asEllipse[(i + 1) % asEllipse.length];
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
assert.ok(gap(6, 616, 96) > closest * 1.3,
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
});
test("a round table's neighbours are about as far apart as the rim says", () => {
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
});
test("more seats, less room, in that order", () => {
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
});
test("nothing to measure between fewer than two seats", () => {
assert.equal(gap(1, 400, 400), Infinity);
assert.equal(gap(9, 0, 0), Infinity);
});
});
describe("which rim a table gets", () => {
// An ellipse until the table is much wider than tall, then a stadium: two rails
// and a half-round cap at each end, which is how a real poker table is built.
const shape = (width, height) => {
const aspect = width / height;
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
};
test("every layout the tool had keeps the ellipse", () => {
assert.equal(shape(844, 608), "ellipse", "desktop");
assert.equal(shape(600, 600), "ellipse", "tablet");
assert.equal(shape(278, 314), "ellipse", "phone upright");
assert.equal(shape(336, 240), "ellipse", "short phone upright");
});
test("a phone on its side gets the stadium", () => {
assert.equal(shape(528, 73), "stadium");
assert.equal(shape(780, 113), "stadium");
});
test("the cap holds one seat instead of stacking two", () => {
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
// the cap, or two of them end up on the same half-circle.
const halfWidth = 44 * (616 / 96), halfHeight = 42;
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
});
test("seat one is still at the top centre, and the ring still mirrors", () => {
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
assert.ok(seats[0].top < 50);
for (let seat = 1; seat < 4; seat++) {
const mirrored = seats[8 - seat];
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
}
});
test("nobody is drawn off the felt", () => {
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
});
});
});
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);
for (let seat = 1; seat < 5; seat++) {
const mirrored = seats[10 - seat];
assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
}
});
test("holds for the smallest and largest tables the seat count allows", () => {
[2, 3, 4, 9, 12, 24].forEach(count => {
const seats = ringPixels(count, 601, 126);
assert.equal(seats.length, count);
seats.forEach((seat, i) => {
assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
});
assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
});
});
test("shares the rim out equally on a round table at any size", () => {
[3, 6, 9, 10, 24].forEach(count => {
const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
const shortest = Math.min(...spacing);
const longest = Math.max(...spacing);
assert.ok(longest - shortest < 0.5,
`${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
});
});
test("asked for no seats, offers no positions", () => {
assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
});
test("puts each bet between its own player and the pot", () => {
// Where a dealer pushes the chips: on that player's line to the middle, short
// of the seat.
const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
[[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
for (let seat = 0; seat < 9; seat++) {
const player = Render.seatPosition(seat, 9, w / h);
const bet = Render.betPosition(seat, 9, w / h);
const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
}
});
});
});
-181
View File
@@ -1,181 +0,0 @@
// Sitting people down, which is the first thing anybody does with the tool.
//
// Naming a chair and dealing that chair in were two separate acts and the second was
// easy to miss. A dealer setting up typed six names, got six seats that still weren't
// in the hand, and the action bar hid itself because by its reckoning nobody was
// playing: six people at the table and a blank screen with no way forward.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
const emptyTable = (seatCount = 10) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, seatCount);
return state;
};
const bar = state => Object.assign(Object.create(ActionBar), { state });
describe("the bar knows the difference between an empty table and a named one", () => {
test("a table nobody has touched gets no bar", () => {
assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
});
test("a table with names on it gets one, dealt in or not", () => {
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats[1].name = "Ben";
assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
});
test("whitespace isn't a player", () => {
const state = emptyTable();
state.seats[0].name = " ";
assert.equal(bar(state).isRunning(), false);
});
test("a table that is playing still gets one", () => {
assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
});
});
describe("naming an empty chair sits somebody down", () => {
// What the seat editor's name field does, as the rule it applies rather than
// through the DOM: a chair with no name and nobody in it is empty, and putting a
// name on it is somebody arriving.
const nameSeat = (player, name) => {
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = name;
if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
};
test("an empty chair gets dealt in", () => {
const state = emptyTable();
nameSeat(state.seats[0], "Ann");
assert.equal(state.seats[0].inHand, true);
assert.equal(state.seats[0].folded, false);
});
test("six names is six players, not six benched seats", () => {
const state = emptyTable();
state.defaultStack = 200; // a table that has told the tool what a buy-in is
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
assert.equal(state.seats.filter(p => p.inHand).length, 6);
assert.equal(bar(state).isRunning(), true);
assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
});
test("renaming somebody who is sitting out doesn't deal them back in", () => {
// A correction, not an arrival. Dealing them in over a typo would be the tool
// overruling a dealer who sat them out on purpose.
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats[0].inHand = false;
nameSeat(state.seats[0], "Anne");
assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
});
test("clearing a name doesn't sit anybody down", () => {
const state = emptyTable();
nameSeat(state.seats[0], "Ann");
nameSeat(state.seats[0], "");
assert.equal(state.seats[0].name, "");
});
test("a chair already in the hand is left alone", () => {
const state = makeTable({ seatCount: 6, seated: [0] });
state.seats[0].folded = true;
nameSeat(state.seats[0], "Ann");
assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
});
});
describe("the way back from a table where everybody is sat out", () => {
test("there are named seats to offer", () => {
// Can't be reached by naming any more, but a table where everyone was sat out
// — or a save from before naming seated people — still lands here.
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
const benched = state.seats
.map((player, index) => ({ player, index }))
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
.map(({ index }) => index);
assert.deepEqual(benched, [0, 1, 2]);
assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
});
test("dealing them all in makes a hand possible", () => {
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
state.seats.forEach(player => {
if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
});
assert.equal(Betting.liveSeats(state).length, 3);
assert.ok(Betting.blindPreview(state), "the table can be dealt");
});
test("an unnamed chair is never swept in by it", () => {
const state = emptyTable();
state.seats[0].name = "Ann";
state.seats.forEach(player => {
if ((player.name || "").trim()) { player.inHand = true; }
});
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
});
});
describe("a table where nobody has bought in", () => {
// Different from a table that has busted out, and the difference is the whole
// point: one needs a starting stack named, the other needs a rebuy. Telling a
// dealer their brand-new players have "no chips left" sends them hunting for the
// wrong thing.
const neverBoughtIn = state => state.seats.every(p =>
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
test("six freshly named players have never bought in", () => {
const state = emptyTable();
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
});
assert.equal(neverBoughtIn(state), true);
assert.equal(Betting.blindPreview(state), null, "and there is no hand to deal");
});
test("a table that has busted out is not the same thing", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats.forEach(p => { p.buyIns = 200; p.stack = 0; });
assert.equal(neverBoughtIn(state), false, "they had chips — they lost them");
});
test("one player still holding chips is not a never-bought-in table either", () => {
const state = emptyTable();
["Ann", "Ben"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
state.seats[0].stack = 50;
assert.equal(neverBoughtIn(state), false);
});
test("naming a starting stack is enough to deal", () => {
// The one field the message now points at. A seat with no stack of its own
// falls back to the table default, so setting it seats everybody's chips at
// once — which is why it's the right thing to send the dealer to.
const state = emptyTable();
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
assert.equal(Betting.blindPreview(state), null);
state.defaultStack = 200;
assert.ok(Betting.blindPreview(state), "one number and the table can play");
});
});
-53
View File
@@ -1,53 +0,0 @@
// Which fields replace their contents when a dealer reaches for them.
const test = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// Enough of an input to answer the question, without a DOM.
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
test("a number box selects", () => {
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
});
test("a decimal keypad box selects", () => {
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
// a number pad without the browser's spinner arrows.
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
});
test("a name does not", () => {
// Selecting all of a name on focus is the same mistake pointing the other way:
// a name is a thing you edit, not a suggestion you replace.
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
});
test("a colour swatch does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
});
test("a checkbox does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
});
test("a textarea does not, whatever it is asked", () => {
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
});
test("a select does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
});
test("case doesn't matter", () => {
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
});
test("nothing at all is not a field", () => {
assert.equal(Money.selectsOnFocus(null), false);
assert.equal(Money.selectsOnFocus(undefined), false);
});
test("a field with no type stated is a text box, so it does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
});
-124
View File
@@ -1,124 +0,0 @@
// Turning the standings into "who hands cash to whom".
const test = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
const paidTo = (plan, name) =>
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
const paidBy = (plan, name) =>
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
test("one loser, one winner", () => {
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
assert.equal(plan.balanced, true);
});
test("everybody flat means nothing to do", () => {
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
assert.deepEqual(plan.payments, []);
assert.equal(plan.balanced, true);
});
test("nobody at all means nothing to do", () => {
const plan = SettleUp.plan([]);
assert.deepEqual(plan.payments, []);
assert.equal(plan.balanced, true);
});
test("each player ends up square", () => {
const table = standings([
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
]);
const plan = SettleUp.plan(table);
table.forEach(row => {
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
});
assert.equal(plan.balanced, true);
});
test("at most one payment fewer than there are people", () => {
const table = standings([
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
]);
const plan = SettleUp.plan(table);
assert.ok(plan.payments.length <= table.length - 1,
`${plan.payments.length} payments for ${table.length} players`);
});
test("a player who owes two people has both lines together", () => {
// The list gets read out one player at a time, so a payer's obligations must not
// be split up by somebody else's.
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
});
test("cents survive", () => {
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
assert.equal(plan.balanced, true);
});
test("more chips than cash leaves somebody unpaid, and says so", () => {
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
// cannot all be paid, and pretending otherwise is how a real person ends up short.
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
assert.equal(plan.shortBy, 25);
assert.equal(plan.overBy, 0);
assert.equal(plan.balanced, false);
});
test("less chips than cash leaves a payer with nowhere to send it", () => {
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
assert.equal(plan.overBy, 25);
assert.equal(plan.shortBy, 0);
assert.equal(plan.balanced, false);
});
test("no payment is ever for nothing", () => {
const plan = SettleUp.plan(standings([
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
]));
assert.ok(plan.payments.every(p => p.amount > 0));
assert.equal(plan.payments.length, 1);
});
test("nobody pays themselves", () => {
const plan = SettleUp.plan(standings([
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
]));
assert.ok(plan.payments.every(p => p.from !== p.to));
});
test("the payments total what the winners are owed", () => {
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
const plan = SettleUp.plan(table);
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
assert.equal(Money.round(moved), 60);
});
test("it settles a real table's standings, not just hand-made numbers", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].buyIns = 100;
state.seats[1].buyIns = 100;
state.seats[2].buyIns = 100;
state.seats[0].stack = 175;
state.seats[1].stack = 90;
state.seats[2].stack = 35;
const rows = HoldemState.sessionStandings(state);
const plan = SettleUp.plan(rows);
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
assert.equal(plan.balanced, true);
rows.forEach(row => {
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
});
});
-51
View File
@@ -1,51 +0,0 @@
// The legend and the key map must not drift apart.
//
// F, C and R are printed on the buttons they drive. A and U were on nothing at all,
// so a dealer who learned the first three from the action bar had no way to find out
// the other two existed. The guard against that happening again is this file.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const Shortcuts = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
test("every shortcut has a line in the legend", () => {
const documented = new Set(Shortcuts.LEGEND.map(entry => entry.action));
const missing = [...new Set(Object.values(Shortcuts.KEYS))].filter(action => !documented.has(action));
assert.deepEqual(missing, [], "a shortcut with no legend line is one nobody can find");
});
test("the legend doesn't describe shortcuts that don't exist", () => {
const real = new Set(Object.values(Shortcuts.KEYS));
const invented = Shortcuts.LEGEND.map(entry => entry.action).filter(action => !real.has(action));
assert.deepEqual(invented, []);
});
test("every legend line names its keys and says what they do", () => {
Shortcuts.LEGEND.forEach(entry => {
assert.ok(Array.isArray(entry.keys) && entry.keys.length > 0, `${entry.action} has no keys`);
assert.ok(entry.keys.every(key => typeof key === "string" && key.trim()), `${entry.action} has a blank key`);
assert.ok(entry.what && entry.what.trim().length > 3, `${entry.action} has no description`);
});
});
test("the keys named match the keys bound", () => {
// "Space" and "Enter" both drive advance; the map holds them as " " and "enter".
const spoken = { space: " ", enter: "enter", esc: "escape" };
Shortcuts.LEGEND.forEach(entry => {
entry.keys.forEach(label => {
const key = spoken[label.toLowerCase()] || label.toLowerCase();
assert.equal(Shortcuts.KEYS[key], entry.action,
`the legend says ${label} is ${entry.action}, the map disagrees`);
});
});
});
test("no two lines claim the same key", () => {
const seen = new Set();
Shortcuts.LEGEND.forEach(entry => entry.keys.forEach(key => {
assert.ok(!seen.has(key.toLowerCase()), `${key} is listed twice`);
seen.add(key.toLowerCase());
}));
});
-110
View File
@@ -1,110 +0,0 @@
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
//
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
// rather than a whole DOM: the only things it asks about are what has focus and
// whether an overlay is open.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const Shortcuts = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
// A stand-in for a focused element. `matches` answers the same selector list the
// real one would, which is the whole of what focusedControlOwnsKey looks at.
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
return {
tagName,
isContentEditable,
matches: selector => selectors.some(s => selector.includes(s)),
};
}
const BUTTON = element("BUTTON", { selectors: ["button"] });
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
const LINK = element("A", { selectors: ["a[href]"] });
const DIV = element("DIV");
const TEXT_FIELD = element("INPUT");
// Runs one keypress against a fresh Shortcuts and reports which action fired.
function press(key, { focused = DIV, overlays = [] } = {}) {
const fired = [];
let defaultPrevented = false;
globalThis.document = {
activeElement: focused,
addEventListener() {},
querySelectorAll: () => overlays,
};
const handlers = {};
for (const action of Object.values(Shortcuts.KEYS)) {
handlers[action] = () => { fired.push(action); return true; };
}
Shortcuts.handlers = handlers;
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
return { fired: fired[0] || null, defaultPrevented };
}
test.after(() => { delete globalThis.document; });
test("Enter on a focused button is left to the button", () => {
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
});
test("Space on a focused button is left to the button", () => {
assert.equal(press(" ", { focused: BUTTON }).fired, null);
});
test("a key left to the control is not swallowed either", () => {
// preventDefault would stop the browser activating the button, which is the
// half of the bug that turned "wrong action" into "no action".
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
});
test("summary and links own Enter and Space too", () => {
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
assert.equal(press(" ", { focused: LINK }).fired, null);
});
test("Space still advances when nothing pressable has focus", () => {
assert.equal(press(" ", { focused: DIV }).fired, "advance");
});
test("Enter still advances when nothing pressable has focus", () => {
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
});
test("the letter shortcuts stay live with a button focused", () => {
// A button does nothing with "f", so there is no press to hand back — and a
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
});
test("typing still silences everything but Escape", () => {
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
});
test("an open overlay still owns the keyboard", () => {
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
});
test("a focused control wins before the overlay check, not after", () => {
// Both would decline here; the point is that neither path fires an action.
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
});
test("modified keys are never shortcuts", () => {
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
const fired = [];
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
assert.deepEqual(fired, []);
});
-202
View File
@@ -1,202 +0,0 @@
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at the
// table volunteers for — everyone would rather see a hand than show one. The tool knew
// who last raised and who sits left of the button and said neither.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
const NAMES = ["Ann", "Ben", "Cal", "Dee"];
const table = (dealerSeatIndex = 0, stack = 200) => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, dealerSeatIndex });
NAMES.forEach((name, i) => { state.seats[i].name = name; });
Betting.beginHand(state);
return state;
};
const call = state => {
const answer = Betting.showdownOrder(state);
return answer && { who: state.seats[answer.index].name, reason: answer.reason };
};
// Everybody left just calls whatever is in front of them, then the street closes.
const checkRound = state => {
let guard = 0;
while (state.actingSeatIndex !== null && guard++ < 12) {
const index = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[index]) > 0) Betting.call(state, index);
else Betting.check(state, index);
}
Betting.closeStreet(state);
};
describe("a bet on the river", () => {
test("the last player to raise shows first", () => {
const state = table(0); // blinds Ben and Cal
checkRound(state); // pre-flop
checkRound(state); // flop
checkRound(state); // turn
Betting.raiseTo(state, state.actingSeatIndex, 20);
const raiser = state.seats.findIndex(p => Betting.committed(p) === 20);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
assert.deepEqual(call(state), { who: state.seats[raiser].name, reason: "aggressor" });
});
test("a re-raise moves it to the last one, not the first", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
const first = state.actingSeatIndex;
Betting.raiseTo(state, first, 20);
const second = state.actingSeatIndex;
Betting.raiseTo(state, second, 60);
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
assert.equal(call(state).who, state.seats[second].name);
});
test("a short all-in still shows first, even though it doesn't reopen the betting", () => {
// The rule about reopening is about who may raise. It says nothing about who
// shows, and the player who shoved is still the last one who put money in.
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
const bettor = state.actingSeatIndex;
Betting.raiseTo(state, bettor, 40);
const shortStack = state.actingSeatIndex;
// Over the $40 but only by $15, where a full raise would need $40 more. Going
// *under* the bet would be a short call, not a short raise, and wouldn't make
// them the aggressor at all.
state.seats[shortStack].stack = 55;
Betting.allIn(state, shortStack);
assert.equal(Betting.committed(state.seats[shortStack]), 55, "raised, if not by enough");
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
assert.equal(call(state).who, state.seats[shortStack].name);
});
});
describe("a river checked through", () => {
test("the first live player left of the button shows first", () => {
const state = table(0); // button Ann
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
assert.deepEqual(call(state), { who: "Ben", reason: "leftOfButton" });
});
test("an earlier street's raiser doesn't carry over to the river", () => {
// The rule is about the *final* round. Leading the flop and then checking the
// river doesn't oblige anyone.
const state = table(0);
checkRound(state); // pre-flop
Betting.raiseTo(state, state.actingSeatIndex, 20); // leads the flop
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
checkRound(state); // turn
checkRound(state); // river
assert.equal(call(state).reason, "leftOfButton");
});
test("it skips a folded seat rather than naming somebody who isn't in the hand", () => {
const state = table(0); // button Ann, so Ben would be first
checkRound(state); checkRound(state); checkRound(state);
Betting.fold(state, 1); // Ben gets out on the river
while (state.actingSeatIndex !== null) {
const i = state.actingSeatIndex;
if (Betting.toCall(state, state.seats[i]) > 0) Betting.call(state, i); else Betting.check(state, i);
}
Betting.closeStreet(state);
assert.equal(call(state).who, "Cal", "the next one round who is still in");
});
});
describe("everyone all-in before the river", () => {
test("the last player who could put money in shows first", () => {
// There was no final round to lead, so "checked round" isn't a choice anybody
// made and the seat left of the button hasn't earned the obligation.
const state = table(0, 30);
checkRound(state); // pre-flop
const shover = state.actingSeatIndex;
Betting.allIn(state, shover);
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
Betting.closeStreet(state);
assert.equal(Betting.canStillBet(state), false, "nobody has chips left to bet");
assert.deepEqual(call(state), { who: state.seats[shover].name, reason: "allInAggressor" });
});
test("a hand where everyone got in for the blind and nobody raised falls to the button", () => {
// Two-dollar stacks against a two-dollar blind: everyone is all-in by calling,
// and calling is not aggression. Nobody chose to lead, so nobody is on the hook
// and it goes back to the seat left of the button.
const state = table(0, 2);
checkRound(state);
assert.equal(Betting.canStillBet(state), false, "nobody has a chip behind");
assert.equal(state.handAggressorIndex, null, "and nobody ever raised");
const answer = call(state);
assert.ok(answer, "there is still a showdown to order");
assert.equal(answer.reason, "leftOfButton");
});
});
describe("when there is nothing to call", () => {
test("one player left isn't a showdown", () => {
const state = table(0);
[1, 2, 3].forEach(i => Betting.fold(state, i));
assert.equal(Betting.showdownOrder(state), null);
});
test("an aggressor who has since folded doesn't get named", () => {
// Can't happen in play — you don't fold after raising unopposed — but the
// index is a number on the state and a reset table could hold a stale one.
const state = table(0);
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
state.streetAggressorIndex = 1;
state.seats[1].folded = true;
const answer = call(state);
assert.notEqual(answer.who, "Ben");
assert.equal(answer.reason, "leftOfButton");
});
test("an index pointing at no seat at all is ignored", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state); checkRound(state);
state.streetAggressorIndex = 99;
assert.equal(call(state).reason, "leftOfButton");
});
});
describe("the tracking resets when it should", () => {
test("a new hand starts with nobody on the hook", () => {
const state = table(0);
checkRound(state); checkRound(state); checkRound(state);
Betting.raiseTo(state, state.actingSeatIndex, 20);
assert.notEqual(state.handAggressorIndex, null);
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.streetAggressorIndex, null);
assert.equal(state.handAggressorIndex, null, "last night's raise isn't this hand's business");
});
test("posting a blind is not aggression", () => {
const state = table(0);
assert.equal(state.streetAggressorIndex, null);
assert.equal(state.handAggressorIndex, null);
});
});
-188
View File
@@ -1,188 +0,0 @@
// The straddle: a blind raise put out before the cards by the player left of the big
// blind, for twice it.
//
// Home games run them constantly, and it is three rules at once — the amount, who is
// first to act, and what the minimum raise becomes. Faking one by dealing and having
// the straddler "raise" gets the last of those right and the other two wrong.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
// straddle seat 3.
const table = (straddles = 0, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
state.straddleCount = straddles;
return state;
};
const dealt = (straddles = 0, extra = {}) => {
const state = table(straddles, extra);
Betting.beginHand(state);
return state;
};
describe("what goes in the middle", () => {
test("the seat left of the big blind puts out twice it", () => {
const state = dealt(1);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
});
test("a re-straddle doubles again, one seat further round", () => {
const state = dealt(2);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
});
test("none of it comes from anywhere but the straddler's stack", () => {
const before = chipsOnTable(table(2));
assert.equal(chipsOnTable(dealt(2)), before);
});
test("the felt is told who straddled, not left to work it out", () => {
assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
assert.deepEqual(dealt(0).postedStraddles, []);
});
});
describe("who is on the clock", () => {
test("action starts left of the straddler, not left of the big blind", () => {
assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
assert.equal(dealt(1).actingSeatIndex, 4);
assert.equal(dealt(2).actingSeatIndex, 5);
});
test("the straddler still has the option when it comes back to them", () => {
const state = dealt(1);
// Everyone calls the straddle round to the big blind.
[4, 5, 0, 1, 2].forEach(seat => {
assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
Betting.call(state, seat);
});
assert.equal(state.actingSeatIndex, 3, "back to the straddler");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
});
test("the straddler checking closes it", () => {
const state = dealt(1);
[4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
Betting.check(state, 3);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("the big blind acts in turn, not last", () => {
const state = dealt(1);
Betting.call(state, 4);
Betting.call(state, 5);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
});
});
describe("what it costs to raise", () => {
test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
// $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
// instead it comes out at $6, and every player at the table would argue.
assert.equal(Betting.minRaiseTo(dealt(1)), 8);
assert.equal(Betting.minRaiseTo(dealt(2)), 16);
assert.equal(Betting.minRaiseTo(dealt(0)), 4);
});
test("what the table owes is measured off the straddle", () => {
const state = dealt(1);
assert.equal(state.currentBet, 4);
assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
});
test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
const state = dealt(1);
Betting.raiseTo(state, 4, 12);
assert.equal(state.currentBet, 12);
assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
});
});
describe("how many a table can take", () => {
test("everyone but the two blinds", () => {
assert.equal(Betting.maxStraddles(table(0)), 4);
assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
});
test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
const heads = table(1, { seatCount: 6, seated: [0, 1] });
assert.equal(Betting.maxStraddles(heads), 0);
assert.deepEqual(Betting.straddleSeats(heads), []);
Betting.beginHand(heads);
assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
});
test("asking for more than the table has stops at the last seat", () => {
const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
state.straddleCount = 99;
assert.equal(Betting.straddleSeats(state).length, 2);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
});
test("a seat with no chips is never handed one", () => {
const state = table(1);
state.seats[3].stack = 0;
// Seat 3 is sat out for the deal, so the straddle passes to seat 4.
assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
});
});
describe("what it doesn't outlive", () => {
test("the hand it was called for", () => {
const state = dealt(1);
HoldemState.startNextHand(state);
assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
assert.deepEqual(state.postedStraddles, []);
});
test("but it survives the button being pushed around before the deal", () => {
// repostBlinds takes every posted chip back and deals again; a straddle that
// vanished there would have been paid once and never returned.
const state = dealt(1);
const before = chipsOnTable(state);
Betting.repostBlinds(state);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
assert.equal(chipsOnTable(state), before);
});
test("a table saved before straddles existed reads as having none", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.straddleCount;
delete parsed.postedStraddles;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.straddleCount, 0);
assert.deepEqual(migrated.postedStraddles, []);
});
});
describe("what the dealer is shown before dealing", () => {
test("the straddles that are coming, and how many more it could take", () => {
const preview = Betting.blindPreview(table(1));
assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
assert.equal(preview.maxStraddles, 4);
});
test("and the preview matches what dealing actually does", () => {
const state = table(2);
const preview = Betting.blindPreview(state);
Betting.beginHand(state);
assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
preview.straddles.forEach(({ index, amount }) => {
assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
});
});
});
-95
View File
@@ -1,95 +0,0 @@
// Two players changing chairs.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("the whole seat moves", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].name = "Ann";
state.seats[0].cards = ["AS", "KD"];
state.seats[0].stack = 175;
state.seats[4].name = "";
HoldemState.swapSeats(state, 0, 4);
assert.equal(state.seats[4].name, "Ann");
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
assert.equal(state.seats[4].stack, 175);
assert.equal(state.seats[0].name, "");
});
test("what they have in the pot goes with them", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
const posted = state.seats.map(p => p.committed);
const before = chipsOnTable(state);
HoldemState.swapSeats(state, 1, 4);
assert.equal(state.seats[4].committed, posted[1]);
assert.equal(state.seats[1].committed, posted[4]);
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
});
test("the button stays on the chair, not on the player", () => {
// A card room moves the button around the table, not around the people. Somebody
// who sits down in the big blind's chair is the big blind.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
state.seats[0].name = "Ann";
HoldemState.swapSeats(state, 0, 2);
assert.equal(state.dealerSeatIndex, 0);
assert.notEqual(state.seats[0].name, "Ann");
});
test("the blinds follow the chairs too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const before = HoldemState.getBlindSeats(state);
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
const after = HoldemState.getBlindSeats(state);
assert.equal(after.sbIndex, before.sbIndex);
assert.equal(after.bbIndex, before.bbIndex);
});
test("swapping a seat with itself does nothing and says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
assert.equal(JSON.stringify(state), before);
});
test("a chair that isn't there is refused rather than swallowed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
assert.equal(JSON.stringify(state), before);
});
test("swapping twice puts everything back", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
const before = JSON.stringify(state);
HoldemState.swapSeats(state, 1, 3);
HoldemState.swapSeats(state, 1, 3);
assert.equal(JSON.stringify(state), before);
});
test("moving into an empty chair leaves an empty chair behind", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[1].name = "Ben";
HoldemState.swapSeats(state, 1, 5);
assert.equal(state.seats[5].name, "Ben");
assert.equal(state.seats[5].inHand, true);
assert.equal(state.seats[1].inHand, false);
});
-155
View File
@@ -1,155 +0,0 @@
// Taking the table off this device.
//
// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
// dies, or who wants to carry on from the tablet after the break, loses the stacks,
// the buy-ins, where the button is and the whole hand log — and the CSV export can't
// bring any of that back, because it records what happened rather than where the
// table stands.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { HoldemState, Betting, makeTable } = require("./load");
const aNight = () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].buyIns = 200;
});
state.seats[2].stack = 340;
state.notes = "Cal owes the bar $12";
Betting.beginHand(state);
HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
return state;
};
describe("a table written out and read back", () => {
test("comes back the same table", () => {
const before = aNight();
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
assert.equal(after.handNumber, before.handNumber);
assert.equal(after.notes, before.notes);
assert.equal(after.handHistory.length, before.handHistory.length);
});
test("keeps the things a CSV can't carry", () => {
// The whole reason this exists: an export records what happened, not where the
// table stands.
const before = aNight();
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
assert.equal(after.street, before.street);
assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
assert.deepEqual(after.chipLegend, before.chipLegend);
});
test("the standings and the bank check agree across the trip", () => {
const before = aNight();
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
});
test("it can be dealt from straight away", () => {
const before = aNight();
HoldemState.startNextHand(before);
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
Betting.beginHand(after);
assert.equal(after.bettingOpen, true);
assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
});
});
describe("what it refuses", () => {
const refuses = (text, because) => {
const result = HoldemState.fromBackup(text);
assert.ok(result.error, because);
assert.equal(result.table, undefined, "and never hands back half a table");
};
test("something that isn't JSON", () => {
refuses("not a file", "unreadable");
refuses("", "empty");
});
test("JSON that isn't a table", () => {
refuses('{"hello":"world"}', "no seats in it");
refuses("[1,2,3]", "an array of nothing");
refuses("null", "null");
});
test("a save from something else that happens to be JSON", () => {
refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
});
test("a file from a newer version, rather than guessing at it", () => {
// Loading it risks reading a field whose meaning has changed, and the dealer
// would find out from the money.
const backup = JSON.parse(HoldemState.toBackup(aNight()));
backup.version = HoldemState.BACKUP_VERSION + 1;
const result = HoldemState.fromBackup(JSON.stringify(backup));
assert.match(result.error, /newer version/i);
});
});
describe("what it accepts anyway", () => {
test("a bare table, with no envelope around it", () => {
// Somebody who copied the storage value out by hand. The envelope is a
// convenience, not a password.
const state = aNight();
const result = HoldemState.fromBackup(JSON.stringify(state));
assert.ok(!result.error);
assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
});
test("a table from before fields that exist now, migrated on the way in", () => {
// Same repairs a save from an older version gets, because it is the same
// function — a second, looser path for imports is how a format grows two
// meanings of the same field.
const state = aNight();
const raw = JSON.parse(JSON.stringify(state));
delete raw.deadButton;
delete raw.postedBlinds;
delete raw.dealtLastHand;
delete raw.discarded;
raw.seats.forEach(p => { delete p.buyIns; });
const { table, error } = HoldemState.fromBackup(JSON.stringify({
kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
}));
assert.ok(!error);
assert.equal(table.deadButton, false);
assert.deepEqual(table.dealtLastHand, []);
assert.equal(table.discarded, 0);
assert.equal(table.seats[0].buyIns, 0);
});
test("the time it was saved comes back with it", () => {
const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
assert.ok(result.savedAt, "so the dealer can tell two files apart");
assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
});
});
describe("the file itself", () => {
test("is named with the date and time, so two saves in a night are told apart", () => {
const at = new Date(2026, 8, 2, 21, 5);
assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
});
test("says what it is, for anyone finding it in a downloads folder", () => {
const parsed = JSON.parse(HoldemState.toBackup(aNight()));
assert.equal(parsed.kind, "casino.holdem.table");
assert.equal(parsed.version, 1);
assert.ok(parsed.table.seats.length > 0);
});
});
-37
View File
@@ -1,37 +0,0 @@
// How the think-clock reads.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const ActionBar = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
test("seconds under a minute", () => {
assert.equal(ActionBar.formatTank(0), "0:00");
assert.equal(ActionBar.formatTank(7), "0:07");
assert.equal(ActionBar.formatTank(59), "0:59");
});
test("minutes and seconds", () => {
assert.equal(ActionBar.formatTank(60), "1:00");
assert.equal(ActionBar.formatTank(241), "4:01");
assert.equal(ActionBar.formatTank(3599), "59:59");
});
test("past the hour it says the hour", () => {
// A table left open on a hand reaches this, and "65:03" is a number the eye reads
// as minutes and seconds when it isn't.
assert.equal(ActionBar.formatTank(3600), "1:00:00");
assert.equal(ActionBar.formatTank(3903), "1:05:03");
assert.equal(ActionBar.formatTank(45296), "12:34:56");
});
test("the minutes are padded once there are hours, and not before", () => {
// "1:5:03" would be wrong; "05:03" on its own would be odd.
assert.equal(ActionBar.formatTank(3903), "1:05:03");
assert.equal(ActionBar.formatTank(303), "5:03");
});
test("a day is still a readable number rather than a wrapped one", () => {
assert.equal(ActionBar.formatTank(90000), "25:00:00");
});
+16 -244
View File
@@ -93,44 +93,10 @@ const LayoutAudit = {
return false;
},
// The modal that is currently on top, if any.
//
// Every check below asks "is this thing reachable", and while a sheet or dialog is
// open the answer for everything behind it is legitimately no — that is what a
// modal is for. Without knowing that, the audit reported the pot wizard covering
// the seats as ten faults and the rulings sheet as seven, which is the noise that
// made it useless in exactly the states nobody had audited. Now it scopes to what
// is actually on top and says so.
MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
"#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
openModal() {
const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
.filter(el => !el.hidden && getComputedStyle(el).display !== "none");
// Last in the DOM wins, which is how they stack.
return open.length > 0 ? open[open.length - 1] : null;
},
// A seat sheet on a phone isn't an overlay element, it's a body class — but it
// covers the felt just the same.
modalScope() {
const overlay = this.openModal();
if (overlay) return overlay;
if (document.body.classList.contains("seatSheetOpen")) {
return document.getElementById("seatEditorPanel") || null;
}
return null;
},
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
const offenders = [];
const warnings = [];
// While something is open on top, only what is inside it can be judged. The
// rest of the page is meant to be unreachable.
const scope = this.modalScope();
const candidates = [...document.querySelectorAll(selector)]
.filter(el => !scope || scope.contains(el));
candidates.forEach(el => {
[...document.querySelectorAll(selector)].forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
@@ -214,12 +180,6 @@ const LayoutAudit = {
collapsedColumns() {
const offenders = [];
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
// Nothing inside something the page has hidden. A hidden element's children
// all measure zero wide, so the standings header — which is hidden along
// with its table until somebody has bought in — was reported as three
// collapsed columns on every empty table. A column nobody can see hasn't
// collapsed; it isn't there.
if (row.closest("[hidden]")) return;
if (getComputedStyle(row).display !== "grid") return;
[...row.children].forEach(cell => {
if (getComputedStyle(cell).display === "none") return;
@@ -383,47 +343,6 @@ const LayoutAudit = {
});
},
// Every element between this one and the document, so an opacity set on a
// container can be seen from the text inside it.
*ancestors(el) {
let node = el.parentElement;
while (node) {
yield node;
node = node.parentElement;
}
},
// How much of this element actually reaches the screen, and what is behind the
// thing doing the fading.
//
// `opacity` on a box fades the whole box — its text and its own background together
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
// halves have to be composited the same way or the number means nothing.
fading(el) {
let alpha = 1;
let outermost = null;
for (let node = el; node; node = node.parentElement) {
const own = parseFloat(getComputedStyle(node).opacity);
if (own < 0.99) {
alpha *= own;
outermost = node;
}
}
if (!outermost) return { alpha: 1, behind: null };
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
},
// The two colours as the screen actually shows them, once the fading is applied.
asPainted(fgRaw, bg, fade) {
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
return {
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
};
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -433,55 +352,19 @@ const LayoutAudit = {
: [...document.querySelectorAll("*")];
nodes.forEach(el => {
// An element's *own* text, not its descendants' — they get measured on
// their own turn, against their own colour.
//
// This used to be "leaf elements only", which quietly stopped measuring
// anything the moment a child was added to it. The undo control went that
// way the day a <kbd> was appended to it: its label became invisible to
// this check, with no sign that anything had changed.
const text = [...el.childNodes]
.filter(node => node.nodeType === 3)
.map(node => node.textContent)
.join(" ")
.trim();
if (el.children.length > 0) return; // leaf text only
const text = (el.textContent || "").trim();
if (!text) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const style = getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none") return;
// Faded text used to be skipped outright as "faded on purpose", and that
// exemption hid a real fault: the folded and sat-out seats dimmed their
// whole box with opacity, so a folded player's stack measured 2.22:1 at
// 11.5px and this check never looked at it. Something invisible is still
// exempt — there's nothing to read at zero — but anything in between is
// reported as unmeasured rather than passed over in silence, because a
// number nobody checked is not the same as a number that's fine.
const fade = this.fading(el);
if (fade.alpha <= 0.02) return;
// A control that is switched off is the one exemption WCAG itself grants —
// an inactive component has no contrast requirement, which is why every
// toolkit dims them. Said out loud rather than skipped in silence: this is
// the only exemption in here, and the moment a dimmed thing turns out to be
// live it stops applying.
const off = el.closest("[disabled], [aria-disabled='true']");
if (off) {
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
return;
}
// aria-hidden used to exempt text here, on the grounds that decoration
// marked for screen readers was decoration for everyone. Those are two
// different questions and the answer differs: the bet chips on the felt are
// aria-hidden because their amounts are already announced by the seat and
// the action bar, and repeating them would be noise — but they are exactly
// what a sighted dealer reads the felt for, and this check had stopped
// looking at them. Hiding something from one audience is not a claim about
// whether the other audience can read it.
//
// Nothing is exempt now. If something genuinely decorative turns up, it
// should say so in its own words rather than borrowing an attribute that
// means something else.
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
// Decoration is exempt by definition: the watermark on the felt is meant
// to be barely there, and holding it to a reading threshold would mean
// making it not a watermark. Marked aria-hidden so screen readers skip it
// too, which is the same claim expressed where it also does some good.
if (el.closest("[aria-hidden='true']")) return;
const fgRaw = this.parseColour(style.color);
if (!fgRaw) return;
@@ -495,8 +378,8 @@ const LayoutAudit = {
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const painted = this.asPainted(fgRaw, bg, fade);
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
best = Math.max(best, this.contrastRatio(fg, bg));
});
const sizePx = parseFloat(style.fontSize);
@@ -574,36 +457,10 @@ const LayoutAudit = {
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
}
const unmeasured = [];
this.visibleControls()
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
// A link inside a sentence is the one target WCAG itself carves out:
// its size is set by the line it sits in, and padding a word out to
// 44px would break the paragraph around it. The rule is about controls
// a thumb goes hunting for, not words in prose. Said out loud, and
// narrow — display:inline, and real text either side of it.
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
el.parentElement &&
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
return;
}
// A control inside a scrolling sheet can sit below its container's
// visible area while still having a viewport rect. elementFromPoint at
// its centre then returns the sheet, effectiveTarget gives up, and the
// check used to pass it in silence — which is how "Confirm Winner(s)",
// the button that pays the pot out, went unmeasured for 36 rounds.
// Bring it into view before probing.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
const box = scroller.getBoundingClientRect();
const own = el.getBoundingClientRect();
if (own.bottom > box.bottom || own.top < box.top) {
el.scrollIntoView({ block: "center" });
}
}
const rect = el.getBoundingClientRect();
// Drawn big enough is big enough — no need to probe, and probing wide
// elements is where the cap above used to produce nonsense. Seats sit
@@ -611,35 +468,15 @@ const LayoutAudit = {
// measuring their reach would report the crowding as a fault.
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
// A checkbox inside a label is tapped by tapping the label, so the
// label is the target. Measured rather than probed, because probing
// fails on anything scrolled off the viewport — which is how a 24px
// checkbox with a 335x44 label around it came up as unprobeable
// instead of as the perfectly good target it is.
const label = el.closest("label");
if (label) {
const lr = label.getBoundingClientRect();
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
}
const size = this.effectiveTarget(el);
// Still can't measure it. That is not a pass — it is the check failing
// to run, and quietly returning here is exactly the kind of exemption
// that hides real faults. Say so instead.
if (!size) {
unmeasured.push(
`${this.describe(el)} :: could not be probed` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
return;
}
if (!size) return;
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
offenders.push(
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
});
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
return { name: `touch targets at least ${minimum}px`, offenders };
},
// Tapping the middle of a seat must give you that seat.
@@ -653,13 +490,6 @@ const LayoutAudit = {
// Reported separately from occlusion because the cause is different: not an
// overlay on top of the table, but two pieces of the table fighting over a tap.
seatOwnership() {
// Seats behind an open sheet aren't fighting each other for taps — nothing is
// meant to reach them at all. Asking the question there produced six offenders
// per modal, every one of them the modal doing its job.
if (this.modalScope()) {
return { name: "a seat's centre belongs to that seat", offenders: [],
skipped: "a sheet is open over the table" };
}
const seats = [...document.querySelectorAll(".seat")];
const offenders = [];
@@ -684,57 +514,8 @@ const LayoutAudit = {
return { name: "a seat's centre belongs to that seat", offenders };
},
// A poker table has one dealer button. Nine pucks all showing "D", one of them
// gold, turns the most-read marker on the felt into a spot-the-difference — and
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
// Both halves are checked here: what's drawn, and what it announces itself as.
oneDealerButton() {
const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => {
const style = getComputedStyle(chip);
if (style.display === "none" || style.visibility === "hidden") return false;
if (parseFloat(style.opacity) <= 0.05) return false;
// A hovered seat deliberately fades its empty slot in — that's the "put the
// button here" preview, and it is on screen because a pointer is sitting on
// that seat. Counting it reports two buttons whenever the mouse happens to
// rest on the table, which is most of the time a mouse exists.
const seat = chip.closest(".seat");
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
return true;
});
const offenders = [];
if (shown.length > 1) {
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
}
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
if (duplicated.length) {
offenders.push(
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
);
}
return { name: "the felt shows one dealer button", offenders };
},
// ---- Runner ----
// Every check this file knows how to make, every time.
//
// Two of them used not to be here. Contrast was simply missing from the list, and
// touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
// has meant nine checks rather than eleven, and the two left out are the two whose
// faults a dealer meets in a dim room with their thumb. Both were written, tested
// and then never run: the contrast checker was built across two rounds and every
// sweep since has been silent about colour.
//
// A check that decides it can't apply here says so out loud rather than reporting
// an empty offenders list, which reads as a pass. touchTargets stands itself down
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
// on it — but "not measured" and "measured and fine" are different answers and the
// runner now prints which one it got.
runAll(options = {}) {
const checks = [
this.touchAction(),
@@ -746,24 +527,15 @@ const LayoutAudit = {
this.horizontalOverflow(),
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
return {
viewport: `${innerWidth}x${innerHeight}`,
failed: checks.filter(c => c.offenders.length > 0),
// Things worth knowing that aren't faults — see occlusion() on toasts.
// Controls a check couldn't probe go here too: not a fault, but not a pass
// either, and a silent skip is how faults hide.
warnings: checks.flatMap(c => [
...(c.warnings || []),
...(c.unmeasured || []),
...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
]),
passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
skipped: checks.filter(c => c.skipped).map(c => c.name),
warnings: checks.flatMap(c => c.warnings || []),
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
},
-83
View File
@@ -1,83 +0,0 @@
// Whether putting more in is called a bet or a raise.
//
// The arithmetic was always right — minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet. This is only what it's called, which for
// a tool a dealer announces the action from is not a small thing.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
// Walks the preflop action round to the big blind, who is owed nothing and gets to
// choose between checking and putting more in.
function toTheBigBlindsOption() {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
Betting.call(state, state.actingSeatIndex);
}
return { state, bigBlind };
}
test("the big blind's option is a raise, not a bet", () => {
const { state, bigBlind } = toTheBigBlindsOption();
assert.equal(state.actingSeatIndex, bigBlind);
assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
});
test("and the minimum there is a real raise, not a call of the blind", () => {
const { state } = toTheBigBlindsOption();
assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
});
test("facing the blinds preflop is a raise", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.wagerVerb(state), "raise");
});
test("first in on the flop is a bet", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
assert.equal(state.street, "flop");
assert.equal(Betting.wagerVerb(state), "bet");
assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
});
test("after that bet it's a raise again", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
Betting.raiseTo(state, state.actingSeatIndex, 10);
assert.equal(Betting.wagerVerb(state), "raise");
});
test("and a bet again on the next street once it's checked around", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
Betting.closeStreet(state);
assert.equal(state.street, "turn");
assert.equal(Betting.wagerVerb(state), "bet");
});
test("a table playing without blinds opens with a bet", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
Betting.beginHand(state);
assert.equal(state.currentBet, 0);
assert.equal(Betting.wagerVerb(state), "bet");
});
test("asking doesn't change anything", () => {
const { state } = toTheBigBlindsOption();
const before = JSON.stringify(state);
Betting.wagerVerb(state);
assert.equal(JSON.stringify(state), before);
});