Merge pull request 'Hold'em dealer: 55 polish rounds, one merge' (#101) from release/casino-holdem-polish into master
Build and Deploy / build-deploy (push) Successful in 1m32s
Build and Deploy / build-deploy (push) Successful in 1m32s
Reviewed-on: #101
This commit was merged in pull request #101.
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="holdemHeaderActions">
|
<div id="holdemHeaderActions">
|
||||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||||
|
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||||
@@ -25,7 +26,6 @@
|
|||||||
<section id="tableSection">
|
<section id="tableSection">
|
||||||
<div id="pokerTableWrap">
|
<div id="pokerTableWrap">
|
||||||
<div id="pokerTable">
|
<div id="pokerTable">
|
||||||
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
|
|
||||||
<div id="potBadge">
|
<div id="potBadge">
|
||||||
<span id="potBadgeLabel">Pot</span>
|
<span id="potBadgeLabel">Pot</span>
|
||||||
<span id="potBadgeAmount">$0</span>
|
<span id="potBadgeAmount">$0</span>
|
||||||
@@ -87,6 +87,32 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
<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">
|
<details class="subPanel">
|
||||||
<summary>Without live betting</summary>
|
<summary>Without live betting</summary>
|
||||||
<div class="subPanelBody">
|
<div class="subPanelBody">
|
||||||
@@ -103,6 +129,7 @@
|
|||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary>Session</summary>
|
<summary>Session</summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
|
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||||
<h3 class="panelSubhead">Standings</h3>
|
<h3 class="panelSubhead">Standings</h3>
|
||||||
<div class="standingsTableWrap">
|
<div class="standingsTableWrap">
|
||||||
<div id="standingsTable">
|
<div id="standingsTable">
|
||||||
@@ -110,14 +137,23 @@
|
|||||||
<span>Player</span>
|
<span>Player</span>
|
||||||
<span class="standingsInHead">In</span>
|
<span class="standingsInHead">In</span>
|
||||||
<span class="standingsWonHead">Won</span>
|
<span class="standingsWonHead">Won</span>
|
||||||
<span>Net</span>
|
<span class="standingsNetHead">Net</span>
|
||||||
<span title="Chips in front of them, less the cash they handed over">Cash out</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>
|
||||||
</div>
|
</div>
|
||||||
<div id="standingsList"></div>
|
<div id="standingsList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||||
|
|
||||||
|
<h3 class="panelSubhead">Waiting list</h3>
|
||||||
|
<form id="waitingListForm" class="waitingAdd">
|
||||||
|
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||||
|
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||||
|
</form>
|
||||||
|
<ol id="waitingList" class="waitingList"></ol>
|
||||||
|
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||||
|
|
||||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||||
<div class="bankReconcileRow">
|
<div class="bankReconcileRow">
|
||||||
<span>Cash taken</span>
|
<span>Cash taken</span>
|
||||||
@@ -127,17 +163,53 @@
|
|||||||
<span>Chips in play</span>
|
<span>Chips in play</span>
|
||||||
<span id="bankChipsInPlay"></span>
|
<span id="bankChipsInPlay"></span>
|
||||||
</div>
|
</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>
|
||||||
|
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
|
||||||
|
<span>Paid out</span>
|
||||||
|
<span id="bankPaidOut"></span>
|
||||||
|
</div>
|
||||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||||
|
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settleUp" hidden>
|
||||||
|
<h3 class="panelSubhead">Settle up</h3>
|
||||||
|
<ul id="settleUpList" class="settleUpList"></ul>
|
||||||
|
<p id="settleUpNote" class="fieldHint"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="panelSubhead">Hands</h3>
|
<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>
|
<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>
|
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||||
|
|
||||||
<div class="handHistoryActions">
|
<div class="handHistoryActions">
|
||||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -147,6 +219,20 @@
|
|||||||
<div id="chipLegendList"></div>
|
<div id="chipLegendList"></div>
|
||||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
<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>
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -163,9 +249,10 @@
|
|||||||
<template id="seatTemplate">
|
<template id="seatTemplate">
|
||||||
<div class="seat" draggable="true">
|
<div class="seat" draggable="true">
|
||||||
<div class="seatChairNumber"></div>
|
<div class="seatChairNumber"></div>
|
||||||
<button class="dealerChip" title="Make this seat the dealer button">D</button>
|
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
|
||||||
<span class="blindBadge sbBadge">SB</span>
|
<span class="blindBadge sbBadge">SB</span>
|
||||||
<span class="blindBadge bbBadge">BB</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="seatPuckName"></div>
|
||||||
<div class="seatPuckCards"></div>
|
<div class="seatPuckCards"></div>
|
||||||
<div class="seatPuckTotal"></div>
|
<div class="seatPuckTotal"></div>
|
||||||
@@ -240,6 +327,7 @@
|
|||||||
<span class="standingsIn"></span>
|
<span class="standingsIn"></span>
|
||||||
<span class="standingsWon"></span>
|
<span class="standingsWon"></span>
|
||||||
<span class="standingsNet"></span>
|
<span class="standingsNet"></span>
|
||||||
|
<span class="standingsChips"></span>
|
||||||
<span class="standingsCashOut"></span>
|
<span class="standingsCashOut"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -288,7 +376,9 @@
|
|||||||
not during it.
|
not during it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="ruling">
|
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
|
||||||
|
|
||||||
|
<section class="ruling" data-short="Misdeal">
|
||||||
<h3>Redeal it — misdeal</h3>
|
<h3>Redeal it — misdeal</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>The first card goes to the wrong seat.</li>
|
<li>The first card goes to the wrong seat.</li>
|
||||||
@@ -298,7 +388,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Board card early">
|
||||||
<h3>A board card turned too early</h3>
|
<h3>A board card turned too early</h3>
|
||||||
<p>
|
<p>
|
||||||
If the fourth board card gets turned before the betting round is finished,
|
If the fourth board card gets turned before the betting round is finished,
|
||||||
@@ -307,7 +397,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Out of turn">
|
||||||
<h3>Acting out of turn</h3>
|
<h3>Acting out of turn</h3>
|
||||||
<p>
|
<p>
|
||||||
A one-off is usually just backed up if no one has acted behind them.
|
A one-off is usually just backed up if no one has acted behind them.
|
||||||
@@ -315,7 +405,41 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Clock">
|
||||||
|
<h3>Calling the clock on a player</h3>
|
||||||
|
<p>
|
||||||
|
Any player may ask for a clock once a decision is taking unreasonably
|
||||||
|
long; it is the dealer, not the table, who grants it. The player then
|
||||||
|
gets a set time and a countdown out loud — thirty seconds and ten is the
|
||||||
|
usual form.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If the count reaches zero with no action, the hand is <em>dead</em> if
|
||||||
|
they were facing a bet, and <em>checked</em> if they were not: nobody is
|
||||||
|
folded for slowness when checking was free. The action bar runs the
|
||||||
|
count once you tap <em>Call the clock</em>, and offers the ruling as one
|
||||||
|
button when it ends.
|
||||||
|
</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">
|
||||||
<h3>A short all-in doesn't reopen the betting</h3>
|
<h3>A short all-in doesn't reopen the betting</h3>
|
||||||
<p>
|
<p>
|
||||||
Somebody shoving in less than a full raise — say $3 more when the last
|
Somebody shoving in less than a full raise — say $3 more when the last
|
||||||
@@ -330,7 +454,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Uncalled bet">
|
||||||
<h3>Chips nobody called come back</h3>
|
<h3>Chips nobody called come back</h3>
|
||||||
<p>
|
<p>
|
||||||
Bet more than anyone was able to call and the excess is returned to you
|
Bet more than anyone was able to call and the excess is returned to you
|
||||||
@@ -340,7 +464,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Oversized chip">
|
||||||
<h3>One big chip, nothing said</h3>
|
<h3>One big chip, nothing said</h3>
|
||||||
<p>
|
<p>
|
||||||
The most common argument at a home table. A player facing a bet who
|
The most common argument at a home table. A player facing a bet who
|
||||||
@@ -356,7 +480,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<section class="ruling" data-short="Minimum raise">
|
||||||
<h3>A raise has to be a full raise</h3>
|
<h3>A raise has to be a full raise</h3>
|
||||||
<p>
|
<p>
|
||||||
At least as much again as the last raise, or the big blind if there
|
At least as much again as the last raise, or the big blind if there
|
||||||
@@ -367,7 +491,23 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ruling">
|
<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">
|
||||||
<h3>The odd chip in a split pot</h3>
|
<h3>The odd chip in a split pot</h3>
|
||||||
<p>
|
<p>
|
||||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||||
@@ -377,6 +517,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</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">
|
<p class="rulingsSource">
|
||||||
Follows the Tournament Directors Association rules, which most rooms and
|
Follows the Tournament Directors Association rules, which most rooms and
|
||||||
home games take their lead from.
|
home games take their lead from.
|
||||||
@@ -443,6 +599,7 @@
|
|||||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
<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/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/state.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/render.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -143,12 +143,18 @@ button,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.unevenCurrency {
|
.unevenCurrency {
|
||||||
color: #777;
|
/* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.unevenAmount {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 46px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -178,13 +184,17 @@ button,
|
|||||||
color: #ffd6d6;
|
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 {
|
.unevenRemoveBtn {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 30px;
|
width: 46px;
|
||||||
height: 44px;
|
height: 46px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: #6a6a6a;
|
/* 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;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -209,11 +219,86 @@ button,
|
|||||||
.unevenPot {
|
.unevenPot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
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;
|
gap: 0.6rem;
|
||||||
padding: 0.3rem 0;
|
padding: 0.3rem 0;
|
||||||
font-size: 0.85rem;
|
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 {
|
.unevenPot + .unevenPot {
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
@@ -510,7 +595,9 @@ button,
|
|||||||
|
|
||||||
.splitFootnote {
|
.splitFootnote {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
color: #6f6f6f;
|
/* 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;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const ActionBar = {
|
|||||||
mount(state, hooks) {
|
mount(state, hooks) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
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.watchHeight();
|
||||||
this.render();
|
this.render();
|
||||||
},
|
},
|
||||||
@@ -29,6 +34,7 @@ const ActionBar = {
|
|||||||
const visible = !host.hidden;
|
const visible = !host.hidden;
|
||||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||||
|
this.publishFeltRoom(height);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof ResizeObserver === "function") {
|
if (typeof ResizeObserver === "function") {
|
||||||
@@ -40,11 +46,66 @@ const ActionBar = {
|
|||||||
publish();
|
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
|
// 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
|
// 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.
|
// 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() {
|
isRunning() {
|
||||||
return Betting.liveSeats(this.state).length > 0;
|
return Betting.liveSeats(this.state).length > 0 ||
|
||||||
|
this.state.seats.some(p => (p.name || "").trim());
|
||||||
},
|
},
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -98,8 +159,12 @@ const ActionBar = {
|
|||||||
row.appendChild(pot);
|
row.appendChild(pot);
|
||||||
|
|
||||||
// How many are still in the hand — part of the standard per-street
|
// How many are still in the hand — part of the standard per-street
|
||||||
// announcement, and the number a dealer is asked for most often.
|
// announcement, and the number a dealer is asked for most often. Before the
|
||||||
const remaining = Betting.liveSeats(this.state).length;
|
// 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;
|
||||||
const players = document.createElement("span");
|
const players = document.createElement("span");
|
||||||
players.className = "actionPlayers";
|
players.className = "actionPlayers";
|
||||||
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
|
||||||
@@ -129,6 +194,32 @@ const ActionBar = {
|
|||||||
tank.title = "How long this player has been thinking";
|
tank.title = "How long this player has been thinking";
|
||||||
tank.textContent = "0:00";
|
tank.textContent = "0:00";
|
||||||
row.appendChild(tank);
|
row.appendChild(tank);
|
||||||
|
|
||||||
|
// Rendered every time and shown by the tick once the wait is long enough
|
||||||
|
// to be worth it, so a clock never costs a re-render at the moment the
|
||||||
|
// dealer reaches for it — and never occupies the bar in the ordinary case
|
||||||
|
// where nobody is holding anybody up.
|
||||||
|
const clock = document.createElement("button");
|
||||||
|
clock.type = "button";
|
||||||
|
clock.id = "actionClockBtn";
|
||||||
|
clock.className = "actionClockBtn";
|
||||||
|
// Once the count is over there is nothing to stop: the ruling has been
|
||||||
|
// made, and the dealer either carries it out or takes an ordinary action,
|
||||||
|
// both of which end the clock themselves. Leaving the button there also
|
||||||
|
// cost real room — at 640x360 its 94px pushed the undo control onto a
|
||||||
|
// second line, and the taller bar covered the two bottom seats.
|
||||||
|
const expiredNow = this.clockRunning() && this.clockLeft() === 0;
|
||||||
|
clock.hidden = !this.clockRunning() || expiredNow;
|
||||||
|
if (this.clockRunning()) {
|
||||||
|
clock.textContent = "Stop the clock";
|
||||||
|
clock.title = "Put the decision back in the player's own time";
|
||||||
|
clock.addEventListener("click", () => { this.stopClock(); this.render(); });
|
||||||
|
} else {
|
||||||
|
clock.textContent = "Call the clock";
|
||||||
|
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
|
||||||
|
clock.addEventListener("click", () => this.callClock());
|
||||||
|
}
|
||||||
|
row.appendChild(clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A standing control rather than a toast per action: a dealer taps this bar
|
// A standing control rather than a toast per action: a dealer taps this bar
|
||||||
@@ -141,6 +232,17 @@ const ActionBar = {
|
|||||||
undo.className = "actionUndo";
|
undo.className = "actionUndo";
|
||||||
undo.title = `Undo ${undoLabel}`;
|
undo.title = `Undo ${undoLabel}`;
|
||||||
undo.textContent = `↶ ${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());
|
undo.addEventListener("click", () => this.hooks.undoLast());
|
||||||
row.appendChild(undo);
|
row.appendChild(undo);
|
||||||
}
|
}
|
||||||
@@ -157,10 +259,86 @@ const ActionBar = {
|
|||||||
NUDGE_AFTER_S: 30,
|
NUDGE_AFTER_S: 30,
|
||||||
SLOW_AFTER_S: 60,
|
SLOW_AFTER_S: 60,
|
||||||
|
|
||||||
|
// ---- Calling the clock ----
|
||||||
|
//
|
||||||
|
// The stopwatch above says how long somebody has been thinking. This is what a
|
||||||
|
// dealer does about it, and it is a procedure with a shape: a player asks for a
|
||||||
|
// clock, the dealer grants it, the player gets a set time and then a countdown,
|
||||||
|
// and if they still haven't acted the hand is decided for them — dead if they were
|
||||||
|
// facing a bet, checked if they weren't.
|
||||||
|
//
|
||||||
|
// The tool knew none of that. A dealer running it had to count in their head while
|
||||||
|
// the table argued about whether it had been thirty seconds, which is the moment
|
||||||
|
// the count needs to be somewhere everybody can see.
|
||||||
|
//
|
||||||
|
// Thirty seconds and then a ten-second countdown is the common form of the rule and
|
||||||
|
// what the TDA writes down. The countdown is the part the table hears out loud, so
|
||||||
|
// it is marked separately rather than being ten more seconds of the same.
|
||||||
|
CLOCK_THINK_S: 30,
|
||||||
|
CLOCK_COUNTDOWN_S: 10,
|
||||||
|
|
||||||
|
clockSeatIndex: null,
|
||||||
|
clockStartedAt: null,
|
||||||
|
|
||||||
|
clockRunning() {
|
||||||
|
return this.clockSeatIndex !== null &&
|
||||||
|
this.clockSeatIndex === this.state.actingSeatIndex &&
|
||||||
|
this.clockStartedAt !== null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seconds left before the hand is decided, or null when no clock is running.
|
||||||
|
// Negative is impossible: it stops at zero and stays there until the dealer acts.
|
||||||
|
clockLeft(now = Date.now()) {
|
||||||
|
if (!this.clockRunning()) return null;
|
||||||
|
const total = this.CLOCK_THINK_S + this.CLOCK_COUNTDOWN_S;
|
||||||
|
return Math.max(0, total - Math.floor((now - this.clockStartedAt) / 1000));
|
||||||
|
},
|
||||||
|
|
||||||
|
// What happens when it reaches zero, in the words the dealer says out loud.
|
||||||
|
// Facing a bet, the hand is dead. Facing none, it is checked — a player who could
|
||||||
|
// have checked for free is not folded for being slow.
|
||||||
|
clockRuling() {
|
||||||
|
const index = this.state.actingSeatIndex;
|
||||||
|
if (index === null || index === undefined) return null;
|
||||||
|
const player = this.state.seats[index];
|
||||||
|
const owed = Betting.toCall(this.state, player);
|
||||||
|
const who = HoldemState.displayName(player, index);
|
||||||
|
return owed > 0
|
||||||
|
? { index, who, verb: "fold", label: `Time — ${who}'s hand is dead`, say: `${who} is facing ${Money.format(owed)}, so the hand is dead.` }
|
||||||
|
: { index, who, verb: "check", label: `Time — check for ${who}`, say: `${who} had nothing to call, so the hand is checked.` };
|
||||||
|
},
|
||||||
|
|
||||||
|
callClock() {
|
||||||
|
const index = this.state.actingSeatIndex;
|
||||||
|
if (index === null || index === undefined) return;
|
||||||
|
this.clockSeatIndex = index;
|
||||||
|
this.clockStartedAt = Date.now();
|
||||||
|
this.clockExpiredShown = false;
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
stopClock() {
|
||||||
|
this.clockSeatIndex = null;
|
||||||
|
this.clockStartedAt = null;
|
||||||
|
this.clockExpiredShown = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Whether the bar has already been rebuilt into its "time is up" shape, so the
|
||||||
|
// one-second tick doesn't rebuild it again every second afterwards.
|
||||||
|
clockExpiredShown: false,
|
||||||
|
|
||||||
tankSeatIndex: null,
|
tankSeatIndex: null,
|
||||||
tankStartedAt: null,
|
tankStartedAt: null,
|
||||||
tankInterval: 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
|
// 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.
|
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||||
syncTankTimer() {
|
syncTankTimer() {
|
||||||
@@ -174,8 +352,15 @@ const ActionBar = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.tankSeatIndex !== index) {
|
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.tankSeatIndex = index;
|
||||||
this.tankStartedAt = Date.now();
|
this.tankStartedAt = Date.now();
|
||||||
|
// A clock belongs to one player's decision. The moment the action moves —
|
||||||
|
// because they acted, or because the dealer took something back — it is
|
||||||
|
// over, and leaving it running would put the next player on a countdown
|
||||||
|
// nobody called on them.
|
||||||
|
this.stopClock();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.tankInterval) {
|
if (!this.tankInterval) {
|
||||||
@@ -197,13 +382,63 @@ const ActionBar = {
|
|||||||
const el = document.getElementById("actionTank");
|
const el = document.getElementById("actionTank");
|
||||||
if (!el || this.tankStartedAt === null) return;
|
if (!el || this.tankStartedAt === null) return;
|
||||||
|
|
||||||
|
// A clock takes the readout over: the number the table is waiting on is no
|
||||||
|
// longer how long this has taken but how long is left. At zero the bar has to
|
||||||
|
// change shape — a ruling and the button that carries it out — so that one
|
||||||
|
// gets a full render, once, rather than being painted in by the tick.
|
||||||
|
const left = this.clockLeft();
|
||||||
|
if (left !== null) {
|
||||||
|
if (left === 0 && !this.clockExpiredShown) {
|
||||||
|
this.clockExpiredShown = true;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
|
||||||
|
el.title = "Time left before the hand is decided";
|
||||||
|
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
|
||||||
|
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The button that starts a clock is offered once somebody has been thinking
|
||||||
|
// long enough for the table to start asking — which is the same threshold the
|
||||||
|
// stopwatch already marks. Before that it is a control nobody needs, and this
|
||||||
|
// bar is docked over the seats.
|
||||||
|
const nudge = document.getElementById("actionClockBtn");
|
||||||
|
if (nudge && !this.clockRunning()) {
|
||||||
|
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||||
|
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
|
||||||
|
? false
|
||||||
|
: waited < this.NUDGE_AFTER_S;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||||
const mins = Math.floor(seconds / 60);
|
el.title = "How long this player has been thinking";
|
||||||
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
el.textContent = this.formatTank(seconds);
|
||||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||||
el.classList.toggle("tankSlow", 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
|
// 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
|
// undivided pot needs no announcement, and a line that's always there stops being
|
||||||
// read.
|
// read.
|
||||||
@@ -246,6 +481,8 @@ const ActionBar = {
|
|||||||
actionAdvance: "↵",
|
actionAdvance: "↵",
|
||||||
raiseConfirm: "↵",
|
raiseConfirm: "↵",
|
||||||
raiseCancel: "esc",
|
raiseCancel: "esc",
|
||||||
|
raiseAllIn: "A",
|
||||||
|
actionUndo: "U",
|
||||||
},
|
},
|
||||||
|
|
||||||
button(label, className, onClick, sublabel) {
|
button(label, className, onClick, sublabel) {
|
||||||
@@ -258,7 +495,10 @@ const ActionBar = {
|
|||||||
main.textContent = label;
|
main.textContent = label;
|
||||||
btn.appendChild(main);
|
btn.appendChild(main);
|
||||||
|
|
||||||
const hint = this.KEY_HINTS[className];
|
// 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);
|
||||||
if (hint) {
|
if (hint) {
|
||||||
const kbd = document.createElement("kbd");
|
const kbd = document.createElement("kbd");
|
||||||
kbd.className = "actionKey";
|
kbd.className = "actionKey";
|
||||||
@@ -294,6 +534,36 @@ const ActionBar = {
|
|||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
const who = HoldemState.displayName(player, index);
|
const who = HoldemState.displayName(player, index);
|
||||||
|
|
||||||
|
// Time is up. The ruling goes on the bar in the words the dealer says out loud,
|
||||||
|
// with the one action that carries it out — and the ordinary buttons stay
|
||||||
|
// underneath, because a player who speaks up as the count ends is still acting
|
||||||
|
// and the dealer is the one who decides whether it was in time.
|
||||||
|
const expired = this.clockLeft() === 0 ? this.clockRuling() : null;
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
if (expired) {
|
||||||
|
const line = document.createElement("p");
|
||||||
|
line.className = "clockExpired";
|
||||||
|
// The ruling in one sentence. `label` is the same call in three words for
|
||||||
|
// the button beside it; saying both left the line doubling back on itself
|
||||||
|
// — "Time — Dee's hand is dead — Dee is facing $2, so the hand is dead."
|
||||||
|
line.textContent = `Time — ${expired.say}`;
|
||||||
|
wrap.appendChild(line);
|
||||||
|
|
||||||
|
const enforce = document.createElement("div");
|
||||||
|
enforce.className = "actionButtons";
|
||||||
|
enforce.appendChild(this.button(
|
||||||
|
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
|
||||||
|
"actionEnforceClock",
|
||||||
|
() => this.commit(
|
||||||
|
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
|
||||||
|
() => {
|
||||||
|
this.stopClock();
|
||||||
|
if (expired.verb === "fold") Betting.fold(this.state, index);
|
||||||
|
else Betting.check(this.state, index);
|
||||||
|
})));
|
||||||
|
wrap.appendChild(enforce);
|
||||||
|
}
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "actionButtons";
|
row.className = "actionButtons";
|
||||||
|
|
||||||
@@ -310,14 +580,19 @@ const ActionBar = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stack > owed) {
|
if (stack > owed) {
|
||||||
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
|
// 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", () => {
|
||||||
this.raiseOpen = true;
|
this.raiseOpen = true;
|
||||||
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
|
||||||
this.render();
|
this.render();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
if (!expired) return row;
|
||||||
|
wrap.appendChild(row);
|
||||||
|
return wrap;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
||||||
@@ -335,7 +610,6 @@ const ActionBar = {
|
|||||||
const player = this.state.seats[index];
|
const player = this.state.seats[index];
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
const maxTo = Betting.round(Betting.committed(player) + stack);
|
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
|
// 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
|
// 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
|
// offer. `legalMin` is what the rules require — for a short stack the two are
|
||||||
@@ -343,6 +617,7 @@ const ActionBar = {
|
|||||||
// or a player whose whole stack is under a full raise never gets told that.
|
// or a player whose whole stack is under a full raise never gets told that.
|
||||||
const legalMin = Betting.minRaiseTo(this.state);
|
const legalMin = Betting.minRaiseTo(this.state);
|
||||||
const minTo = Math.min(legalMin, maxTo);
|
const minTo = Math.min(legalMin, maxTo);
|
||||||
|
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "raisePad";
|
wrap.className = "raisePad";
|
||||||
@@ -350,15 +625,21 @@ const ActionBar = {
|
|||||||
const quick = document.createElement("div");
|
const quick = document.createElement("div");
|
||||||
quick.className = "raiseQuick";
|
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 = [
|
const options = [
|
||||||
{ label: "Min", value: minTo },
|
{ label: "Min", value: minTo },
|
||||||
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
{ label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
|
||||||
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
{ label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
|
||||||
{ label: "All-in", value: maxTo },
|
{ label: "All-in", value: maxTo },
|
||||||
];
|
];
|
||||||
options.forEach(option => {
|
options.forEach(option => {
|
||||||
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
const value = Math.max(minTo, Math.min(option.value, maxTo));
|
||||||
const btn = this.button(option.label, "raiseQuickBtn", () => {
|
const btn = this.button(option.label, `raiseQuickBtn${option.label === "All-in" ? " raiseAllIn" : ""}`, () => {
|
||||||
this.raiseDraft = value;
|
this.raiseDraft = value;
|
||||||
this.render();
|
this.render();
|
||||||
}, Money.format(value));
|
}, Money.format(value));
|
||||||
@@ -375,7 +656,7 @@ const ActionBar = {
|
|||||||
input.inputMode = "decimal";
|
input.inputMode = "decimal";
|
||||||
input.className = "raiseInput";
|
input.className = "raiseInput";
|
||||||
input.value = this.raiseDraft ?? minTo;
|
input.value = this.raiseDraft ?? minTo;
|
||||||
input.setAttribute("aria-label", "Raise to amount");
|
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
|
||||||
this.updateRaiseNote(wrap, legalMin, maxTo);
|
this.updateRaiseNote(wrap, legalMin, maxTo);
|
||||||
@@ -387,11 +668,16 @@ const ActionBar = {
|
|||||||
this.render();
|
this.render();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
|
// "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", () => {
|
||||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||||
this.raiseOpen = false;
|
this.raiseOpen = false;
|
||||||
const who = HoldemState.displayName(player, index);
|
const who = HoldemState.displayName(player, index);
|
||||||
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
|
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));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
wrap.appendChild(entry);
|
wrap.appendChild(entry);
|
||||||
@@ -420,6 +706,7 @@ const ActionBar = {
|
|||||||
|
|
||||||
const typed = Number(this.raiseDraft);
|
const typed = Number(this.raiseDraft);
|
||||||
const current = this.state.currentBet || 0;
|
const current = this.state.currentBet || 0;
|
||||||
|
const raising = Betting.wagerVerb(this.state) === "raise";
|
||||||
|
|
||||||
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
|
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
|
||||||
|
|
||||||
@@ -428,16 +715,20 @@ const ActionBar = {
|
|||||||
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
|
||||||
|
|
||||||
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
|
||||||
note.textContent =
|
// The way out of an under-minimum differs: with a bet in front of you the
|
||||||
`${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.`;
|
// 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.className = "raiseNote raiseNoteWarn";
|
note.className = "raiseNote raiseNoteWarn";
|
||||||
note.hidden = false;
|
note.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
|
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
|
||||||
note.textContent =
|
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 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.className = "raiseNote raiseNoteInfo";
|
note.className = "raiseNote raiseNoteInfo";
|
||||||
note.hidden = false;
|
note.hidden = false;
|
||||||
return;
|
return;
|
||||||
@@ -466,34 +757,161 @@ const ActionBar = {
|
|||||||
// by itself the instant the previous hand ended, which meant blinds were committed
|
// 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
|
// 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.
|
// 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() {
|
renderDealPrompt() {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "dealPrompt";
|
wrap.className = "dealPrompt";
|
||||||
|
|
||||||
const preview = Betting.blindPreview(this.state);
|
const preview = Betting.blindPreview(this.state);
|
||||||
const seated = Betting.liveSeats(this.state).length;
|
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");
|
const line = document.createElement("p");
|
||||||
line.className = "dealPromptLine";
|
line.className = "dealPromptLine";
|
||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
// Two players is the least you can deal. Say what's missing rather than
|
// Two players is the least you can deal. Say what's missing rather than
|
||||||
// offering a button that would quietly do nothing.
|
// 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
|
line.textContent = seated === 0
|
||||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||||
: "Only one player is dealt in — a hand needs two.";
|
: "Only one player is dealt in — a hand needs two.";
|
||||||
|
}
|
||||||
line.classList.add("dealPromptBlocked");
|
line.classList.add("dealPromptBlocked");
|
||||||
wrap.appendChild(line);
|
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;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
// Named seats that aren't in the hand. Naming a chair now seats whoever is
|
||||||
line.textContent =
|
// in it, so reaching this takes something else — a table where everyone was
|
||||||
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
// sat out, or a save from before that was true — but the way out of it
|
||||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
// 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) +
|
||||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||||
|
line.appendChild(blinds);
|
||||||
wrap.appendChild(line);
|
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
|
// 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
|
// before it happens rather than discovered afterwards — a fresh table starts
|
||||||
// the button on seat 1 whether or not anyone is sitting there.
|
// the button on seat 1 whether or not anyone is sitting there.
|
||||||
@@ -505,8 +923,61 @@ const ActionBar = {
|
|||||||
wrap.appendChild(moved);
|
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");
|
const row = document.createElement("div");
|
||||||
row.className = "actionButtons";
|
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.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||||
}));
|
}));
|
||||||
@@ -515,13 +986,41 @@ const ActionBar = {
|
|||||||
return wrap;
|
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() {
|
renderStreetDone() {
|
||||||
const row = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
row.className = "actionButtons";
|
|
||||||
|
|
||||||
const isRiver = this.state.street === "river";
|
const isRiver = this.state.street === "river";
|
||||||
const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`;
|
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", () => {
|
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||||
if (isRiver) {
|
if (isRiver) {
|
||||||
this.hooks.resolvePot();
|
this.hooks.resolvePot();
|
||||||
@@ -529,22 +1028,33 @@ const ActionBar = {
|
|||||||
}
|
}
|
||||||
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||||
}));
|
}));
|
||||||
|
wrap.appendChild(row);
|
||||||
|
|
||||||
return row;
|
return wrap;
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHandOver() {
|
renderHandOver() {
|
||||||
const row = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
row.className = "actionButtons";
|
|
||||||
const live = Betting.liveSeats(this.state);
|
const live = Betting.liveSeats(this.state);
|
||||||
const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null;
|
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(
|
row.appendChild(this.button(
|
||||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||||
"actionAdvance",
|
"actionAdvance",
|
||||||
() => this.hooks.resolvePot()
|
() => this.hooks.resolvePot()
|
||||||
));
|
));
|
||||||
return row;
|
wrap.appendChild(row);
|
||||||
|
return wrap;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,28 +32,159 @@ const Betting = {
|
|||||||
// Who would post if the hand were dealt right now. Used to preview the blinds
|
// 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
|
// while the table is still being set, so a dealer can see where they'll land
|
||||||
// before any money moves.
|
// 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) {
|
blindPreview(state) {
|
||||||
// Dealing relocates a button parked on an empty chair before it works out who
|
// 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
|
// 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
|
// 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
|
// produced — the preview promising one thing and doing another is exactly the
|
||||||
// guessing this whole step exists to remove.
|
// guessing this whole step exists to remove. Same reason the broke seats come
|
||||||
const buttonIndex = HoldemState.buttonSeatForDeal(state);
|
// out first: the preview once promised a small blind from a seat with no chips.
|
||||||
const asDealt = buttonIndex === state.dealerSeatIndex
|
const dealt = this.tableAsDealt(state);
|
||||||
? state
|
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||||
: { ...state, dealerSeatIndex: buttonIndex };
|
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||||
|
? dealt
|
||||||
|
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||||
|
|
||||||
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
|
const satOut = this.brokeSeats(state);
|
||||||
if (sbIndex === null || bbIndex === null) return null;
|
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
|
||||||
|
if (bbIndex === null) return null;
|
||||||
return {
|
return {
|
||||||
sbIndex, bbIndex, headsUp, manual: !!manual,
|
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(),
|
||||||
buttonIndex,
|
buttonIndex,
|
||||||
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
buttonMoves: buttonIndex !== state.dealerSeatIndex,
|
||||||
|
satOut,
|
||||||
smallBlind: this.round(state.smallBlind || 0),
|
smallBlind: this.round(state.smallBlind || 0),
|
||||||
bigBlind: this.round(state.bigBlind || 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) {
|
committed(player) {
|
||||||
return this.round(player.committed || 0);
|
return this.round(player.committed || 0);
|
||||||
},
|
},
|
||||||
@@ -117,6 +248,20 @@ const Betting = {
|
|||||||
return null;
|
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
|
// 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
|
// 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.
|
// used to drift apart from each other, so there is one implementation of it.
|
||||||
@@ -182,6 +327,11 @@ const Betting = {
|
|||||||
|
|
||||||
if (newBet > previousBet) {
|
if (newBet > previousBet) {
|
||||||
const increase = this.round(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
|
// 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.
|
// last one, or the big blind if there hasn't been a raise yet.
|
||||||
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||||
@@ -243,6 +393,10 @@ const Betting = {
|
|||||||
|
|
||||||
state.currentBet = 0;
|
state.currentBet = 0;
|
||||||
state.lastRaiseSize = 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;
|
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
|
||||||
state.street = this.STREETS[nextStreetIndex] || "river";
|
state.street = this.STREETS[nextStreetIndex] || "river";
|
||||||
@@ -289,6 +443,11 @@ const Betting = {
|
|||||||
// True when the hand is still live but nobody can bet again — the dealer is just
|
// True when the hand is still live but nobody can bet again — the dealer is just
|
||||||
// running the board out.
|
// running the board out.
|
||||||
isRunningOut(state) {
|
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);
|
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -300,6 +459,96 @@ const Betting = {
|
|||||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Chips the pot has been priced with but nobody has actually paid.
|
||||||
|
//
|
||||||
|
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
|
||||||
|
// bet leaves the stack, sits in front of the player, and is swept into their hand
|
||||||
|
// total at the end of the street. The other way is the "without live betting" panel
|
||||||
|
// — a base pot amount, or chips counted out on a seat — where the dealer is
|
||||||
|
// recording a hand they are watching rather than driving, and nothing ever leaves
|
||||||
|
// anybody's stack.
|
||||||
|
//
|
||||||
|
// The pot wizard prices both the same way, and awarding it credits the winner. So
|
||||||
|
// in that second mode the table simply grew: measured at a three-handed table with
|
||||||
|
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
|
||||||
|
// left the bank check blaming a rebuy that never happened.
|
||||||
|
//
|
||||||
|
// So before the pot is pushed, whatever was assumed rather than taken comes off the
|
||||||
|
// stacks that assumed it. Chips already committed or swept are left alone; they
|
||||||
|
// have been paid once already, and taking them twice is the same bug the other way
|
||||||
|
// round.
|
||||||
|
collectAssumedContributions(state) {
|
||||||
|
const taken = [];
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
|
||||||
|
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||||
|
const owed = this.round(priced - alreadyPaid);
|
||||||
|
if (owed <= this.EPSILON_CHIPS) return;
|
||||||
|
|
||||||
|
// Never more than they are holding. A seat priced above its own stack is a
|
||||||
|
// dealer's typo or a player who is short, and inventing the difference is
|
||||||
|
// what this whole function exists to stop.
|
||||||
|
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
|
||||||
|
if (moved <= this.EPSILON_CHIPS) return;
|
||||||
|
|
||||||
|
player.stack = this.round(this.stackOf(state, player) - moved);
|
||||||
|
// Recorded where a swept bet lives, so the money is in exactly the place
|
||||||
|
// the rest of the tool looks for chips that are in the middle this hand.
|
||||||
|
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||||
|
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
|
||||||
|
});
|
||||||
|
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
|
||||||
|
},
|
||||||
|
|
||||||
|
// A misdeal: the hand never happened, so every chip goes back where it came from.
|
||||||
|
//
|
||||||
|
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||||
|
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
|
||||||
|
// of the first two cards exposed — and the tool had no way to do it. The only
|
||||||
|
// control that ended a live hand was Next Hand, which says out loud that it takes
|
||||||
|
// the money off the table without paying anybody. That is right for an abandoned
|
||||||
|
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
|
||||||
|
// who put them in.
|
||||||
|
//
|
||||||
|
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
|
||||||
|
// while the action history is still there. It lives in memory, so a dealer who
|
||||||
|
// refreshes the page mid-hand, or who is three calls in, has nothing.
|
||||||
|
//
|
||||||
|
// What is deliberately kept: the button, the blinds, the straddle and anyone
|
||||||
|
// posting in. A misdeal is the *same* hand dealt again, not the next one.
|
||||||
|
takeHandBack(state) {
|
||||||
|
const returned = [];
|
||||||
|
state.seats.forEach((player, index) => {
|
||||||
|
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||||
|
if (back > 0) {
|
||||||
|
player.stack = this.round(this.stackOf(state, player) + back);
|
||||||
|
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
|
||||||
|
}
|
||||||
|
player.committed = 0;
|
||||||
|
player.amountOverride = null;
|
||||||
|
player.hasActed = false;
|
||||||
|
player.folded = false;
|
||||||
|
player.cards = ["", ""];
|
||||||
|
});
|
||||||
|
|
||||||
|
state.bettingOpen = false;
|
||||||
|
state.street = "preflop";
|
||||||
|
state.currentBet = 0;
|
||||||
|
state.lastRaiseSize = 0;
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
state.streetAggressorIndex = null;
|
||||||
|
state.handAggressorIndex = null;
|
||||||
|
// Nothing was posted, as far as the next rotation is concerned: the hand this
|
||||||
|
// would have counted as is being dealt again from the same button.
|
||||||
|
state.postedBlinds = null;
|
||||||
|
state.postedStraddles = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
returned,
|
||||||
|
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// Pulls the blinds back off the table and posts them again from wherever the
|
// Pulls the blinds back off the table and posts them again from wherever the
|
||||||
// button and the blind assignment now say they belong. Refunding first is what
|
// button and the blind assignment now say they belong. Refunding first is what
|
||||||
// keeps the chip count straight when the button gets passed round three or four
|
// keeps the chip count straight when the button gets passed round three or four
|
||||||
@@ -325,6 +574,26 @@ const Betting = {
|
|||||||
state.street = "preflop";
|
state.street = "preflop";
|
||||||
state.currentBet = 0;
|
state.currentBet = 0;
|
||||||
state.lastRaiseSize = 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
|
// 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
|
// somebody before the blinds are worked out — not on the empty seat 1 a
|
||||||
@@ -340,24 +609,148 @@ const Betting = {
|
|||||||
const bb = Number(state.bigBlind) || 0;
|
const bb = Number(state.bigBlind) || 0;
|
||||||
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
|
||||||
|
|
||||||
if (sbIndex === null || bbIndex === null) {
|
// 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) {
|
||||||
state.currentBet = 0;
|
state.currentBet = 0;
|
||||||
state.actingSeatIndex = null;
|
state.actingSeatIndex = null;
|
||||||
|
state.postedBlinds = null;
|
||||||
|
state.postedStraddles = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sb > 0) this.putIn(state, sbIndex, sb);
|
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
|
||||||
if (bb > 0) this.putIn(state, bbIndex, bb);
|
if (bb > 0) this.putIn(state, bbIndex, bb);
|
||||||
|
|
||||||
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
|
// Anyone the dealer said would post to come in past the blinds. It's a big
|
||||||
state.lastRaiseSize = bb || 0;
|
// 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);
|
||||||
|
|
||||||
// Heads-up the button is the small blind and acts first before the flop; with
|
// 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
|
// 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.
|
// 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
|
state.actingSeatIndex = headsUp
|
||||||
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
||||||
: this.nextToAct(state, bbIndex);
|
: 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Winners get their share put back in front of them.
|
// Winners get their share put back in front of them.
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ const HandHistoryExport = {
|
|||||||
// text, so the one thing anybody opens this file to do — total a column and see
|
// 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
|
// 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.
|
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||||
toCsv(handHistory, standings = []) {
|
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
||||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
// 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"]];
|
||||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||||
[...handHistory].reverse().forEach(entry => {
|
[...handHistory].reverse().forEach(entry => {
|
||||||
const payouts = (entry.payouts || [])
|
const payouts = (entry.payouts || [])
|
||||||
@@ -30,6 +34,7 @@ const HandHistoryExport = {
|
|||||||
entry.handNumber,
|
entry.handNumber,
|
||||||
this.formatTimestamp(entry.timestamp),
|
this.formatTimestamp(entry.timestamp),
|
||||||
Money.forSheet(entry.pot),
|
Money.forSheet(entry.pot),
|
||||||
|
Money.forSheet(entry.rake || 0),
|
||||||
payouts,
|
payouts,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -41,18 +46,59 @@ const HandHistoryExport = {
|
|||||||
rows.push(["Session Standings"]);
|
rows.push(["Session Standings"]);
|
||||||
// Bought in and chips now are what a cash-out is settled from; paid in and
|
// 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.
|
// won describe the hands. Both belong in the file the dealer hands over.
|
||||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
|
//
|
||||||
|
// 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"]);
|
||||||
standings.forEach(row => {
|
standings.forEach(row => {
|
||||||
rows.push([
|
rows.push([
|
||||||
row.name,
|
// Somebody who has gone home is marked in the file the same way the
|
||||||
|
// panel marks them, because this is the copy that gets read at the
|
||||||
|
// end of the night when nobody remembers who has already been paid.
|
||||||
|
row.cashedOut ? `${row.name} (paid out)` : row.name,
|
||||||
Money.forSheet(row.in),
|
Money.forSheet(row.in),
|
||||||
Money.forSheet(row.won),
|
Money.forSheet(row.won),
|
||||||
Money.forSheet(row.net),
|
Money.forSheet(row.net),
|
||||||
Money.forSheet(row.boughtIn || 0),
|
Money.forSheet(row.boughtIn || 0),
|
||||||
Money.forSheet(row.chipsNow || 0),
|
Money.forSheet(row.chipsNow || 0),
|
||||||
Money.forSheet(row.cashPosition || 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");
|
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||||
@@ -65,8 +111,8 @@ const HandHistoryExport = {
|
|||||||
return `holdem-hand-history-${stamp}.csv`;
|
return `holdem-hand-history-${stamp}.csv`;
|
||||||
},
|
},
|
||||||
|
|
||||||
download(handHistory, standings = []) {
|
download(handHistory, standings = [], options = {}) {
|
||||||
const csv = this.toCsv(handHistory, standings);
|
const csv = this.toCsv(handHistory, standings, options);
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -56,6 +56,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
|
||||||
|
|
||||||
|
// The waiting list, from the Session panel and from an empty chair's menu.
|
||||||
|
// The state change goes inside runUndoable, not before it: the snapshot is
|
||||||
|
// taken as the first thing that happens, so anything done ahead of it is
|
||||||
|
// already baked into what Undo would restore.
|
||||||
|
seatWaiting: (id, index) => {
|
||||||
|
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||||
|
const seat = state.seats[index];
|
||||||
|
if (!entry || !seat) {
|
||||||
|
Toast.show("They aren't on the list any more.", { duration: 3000 });
|
||||||
|
hooks.rerender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const occupied = HoldemState.seatInPlay(seat);
|
||||||
|
if (occupied) {
|
||||||
|
Toast.show(`${HoldemState.displayName(seat, index)} is already in that seat.`, { duration: 3500 });
|
||||||
|
hooks.rerender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runUndoable(`${entry.name} takes seat ${index + 1}. Add their chips when they buy in.`,
|
||||||
|
() => HoldemState.seatFromWaitingList(state, id, index));
|
||||||
|
},
|
||||||
|
|
||||||
|
dropWaiting: id => {
|
||||||
|
const entry = (state.waitingList || []).find(row => row.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
runUndoable(`${entry.name} taken off the waiting list.`,
|
||||||
|
() => HoldemState.removeFromWaitingList(state, id));
|
||||||
|
},
|
||||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,6 +125,52 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Somebody is done for the night. The dealer counts their chips out, the chair
|
||||||
|
// opens, and both halves of what happened stay on the record — the cash the box
|
||||||
|
// took from them, and the chips it handed back. Wiping the seat by hand did
|
||||||
|
// neither: it left the bank check declaring their whole stack missing for the
|
||||||
|
// rest of the night.
|
||||||
|
cashOut: async index => {
|
||||||
|
const allowed = HoldemState.canCashOut(state, index);
|
||||||
|
if (!allowed.ok) {
|
||||||
|
Toast.show(allowed.reason, { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = HoldemState.cashOutPreview(state, index);
|
||||||
|
const result = preview.net === 0
|
||||||
|
? "even for the night"
|
||||||
|
: preview.net > 0
|
||||||
|
? `up ${Money.format(preview.net)}`
|
||||||
|
: `down ${Money.format(Math.abs(preview.net))}`;
|
||||||
|
const chips = preview.breakdown ? ` — ${preview.breakdown}` : "";
|
||||||
|
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: `Cash out ${preview.name}?`,
|
||||||
|
message: `Count out ${Money.format(preview.chips)}${chips}. They bought in for ` +
|
||||||
|
`${Money.format(preview.boughtIn)}, so they leave ${result}. The chair opens up, and the ` +
|
||||||
|
`night's record keeps both their buy-in and this payout so the bank still balances.`,
|
||||||
|
confirmLabel: `Pay ${Money.format(preview.chips)}`,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
runUndoable(`${preview.name} cashed out for ${Money.format(preview.chips)}.`,
|
||||||
|
() => HoldemState.cashOut(state, index));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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 => {
|
makeDealer: index => {
|
||||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
// 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
|
// After it, the blinds have to follow the button or the badges and the
|
||||||
@@ -108,6 +183,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||||
: `Button on ${nameOf(index)}.`, () => {
|
: `Button on ${nameOf(index)}.`, () => {
|
||||||
state.dealerSeatIndex = 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);
|
if (willRepost) Betting.repostBlinds(state);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -140,11 +220,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
editSeat: index => Render.toggleSeatEditor(index),
|
editSeat: index => Render.toggleSeatEditor(index),
|
||||||
|
|
||||||
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
|
||||||
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
|
// 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 => {
|
||||||
// Chips in front and cash in are different questions — the stack moves
|
// Chips in front and cash in are different questions — the stack moves
|
||||||
// every hand, what they've handed over only moves when they buy.
|
// every hand, what they've handed over only moves when they buy.
|
||||||
player.stack = Money.round(Betting.stackOf(state, player) + amount);
|
p.stack = Money.round(Betting.stackOf(state, p) + amount);
|
||||||
player.buyIns = Money.round((Number(player.buyIns) || 0) + 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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -158,11 +254,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
|
|
||||||
PotWizard.open(state, payouts => {
|
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
|
||||||
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||||
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
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);
|
||||||
|
}
|
||||||
|
// Anything the pot was priced with but nobody actually paid — the
|
||||||
|
// base pot and counted chips of the no-live-betting flow — comes off
|
||||||
|
// the stacks that owe it before the winner is paid. Otherwise the
|
||||||
|
// table gains the whole pot out of nowhere.
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
if (payouts.length > 0) {
|
if (payouts.length > 0) {
|
||||||
HoldemState.recordHand(state, payouts);
|
HoldemState.recordHand(state, payouts, rake);
|
||||||
// Winnings go straight back in front of the player, so the next
|
// Winnings go straight back in front of the player, so the next
|
||||||
// hand's stacks are already right.
|
// hand's stacks are already right.
|
||||||
Betting.creditWinnings(state, payouts);
|
Betting.creditWinnings(state, payouts);
|
||||||
@@ -266,12 +371,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// in progress. Shrinking the table throws these away permanently, and it also
|
// in progress. Shrinking the table throws these away permanently, and it also
|
||||||
// orphans them in the session standings, where they keep their winnings but lose
|
// orphans them in the session standings, where they keep their winnings but lose
|
||||||
// the stack and buy-in the settle-up is worked out from.
|
// the stack and buy-in the settle-up is worked out from.
|
||||||
const seatHasSomeoneIn = player =>
|
// One question, asked in one place. This predicate existed here, in the seat menu,
|
||||||
!!(player.name && player.name.trim()) ||
|
// in the action bar and twice in the state module, in four slightly different
|
||||||
player.inHand ||
|
// spellings — and a chair that counts as occupied for the waiting list but empty
|
||||||
(Number(player.stack) || 0) > 0 ||
|
// for the seat count is how somebody's chips get thrown away.
|
||||||
(Number(player.buyIns) || 0) > 0 ||
|
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
|
||||||
(Number(player.amountOverride) || 0) > 0;
|
|
||||||
|
|
||||||
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
||||||
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
||||||
@@ -330,6 +434,59 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A misdeal. Everything in the middle goes back to whoever put it there, and the
|
||||||
|
// same button deals the same hand again.
|
||||||
|
// The seats holding the table's starting stack, written down as cash taken.
|
||||||
|
document.getElementById("bankRecordBuyInsBtn").addEventListener("click", async () => {
|
||||||
|
const starting = HoldemState.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||||
|
if (starting.length === 0) return;
|
||||||
|
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||||
|
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: `Write down ${Money.format(total)} taken?`,
|
||||||
|
message: `${starting.map(row => `${row.name} ${Money.format(row.chips)}`).join(", ")} — the starting stack ` +
|
||||||
|
`each of them is playing with. Only do this if the cash is actually in the box.`,
|
||||||
|
confirmLabel: "Write it down",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
runUndoable(`${Money.format(total)} recorded from ${starting.length} ${starting.length === 1 ? "player" : "players"}.`,
|
||||||
|
() => HoldemState.recordStartingBuyIns(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("waitingListForm").addEventListener("submit", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const field = document.getElementById("waitingNameInput");
|
||||||
|
const name = field.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
field.value = "";
|
||||||
|
runUndoable(`${name} added to the waiting list.`, () => HoldemState.addToWaitingList(state, name));
|
||||||
|
field.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("misdealBtn").addEventListener("click", async () => {
|
||||||
|
const inPlay = HoldemState.unresolvedContributions(state);
|
||||||
|
const acted = state.seats.filter(p => p.hasActed).length;
|
||||||
|
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Misdeal — take the hand back?",
|
||||||
|
message: inPlay > Betting.EPSILON_CHIPS
|
||||||
|
? `${Money.format(inPlay)} goes back to the players who put it in, cards are cleared, and the same ` +
|
||||||
|
`button deals again.${acted > 1 ? " Note that most rooms let a deal stand once two players have acted." : ""}`
|
||||||
|
: "Cards are cleared and the same button deals again. Nothing has been put in yet, so no chips move.",
|
||||||
|
confirmLabel: "Take it back",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// The label is both the toast a dealer reads now and the words on the undo
|
||||||
|
// control afterwards, so it says what happened to the money rather than naming
|
||||||
|
// the ruling: "the misdeal" told a table nothing about where their chips went.
|
||||||
|
const label = inPlay > Betting.EPSILON_CHIPS
|
||||||
|
? `Misdeal — ${Money.format(inPlay)} returned, same button deals again.`
|
||||||
|
: "Misdeal — nothing was in yet, same button deals again.";
|
||||||
|
runUndoable(label, () => Betting.takeHandBack(state));
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
||||||
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||||
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||||
@@ -347,10 +504,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// startNextHand moves the button on by one; say where it landed so the
|
// Say where the button landed so the dealer can confirm the rotation without
|
||||||
// dealer can confirm the rotation without hunting for the badge.
|
// hunting for the badge. Asked of the rotation itself rather than guessed at as
|
||||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
// "the next chair along" — which was only ever right at a table with nobody
|
||||||
const where = state.dedicatedDealer
|
// 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}`
|
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||||
: `button on seat ${nextDealer + 1}`;
|
: `button on seat ${nextDealer + 1}`;
|
||||||
const discarded = unresolved > Betting.EPSILON_CHIPS
|
const discarded = unresolved > Betting.EPSILON_CHIPS
|
||||||
@@ -358,6 +522,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
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);
|
HoldemState.startNextHand(state);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -378,6 +548,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
Toast.show("Table reset.", { duration: 2500 });
|
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);
|
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||||
|
|
||||||
[
|
[
|
||||||
@@ -392,6 +585,25 @@ 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("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||||
|
|
||||||
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||||
@@ -406,7 +618,90 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
// 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("closeRulingsBtn").addEventListener("click", closeRulings);
|
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
||||||
rulingsOverlay.addEventListener("click", e => {
|
rulingsOverlay.addEventListener("click", e => {
|
||||||
if (e.target === rulingsOverlay) closeRulings();
|
if (e.target === rulingsOverlay) closeRulings();
|
||||||
@@ -438,13 +733,108 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
HoldemState.clearHistory(state);
|
HoldemState.clearHistory(state);
|
||||||
|
Render.historyFilter = "";
|
||||||
|
document.getElementById("handHistoryFilter").value = "";
|
||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
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", () => {
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||||
if ((state.handHistory || []).length === 0) return;
|
if ((state.handHistory || []).length === 0) return;
|
||||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
// 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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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,6 +48,114 @@ const Money = {
|
|||||||
return (this.toCents(amount) / 100).toFixed(2);
|
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
|
// "+$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.
|
// glance, and the sign belongs out front where the eye lands first.
|
||||||
formatSigned(amount) {
|
formatSigned(amount) {
|
||||||
|
|||||||
@@ -232,6 +232,24 @@ const PotWizard = {
|
|||||||
return wrap;
|
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() {
|
renderSummary() {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "potWizardSummary";
|
wrap.className = "potWizardSummary";
|
||||||
@@ -240,16 +258,43 @@ const PotWizard = {
|
|||||||
heading.textContent = "Final Payouts";
|
heading.textContent = "Final Payouts";
|
||||||
wrap.appendChild(heading);
|
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) {
|
if (this.totals.size === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "fieldHint";
|
empty.className = "fieldHint";
|
||||||
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
|
||||||
wrap.appendChild(empty);
|
wrap.appendChild(empty);
|
||||||
} else {
|
} else {
|
||||||
|
const legend = (this.state && this.state.chipLegend) || [];
|
||||||
const list = document.createElement("ul");
|
const list = document.createElement("ul");
|
||||||
for (const [id, amount] of this.totals.entries()) {
|
for (const { id, amount } of split.payouts) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
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);
|
list.appendChild(li);
|
||||||
}
|
}
|
||||||
wrap.appendChild(list);
|
wrap.appendChild(list);
|
||||||
@@ -262,13 +307,12 @@ const PotWizard = {
|
|||||||
applyBtn.className = "holdemBtn holdemBtnAccent";
|
applyBtn.className = "holdemBtn holdemBtnAccent";
|
||||||
applyBtn.textContent = "Start Next Hand";
|
applyBtn.textContent = "Start Next Hand";
|
||||||
applyBtn.addEventListener("click", () => {
|
applyBtn.addEventListener("click", () => {
|
||||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
// Recomputed rather than reused from the render: the same numbers, but
|
||||||
id,
|
// taken at the moment the dealer commits rather than when they last
|
||||||
label: this.playerLabelById.get(id),
|
// looked at the screen.
|
||||||
amount,
|
const paid = this.rakeSplit();
|
||||||
}));
|
|
||||||
this.close();
|
this.close();
|
||||||
this.onFinished(payouts);
|
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
|
||||||
});
|
});
|
||||||
actions.appendChild(applyBtn);
|
actions.appendChild(applyBtn);
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,80 @@ 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() {
|
updateUneven() {
|
||||||
const players = this.entries
|
const players = this.entries
|
||||||
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
||||||
@@ -218,7 +292,7 @@ const QuickSplit = {
|
|||||||
}
|
}
|
||||||
empty.hidden = true;
|
empty.hidden = true;
|
||||||
|
|
||||||
const addRow = (label, amount, who, className) => {
|
const addRow = (label, amount, who, className, chopWays) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = `unevenPot ${className || ""}`;
|
row.className = `unevenPot ${className || ""}`;
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
@@ -234,9 +308,19 @@ const QuickSplit = {
|
|||||||
row.appendChild(value);
|
row.appendChild(value);
|
||||||
row.appendChild(eligible);
|
row.appendChild(eligible);
|
||||||
host.appendChild(row);
|
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(", ")));
|
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
|
||||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
||||||
|
|
||||||
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const Render = {
|
|||||||
this.renderNotes();
|
this.renderNotes();
|
||||||
this.updatePotBadge();
|
this.updatePotBadge();
|
||||||
this.renderSetupFields();
|
this.renderSetupFields();
|
||||||
|
this.renderHeaderActions();
|
||||||
this.renderTableHint();
|
this.renderTableHint();
|
||||||
this.renderStandings();
|
this.renderStandings();
|
||||||
this.renderHandHistory();
|
this.renderHandHistory();
|
||||||
@@ -47,13 +48,46 @@ const Render = {
|
|||||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
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)}.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
|
||||||
|
// is nothing to take back, and afterwards the header would be offering to unwind a
|
||||||
|
// hand that has already been paid — so the button comes and goes with the hand
|
||||||
|
// rather than sitting there all night waiting to be pressed by accident.
|
||||||
|
renderHeaderActions() {
|
||||||
|
const misdeal = document.getElementById("misdealBtn");
|
||||||
|
if (!misdeal) return;
|
||||||
|
misdeal.hidden = !this.state.bettingOpen;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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() {
|
renderTableHint() {
|
||||||
const hint = document.getElementById("tableHint");
|
const hint = document.getElementById("tableHint");
|
||||||
// Gestures are invisible until someone tells you about them, and this is the
|
// Gestures are invisible until someone tells you about them, and this is the
|
||||||
// only place on the table with room to say so.
|
// only place on the table with room to say so.
|
||||||
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
|
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.";
|
||||||
},
|
},
|
||||||
|
|
||||||
renderNotes() {
|
renderNotes() {
|
||||||
@@ -72,24 +106,205 @@ const Render = {
|
|||||||
|
|
||||||
// ---- Seats around the table (compact pucks) ----
|
// ---- Seats around the table (compact pucks) ----
|
||||||
|
|
||||||
seatPosition(index, count) {
|
seatPosition(index, count, aspect = this.feltAspect()) {
|
||||||
return this.positionOnRing(index, count, 44, 42);
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bets sit on the felt between their player and the pot, the way chips actually
|
// 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
|
// 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.
|
// match the seat ring (see the CSS), so this one radius holds at every size.
|
||||||
betPosition(index, count) {
|
//
|
||||||
return this.positionOnRing(index, count, 27, 25);
|
// 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);
|
||||||
|
return {
|
||||||
|
left: 50 + (seat.left - 50) * (27 / 44),
|
||||||
|
top: 50 + (seat.top - 50) * (25 / 42),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
positionOnRing(index, count, rx, ry) {
|
// Seats spaced evenly around the rim — by the distance between them, not by the
|
||||||
const angle = -90 + (360 / count) * index;
|
// angle they sit at.
|
||||||
const radians = (angle * Math.PI) / 180;
|
//
|
||||||
return {
|
// Equal angles are only equal spacing on a circle. The felt is an ellipse, and a
|
||||||
left: 50 + rx * Math.cos(radians),
|
// phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36°
|
||||||
top: 50 + ry * Math.sin(radians),
|
// 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
|
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
|
||||||
@@ -161,8 +376,15 @@ const Render = {
|
|||||||
renderSeats() {
|
renderSeats() {
|
||||||
const layer = document.getElementById("seatsLayer");
|
const layer = document.getElementById("seatsLayer");
|
||||||
layer.innerHTML = "";
|
layer.innerHTML = "";
|
||||||
|
this.updateSeatDensity();
|
||||||
const template = document.getElementById("seatTemplate");
|
const template = document.getElementById("seatTemplate");
|
||||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
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) => {
|
this.state.seats.forEach((player, index) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
@@ -171,11 +393,40 @@ const Render = {
|
|||||||
node.style.top = `${pos.top}%`;
|
node.style.top = `${pos.top}%`;
|
||||||
node.dataset.seatIndex = String(index);
|
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;
|
node.querySelector(".seatChairNumber").textContent = index + 1;
|
||||||
|
|
||||||
const dealerChip = node.querySelector(".dealerChip");
|
const dealerChip = node.querySelector(".dealerChip");
|
||||||
dealerChip.classList.toggle("active", index === dealerIndex);
|
const hasButton = index === dealerIndex;
|
||||||
|
dealerChip.classList.toggle("active", hasButton);
|
||||||
|
// Marked on the seat as well as the chip, so the puck's own text can keep
|
||||||
|
// out from under it. A name is centred across the whole puck and the chip
|
||||||
|
// sits in the corner on a layer above: measured, "Christopher" on the
|
||||||
|
// button lost 20px of its 64px box to the chip and showed no ellipsis,
|
||||||
|
// because the text believed it had the room it was painted out of.
|
||||||
|
node.classList.toggle("seatHasButton", hasButton);
|
||||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
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,
|
// 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
|
// so it goes through the same hook — otherwise this one path would move
|
||||||
// the button without moving the blinds that follow it.
|
// the button without moving the blinds that follow it.
|
||||||
@@ -197,6 +448,14 @@ const Render = {
|
|||||||
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||||||
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
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);
|
this.updatePuckContent(node, player, index);
|
||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||||
|
|
||||||
@@ -269,11 +528,18 @@ const Render = {
|
|||||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||||
this.fitAmount(totalEl);
|
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 stackEl = node.querySelector(".seatPuckStack");
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
const allIn = Betting.isAllIn(this.state, player);
|
||||||
|
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
|
||||||
|
stackEl.classList.toggle("seatPuckAllIn", allIn);
|
||||||
this.fitAmount(stackEl);
|
this.fitAmount(stackEl);
|
||||||
|
|
||||||
|
node.classList.toggle("seatAllIn", allIn);
|
||||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||||
node.classList.toggle("seatEmpty", empty);
|
node.classList.toggle("seatEmpty", empty);
|
||||||
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
node.classList.toggle("seatInactive", !player.inHand && !empty);
|
||||||
@@ -478,13 +744,41 @@ const Render = {
|
|||||||
this.updatePotBadge();
|
this.updatePotBadge();
|
||||||
this.renderSeatTotal(form, player);
|
this.renderSeatTotal(form, player);
|
||||||
this.refreshPuck(index);
|
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) {
|
wireSeatFields(form, player, index) {
|
||||||
const nameInput = form.querySelector(".seatNameInput");
|
const nameInput = form.querySelector(".seatNameInput");
|
||||||
nameInput.value = player.name;
|
nameInput.value = player.name;
|
||||||
nameInput.addEventListener("input", () => {
|
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;
|
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);
|
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||||
this.commitSeatChange(form, player, index);
|
this.commitSeatChange(form, player, index);
|
||||||
});
|
});
|
||||||
@@ -630,6 +924,55 @@ const Render = {
|
|||||||
|
|
||||||
list.appendChild(row);
|
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;
|
||||||
|
|
||||||
|
// What each player is holding, asked the same way the felt asks it — which
|
||||||
|
// includes the table's starting stack standing in for a seat that has never
|
||||||
|
// been given one. Reading `player.stack` here meant that a table opened the
|
||||||
|
// ordinary way, with "everyone starts with 200", showed an empty colour-up
|
||||||
|
// panel: nobody to count out, at the table where every single stack is a
|
||||||
|
// round number made of the largest chips there are.
|
||||||
|
const legend = this.state.chipLegend || [];
|
||||||
|
const rows = this.state.seats
|
||||||
|
.map((player, index) => ({ player, index, held: HoldemState.chipsHeldBy(this.state, player) }))
|
||||||
|
.filter(({ held }) => held > 0)
|
||||||
|
.map(({ player, index, held }) => ({
|
||||||
|
name: HoldemState.displayName(player, index),
|
||||||
|
stack: held,
|
||||||
|
chips: Money.formatChipBreakdown(held, 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 ----
|
// ---- Pot breakdown ----
|
||||||
@@ -692,7 +1035,14 @@ const Render = {
|
|||||||
const template = document.getElementById("standingsRowTemplate");
|
const template = document.getElementById("standingsRowTemplate");
|
||||||
standings.forEach(row => {
|
standings.forEach(row => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
node.querySelector(".standingsName").textContent = row.name;
|
// Somebody who has gone home is still on the list — they played, and the
|
||||||
|
// box took their cash — but the number beside them is money already handed
|
||||||
|
// over, not chips waiting to be counted. Marked, or a dealer settling up at
|
||||||
|
// 2am pays them twice.
|
||||||
|
node.classList.toggle("standingsGone", !!row.cashedOut);
|
||||||
|
node.querySelector(".standingsName").textContent = row.cashedOut
|
||||||
|
? `${row.name} · paid out`
|
||||||
|
: row.name;
|
||||||
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
node.querySelector(".standingsIn").textContent = Money.format(row.in);
|
||||||
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
node.querySelector(".standingsWon").textContent = Money.format(row.won);
|
||||||
|
|
||||||
@@ -701,9 +1051,15 @@ const Render = {
|
|||||||
netEl.classList.toggle("netUp", row.net > 0);
|
netEl.classList.toggle("netUp", row.net > 0);
|
||||||
netEl.classList.toggle("netDown", row.net < 0);
|
netEl.classList.toggle("netDown", row.net < 0);
|
||||||
|
|
||||||
// What they'd actually be handed if the night ended now: chips in front of
|
// The cash to count out, which is a plain amount and never signed — this
|
||||||
// them less the cash they gave the banker. This was computed and written to
|
// is the one the banker's hands follow. It sat only in the CSV: on screen
|
||||||
// the CSV all along, but never shown to the person doing the paying out.
|
// 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.
|
||||||
const cashEl = node.querySelector(".standingsCashOut");
|
const cashEl = node.querySelector(".standingsCashOut");
|
||||||
cashEl.textContent = Money.formatSigned(row.cashPosition);
|
cashEl.textContent = Money.formatSigned(row.cashPosition);
|
||||||
cashEl.classList.toggle("netUp", row.cashPosition > 0);
|
cashEl.classList.toggle("netUp", row.cashPosition > 0);
|
||||||
@@ -713,6 +1069,152 @@ const Render = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.renderBankReconciliation();
|
this.renderBankReconciliation();
|
||||||
|
this.renderSettleUp(standings);
|
||||||
|
this.renderWaitingList();
|
||||||
|
this.renderStorageWarning();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Who is standing behind the chairs, in the order they turned up. Each one carries
|
||||||
|
// the two things a dealer does with them: sit them down, or take them off the list
|
||||||
|
// because they've gone to the bar.
|
||||||
|
renderWaitingList() {
|
||||||
|
const list = document.getElementById("waitingList");
|
||||||
|
const hint = document.getElementById("waitingEmptyHint");
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const waiting = this.state.waitingList || [];
|
||||||
|
const open = HoldemState.openSeats(this.state);
|
||||||
|
list.innerHTML = "";
|
||||||
|
hint.hidden = waiting.length > 0;
|
||||||
|
|
||||||
|
waiting.forEach((entry, position) => {
|
||||||
|
const row = document.createElement("li");
|
||||||
|
row.className = "waitingRow";
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "waitingName";
|
||||||
|
name.textContent = entry.name;
|
||||||
|
row.appendChild(name);
|
||||||
|
|
||||||
|
// Only the person at the front is offered a seat, because a list nobody
|
||||||
|
// keeps in order is just a pile of names — and jumping the queue is the
|
||||||
|
// thing the dealer gets shouted at for.
|
||||||
|
if (position === 0 && open.length > 0) {
|
||||||
|
const seat = document.createElement("button");
|
||||||
|
seat.type = "button";
|
||||||
|
seat.className = "holdemBtn holdemBtnSmall waitingSeatBtn";
|
||||||
|
seat.textContent = `Seat ${open[0] + 1}`;
|
||||||
|
seat.title = `Sit ${entry.name} in seat ${open[0] + 1}`;
|
||||||
|
seat.addEventListener("click", () => this.hooks.seatWaiting(entry.id, open[0]));
|
||||||
|
row.appendChild(seat);
|
||||||
|
} else if (position === 0) {
|
||||||
|
const full = document.createElement("span");
|
||||||
|
full.className = "waitingNote";
|
||||||
|
full.textContent = "table full";
|
||||||
|
row.appendChild(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drop = document.createElement("button");
|
||||||
|
drop.type = "button";
|
||||||
|
drop.className = "holdemBtn holdemBtnSmall holdemBtnGhost waitingDropBtn";
|
||||||
|
drop.textContent = "✕";
|
||||||
|
drop.setAttribute("aria-label", `Take ${entry.name} off the waiting list`);
|
||||||
|
drop.title = `Take ${entry.name} off the list`;
|
||||||
|
drop.addEventListener("click", () => this.hooks.dropWaiting(entry.id));
|
||||||
|
row.appendChild(drop);
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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
|
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
||||||
@@ -729,18 +1231,88 @@ const Render = {
|
|||||||
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
||||||
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chips handed back to people who have gone home. The third way money leaves
|
||||||
|
// this table on purpose, and the one a dealer is asked about most: "where did
|
||||||
|
// Ann's four hundred go?"
|
||||||
|
const paidRow = document.getElementById("bankPaidOutRow");
|
||||||
|
if (paidRow) {
|
||||||
|
paidRow.hidden = !(bank.paidOut > 0);
|
||||||
|
if (bank.paidOut > 0) {
|
||||||
|
document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const verdict = document.getElementById("bankReconcileVerdict");
|
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,
|
||||||
|
bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
|
||||||
|
].filter(Boolean);
|
||||||
if (bank.balanced) {
|
if (bank.balanced) {
|
||||||
verdict.textContent = "Balanced — the cash covers every chip on the table.";
|
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.";
|
||||||
} else if (bank.difference > 0) {
|
} else if (bank.difference > 0) {
|
||||||
verdict.textContent =
|
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
|
||||||
`${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.`;
|
// dealer's next move is to go to a particular seat, and the tool knows
|
||||||
|
// which. The commonest cause by far is the table's own starting stack:
|
||||||
|
// seats holding chips that were never bought, because "everyone starts
|
||||||
|
// with 200" is how a night opens.
|
||||||
|
const owing = HoldemState.seatsOwingCash(this.state);
|
||||||
|
const names = owing.map(row => row.name);
|
||||||
|
const who = names.length === 1
|
||||||
|
? names[0]
|
||||||
|
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||||
|
verdict.textContent = owing.length > 0
|
||||||
|
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
|
||||||
|
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
|
||||||
|
: `${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.`;
|
||||||
} else {
|
} else {
|
||||||
verdict.textContent =
|
verdict.textContent =
|
||||||
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
|
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
|
||||||
}
|
}
|
||||||
verdict.classList.toggle("bankBalanced", bank.balanced);
|
verdict.classList.toggle("bankBalanced", bank.balanced);
|
||||||
verdict.classList.toggle("bankOff", !bank.balanced);
|
verdict.classList.toggle("bankOff", !bank.balanced);
|
||||||
|
|
||||||
|
// One tap for the seats that are only holding the table's starting stack —
|
||||||
|
// which is the dealer's own instruction, said once in the setup panel, rather
|
||||||
|
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
|
||||||
|
// sentence above and left to them, because only they know what it was.
|
||||||
|
const record = document.getElementById("bankRecordBuyInsBtn");
|
||||||
|
if (record) {
|
||||||
|
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
|
||||||
|
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
|
||||||
|
record.hidden = starting.length === 0;
|
||||||
|
if (starting.length > 0) {
|
||||||
|
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
|
||||||
|
record.title = starting.length === 1
|
||||||
|
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
|
||||||
|
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Hand counter + history ----
|
// ---- Hand counter + history ----
|
||||||
@@ -773,9 +1345,50 @@ const Render = {
|
|||||||
newest ? newest.handNumber : "",
|
newest ? newest.handNumber : "",
|
||||||
newest ? newest.timestamp : "",
|
newest ? newest.timestamp : "",
|
||||||
newest ? newest.pot : "",
|
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("|");
|
].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
|
// 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
|
// 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
|
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||||
@@ -794,10 +1407,29 @@ const Render = {
|
|||||||
emptyHint.hidden = history.length > 0;
|
emptyHint.hidden = history.length > 0;
|
||||||
document.getElementById("exportHistoryBtn").disabled = 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.
|
// Built off-document so the panel is laid out once instead of once per row.
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
const template = document.getElementById("handHistoryRowTemplate");
|
const template = document.getElementById("handHistoryRowTemplate");
|
||||||
history.forEach(entry => {
|
shown.forEach(entry => {
|
||||||
const row = template.content.firstElementChild.cloneNode(true);
|
const row = template.content.firstElementChild.cloneNode(true);
|
||||||
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
|
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
|
||||||
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
|
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ const SeatMenu = {
|
|||||||
hooks: null,
|
hooks: null,
|
||||||
seatIndex: null,
|
seatIndex: null,
|
||||||
|
|
||||||
|
mode: null,
|
||||||
|
|
||||||
open(state, hooks, seatIndex) {
|
open(state, hooks, seatIndex) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
this.hooks = hooks;
|
||||||
this.seatIndex = seatIndex;
|
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();
|
this.render();
|
||||||
document.getElementById("seatMenuOverlay").hidden = false;
|
document.getElementById("seatMenuOverlay").hidden = false;
|
||||||
},
|
},
|
||||||
@@ -41,6 +47,27 @@ const SeatMenu = {
|
|||||||
run: () => this.hooks.toggleDealtIn(index),
|
run: () => this.hooks.toggleDealtIn(index),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An empty chair with people waiting: the dealer taps the chair somebody is
|
||||||
|
// about to sit in, which is what their hand is doing anyway. Only the front of
|
||||||
|
// the queue is offered, for the same reason the panel only offers them — a list
|
||||||
|
// that isn't kept in order is just a pile of names.
|
||||||
|
const waiting = (this.state.waitingList || [])[0];
|
||||||
|
const emptyChair = !HoldemState.seatInPlay(player);
|
||||||
|
if (waiting && emptyChair) {
|
||||||
|
actions.push({
|
||||||
|
label: `Seat ${waiting.name}`,
|
||||||
|
className: "menuAccent",
|
||||||
|
run: () => this.hooks.seatWaiting(waiting.id, index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving for the night, which is a different thing from sitting out a hand:
|
||||||
|
// the chips go back over the rail and the chair opens. Offered only for a seat
|
||||||
|
// that has somebody in it, since an empty chair has nobody to pay.
|
||||||
|
if (HoldemState.seatInPlay(player)) {
|
||||||
|
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||||
}
|
}
|
||||||
@@ -63,29 +90,81 @@ 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) });
|
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||||
return actions;
|
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() {
|
render() {
|
||||||
const player = this.state.seats[this.seatIndex];
|
const player = this.state.seats[this.seatIndex];
|
||||||
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, 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);
|
||||||
|
|
||||||
const stack = Betting.stackOf(this.state, player);
|
const stack = Betting.stackOf(this.state, player);
|
||||||
const owed = Betting.toCall(this.state, player);
|
const owed = Betting.toCall(this.state, player);
|
||||||
const bits = [];
|
const bits = [];
|
||||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||||
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
|
subtitle.textContent = bits.join(" · ");
|
||||||
|
|
||||||
const list = document.getElementById("seatMenuActions");
|
|
||||||
list.innerHTML = "";
|
|
||||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||||
btn.textContent = action.label;
|
btn.textContent = action.label;
|
||||||
btn.addEventListener("click", () => {
|
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();
|
this.close();
|
||||||
action.run();
|
action.run();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 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,6 +18,23 @@ const Shortcuts = {
|
|||||||
escape: "cancel",
|
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) {
|
mount(handlers) {
|
||||||
this.handlers = handlers;
|
this.handlers = handlers;
|
||||||
document.addEventListener("keydown", e => this.onKey(e));
|
document.addEventListener("keydown", e => this.onKey(e));
|
||||||
@@ -40,6 +57,22 @@ const Shortcuts = {
|
|||||||
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
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) {
|
onKey(e) {
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
|
||||||
@@ -47,6 +80,8 @@ const Shortcuts = {
|
|||||||
const action = this.KEYS[key];
|
const action = this.KEYS[key];
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
|
if (this.focusedControlOwnsKey(key)) return;
|
||||||
|
|
||||||
// Escape is the exception: it should still close the raise pad even if focus
|
// Escape is the exception: it should still close the raise pad even if focus
|
||||||
// happens to be sitting in its amount field.
|
// happens to be sitting in its amount field.
|
||||||
if (this.isTyping() && !(action === "cancel")) return;
|
if (this.isTyping() && !(action === "cancel")) return;
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const HoldemState = {
|
|||||||
notes: "",
|
notes: "",
|
||||||
handNumber: 1,
|
handNumber: 1,
|
||||||
handHistory: [],
|
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,
|
smallBlind: 1,
|
||||||
bigBlind: 2,
|
bigBlind: 2,
|
||||||
defaultStack: null,
|
defaultStack: null,
|
||||||
@@ -78,27 +81,185 @@ const HoldemState = {
|
|||||||
// button order is the wrong answer often enough that it has to be
|
// button order is the wrong answer often enough that it has to be
|
||||||
// overridable. Cleared at the start of every hand.
|
// overridable. Cleared at the start of every hand.
|
||||||
blindOverride: null,
|
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,
|
||||||
|
// People who have taken their chips and gone. Kept for the night rather
|
||||||
|
// than forgotten with the seat: the box took their cash and paid it back
|
||||||
|
// out, and both halves have to stay on the record or the bank check reads
|
||||||
|
// as a shortfall for the rest of the evening.
|
||||||
|
cashedOut: [],
|
||||||
|
// People standing behind the chairs. A dealer at a full table keeps this
|
||||||
|
// list on a napkin; keeping it here means the tool can offer the next seat
|
||||||
|
// that opens to whoever has been waiting longest.
|
||||||
|
waitingList: [],
|
||||||
|
// 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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
load() {
|
// 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 {
|
try {
|
||||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
||||||
if (!raw) return this.createDefault();
|
} catch {
|
||||||
const parsed = JSON.parse(raw);
|
// No room to keep it. Nothing more to try, and the warning below is
|
||||||
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
// 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;
|
||||||
|
{
|
||||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
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
|
// Tables saved before live betting existed are missing its fields; fill
|
||||||
// them in rather than making the dealer reset a table mid-session.
|
// them in rather than making the dealer reset a table mid-session.
|
||||||
const defaults = this.createDefault();
|
const defaults = this.createDefault();
|
||||||
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
["smallBlind", "bigBlind", "defaultStack", "street", "currentBet", "lastRaiseSize",
|
||||||
"actingSeatIndex", "blindOverride"]
|
"actingSeatIndex", "blindOverride", "postedBlinds",
|
||||||
|
"streetAggressorIndex", "handAggressorIndex"]
|
||||||
.forEach(key => {
|
.forEach(key => {
|
||||||
if (parsed[key] === undefined) parsed[key] = defaults[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));
|
||||||
|
parsed.waitingList = Array.isArray(parsed.waitingList)
|
||||||
|
? parsed.waitingList
|
||||||
|
.map(row => (typeof row === "string" ? { id: null, name: row } : row))
|
||||||
|
.filter(row => row && String(row.name || "").trim())
|
||||||
|
.map(row => ({
|
||||||
|
id: row.id || this.makeId("waiting"),
|
||||||
|
name: String(row.name).trim().slice(0, 24),
|
||||||
|
addedAt: Number(row.addedAt) || 0,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
parsed.cashedOut = Array.isArray(parsed.cashedOut)
|
||||||
|
? parsed.cashedOut
|
||||||
|
.filter(row => row && row.id)
|
||||||
|
.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: String(row.name || "").slice(0, 40),
|
||||||
|
buyIns: Math.max(0, Money.round(Number(row.buyIns) || 0)),
|
||||||
|
chips: Math.max(0, Money.round(Number(row.chips) || 0)),
|
||||||
|
at: Number(row.at) || 0,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||||
|
|
||||||
// A table saved before dealing and betting were separate has no flag, and
|
// 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
|
// guessing false would show "deal the hand" over a hand already in
|
||||||
// progress. Anything already committed, or anyone already on the clock,
|
// progress. Anything already committed, or anyone already on the clock,
|
||||||
@@ -117,17 +278,111 @@ const HoldemState = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
|
||||||
return this.createDefault();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- 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) {
|
save(state) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
|
||||||
} catch {
|
return true;
|
||||||
// Storage full/unavailable (private browsing, etc.) — the table still works
|
} catch (error) {
|
||||||
// for the rest of the session, it just won't survive a refresh.
|
// 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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -245,6 +500,10 @@ const HoldemState = {
|
|||||||
// sits on nobody most hands — and that isn't only untidy: position and blinds are
|
// 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
|
// 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.
|
// 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) {
|
advanceButton(state) {
|
||||||
for (let step = 1; step <= state.seatCount; step++) {
|
for (let step = 1; step <= state.seatCount; step++) {
|
||||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
@@ -283,6 +542,10 @@ const HoldemState = {
|
|||||||
buttonSeatForDeal(state) {
|
buttonSeatForDeal(state) {
|
||||||
const seated = state.seats[state.dealerSeatIndex];
|
const seated = state.seats[state.dealerSeatIndex];
|
||||||
if (seated && seated.inHand) return 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++) {
|
for (let step = 0; step < state.seatCount; step++) {
|
||||||
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
const index = this.nextSeatIndex(state, state.dealerSeatIndex, step);
|
||||||
if (state.seats[index].inHand) return index;
|
if (state.seats[index].inHand) return index;
|
||||||
@@ -305,6 +568,13 @@ const HoldemState = {
|
|||||||
return o;
|
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
|
// 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.
|
// small blind, which is the opposite of the usual order.
|
||||||
getBlindSeats(state) {
|
getBlindSeats(state) {
|
||||||
@@ -336,11 +606,88 @@ const HoldemState = {
|
|||||||
return { dealerIndex, sbIndex, bbIndex, headsUp: true };
|
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 };
|
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) {
|
startNextHand(state) {
|
||||||
|
const rotation = this.nextButtonRotation(state);
|
||||||
|
if (rotation.buttonIndex === null) {
|
||||||
this.advanceButton(state);
|
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;
|
||||||
// Cards aren't out and nothing is posted until the dealer says so.
|
// Cards aren't out and nothing is posted until the dealer says so.
|
||||||
state.bettingOpen = false;
|
state.bettingOpen = false;
|
||||||
state.actingSeatIndex = null;
|
state.actingSeatIndex = null;
|
||||||
@@ -351,6 +698,13 @@ const HoldemState = {
|
|||||||
// the next one would freeze the blinds on two players for the rest of the
|
// 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.
|
// night, which is the opposite of what naming them once is for.
|
||||||
state.blindOverride = null;
|
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 => {
|
state.seats.forEach(p => {
|
||||||
p.folded = false;
|
p.folded = false;
|
||||||
p.cards = ["", ""];
|
p.cards = ["", ""];
|
||||||
@@ -370,11 +724,15 @@ const HoldemState = {
|
|||||||
// Must be called before startNextHand() clears the seats: the per-player `ledger`
|
// 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,
|
// 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.
|
// and it needs this hand's contributions while they're still on the table.
|
||||||
recordHand(state, payouts) {
|
recordHand(state, payouts, rake = 0) {
|
||||||
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||||
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
||||||
|
|
||||||
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
|
// 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 wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
|
||||||
const ledger = state.seats
|
const ledger = state.seats
|
||||||
.map((player, index) => ({
|
.map((player, index) => ({
|
||||||
@@ -389,6 +747,7 @@ const HoldemState = {
|
|||||||
handNumber: state.handNumber,
|
handNumber: state.handNumber,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
pot,
|
pot,
|
||||||
|
rake: taken,
|
||||||
payouts,
|
payouts,
|
||||||
ledger,
|
ledger,
|
||||||
});
|
});
|
||||||
@@ -424,6 +783,15 @@ const HoldemState = {
|
|||||||
// Cash bought in and chips currently in front don't come from the hand log at
|
// Cash bought in and chips currently in front don't come from the hand log at
|
||||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||||
|
// And for anyone who has gone home, off the record their cash-out left behind.
|
||||||
|
// Without this they read as a player who bought in for nothing and is holding
|
||||||
|
// nothing, in the very list the dealer settles the night from.
|
||||||
|
const goneById = new Map((state.cashedOut || []).map(row => [row.id, row]));
|
||||||
|
|
||||||
|
goneById.forEach((row, id) => {
|
||||||
|
if (totals.has(id)) return;
|
||||||
|
totals.set(id, { id, name: row.name, in: 0, won: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
// Anyone who has money in the game belongs in the settle-up, whether or not
|
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||||
@@ -435,7 +803,7 @@ const HoldemState = {
|
|||||||
if (totals.has(player.id)) return;
|
if (totals.has(player.id)) return;
|
||||||
const hasMoneyInPlay =
|
const hasMoneyInPlay =
|
||||||
(Number(player.buyIns) || 0) > 0 ||
|
(Number(player.buyIns) || 0) > 0 ||
|
||||||
(Number(player.stack) || 0) > 0 ||
|
this.chipsHeldBy(state, player) > 0 ||
|
||||||
(Number(player.amountOverride) || 0) > 0 ||
|
(Number(player.amountOverride) || 0) > 0 ||
|
||||||
(Number(player.committed) || 0) > 0;
|
(Number(player.committed) || 0) > 0;
|
||||||
if (!hasMoneyInPlay) return;
|
if (!hasMoneyInPlay) return;
|
||||||
@@ -445,8 +813,16 @@ const HoldemState = {
|
|||||||
return Array.from(totals.values())
|
return Array.from(totals.values())
|
||||||
.map(t => {
|
.map(t => {
|
||||||
const seat = seatById.get(t.id);
|
const seat = seatById.get(t.id);
|
||||||
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
const gone = goneById.get(t.id);
|
||||||
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
const boughtIn = seat
|
||||||
|
? Money.round(Number(seat.buyIns) || 0)
|
||||||
|
: (gone ? gone.buyIns : 0);
|
||||||
|
// What a departed player is "holding" is what the box handed them on
|
||||||
|
// the way out. It is no longer on the table, so it is marked as paid
|
||||||
|
// rather than counted again at the end of the night.
|
||||||
|
const chipsNow = seat
|
||||||
|
? this.chipsHeldBy(state, seat)
|
||||||
|
: (gone ? gone.chips : 0);
|
||||||
// Chips already pushed into the hand in progress are still this
|
// Chips already pushed into the hand in progress are still this
|
||||||
// player's money until it's won. Leaving them out makes the cash
|
// player's money until it's won. Leaving them out makes the cash
|
||||||
// position disagree with the hand log for as long as a hand is open —
|
// position disagree with the hand log for as long as a hand is open —
|
||||||
@@ -454,6 +830,14 @@ const HoldemState = {
|
|||||||
// window is almost always.
|
// window is almost always.
|
||||||
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
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 {
|
return {
|
||||||
...t,
|
...t,
|
||||||
in: Money.round(t.in),
|
in: Money.round(t.in),
|
||||||
@@ -461,12 +845,40 @@ const HoldemState = {
|
|||||||
net: Money.round(t.won - t.in),
|
net: Money.round(t.won - t.in),
|
||||||
boughtIn,
|
boughtIn,
|
||||||
chipsNow,
|
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),
|
||||||
|
// Set once they have taken their chips and left, so the panel and
|
||||||
|
// the export can say so rather than showing a player still sitting
|
||||||
|
// there with a stack.
|
||||||
|
cashedOut: !!gone && !seat,
|
||||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||||
|
notes,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.net - a.net);
|
.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.
|
// 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
|
// Every chip on the table is a promise to pay cash for it later. If the chips in
|
||||||
@@ -477,21 +889,301 @@ const HoldemState = {
|
|||||||
//
|
//
|
||||||
// Chips in play counts everything a player could hand back: what's in front of
|
// 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.
|
// them and what's in the middle waiting to be won.
|
||||||
|
// ---- The people standing behind the chairs ----
|
||||||
|
//
|
||||||
|
// A full home game always has a couple of them, and the list lives on a napkin or
|
||||||
|
// in the dealer's head — which is where the argument comes from an hour later about
|
||||||
|
// who said they were next. Kept in order of arrival, because that is the only
|
||||||
|
// ordering anybody has ever agreed to.
|
||||||
|
|
||||||
|
addToWaitingList(state, name) {
|
||||||
|
const trimmed = String(name || "").trim().slice(0, 24);
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (!Array.isArray(state.waitingList)) state.waitingList = [];
|
||||||
|
const entry = { id: this.makeId("waiting"), name: trimmed, addedAt: Date.now() };
|
||||||
|
state.waitingList.push(entry);
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromWaitingList(state, id) {
|
||||||
|
if (!Array.isArray(state.waitingList)) return null;
|
||||||
|
const index = state.waitingList.findIndex(row => row.id === id);
|
||||||
|
if (index < 0) return null;
|
||||||
|
return state.waitingList.splice(index, 1)[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Chairs with nobody in them, in seat order. What "a seat has opened" means, and
|
||||||
|
// the same question the deal prompt answers when it counts the players.
|
||||||
|
openSeats(state) {
|
||||||
|
return state.seats
|
||||||
|
.map((player, index) => ({ player, index }))
|
||||||
|
.filter(({ player }) => !this.seatInPlay(player))
|
||||||
|
.map(({ index }) => index);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sit the next person down. Their name goes on the chair and nothing else does:
|
||||||
|
// they haven't bought in yet, and guessing a stack for them would put chips on the
|
||||||
|
// table that nobody has paid for — which is the one thing the bank check exists to
|
||||||
|
// catch.
|
||||||
|
seatFromWaitingList(state, id, index) {
|
||||||
|
const seat = state.seats[index];
|
||||||
|
if (!seat) return { ok: false, reason: "There's no such seat." };
|
||||||
|
if (this.seatInPlay(seat)) {
|
||||||
|
return { ok: false, reason: `${this.displayName(seat, index)} is already in that seat.` };
|
||||||
|
}
|
||||||
|
const entry = this.removeFromWaitingList(state, id);
|
||||||
|
if (!entry) return { ok: false, reason: "They aren't on the list any more." };
|
||||||
|
|
||||||
|
seat.name = entry.name;
|
||||||
|
// Not dealt in and holding nothing: the next thing that happens is the dealer
|
||||||
|
// taking their money, and Add chips is what says so.
|
||||||
|
seat.inHand = false;
|
||||||
|
return { ok: true, name: entry.name, index };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Somebody leaving with their chips ----
|
||||||
|
//
|
||||||
|
// The most ordinary thing that happens at a home game after midnight, and the tool
|
||||||
|
// had nothing for it. A dealer's only way to free the chair was to wipe the seat by
|
||||||
|
// hand, and then the bank check — which adds up cash taken against chips on the
|
||||||
|
// table — declared the departing player's whole stack missing: "someone has paid
|
||||||
|
// for chips they haven't been given", for the rest of the night. Wiping their
|
||||||
|
// buy-in as well silenced it, at the price of losing their night from the standings
|
||||||
|
// and the file the dealer hands over.
|
||||||
|
//
|
||||||
|
// Cashing out is two facts, and both have to survive the empty chair: the box took
|
||||||
|
// their cash, and the box paid their chips back out.
|
||||||
|
|
||||||
|
// Can this seat be cashed out right now?
|
||||||
|
canCashOut(state, index) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
if (!player) return { ok: false, reason: "There's nobody in that seat." };
|
||||||
|
const inPot = Money.round((Number(player.committed) || 0) + (Number(player.amountOverride) || 0));
|
||||||
|
if (inPot > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
// Paying someone out with money still in the middle would either take
|
||||||
|
// chips out of a pot other people can win, or hand them chips twice.
|
||||||
|
reason: `${this.displayName(player, index)} has ${Money.format(inPot)} in the pot. Finish the hand first.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
// What the dealer counts out, and what the seat's night comes to.
|
||||||
|
cashOutPreview(state, index) {
|
||||||
|
const player = state.seats[index];
|
||||||
|
if (!player) return null;
|
||||||
|
// Betting owns the answer to "what is this seat holding" — including the rule
|
||||||
|
// that a seat which has never been given a stack is holding the table's
|
||||||
|
// starting one. Asking it rather than working it out again here: the first
|
||||||
|
// draft of this did, read `Number(null)` as a perfectly good zero, and offered
|
||||||
|
// to pay a player nothing.
|
||||||
|
const chips = Money.round(Math.max(0, Betting.stackOf(state, player)));
|
||||||
|
const boughtIn = Money.round(Math.max(0, Number(player.buyIns) || 0));
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
name: this.displayName(player, index),
|
||||||
|
chips,
|
||||||
|
boughtIn,
|
||||||
|
// Their result for the night, in the direction a banker says it: positive
|
||||||
|
// means the box is paying out more than it took from them.
|
||||||
|
net: Money.round(chips - boughtIn),
|
||||||
|
breakdown: Money.formatChipBreakdown(chips, state.chipLegend),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hand them their chips and free the chair, keeping both halves on the record.
|
||||||
|
cashOut(state, index) {
|
||||||
|
const allowed = this.canCashOut(state, index);
|
||||||
|
if (!allowed.ok) return allowed;
|
||||||
|
|
||||||
|
const preview = this.cashOutPreview(state, index);
|
||||||
|
const leavingId = state.seats[index].id;
|
||||||
|
if (!Array.isArray(state.cashedOut)) state.cashedOut = [];
|
||||||
|
state.cashedOut.push({
|
||||||
|
id: leavingId,
|
||||||
|
name: preview.name,
|
||||||
|
buyIns: preview.boughtIn,
|
||||||
|
chips: preview.chips,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The chair opens for the next player. A fresh id, because the person who had
|
||||||
|
// it has gone and their night is now filed under the old one — reusing it would
|
||||||
|
// merge two people's results the moment somebody new sits down.
|
||||||
|
const seat = this.makePlayer();
|
||||||
|
state.seats[index] = seat;
|
||||||
|
// Anyone owed a hand by this seat is no longer owed one.
|
||||||
|
state.postingIn = (state.postingIn || []).filter(id => id !== leavingId);
|
||||||
|
return { ok: true, ...preview };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Is anybody actually in this chair? A seat with a name on it, chips in front of
|
||||||
|
// it, cash against it or a hand dealt to it. Empty chairs are not players, which
|
||||||
|
// matters the moment the table's starting stack starts standing in for a seat that
|
||||||
|
// has never been given one — every empty chair would otherwise look like somebody
|
||||||
|
// holding two hundred dollars.
|
||||||
|
seatInPlay(player) {
|
||||||
|
if (!player) return false;
|
||||||
|
return !!(player.name || "").trim() ||
|
||||||
|
(Number(player.buyIns) || 0) > 0 ||
|
||||||
|
(Number(player.stack) || 0) > 0 ||
|
||||||
|
(Number(player.committed) || 0) > 0 ||
|
||||||
|
(Number(player.amountOverride) || 0) > 0 ||
|
||||||
|
player.inHand === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// What a seat is holding, the way the felt and the betting see it — which includes
|
||||||
|
// the table's starting stack standing in for a seat that has never been given one.
|
||||||
|
//
|
||||||
|
// This is the number the money side of the tool was missing. "Starting stack" is
|
||||||
|
// offered in the setup panel as the ordinary way to open a table, and everything a
|
||||||
|
// dealer looks at — the puck, the bar, what a player can bet — reads it. The bank
|
||||||
|
// check and the standings read `seat.stack` directly instead, which is null for
|
||||||
|
// exactly those seats: measured on a three-handed table with a $200 starting stack
|
||||||
|
// and nobody's own stack set, the felt played for $600 while the bank check said
|
||||||
|
// "no chips in play, balanced" and the standings listed nobody at all.
|
||||||
|
chipsHeldBy(state, player) {
|
||||||
|
if (!this.seatInPlay(player)) return 0;
|
||||||
|
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seats playing with chips the box has no cash against.
|
||||||
|
//
|
||||||
|
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||||
|
// stack has never been given chips of its own — the dealer set "everyone starts
|
||||||
|
// with 200" and sat people down, which is the ordinary way to open a table and the
|
||||||
|
// reason this whole gap existed. A seat with its own stack and no buy-in is a
|
||||||
|
// rebuy somebody forgot to write down, and only the dealer knows what it was.
|
||||||
|
seatsOwingCash(state) {
|
||||||
|
return state.seats
|
||||||
|
.map((player, index) => ({ player, index }))
|
||||||
|
.filter(({ player }) => this.chipsHeldBy(state, player) > 0 &&
|
||||||
|
!((Number(player.buyIns) || 0) > 0))
|
||||||
|
.map(({ player, index }) => ({
|
||||||
|
index,
|
||||||
|
name: this.displayName(player, index),
|
||||||
|
chips: this.chipsHeldBy(state, player),
|
||||||
|
// True when they are holding the table's starting stack rather than one
|
||||||
|
// the dealer typed for them.
|
||||||
|
fromStartingStack: player.stack === null || player.stack === undefined ||
|
||||||
|
String(player.stack).trim() === "",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Write down the cash for the seats that are holding the table's starting stack.
|
||||||
|
// Only those: what somebody else's unrecorded rebuy was is a question only the
|
||||||
|
// dealer can answer, and guessing it would be the tool inventing money.
|
||||||
|
recordStartingBuyIns(state) {
|
||||||
|
const owed = this.seatsOwingCash(state).filter(row => row.fromStartingStack);
|
||||||
|
owed.forEach(row => { state.seats[row.index].buyIns = row.chips; });
|
||||||
|
return {
|
||||||
|
seats: owed.length,
|
||||||
|
total: Money.round(owed.reduce((sum, row) => sum + row.chips, 0)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
bankReconciliation(state) {
|
bankReconciliation(state) {
|
||||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
const gone = Array.isArray(state.cashedOut) ? state.cashedOut : [];
|
||||||
|
// Cash the box took from people who have since left counts as taken: it is in
|
||||||
|
// the box, and forgetting it with the seat is what used to make a cash-out read
|
||||||
|
// as a shortfall all night.
|
||||||
|
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0) +
|
||||||
|
gone.reduce((sum, row) => sum + Math.max(0, Number(row.buyIns) || 0), 0);
|
||||||
const chipsInPlay = state.seats.reduce(
|
const chipsInPlay = state.seats.reduce(
|
||||||
(sum, p) =>
|
(sum, p) =>
|
||||||
sum +
|
sum +
|
||||||
Math.max(0, Number(p.stack) || 0) +
|
this.chipsHeldBy(state, p) +
|
||||||
Math.max(0, Number(p.committed) || 0) +
|
Math.max(0, Number(p.committed) || 0) +
|
||||||
Math.max(0, Number(p.amountOverride) || 0),
|
Math.max(0, Number(p.amountOverride) || 0),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
const difference = Money.round(chipsInPlay - cashIn);
|
// 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));
|
||||||
|
|
||||||
|
// Chips handed back over the rail. Off the table like the other two, and the
|
||||||
|
// one the dealer can point at when somebody asks where the money went.
|
||||||
|
const paidOut = Money.round(gone.reduce((sum, row) => sum + Math.max(0, Number(row.chips) || 0), 0));
|
||||||
|
|
||||||
|
const difference = Money.round(chipsInPlay + discarded + raked + paidOut - cashIn);
|
||||||
return {
|
return {
|
||||||
cashIn: Money.round(cashIn),
|
cashIn: Money.round(cashIn),
|
||||||
chipsInPlay: Money.round(chipsInPlay),
|
chipsInPlay: Money.round(chipsInPlay),
|
||||||
|
discarded,
|
||||||
|
raked,
|
||||||
|
paidOut,
|
||||||
difference,
|
difference,
|
||||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||||
|
|||||||
+16
-1
@@ -36,7 +36,22 @@ 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
|
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
|
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
|
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||||
phones on their side.
|
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.
|
||||||
|
|
||||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
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
|
this one needs a browser and a running site. Run it after any change that adds or
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Chips the pot was priced with that nobody actually paid.
|
||||||
|
//
|
||||||
|
// There are two ways a contribution gets into this tool. Tapping the action moves
|
||||||
|
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
|
||||||
|
// hand total at the end of the street. The other is the "without live betting" panel —
|
||||||
|
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
|
||||||
|
// hand they are watching rather than driving, and nothing ever leaves a stack.
|
||||||
|
//
|
||||||
|
// The pot wizard prices both the same way and awarding it credits the winner, so in
|
||||||
|
// that second mode the table simply grew.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
const basePotTable = (base = 50, stack = 200) => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 4);
|
||||||
|
state.basePotAmount = base;
|
||||||
|
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].inHand = true;
|
||||||
|
state.seats[i].stack = stack;
|
||||||
|
state.seats[i].buyIns = stack;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pricedPot = (state) => state.seats.reduce(
|
||||||
|
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||||
|
|
||||||
|
describe("what nobody paid", () => {
|
||||||
|
test("comes off the stacks that owe it", () => {
|
||||||
|
const state = basePotTable();
|
||||||
|
const collected = Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(collected.total, 150);
|
||||||
|
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and lands where the rest of the tool looks for chips in the middle", () => {
|
||||||
|
const state = basePotTable();
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
|
||||||
|
assert.equal(HoldemState.unresolvedContributions(state), 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips counted out on a seat are money too", () => {
|
||||||
|
const state = basePotTable(0);
|
||||||
|
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
|
||||||
|
const collected = Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(collected.total, 20);
|
||||||
|
assert.equal(state.seats[0].stack, 180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what has already been paid", () => {
|
||||||
|
test("a live bet is left exactly alone", () => {
|
||||||
|
const state = basePotTable(0);
|
||||||
|
state.smallBlind = 5;
|
||||||
|
state.bigBlind = 10;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||||
|
|
||||||
|
const collected = Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(collected.total, 0, "the blinds are already off their stacks");
|
||||||
|
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nor is a swept street taken a second time", () => {
|
||||||
|
const state = basePotTable(0);
|
||||||
|
state.smallBlind = 5;
|
||||||
|
state.bigBlind = 10;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||||
|
|
||||||
|
assert.equal(Betting.collectAssumedContributions(state).total, 0);
|
||||||
|
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a base pot on top of a live bet only collects the difference", () => {
|
||||||
|
// The base pot says "assume everyone is in for fifty". Somebody who has already
|
||||||
|
// put twenty in owes thirty of it, not fifty.
|
||||||
|
const state = basePotTable(50);
|
||||||
|
state.seats[0].committed = 20;
|
||||||
|
state.seats[0].stack = 180;
|
||||||
|
const collected = Betting.collectAssumedContributions(state);
|
||||||
|
const ann = collected.taken.find(row => row.name === "Ann");
|
||||||
|
assert.equal(ann.amount, 30);
|
||||||
|
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what it will not do", () => {
|
||||||
|
test("take more than a player is holding", () => {
|
||||||
|
const state = basePotTable(50);
|
||||||
|
state.seats[1].stack = 20;
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
|
||||||
|
assert.equal(state.seats[1].amountOverride, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("touch an empty chair", () => {
|
||||||
|
const state = basePotTable();
|
||||||
|
const before = { ...state.seats[3] };
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(state.seats[3].stack, before.stack);
|
||||||
|
assert.equal(state.seats[3].amountOverride, before.amountOverride);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("or a seat that isn't in the hand", () => {
|
||||||
|
// The base pot stands in for "everyone put the same in", and somebody sitting
|
||||||
|
// out did not.
|
||||||
|
const state = basePotTable();
|
||||||
|
state.seats[2].inHand = false;
|
||||||
|
const collected = Betting.collectAssumedContributions(state);
|
||||||
|
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
|
||||||
|
assert.equal(state.seats[2].stack, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the whole hand", () => {
|
||||||
|
test("conserves the chips on the table, which it did not before", () => {
|
||||||
|
const state = basePotTable();
|
||||||
|
const total = chipsOnTable(state);
|
||||||
|
const pot = pricedPot(state);
|
||||||
|
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and the bank check stays balanced all the way through", () => {
|
||||||
|
const state = basePotTable();
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
|
||||||
|
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a live-betting hand is unchanged by any of this", () => {
|
||||||
|
const state = basePotTable(0);
|
||||||
|
state.smallBlind = 5;
|
||||||
|
state.bigBlind = 10;
|
||||||
|
const total = chipsOnTable(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
const pot = HoldemState.unresolvedContributions(state);
|
||||||
|
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
assert.equal(chipsOnTable(state), total);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collecting twice takes nothing the second time", () => {
|
||||||
|
// The pot wizard can be opened, cancelled and opened again.
|
||||||
|
const state = basePotTable();
|
||||||
|
Betting.collectAssumedContributions(state);
|
||||||
|
const stacks = state.seats.map(p => p.stack);
|
||||||
|
const again = Betting.collectAssumedContributions(state);
|
||||||
|
assert.equal(again.total, 0);
|
||||||
|
assert.deepEqual(state.seats.map(p => p.stack), stacks);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// 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"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => {
|
|||||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Calling the clock on a player.
|
||||||
|
//
|
||||||
|
// The bar already had a stopwatch saying how long somebody had been thinking. This is
|
||||||
|
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
|
||||||
|
// out loud, and a ruling at the end that depends on what the player was facing.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { ActionBar, Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
|
||||||
|
const onTable = (state) => {
|
||||||
|
ActionBar.state = state;
|
||||||
|
ActionBar.render = () => {};
|
||||||
|
ActionBar.stopClock();
|
||||||
|
return ActionBar;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dealt = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("starting one", () => {
|
||||||
|
test("it runs on the player who is actually on the clock", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(bar.clockRunning(), true);
|
||||||
|
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nobody to act there is nothing to call", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
const bar = onTable(state);
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(bar.clockRunning(), false);
|
||||||
|
assert.equal(bar.clockLeft(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the dealer can put it away again", () => {
|
||||||
|
const bar = onTable(dealt());
|
||||||
|
bar.callClock();
|
||||||
|
bar.stopClock();
|
||||||
|
assert.equal(bar.clockRunning(), false);
|
||||||
|
assert.equal(bar.clockLeft(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the count", () => {
|
||||||
|
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
|
||||||
|
|
||||||
|
test("is the player's own time and then a countdown", () => {
|
||||||
|
const bar = onTable(dealt());
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
|
||||||
|
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
|
||||||
|
assert.equal(at(bar, 35), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops at zero rather than going negative", () => {
|
||||||
|
const bar = onTable(dealt());
|
||||||
|
bar.callClock();
|
||||||
|
assert.equal(at(bar, 40), 0);
|
||||||
|
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what happens at zero", () => {
|
||||||
|
test("facing a bet, the hand is dead", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
// Whoever is up pre-flop is facing the big blind.
|
||||||
|
const ruling = bar.clockRuling();
|
||||||
|
assert.equal(ruling.verb, "fold");
|
||||||
|
assert.match(ruling.label, /hand is dead/);
|
||||||
|
assert.match(ruling.say, /facing \$2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
|
||||||
|
const state = dealt();
|
||||||
|
// Everyone calls round to the big blind, who has nothing to call.
|
||||||
|
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
|
||||||
|
const bar = onTable(state);
|
||||||
|
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
|
||||||
|
const ruling = bar.clockRuling();
|
||||||
|
assert.equal(ruling.verb, "check");
|
||||||
|
assert.match(ruling.label, /check for/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the ruling names the player, because the dealer has to say it out loud", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.seats[state.actingSeatIndex].name = "Priya";
|
||||||
|
const ruling = onTable(state).clockRuling();
|
||||||
|
assert.match(ruling.label, /Priya/);
|
||||||
|
assert.match(ruling.say, /Priya/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nobody to act there is no ruling to make", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.actingSeatIndex = null;
|
||||||
|
assert.equal(onTable(state).clockRuling(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what ends it", () => {
|
||||||
|
test("the player acting", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
bar.callClock();
|
||||||
|
const wasOn = state.actingSeatIndex;
|
||||||
|
Betting.call(state, wasOn);
|
||||||
|
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
|
||||||
|
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and it doesn't follow the action to the next player", () => {
|
||||||
|
// A clock is called on one decision. Left running it would put a countdown on
|
||||||
|
// somebody nobody asked about.
|
||||||
|
const state = dealt();
|
||||||
|
const bar = onTable(state);
|
||||||
|
bar.callClock();
|
||||||
|
Betting.call(state, state.actingSeatIndex);
|
||||||
|
assert.equal(bar.clockLeft(), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Somebody leaving with their chips.
|
||||||
|
//
|
||||||
|
// The most ordinary thing that happens at a home game after midnight, and the tool had
|
||||||
|
// nothing for it. Freeing the chair meant wiping the seat by hand, and then the bank
|
||||||
|
// check — cash taken against chips on the table — declared the departing player's whole
|
||||||
|
// stack missing for the rest of the night. Wiping their buy-in too silenced it, at the
|
||||||
|
// price of losing their night from the standings and the file the dealer hands over.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Money, HoldemState, Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const table = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||||
|
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].buyIns = 200;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("counting them out", () => {
|
||||||
|
test("the preview is what the dealer's hands follow", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
const preview = HoldemState.cashOutPreview(state, 0);
|
||||||
|
assert.equal(preview.name, "Ann");
|
||||||
|
assert.equal(preview.chips, 350, "what they are holding");
|
||||||
|
assert.equal(preview.boughtIn, 200);
|
||||||
|
assert.equal(preview.net, 150, "up for the night");
|
||||||
|
assert.match(preview.breakdown, /\d+×\$\d+/, "and the chips that make it");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a player who is down reads as down, not as a smaller stack", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 60;
|
||||||
|
assert.equal(HoldemState.cashOutPreview(state, 1).net, -140);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat that never set a stack is holding the table's starting stack", () => {
|
||||||
|
const state = table();
|
||||||
|
state.defaultStack = 200;
|
||||||
|
state.seats[2].stack = null;
|
||||||
|
assert.equal(HoldemState.cashOutPreview(state, 2).chips, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what it refuses", () => {
|
||||||
|
test("paying somebody with money still in the middle", () => {
|
||||||
|
const state = table();
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const bb = state.seats.findIndex(p => (Number(p.committed) || 0) > 0);
|
||||||
|
const refusal = HoldemState.canCashOut(state, bb);
|
||||||
|
assert.equal(refusal.ok, false);
|
||||||
|
assert.match(refusal.reason, /in the pot/);
|
||||||
|
// And the refusal is the whole story: nothing moved.
|
||||||
|
assert.equal(HoldemState.cashOut(state, bb).ok, false);
|
||||||
|
assert.equal((state.cashedOut || []).length, 0);
|
||||||
|
assert.ok(state.seats[bb].name, "the seat is untouched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty chair, which has nobody to pay", () => {
|
||||||
|
const state = table();
|
||||||
|
const refusal = HoldemState.canCashOut(state, 99);
|
||||||
|
assert.equal(refusal.ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the chair afterwards", () => {
|
||||||
|
test("is empty and ready for the next player", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
const seat = state.seats[0];
|
||||||
|
assert.equal(seat.name, "");
|
||||||
|
assert.equal(seat.stack, null);
|
||||||
|
assert.equal(seat.buyIns, 0);
|
||||||
|
assert.equal(seat.inHand, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("belongs to somebody new, so two people's nights can't merge", () => {
|
||||||
|
const state = table();
|
||||||
|
const wasId = state.seats[0].id;
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
assert.notEqual(state.seats[0].id, wasId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and isn't owed the blind the person before them was posting", () => {
|
||||||
|
const state = table();
|
||||||
|
state.postingIn = [state.seats[0].id, state.seats[1].id];
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
assert.deepEqual(state.postingIn, [state.seats[1].id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the bank check", () => {
|
||||||
|
test("balanced before, balanced after — the chips went somewhere known", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
state.seats[1].stack = 50;
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.paidOut, 350);
|
||||||
|
assert.equal(bank.cashIn, 800, "their buy-in is still cash the box took");
|
||||||
|
assert.equal(bank.chipsInPlay, 450);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("this is the fault it fixes: wiping the seat by hand loses $350", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
state.seats[1].stack = 50; // so the table starts out balanced
|
||||||
|
// What a dealer had to do before: empty the chair themselves.
|
||||||
|
state.seats[0].name = "";
|
||||||
|
state.seats[0].stack = null;
|
||||||
|
state.seats[0].inHand = false;
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, -350, "the tool called it missing money");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips that really are missing still show as missing", () => {
|
||||||
|
// Counting a cash-out must not become a way of swallowing a genuine shortfall.
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
state.seats[1].stack = 50; // balanced to start with
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
state.seats[2].stack = 150; // and now fifty chips have gone astray
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, -50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rake and a cash-out are counted apart", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
state.rakeTaken = 12;
|
||||||
|
state.seats[1].stack = 38; // the rake came out of Ben's stack
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.raked, 12);
|
||||||
|
assert.equal(bank.paidOut, 350);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the record of their night", () => {
|
||||||
|
test("they stay in the standings with what they bought and what they left with", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
|
||||||
|
const ann = HoldemState.sessionStandings(state).find(row => row.name === "Ann");
|
||||||
|
assert.ok(ann, "still on the list");
|
||||||
|
assert.equal(ann.boughtIn, 200);
|
||||||
|
assert.equal(ann.chipsInFront, 350, "what the box handed over");
|
||||||
|
assert.equal(ann.cashPosition, 150);
|
||||||
|
assert.equal(ann.cashedOut, true, "and marked, so nobody pays them twice");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody still sitting there is not marked", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
const ben = HoldemState.sessionStandings(state).find(row => row.name === "Ben");
|
||||||
|
assert.equal(ben.cashedOut, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two people leaving are both kept", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
state.seats[1].stack = 50;
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
HoldemState.cashOut(state, 1);
|
||||||
|
assert.equal(state.cashedOut.length, 2);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).paidOut, 400);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table saved before any of this existed has nobody cashed out", () => {
|
||||||
|
const parsed = JSON.parse(JSON.stringify(table()));
|
||||||
|
delete parsed.cashedOut;
|
||||||
|
const migrated = HoldemState.migrate(parsed);
|
||||||
|
assert.deepEqual(migrated.cashedOut, []);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(migrated).paidOut, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nonsense in a saved record is cleaned up rather than trusted", () => {
|
||||||
|
const parsed = JSON.parse(JSON.stringify(table()));
|
||||||
|
parsed.cashedOut = [
|
||||||
|
{ id: "a", name: "Ann", buyIns: -5, chips: "350", at: "nope" },
|
||||||
|
{ name: "no id" },
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
const migrated = HoldemState.migrate(parsed);
|
||||||
|
assert.equal(migrated.cashedOut.length, 1);
|
||||||
|
assert.equal(migrated.cashedOut[0].buyIns, 0);
|
||||||
|
assert.equal(migrated.cashedOut[0].chips, 350);
|
||||||
|
assert.equal(migrated.cashedOut[0].at, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the table carries on", () => {
|
||||||
|
test("the next hand deals round the empty chair", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
|
||||||
|
assert.equal(state.seats[0].inHand, false, "nobody is in the empty chair");
|
||||||
|
assert.equal(Betting.liveSeats(state).length, 3);
|
||||||
|
assert.ok(state.actingSeatIndex !== null, "somebody is on the clock");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and the money still adds up once it has", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 350;
|
||||||
|
state.seats[1].stack = 50;
|
||||||
|
HoldemState.cashOut(state, 0);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(Money.round(bank.chipsInPlay + bank.paidOut), bank.cashIn);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const Money = load("money");
|
|||||||
const PotCalculator = load("potCalculator");
|
const PotCalculator = load("potCalculator");
|
||||||
const SplitCalculator = load("splitCalculator");
|
const SplitCalculator = load("splitCalculator");
|
||||||
const Betting = load("betting");
|
const Betting = load("betting");
|
||||||
|
const SettleUp = load("settleUp");
|
||||||
const HandHistoryExport = load("handHistoryExport");
|
const HandHistoryExport = load("handHistoryExport");
|
||||||
|
|
||||||
// render.js exports { Render } rather than the namespace directly.
|
// render.js exports { Render } rather than the namespace directly.
|
||||||
@@ -28,6 +29,10 @@ globalThis.Render = Render;
|
|||||||
const HoldemState = require(path.join(SRC, "state.js"));
|
const HoldemState = require(path.join(SRC, "state.js"));
|
||||||
globalThis.HoldemState = HoldemState;
|
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 ----------------------------------------------------------
|
// ---- Table builders ----------------------------------------------------------
|
||||||
|
|
||||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||||
@@ -77,8 +82,10 @@ module.exports = {
|
|||||||
HandHistoryExport,
|
HandHistoryExport,
|
||||||
PotCalculator,
|
PotCalculator,
|
||||||
SplitCalculator,
|
SplitCalculator,
|
||||||
|
SettleUp,
|
||||||
Betting,
|
Betting,
|
||||||
HoldemState,
|
HoldemState,
|
||||||
|
ActionBar,
|
||||||
makeTable,
|
makeTable,
|
||||||
chipsOnTable,
|
chipsOnTable,
|
||||||
contributions,
|
contributions,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Taking a hand back.
|
||||||
|
//
|
||||||
|
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||||
|
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one of
|
||||||
|
// the first two cards exposed — and the tool had no way to do it. The only control that
|
||||||
|
// ended a live hand was Next Hand, which says out loud that it takes the money off the
|
||||||
|
// table without paying anybody. That is right for an abandoned pot and wrong for a
|
||||||
|
// misdeal, where the blinds and the calls belong to the people who put them in.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Money, Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||||
|
|
||||||
|
const dealt = (extra = {}) => {
|
||||||
|
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10, dealerSeatIndex: 0, ...extra });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("the money", () => {
|
||||||
|
test("goes back to the people who put it in", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const before = state.seats.map(p => Betting.stackOf(state, p) + Betting.committed(p));
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("including calls and raises, not just the blinds", () => {
|
||||||
|
const state = dealt();
|
||||||
|
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||||
|
Betting.call(state, state.actingSeatIndex);
|
||||||
|
assert.ok(Betting.potSoFar(state) > 50, "there is real money in the middle");
|
||||||
|
|
||||||
|
const taken = Betting.takeHandBack(state);
|
||||||
|
assert.equal(taken.total, 95, "blinds and both wagers");
|
||||||
|
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and across streets, once chips have been swept into hand totals", () => {
|
||||||
|
const state = dealt();
|
||||||
|
[3, 4, 5, 0, 1, 2].forEach(seat => {
|
||||||
|
if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||||
|
});
|
||||||
|
Betting.closeStreet(state);
|
||||||
|
assert.ok(state.seats.some(p => (Number(p.amountOverride) || 0) > 0), "the flop's money is banked");
|
||||||
|
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), [200, 200, 200, 200, 200, 200]);
|
||||||
|
assert.deepEqual(state.seats.map(p => p.amountOverride), [null, null, null, null, null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the table has exactly the chips it started with", () => {
|
||||||
|
const fresh = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||||
|
const total = chipsOnTable(fresh);
|
||||||
|
const state = dealt();
|
||||||
|
Betting.raiseTo(state, state.actingSeatIndex, 40);
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.equal(chipsOnTable(state), total);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing is thrown away — the discard pile doesn't move", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.discarded = 8;
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.equal(state.discarded, 8, "a misdeal is not an abandoned pot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it says what went back and to whom", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const taken = Betting.takeHandBack(state);
|
||||||
|
assert.equal(taken.returned.length, 2, "the two blinds");
|
||||||
|
assert.deepEqual(taken.returned.map(r => r.amount).sort((a, b) => a - b), [5, 10]);
|
||||||
|
assert.ok(taken.returned.every(r => r.name), "named, because the dealer says it out loud");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the table afterwards", () => {
|
||||||
|
test("is waiting to deal the same hand again", () => {
|
||||||
|
const state = dealt();
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.equal(state.bettingOpen, false, "the dealer says when");
|
||||||
|
assert.equal(state.street, "preflop");
|
||||||
|
assert.equal(state.actingSeatIndex, null);
|
||||||
|
assert.equal(state.currentBet, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the button where it was — a misdeal is not the next hand", () => {
|
||||||
|
const state = dealt({ dealerSeatIndex: 3 });
|
||||||
|
const handNumber = state.handNumber;
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.equal(state.dealerSeatIndex, 3);
|
||||||
|
assert.equal(state.handNumber, handNumber, "no hand has been played");
|
||||||
|
assert.equal((state.handHistory || []).length, 0, "and none is logged");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the straddle, which was announced and still stands", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 5, bigBlind: 10 });
|
||||||
|
state.straddleCount = 1;
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.deepEqual(state.postedStraddles, [3]);
|
||||||
|
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.equal(state.straddleCount, 1, "still on for the redeal");
|
||||||
|
assert.deepEqual(state.postedStraddles, [], "but nothing is out there yet");
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.deepEqual(state.postedStraddles, [3], "and it goes out again");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears the cards and the folds, because none of it happened", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.seats[3].cards = ["As", "Kd"];
|
||||||
|
Betting.fold(state, 3);
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.deepEqual(state.seats[3].cards, ["", ""]);
|
||||||
|
assert.equal(state.seats[3].folded, false);
|
||||||
|
assert.ok(state.seats.every(p => !p.hasActed));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deals again cleanly, with the same blinds on the same players", () => {
|
||||||
|
const state = dealt();
|
||||||
|
const first = state.seats.map(p => Betting.committed(p));
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.deepEqual(state.seats.map(p => Betting.committed(p)), first);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the bank still balances", () => {
|
||||||
|
test("through a misdeal and the redeal after it", () => {
|
||||||
|
const state = dealt();
|
||||||
|
state.seats.forEach(p => { p.buyIns = 200; });
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
|
||||||
|
Betting.raiseTo(state, state.actingSeatIndex, 60);
|
||||||
|
Betting.takeHandBack(state);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
assert.equal(bank.cashIn, 1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taking back a hand nobody had money in", () => {
|
||||||
|
test("is harmless: no blinds, nothing moves", () => {
|
||||||
|
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 0, bigBlind: 0 });
|
||||||
|
Betting.beginHand(state);
|
||||||
|
const taken = Betting.takeHandBack(state);
|
||||||
|
assert.equal(taken.total, 0);
|
||||||
|
assert.deepEqual(taken.returned, []);
|
||||||
|
assert.equal(state.bettingOpen, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,11 +96,26 @@ describe("the exported CSV", () => {
|
|||||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Standings rows: every column after the name has to be numeric.
|
// 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.
|
||||||
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
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 => {
|
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||||
splitRow(line).slice(1).forEach(cell => {
|
const cells = splitRow(line);
|
||||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
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`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// One question, one answer.
|
||||||
|
//
|
||||||
|
// The round before this found the bank check and the standings reading `seat.stack`
|
||||||
|
// where the felt reads the table's starting stack — two parts of the tool disagreeing
|
||||||
|
// about how much money is on the table. This is the sweep that followed: everywhere
|
||||||
|
// else the same two questions get asked, asked the same way.
|
||||||
|
//
|
||||||
|
// "What is this seat holding?" -> HoldemState.chipsHeldBy
|
||||||
|
// "Is anybody in this chair?" -> HoldemState.seatInPlay
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState, Betting, SettleUp } = require("./load");
|
||||||
|
|
||||||
|
const openedOnTheDefault = (stack = 200) => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 6);
|
||||||
|
state.defaultStack = stack;
|
||||||
|
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].inHand = true;
|
||||||
|
state.seats[i].buyIns = stack;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("what a seat is holding", () => {
|
||||||
|
test("is the same number the felt plays with", () => {
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
state.seats.forEach((player, i) => {
|
||||||
|
if (i > 2) return;
|
||||||
|
assert.equal(HoldemState.chipsHeldBy(state, player), Betting.stackOf(state, player));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and is nothing at all for an empty chair", () => {
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
// Betting's own answer for an empty chair is the table default, because it is
|
||||||
|
// only ever asked about seats that are in a hand. The money side has to ask
|
||||||
|
// about all ten chairs, so it checks whether anybody is in them first.
|
||||||
|
assert.equal(Betting.stackOf(state, state.seats[5]), 200);
|
||||||
|
assert.equal(HoldemState.chipsHeldBy(state, state.seats[5]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counts a stack the dealer typed over the table's default", () => {
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
state.seats[0].stack = 75;
|
||||||
|
assert.equal(HoldemState.chipsHeldBy(state, state.seats[0]), 75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("is anybody in this chair", () => {
|
||||||
|
const empty = () => HoldemState.createDefault().seats[0];
|
||||||
|
|
||||||
|
test("an untouched chair is empty", () => {
|
||||||
|
assert.equal(HoldemState.seatInPlay(empty()), false);
|
||||||
|
assert.equal(HoldemState.seatInPlay(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name, chips, cash, a bet or a dealt hand all count", () => {
|
||||||
|
[
|
||||||
|
["a name", p => { p.name = "Ann"; }],
|
||||||
|
["chips", p => { p.stack = 50; }],
|
||||||
|
["cash taken", p => { p.buyIns = 100; }],
|
||||||
|
["a bet in front of them", p => { p.committed = 5; }],
|
||||||
|
["money already swept in", p => { p.amountOverride = 20; }],
|
||||||
|
["a hand dealt to them", p => { p.inHand = true; }],
|
||||||
|
].forEach(([what, mark]) => {
|
||||||
|
const seat = empty();
|
||||||
|
mark(seat);
|
||||||
|
assert.equal(HoldemState.seatInPlay(seat), true, what);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("whitespace is not a name", () => {
|
||||||
|
const seat = empty();
|
||||||
|
seat.name = " ";
|
||||||
|
assert.equal(HoldemState.seatInPlay(seat), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the parts that used to disagree", () => {
|
||||||
|
test("the open seats and the seat count see the same empty chairs", () => {
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
// Three players, six chairs.
|
||||||
|
assert.deepEqual(HoldemState.openSeats(state), [3, 4, 5]);
|
||||||
|
const occupied = state.seats.filter(p => HoldemState.seatInPlay(p)).length;
|
||||||
|
assert.equal(occupied + HoldemState.openSeats(state).length, state.seats.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chair holding only a dealt hand is not offered to the waiting list", () => {
|
||||||
|
// The old test looked at name, buy-ins and stack. A seat marked in-hand with
|
||||||
|
// none of those would have been handed to the next person in the queue, on top
|
||||||
|
// of whoever was already playing it.
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
state.seats[4].inHand = true;
|
||||||
|
assert.deepEqual(HoldemState.openSeats(state), [3, 5]);
|
||||||
|
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 4);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the bank, the standings and the settle-up all price the night the same way", () => {
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
state.seats[0].stack = 320; // Ann is up
|
||||||
|
state.seats[1].stack = 80; // Ben is down
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
const standings = HoldemState.sessionStandings(state);
|
||||||
|
const chipsFromStandings = standings
|
||||||
|
.filter(row => !row.cashedOut)
|
||||||
|
.reduce((sum, row) => sum + row.chipsInFront, 0);
|
||||||
|
|
||||||
|
assert.equal(bank.chipsInPlay, chipsFromStandings, "same chips on the table");
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
|
||||||
|
// And the payments the dealer is told to make add up to the same money.
|
||||||
|
const plan = SettleUp.plan(standings);
|
||||||
|
const owed = standings.reduce((sum, row) => sum + Math.max(0, row.cashPosition), 0);
|
||||||
|
const paid = plan.payments.reduce((sum, row) => sum + row.amount, 0);
|
||||||
|
assert.equal(paid + (plan.shortBy || 0) - (plan.overBy || 0), owed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a player still holding the table's starting stack is in all three", () => {
|
||||||
|
const state = openedOnTheDefault();
|
||||||
|
const standings = HoldemState.sessionStandings(state);
|
||||||
|
assert.equal(standings.length, 3);
|
||||||
|
assert.deepEqual(standings.map(row => row.chipsInFront), [200, 200, 200]);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||||
|
assert.deepEqual(SettleUp.plan(standings).payments, [], "everyone is even, so nobody pays anybody");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,12 +68,24 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const page = await browser.newPage();
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
|
|
||||||
for (const [width, height, label] of VIEWPORTS) {
|
// 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, rawLabel] of VIEWPORTS) {
|
||||||
|
const label = `${rawLabel} · ${hand}`;
|
||||||
await page.setViewportSize({ width, height });
|
await page.setViewportSize({ width, height });
|
||||||
|
|
||||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||||
@@ -103,7 +115,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||||
|
const label = `${rawLabel} · ${hand}`;
|
||||||
await page.setViewportSize({ width, height });
|
await page.setViewportSize({ width, height });
|
||||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -114,11 +127,15 @@ async function main() {
|
|||||||
checks++;
|
checks++;
|
||||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
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) });
|
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 context.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
|
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
|
||||||
warnings.forEach(w => console.log(` note: ${w}`));
|
warnings.forEach(w => console.log(` note: ${w}`));
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
console.log("clean");
|
console.log("clean");
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// 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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// 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());
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// 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, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// The chips the felt plays with are the chips the box is checked against.
|
||||||
|
//
|
||||||
|
// "Starting stack" is offered in the setup panel as the ordinary way to open a table —
|
||||||
|
// "seats without their own stack start here" — and everything a dealer looks at reads
|
||||||
|
// it: the puck, the action bar, what a player is allowed to bet. The money side did
|
||||||
|
// not. It read `seat.stack` directly, which is null for exactly those seats, so a
|
||||||
|
// three-handed table playing for $600 reported no chips in play, a balanced bank and a
|
||||||
|
// standings list with nobody on it.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { Money, HoldemState, Betting } = require("./load");
|
||||||
|
|
||||||
|
const openedWithStartingStack = (stack = 200, names = ["Ann", "Ben", "Cal"]) => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 6);
|
||||||
|
state.defaultStack = stack;
|
||||||
|
names.forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].inHand = true;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("what the table is playing for", () => {
|
||||||
|
test("is what the bank counts", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
// What the felt says each of them has.
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => Betting.stackOf(state, p)), [200, 200, 200]);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and the box being empty is a problem it now reports", () => {
|
||||||
|
const bank = HoldemState.bankReconciliation(openedWithStartingStack());
|
||||||
|
assert.equal(bank.cashIn, 0);
|
||||||
|
assert.equal(bank.difference, 600);
|
||||||
|
assert.equal(bank.balanced, false, "six hundred dollars of chips nobody paid for");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty chairs are not people holding two hundred dollars", () => {
|
||||||
|
// The starting stack stands in for a seat that hasn't been given one, and every
|
||||||
|
// empty chair is such a seat. Counting them would invent $600 of chips at an
|
||||||
|
// empty table.
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).chipsInPlay, 600, "three players, not six chairs");
|
||||||
|
|
||||||
|
const empty = HoldemState.createDefault();
|
||||||
|
empty.defaultStack = 200;
|
||||||
|
assert.equal(HoldemState.bankReconciliation(empty).chipsInPlay, 0);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(empty).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once the cash is written down it balances", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table where everyone has their own stack is unchanged", () => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, 4);
|
||||||
|
["Ann", "Ben"].forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].stack = 150;
|
||||||
|
state.seats[i].buyIns = 150;
|
||||||
|
});
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.chipsInPlay, 300);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("who is on the settle-up list", () => {
|
||||||
|
test("everyone holding chips, including the ones holding the table's own stack", () => {
|
||||||
|
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||||
|
assert.deepEqual(rows.map(r => r.name), ["Ann", "Ben", "Cal"]);
|
||||||
|
assert.deepEqual(rows.map(r => r.chipsInFront), [200, 200, 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nothing bought in yet, which is the thing to notice", () => {
|
||||||
|
const rows = HoldemState.sessionStandings(openedWithStartingStack());
|
||||||
|
assert.deepEqual(rows.map(r => r.boughtIn), [0, 0, 0]);
|
||||||
|
assert.deepEqual(rows.map(r => r.cashPosition), [200, 200, 200]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("naming the seats the cash is missing for", () => {
|
||||||
|
test("says who, and which kind of missing it is", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
state.seats[2].stack = 320; // Cal typed his own: an unrecorded rebuy
|
||||||
|
|
||||||
|
const owing = HoldemState.seatsOwingCash(state);
|
||||||
|
assert.deepEqual(owing.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||||
|
assert.deepEqual(owing.map(row => row.fromStartingStack), [true, true, false]);
|
||||||
|
assert.deepEqual(owing.map(row => row.chips), [200, 200, 320]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat whose buy-in is written down isn't on the list", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
state.seats[0].buyIns = 200;
|
||||||
|
assert.deepEqual(HoldemState.seatsOwingCash(state).map(row => row.name), ["Ben", "Cal"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and a table where nothing is owed has nobody on it", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||||
|
assert.deepEqual(HoldemState.seatsOwingCash(state), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writing the starting stacks down", () => {
|
||||||
|
test("records exactly what those seats are holding", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
const done = HoldemState.recordStartingBuyIns(state);
|
||||||
|
assert.equal(done.seats, 3);
|
||||||
|
assert.equal(done.total, 600);
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and leaves somebody else's forgotten rebuy alone", () => {
|
||||||
|
// Only the dealer knows what that was. Guessing would be the tool inventing
|
||||||
|
// money, which is the fault this whole round is about.
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
state.seats[2].stack = 320;
|
||||||
|
HoldemState.recordStartingBuyIns(state);
|
||||||
|
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 0]);
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, 320, "still exactly Cal's chips, and still reported");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does nothing on a table that doesn't need it", () => {
|
||||||
|
const state = openedWithStartingStack();
|
||||||
|
state.seats.slice(0, 3).forEach(p => { p.buyIns = 200; });
|
||||||
|
const done = HoldemState.recordStartingBuyIns(state);
|
||||||
|
assert.equal(done.seats, 0);
|
||||||
|
assert.equal(done.total, 0);
|
||||||
|
assert.deepEqual(state.seats.slice(0, 3).map(p => p.buyIns), [200, 200, 200]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("through a hand", () => {
|
||||||
|
test("the money adds up from the deal to the payout", () => {
|
||||||
|
const state = openedWithStartingStack(200, ["Ann", "Ben", "Cal", "Dee"]);
|
||||||
|
HoldemState.recordStartingBuyIns(state);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
|
||||||
|
Betting.beginHand(state);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "blinds are still on the table");
|
||||||
|
|
||||||
|
[3, 0, 1].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
|
||||||
|
const pot = Betting.potSoFar(state);
|
||||||
|
Betting.sweepToHandTotals(state);
|
||||||
|
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||||
|
HoldemState.startNextHand(state);
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
assert.equal(bank.chipsInPlay, 800, "four buy-ins of two hundred, wherever they have moved to");
|
||||||
|
assert.ok(state.seats[0].stack > 200, "and the winner is holding more than they sat down with");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// 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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
+304
-16
@@ -93,10 +93,44 @@ const LayoutAudit = {
|
|||||||
return false;
|
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") {
|
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
[...document.querySelectorAll(selector)].forEach(el => {
|
// 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 => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) return;
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
@@ -180,6 +214,12 @@ const LayoutAudit = {
|
|||||||
collapsedColumns() {
|
collapsedColumns() {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
[...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;
|
if (getComputedStyle(row).display !== "grid") return;
|
||||||
[...row.children].forEach(cell => {
|
[...row.children].forEach(cell => {
|
||||||
if (getComputedStyle(cell).display === "none") return;
|
if (getComputedStyle(cell).display === "none") return;
|
||||||
@@ -343,6 +383,47 @@ 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 } = {}) {
|
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
const unmeasured = [];
|
const unmeasured = [];
|
||||||
@@ -352,19 +433,55 @@ const LayoutAudit = {
|
|||||||
: [...document.querySelectorAll("*")];
|
: [...document.querySelectorAll("*")];
|
||||||
|
|
||||||
nodes.forEach(el => {
|
nodes.forEach(el => {
|
||||||
if (el.children.length > 0) return; // leaf text only
|
// An element's *own* text, not its descendants' — they get measured on
|
||||||
const text = (el.textContent || "").trim();
|
// 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 (!text) return;
|
if (!text) return;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) return;
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
const style = getComputedStyle(el);
|
const style = getComputedStyle(el);
|
||||||
if (style.visibility === "hidden" || style.display === "none") return;
|
if (style.visibility === "hidden" || style.display === "none") return;
|
||||||
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
|
// Faded text used to be skipped outright as "faded on purpose", and that
|
||||||
// Decoration is exempt by definition: the watermark on the felt is meant
|
// exemption hid a real fault: the folded and sat-out seats dimmed their
|
||||||
// to be barely there, and holding it to a reading threshold would mean
|
// whole box with opacity, so a folded player's stack measured 2.22:1 at
|
||||||
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
// 11.5px and this check never looked at it. Something invisible is still
|
||||||
// too, which is the same claim expressed where it also does some good.
|
// exempt — there's nothing to read at zero — but anything in between is
|
||||||
if (el.closest("[aria-hidden='true']")) return;
|
// 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.
|
||||||
|
|
||||||
const fgRaw = this.parseColour(style.color);
|
const fgRaw = this.parseColour(style.color);
|
||||||
if (!fgRaw) return;
|
if (!fgRaw) return;
|
||||||
@@ -378,8 +495,8 @@ const LayoutAudit = {
|
|||||||
// The kindest reading available: best contrast across every candidate.
|
// The kindest reading available: best contrast across every candidate.
|
||||||
let best = 0;
|
let best = 0;
|
||||||
candidates.forEach(bg => {
|
candidates.forEach(bg => {
|
||||||
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
const painted = this.asPainted(fgRaw, bg, fade);
|
||||||
best = Math.max(best, this.contrastRatio(fg, bg));
|
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
|
||||||
});
|
});
|
||||||
|
|
||||||
const sizePx = parseFloat(style.fontSize);
|
const sizePx = parseFloat(style.fontSize);
|
||||||
@@ -457,10 +574,36 @@ const LayoutAudit = {
|
|||||||
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
||||||
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
||||||
}
|
}
|
||||||
|
const unmeasured = [];
|
||||||
this.visibleControls()
|
this.visibleControls()
|
||||||
.filter(el => el.type !== "color")
|
.filter(el => el.type !== "color")
|
||||||
.filter(el => !(exempt && el.matches(exempt)))
|
.filter(el => !(exempt && el.matches(exempt)))
|
||||||
.forEach(el => {
|
.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();
|
const rect = el.getBoundingClientRect();
|
||||||
// Drawn big enough is big enough — no need to probe, and probing wide
|
// 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
|
// elements is where the cap above used to produce nonsense. Seats sit
|
||||||
@@ -468,15 +611,35 @@ const LayoutAudit = {
|
|||||||
// measuring their reach would report the crowding as a fault.
|
// measuring their reach would report the crowding as a fault.
|
||||||
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
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);
|
const size = this.effectiveTarget(el);
|
||||||
if (!size) return;
|
// 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.width >= minimum - 1 && size.height >= minimum - 1) return;
|
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
||||||
offenders.push(
|
offenders.push(
|
||||||
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
||||||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tapping the middle of a seat must give you that seat.
|
// Tapping the middle of a seat must give you that seat.
|
||||||
@@ -490,6 +653,13 @@ const LayoutAudit = {
|
|||||||
// Reported separately from occlusion because the cause is different: not an
|
// 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.
|
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||||
seatOwnership() {
|
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 seats = [...document.querySelectorAll(".seat")];
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
|
|
||||||
@@ -514,8 +684,115 @@ const LayoutAudit = {
|
|||||||
return { name: "a seat's centre belongs to that seat", offenders };
|
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 };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anything that says it is hidden and is drawn anyway.
|
||||||
|
//
|
||||||
|
// `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
|
||||||
|
// rule in the cascade, so any class that sets a display beats it — and in this tool
|
||||||
|
// every button class does. The result is an element the code believes is gone,
|
||||||
|
// sitting on the screen: found as a 21x44 empty box under the bank check, and this
|
||||||
|
// file's own touch check had been calling it "could not be probed" rather than
|
||||||
|
// saying the plainer thing, which is that it should not be there at all.
|
||||||
|
hiddenButPainted() {
|
||||||
|
const offenders = [...document.querySelectorAll("[hidden]")]
|
||||||
|
.filter(el => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 || rect.height === 0) return false;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden";
|
||||||
|
})
|
||||||
|
.map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
|
||||||
|
return { name: "hidden means hidden", offenders };
|
||||||
|
},
|
||||||
|
|
||||||
|
// A seat's name, painted under the decoration on its own puck.
|
||||||
|
//
|
||||||
|
// The chip that marks the button sits in the puck's top-right corner on a layer
|
||||||
|
// above the text, and the name is centred across the whole width. A long one
|
||||||
|
// therefore ran underneath it and stopped — measured, "Christopher" on the button
|
||||||
|
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
|
||||||
|
// concerned it had all the room it needed. Nothing else here would have caught it:
|
||||||
|
// the occlusion check is about controls a thumb has to reach, and this is a label
|
||||||
|
// a dealer has to read.
|
||||||
|
seatLabelsClear() {
|
||||||
|
const offenders = [];
|
||||||
|
document.querySelectorAll(".seat").forEach(seat => {
|
||||||
|
const name = seat.querySelector(".seatPuckName");
|
||||||
|
if (!name || !name.textContent.trim()) return;
|
||||||
|
const box = name.getBoundingClientRect();
|
||||||
|
if (!box.width || !box.height) return;
|
||||||
|
// Padding is room the text has already been kept out of, so it is not
|
||||||
|
// somewhere the text can be covered.
|
||||||
|
const style = getComputedStyle(name);
|
||||||
|
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
||||||
|
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
||||||
|
|
||||||
|
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||||
|
const over = mark.getBoundingClientRect();
|
||||||
|
if (!over.width || !over.height) return;
|
||||||
|
const marked = getComputedStyle(mark);
|
||||||
|
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||||
|
parseFloat(marked.opacity) < 0.05) return;
|
||||||
|
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
|
||||||
|
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||||
|
if (covered > 1 && vertical > 1) {
|
||||||
|
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { name: "seat names aren't painted under their own badges", offenders };
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Runner ----
|
// ---- 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 = {}) {
|
runAll(options = {}) {
|
||||||
const checks = [
|
const checks = [
|
||||||
this.touchAction(),
|
this.touchAction(),
|
||||||
@@ -527,15 +804,26 @@ const LayoutAudit = {
|
|||||||
this.horizontalOverflow(),
|
this.horizontalOverflow(),
|
||||||
this.fieldFontSize(),
|
this.fieldFontSize(),
|
||||||
this.seatOwnership(),
|
this.seatOwnership(),
|
||||||
|
this.oneDealerButton(),
|
||||||
|
this.hiddenButPainted(),
|
||||||
|
this.seatLabelsClear(),
|
||||||
|
this.contrast(options.contrast),
|
||||||
|
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||||
];
|
];
|
||||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
viewport: `${innerWidth}x${innerHeight}`,
|
viewport: `${innerWidth}x${innerHeight}`,
|
||||||
failed: checks.filter(c => c.offenders.length > 0),
|
failed: checks.filter(c => c.offenders.length > 0),
|
||||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||||
warnings: checks.flatMap(c => c.warnings || []),
|
// Controls a check couldn't probe go here too: not a fault, but not a pass
|
||||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
// 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),
|
||||||
clean: checks.every(c => c.offenders.length === 0),
|
clean: checks.every(c => c.offenders.length === 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// The people standing behind the chairs.
|
||||||
|
//
|
||||||
|
// A full home game always has a couple of them, and the list lives on a napkin or in
|
||||||
|
// the dealer's head — which is where the argument comes from an hour later about who
|
||||||
|
// said they were next. Order of arrival, because it is the only ordering anybody has
|
||||||
|
// ever agreed to.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState } = require("./load");
|
||||||
|
|
||||||
|
const table = (seated = 2, seats = 4) => {
|
||||||
|
const state = HoldemState.createDefault();
|
||||||
|
HoldemState.resizeSeats(state, seats);
|
||||||
|
["Ann", "Ben", "Cal", "Dee"].slice(0, seated).forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].stack = 200;
|
||||||
|
state.seats[i].buyIns = 200;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("keeping the list", () => {
|
||||||
|
test("names go on in the order they arrive", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
HoldemState.addToWaitingList(state, "Sam");
|
||||||
|
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya", "Sam"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("whitespace is trimmed and nothing empty gets on", () => {
|
||||||
|
const state = table();
|
||||||
|
assert.ok(HoldemState.addToWaitingList(state, " Priya "));
|
||||||
|
assert.equal(HoldemState.addToWaitingList(state, " "), null);
|
||||||
|
assert.equal(HoldemState.addToWaitingList(state, ""), null);
|
||||||
|
assert.deepEqual(state.waitingList.map(row => row.name), ["Priya"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two people with the same name are two people", () => {
|
||||||
|
// Home games have two Mikes. The list is keyed by id, not by name.
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Mike");
|
||||||
|
HoldemState.addToWaitingList(state, "Mike");
|
||||||
|
assert.equal(state.waitingList.length, 2);
|
||||||
|
assert.notEqual(state.waitingList[0].id, state.waitingList[1].id);
|
||||||
|
|
||||||
|
HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
|
||||||
|
assert.equal(state.waitingList.length, 1, "only one of them leaves");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("taking somebody off gives them back, so the toast can name them", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
const gone = HoldemState.removeFromWaitingList(state, state.waitingList[0].id);
|
||||||
|
assert.equal(gone.name, "Priya");
|
||||||
|
assert.deepEqual(state.waitingList, []);
|
||||||
|
assert.equal(HoldemState.removeFromWaitingList(state, "not-a-real-id"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("which chairs are open", () => {
|
||||||
|
test("the ones with nobody in them, in seat order", () => {
|
||||||
|
assert.deepEqual(HoldemState.openSeats(table(2, 4)), [2, 3]);
|
||||||
|
assert.deepEqual(HoldemState.openSeats(table(4, 4)), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a chair with chips or a buy-in on it is not open", () => {
|
||||||
|
// Somebody who has stepped away from the table still owns their seat.
|
||||||
|
const state = table(2, 4);
|
||||||
|
state.seats[2].stack = 120;
|
||||||
|
state.seats[3].buyIns = 100;
|
||||||
|
assert.deepEqual(HoldemState.openSeats(state), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sitting somebody down", () => {
|
||||||
|
test("their name goes on the chair and they come off the list", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
HoldemState.addToWaitingList(state, "Sam");
|
||||||
|
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(state.seats[2].name, "Priya");
|
||||||
|
assert.deepEqual(state.waitingList.map(row => row.name), ["Sam"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nothing in front of them, because they haven't bought in yet", () => {
|
||||||
|
// Guessing a stack would put chips on the table nobody has paid for, which is
|
||||||
|
// the one thing the bank check exists to catch.
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 2);
|
||||||
|
|
||||||
|
assert.equal(state.seats[2].stack, null);
|
||||||
|
assert.equal(state.seats[2].buyIns, 0);
|
||||||
|
assert.equal(state.seats[2].inHand, false, "they're in the next hand, not this one");
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never into a chair somebody is already in", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
const result = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 0);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.reason, /Ann is already/);
|
||||||
|
assert.equal(state.waitingList.length, 1, "and they keep their place in the queue");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nor from a name that has already been seated", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
const id = state.waitingList[0].id;
|
||||||
|
HoldemState.seatFromWaitingList(state, id, 2);
|
||||||
|
const again = HoldemState.seatFromWaitingList(state, id, 3);
|
||||||
|
assert.equal(again.ok, false);
|
||||||
|
assert.equal(state.seats[3].name, "", "the second chair is untouched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a seat that doesn't exist is refused rather than invented", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
assert.equal(HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 99).ok, false);
|
||||||
|
assert.equal(state.waitingList.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a seat opening up", () => {
|
||||||
|
test("cashing somebody out is what makes room for the next one", () => {
|
||||||
|
const state = table(4, 4);
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
assert.deepEqual(HoldemState.openSeats(state), [], "the table is full");
|
||||||
|
|
||||||
|
HoldemState.cashOut(state, 1);
|
||||||
|
assert.deepEqual(HoldemState.openSeats(state), [1]);
|
||||||
|
|
||||||
|
const seated = HoldemState.seatFromWaitingList(state, state.waitingList[0].id, 1);
|
||||||
|
assert.equal(seated.ok, true);
|
||||||
|
assert.equal(state.seats[1].name, "Priya");
|
||||||
|
// Ben's night is still on the record, and the money still adds up.
|
||||||
|
assert.equal(state.cashedOut.length, 1);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what a saved table remembers", () => {
|
||||||
|
test("the list survives a reload", () => {
|
||||||
|
const state = table();
|
||||||
|
HoldemState.addToWaitingList(state, "Priya");
|
||||||
|
const migrated = HoldemState.migrate(JSON.parse(JSON.stringify(state)));
|
||||||
|
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table saved before the list existed has nobody waiting", () => {
|
||||||
|
const parsed = JSON.parse(JSON.stringify(table()));
|
||||||
|
delete parsed.waitingList;
|
||||||
|
assert.deepEqual(HoldemState.migrate(parsed).waitingList, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("junk in a saved list is cleaned up rather than trusted", () => {
|
||||||
|
const parsed = JSON.parse(JSON.stringify(table()));
|
||||||
|
parsed.waitingList = [{ name: " Priya " }, { name: "" }, null, "Sam"];
|
||||||
|
const migrated = HoldemState.migrate(parsed);
|
||||||
|
assert.deepEqual(migrated.waitingList.map(row => row.name), ["Priya", "Sam"]);
|
||||||
|
assert.ok(migrated.waitingList.every(row => row.id), "and everybody gets an id");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user