Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8616636212 | ||
|
|
4db6f6e4d3 | ||
|
|
1c2e716508 | ||
|
|
1689ecb4f0 | ||
|
|
55eb569d3b | ||
|
|
86886ac126 | ||
|
|
f966d8c340 | ||
|
|
0e2821257f | ||
|
|
c19575ab10 | ||
|
|
8aa90e90a4 | ||
|
|
dc5db16ffd |
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<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="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
@@ -86,6 +87,32 @@
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>House take</summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Rake (%)</span>
|
||||
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Cap ($)</span>
|
||||
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
<span>No flop, no drop</span>
|
||||
<input type="checkbox" id="rakeNoFlopInput" />
|
||||
</label>
|
||||
<p class="fieldHint">
|
||||
Taken from the pot when it's awarded, and shown before anything is pushed.
|
||||
Rounded down to a chip the table has, so the players keep what the house
|
||||
can't make. Leave the percentage at zero for a game that doesn't rake.
|
||||
</p>
|
||||
<p class="fieldHint" id="rakeTakenHint" hidden></p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>Without live betting</summary>
|
||||
<div class="subPanelBody">
|
||||
@@ -119,6 +146,14 @@
|
||||
</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>
|
||||
|
||||
<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 class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
@@ -132,7 +167,16 @@
|
||||
<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>
|
||||
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
|
||||
</div>
|
||||
|
||||
<div id="settleUp" hidden>
|
||||
@@ -361,6 +405,23 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -318,6 +318,20 @@ button,
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* `hidden` means hidden.
|
||||
|
||||
The attribute only carries `display: none` from the browser's own stylesheet, which
|
||||
is the weakest rule there is: any class that sets a display — and every button here
|
||||
sets `inline-flex` — beats it. That is not a hypothetical. Seven overlays in this
|
||||
file already carry their own `[hidden] { display: none }` because each one was found
|
||||
the hard way, and the eighth was a button that shipped as a 21x44 empty box under the
|
||||
bank check on every table that didn't need it.
|
||||
|
||||
One rule, for everything, so there is no ninth. */
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---------- Poker table ---------- */
|
||||
|
||||
#pokerTableWrap {
|
||||
@@ -820,6 +834,43 @@ button,
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Seats close enough together that full-size pucks would overlap. The class is set
|
||||
from a measurement render.js takes off the ring itself — the closest two neighbours,
|
||||
straight line — because no media query can see it: the same 390px phone is roomy at
|
||||
six seats and crowded at nine, and a wide landscape felt crowds at its ends while
|
||||
its rim says there is space. Not scoped to a screen size for the same reason. */
|
||||
.seatsTight .seat {
|
||||
/* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
|
||||
cleared every overlap at nine seats where 58 left two — but in the state that
|
||||
actually matters, a hand with a side pot where the bar has grown and the felt has
|
||||
shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
|
||||
density is the felt being small, not the puck being wide, and paying for it with
|
||||
an ellipsis on every long name buys nothing. What has to hold is that each seat
|
||||
keeps its own centre, so a tap lands on the player it was aimed at, and that is
|
||||
measured at every size. */
|
||||
width: 58px;
|
||||
min-height: 38px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.seatsTight .seatPuckName {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.seatsTight .seatPuckStack {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
|
||||
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
|
||||
come from somewhere — and of the four things on a puck, the cards are the one the
|
||||
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
|
||||
still shows the cards in full. */
|
||||
.seatsTight .seatPuckCards {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||
|
||||
.seatEditorPanel {
|
||||
@@ -2217,6 +2268,147 @@ body.hasActionBar #holdemSidebar {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Starting and stopping a clock. Quiet, because it only appears once somebody has
|
||||
been thinking long enough for the table to ask.
|
||||
|
||||
44px like everything else a thumb has to find. It was drawn at 36 to keep the status
|
||||
line short, and the audit called it at every touch viewport the moment it ran — which
|
||||
is the rule doing its job: this control kills a hand, and the argument for shaving it
|
||||
was that the row it sits in is made of text. */
|
||||
.actionClockBtn {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #5b4a2a;
|
||||
background: rgba(216, 180, 106, 0.12);
|
||||
color: #e8cf9a;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The count has run out. Said in the dealer's own words, above the button that
|
||||
carries it out — and coloured like the thing it is, which is a hand about to be
|
||||
killed for slow play. */
|
||||
.clockExpired {
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(224, 123, 123, 0.12);
|
||||
border: 1px solid rgba(224, 123, 123, 0.45);
|
||||
color: #f3c9c9;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actionBtn.actionEnforceClock {
|
||||
background: linear-gradient(180deg, #6b2b2b, #4a1d1d);
|
||||
border-color: #8c3a3a;
|
||||
color: #ffe2e2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* A player who has taken their chips and gone. Still on the list, because the box took
|
||||
their cash and the night's record has to say so, but set apart so nobody counts money
|
||||
out to them twice at 2am.
|
||||
|
||||
Set apart by a stripe rather than by fading the row, which is what this first did:
|
||||
at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the
|
||||
list a dealer settles the night from. The row says "paid out" in words next to the
|
||||
name — it doesn't need to be hard to read as well. */
|
||||
.standingsRow.standingsGone {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 2px solid #4a4a4a;
|
||||
padding-left: 0.35rem;
|
||||
}
|
||||
|
||||
.standingsRow.standingsGone .standingsName {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* The people standing behind the chairs. A short list in a panel that already has a
|
||||
lot in it, so it is built from rows rather than a table: a name, the seat it can go
|
||||
in, and a way off the list. */
|
||||
.waitingAdd {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.waitingAdd input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
background: #141414;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
color: #eee;
|
||||
font-family: inherit;
|
||||
/* 16px, or iOS zooms the whole page in when it takes focus. */
|
||||
font-size: 16px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.waitingAdd .holdemBtn {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
/* "Add" is three letters, which came out 43px wide — one pixel under the rule, and
|
||||
the audit called it. A thumb doesn't know the word is short. */
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.waitingList {
|
||||
list-style: none;
|
||||
counter-reset: waiting;
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.waitingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
counter-increment: waiting;
|
||||
}
|
||||
|
||||
/* The position in the queue, which is the whole point of the list. */
|
||||
.waitingRow::before {
|
||||
content: counter(waiting) ".";
|
||||
flex: none;
|
||||
min-width: 1.2rem;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.waitingName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #e8e8e8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.waitingNote {
|
||||
flex: none;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.waitingRow .holdemBtn {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.waitingDropBtn {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* ---------- Side-pot breakdown ---------- */
|
||||
|
||||
.potBreakdown {
|
||||
@@ -2861,6 +3053,20 @@ body.hasActionBar #toastHost {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* What the house takes, above the payouts it comes out of. Stated in the summary
|
||||
rather than only in the log afterwards: this is the dealer's last chance to
|
||||
disagree with the arithmetic before the chips move, and money taken quietly is the
|
||||
one thing a tool like this must never do. */
|
||||
.potWizardRake {
|
||||
margin: 0 0 0.7rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(216, 180, 106, 0.1);
|
||||
border: 1px solid rgba(216, 180, 106, 0.35);
|
||||
color: #e8cf9a;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.potWizardSummary ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -2921,34 +3127,20 @@ body.hasActionBar #toastHost {
|
||||
That trade was real then and isn't now: seats are spaced by distance around the
|
||||
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
|
||||
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
|
||||
inside it: capped, the felt is 240px and the ring clears the bar by 50, with the
|
||||
audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is
|
||||
covered rather than merely below the fold. */
|
||||
inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
|
||||
seat that is covered rather than merely below the fold.
|
||||
|
||||
The room is spent on height, not on both. Capping the *width* by it — the wide
|
||||
layout's way, because there the felt is sized from its width — kept the table
|
||||
square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
|
||||
max-height leaves the width alone and lets the aspect ratio give way, so a short
|
||||
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
|
||||
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
|
||||
that was crowded at 240 comes out clean. Where the room isn't the binding
|
||||
constraint — 390x844 and up — nothing changes and the table stays round. */
|
||||
#pokerTableWrap {
|
||||
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
|
||||
}
|
||||
|
||||
/* A felt small enough that the rim can't give each seat its own puck width. Set
|
||||
from the measured rim in render.js rather than from the window, because the
|
||||
question is how much table there is per player, which no media query can see:
|
||||
nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */
|
||||
.seatsTight .seat {
|
||||
width: 58px;
|
||||
min-height: 38px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.seatsTight .seatPuckName {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.seatsTight .seatPuckStack {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.seatsTight .seatPuckCards {
|
||||
font-size: 0.58rem;
|
||||
max-width: none;
|
||||
max-height: var(--feltRoomPx, 100vh);
|
||||
}
|
||||
|
||||
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
|
||||
@@ -3020,10 +3212,18 @@ body.hasActionBar #toastHost {
|
||||
}
|
||||
|
||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||
so half a puck of padding is exactly enough to stop the overhang. */
|
||||
so half a puck of padding is exactly enough to stop the overhang.
|
||||
|
||||
Half a puck is the right answer when the felt is tall enough to spare it. On a
|
||||
phone held sideways it isn't: the felt is 96px tall there once the action bar has
|
||||
grown, and 26px off the top and bottom left the ring 44px of height to work with
|
||||
— less than half the table. That is what kept the seats at the ends stacked no
|
||||
matter what shape the rim was.
|
||||
|
||||
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
|
||||
#seatsLayer,
|
||||
#betsLayer {
|
||||
inset: 26px 44px;
|
||||
inset: min(26px, 12%) 44px;
|
||||
}
|
||||
|
||||
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
||||
@@ -3312,6 +3512,11 @@ body.hasActionBar #toastHost {
|
||||
a table that is already as short as the screen allows. */
|
||||
aspect-ratio: auto;
|
||||
max-width: none;
|
||||
/* This branch already spends the measured room on its own height. The phone
|
||||
block's max-height is the same number arriving twice, and applying it here
|
||||
took the felt down to the floor value and crowded the ring — measured at
|
||||
640x360, two seats lost their centres to their neighbours. */
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* The page's own top padding is 32px of nothing, on the screen that has the least
|
||||
@@ -3381,12 +3586,30 @@ body.hasActionBar #toastHost {
|
||||
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* The clock's ruling, said in a sentence, is a line this screen cannot pay for:
|
||||
measured at 640x360, the bar goes from 152px to 269px when the count runs out and
|
||||
the bottom seats end up behind it. The button carries the same ruling in three
|
||||
words — "Kill Dee's hand" — so the sentence is what goes, not the call. */
|
||||
.clockExpired {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nine seats around a table this small overlap each other, so they shrink to suit. */
|
||||
.seat {
|
||||
width: 62px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* The same inset the phone layout uses, which this branch never had because it is
|
||||
matched on height rather than width — so an 844px-wide phone on its side drew its
|
||||
ring against the felt's full box. With the rim a stadium, the top rail is flat,
|
||||
which put the button seat's dealer chip over the Resolve Pot button in the header
|
||||
above. Percentages of a short felt, so the ring keeps the height it needs. */
|
||||
#seatsLayer,
|
||||
#betsLayer {
|
||||
inset: min(26px, 12%) 44px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ---------- Short phones, upright ----------
|
||||
|
||||
@@ -194,6 +194,32 @@ const ActionBar = {
|
||||
tank.title = "How long this player has been thinking";
|
||||
tank.textContent = "0:00";
|
||||
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
|
||||
@@ -233,6 +259,74 @@ const ActionBar = {
|
||||
NUDGE_AFTER_S: 30,
|
||||
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,
|
||||
tankStartedAt: null,
|
||||
tankInterval: null,
|
||||
@@ -262,6 +356,11 @@ const ActionBar = {
|
||||
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
||||
this.tankSeatIndex = index;
|
||||
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) {
|
||||
@@ -283,6 +382,36 @@ const ActionBar = {
|
||||
const el = document.getElementById("actionTank");
|
||||
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.";
|
||||
@@ -405,6 +534,36 @@ const ActionBar = {
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
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");
|
||||
row.className = "actionButtons";
|
||||
|
||||
@@ -431,7 +590,9 @@ const ActionBar = {
|
||||
}));
|
||||
}
|
||||
|
||||
return row;
|
||||
if (!expired) return row;
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a
|
||||
|
||||
@@ -459,6 +459,96 @@ const Betting = {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -20,7 +20,11 @@ const HandHistoryExport = {
|
||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
||||
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].reverse().forEach(entry => {
|
||||
const payouts = (entry.payouts || [])
|
||||
@@ -30,6 +34,7 @@ const HandHistoryExport = {
|
||||
entry.handNumber,
|
||||
this.formatTimestamp(entry.timestamp),
|
||||
Money.forSheet(entry.pot),
|
||||
Money.forSheet(entry.rake || 0),
|
||||
payouts,
|
||||
]);
|
||||
});
|
||||
@@ -49,7 +54,10 @@ const HandHistoryExport = {
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||
standings.forEach(row => {
|
||||
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.won),
|
||||
Money.forSheet(row.net),
|
||||
|
||||
@@ -56,6 +56,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -96,6 +125,39 @@ 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) => {
|
||||
@@ -192,11 +254,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
|
||||
PotWizard.open(state, payouts => {
|
||||
PotWizard.open(state, (payouts, { rake = 0 } = {}) => {
|
||||
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) {
|
||||
HoldemState.recordHand(state, payouts);
|
||||
HoldemState.recordHand(state, payouts, rake);
|
||||
// Winnings go straight back in front of the player, so the next
|
||||
// hand's stacks are already right.
|
||||
Betting.creditWinnings(state, payouts);
|
||||
@@ -300,12 +371,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// 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
|
||||
// the stack and buy-in the settle-up is worked out from.
|
||||
const seatHasSomeoneIn = player =>
|
||||
!!(player.name && player.name.trim()) ||
|
||||
player.inHand ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0;
|
||||
// One question, asked in one place. This predicate existed here, in the seat menu,
|
||||
// in the action bar and twice in the state module, in four slightly different
|
||||
// spellings — and a chair that counts as occupied for the waiting list but empty
|
||||
// for the seat count is how somebody's chips get thrown away.
|
||||
const seatHasSomeoneIn = player => HoldemState.seatInPlay(player);
|
||||
|
||||
document.getElementById("seatCountInput").addEventListener("change", async e => {
|
||||
const requested = Math.max(2, Math.min(24, Math.floor(Number(e.target.value)) || state.seatCount));
|
||||
@@ -364,6 +434,59 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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 () => {
|
||||
// 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
|
||||
@@ -462,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("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||
|
||||
@@ -86,6 +86,21 @@ const Money = {
|
||||
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 };
|
||||
|
||||
@@ -232,6 +232,24 @@ const PotWizard = {
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// What the house takes out of this pot, and what is left to push.
|
||||
//
|
||||
// Worked out here rather than at the end so the dealer sees it before anything
|
||||
// moves: the summary is the last chance to disagree with the arithmetic, and a
|
||||
// rake that only appeared in the hand log afterwards would be the tool taking
|
||||
// money quietly.
|
||||
rakeSplit() {
|
||||
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
|
||||
const sawFlop = (this.state.street || "preflop") !== "preflop";
|
||||
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
|
||||
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
|
||||
},
|
||||
|
||||
renderSummary() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "potWizardSummary";
|
||||
@@ -240,6 +258,20 @@ const PotWizard = {
|
||||
heading.textContent = "Final Payouts";
|
||||
wrap.appendChild(heading);
|
||||
|
||||
const split = this.rakeSplit();
|
||||
const rakeSet = Number((this.state.rake || {}).percent) > 0;
|
||||
if (rakeSet && this.totals.size > 0) {
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardRake";
|
||||
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
|
||||
line.textContent = split.rake > 0
|
||||
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
|
||||
: split.sawFlop
|
||||
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
|
||||
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
|
||||
if (this.totals.size === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "fieldHint";
|
||||
@@ -248,7 +280,7 @@ const PotWizard = {
|
||||
} else {
|
||||
const legend = (this.state && this.state.chipLegend) || [];
|
||||
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");
|
||||
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
|
||||
|
||||
@@ -275,13 +307,12 @@ const PotWizard = {
|
||||
applyBtn.className = "holdemBtn holdemBtnAccent";
|
||||
applyBtn.textContent = "Start Next Hand";
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
// Recomputed rather than reused from the render: the same numbers, but
|
||||
// taken at the moment the dealer commits rather than when they last
|
||||
// looked at the screen.
|
||||
const paid = this.rakeSplit();
|
||||
this.close();
|
||||
this.onFinished(payouts);
|
||||
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
|
||||
});
|
||||
actions.appendChild(applyBtn);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const Render = {
|
||||
this.renderNotes();
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderHeaderActions();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
@@ -47,6 +48,29 @@ const Render = {
|
||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
|
||||
const rake = this.state.rake || {};
|
||||
document.getElementById("rakePercentInput").value = rake.percent || "";
|
||||
document.getElementById("rakeCapInput").value = rake.cap ?? "";
|
||||
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
|
||||
|
||||
// What the house has actually taken tonight, said where the rake is set rather
|
||||
// than only in the bank check — a dealer who changes the percentage mid-session
|
||||
// wants to see the running total right there.
|
||||
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
|
||||
const hint = document.getElementById("rakeTakenHint");
|
||||
hint.hidden = taken <= 0;
|
||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
||||
},
|
||||
|
||||
// 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
|
||||
@@ -91,12 +115,21 @@ const Render = {
|
||||
// circle when there's nothing to measure — the same answer the ring gave before it
|
||||
// asked.
|
||||
feltAspect() {
|
||||
if (typeof document === "undefined") return 1;
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
if (!wrap) return 1;
|
||||
const box = wrap.getBoundingClientRect();
|
||||
if (!box.width || !box.height) return 1;
|
||||
return box.width / box.height;
|
||||
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
|
||||
@@ -108,8 +141,11 @@ const Render = {
|
||||
// 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 angle = this.ringAngles(count, 44, 42, aspect)[index];
|
||||
return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
|
||||
const seat = this.seatPosition(index, count, aspect);
|
||||
return {
|
||||
left: 50 + (seat.left - 50) * (27 / 44),
|
||||
top: 50 + (seat.top - 50) * (25 / 42),
|
||||
};
|
||||
},
|
||||
|
||||
// Seats spaced evenly around the rim — by the distance between them, not by the
|
||||
@@ -127,33 +163,100 @@ const Render = {
|
||||
// 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) {
|
||||
const target = this.ringAngles(count, rx, ry, aspect)[index];
|
||||
return {
|
||||
left: 50 + rx * Math.cos(target),
|
||||
top: 50 + ry * Math.sin(target),
|
||||
};
|
||||
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: {},
|
||||
|
||||
// How much rim each seat gets, in pixels. Ramanujan's approximation for an
|
||||
// ellipse's perimeter, which is well inside a pixel at these sizes.
|
||||
rimPerSeat(count) {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
if (!wrap || !count) return Infinity;
|
||||
const box = wrap.getBoundingClientRect();
|
||||
const a = (box.width * 44) / 100;
|
||||
const b = (box.height * 42) / 100;
|
||||
if (!a || !b) return Infinity;
|
||||
const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
|
||||
return perimeter / count;
|
||||
// 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. Under about that much rim each, neighbours start covering
|
||||
// each other's middles, and a tap meant for one player lands on the next — so the
|
||||
// pucks come down a size rather than the table pretending it has room it hasn't.
|
||||
SEAT_ROOM_PX: 88,
|
||||
// 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
|
||||
@@ -162,7 +265,7 @@ const Render = {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
if (!layer || !this.state) return;
|
||||
layer.classList.toggle("seatsTight",
|
||||
this.rimPerSeat(this.state.seatCount) < this.SEAT_ROOM_PX);
|
||||
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
|
||||
},
|
||||
|
||||
// The angles that cut the rim into `count` equal lengths, starting at the top.
|
||||
@@ -833,14 +936,20 @@ const Render = {
|
||||
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 }))
|
||||
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
|
||||
.map(({ player, index }) => ({
|
||||
.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: Betting.round(Number(player.stack) || 0),
|
||||
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
|
||||
stack: held,
|
||||
chips: Money.formatChipBreakdown(held, legend),
|
||||
}))
|
||||
.filter(row => row.chips);
|
||||
|
||||
@@ -920,7 +1029,14 @@ const Render = {
|
||||
const template = document.getElementById("standingsRowTemplate");
|
||||
standings.forEach(row => {
|
||||
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(".standingsWon").textContent = Money.format(row.won);
|
||||
|
||||
@@ -948,9 +1064,63 @@ const Render = {
|
||||
|
||||
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
|
||||
@@ -1066,20 +1236,77 @@ const Render = {
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
// 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) {
|
||||
verdict.textContent = bank.discarded > 0
|
||||
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
|
||||
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) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
|
||||
// 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 {
|
||||
verdict.textContent =
|
||||
`${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("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 ----
|
||||
|
||||
@@ -47,6 +47,27 @@ const SeatMenu = {
|
||||
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) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
@@ -104,6 +104,21 @@ const HoldemState = {
|
||||
// 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.
|
||||
@@ -213,6 +228,36 @@ const HoldemState = {
|
||||
// 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
|
||||
@@ -679,11 +724,15 @@ const HoldemState = {
|
||||
// Must be called before startNextHand() clears the seats: the per-player `ledger`
|
||||
// it stores alongside the payouts is what session standings are summed from later,
|
||||
// and it needs this hand's contributions while they're still on the table.
|
||||
recordHand(state, payouts) {
|
||||
recordHand(state, payouts, rake = 0) {
|
||||
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||
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 ledger = state.seats
|
||||
.map((player, index) => ({
|
||||
@@ -698,6 +747,7 @@ const HoldemState = {
|
||||
handNumber: state.handNumber,
|
||||
timestamp: Date.now(),
|
||||
pot,
|
||||
rake: taken,
|
||||
payouts,
|
||||
ledger,
|
||||
});
|
||||
@@ -733,6 +783,15 @@ const HoldemState = {
|
||||
// Cash bought in and chips currently in front don't come from the hand log at
|
||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||
// 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
|
||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||
@@ -744,7 +803,7 @@ const HoldemState = {
|
||||
if (totals.has(player.id)) return;
|
||||
const hasMoneyInPlay =
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
this.chipsHeldBy(state, player) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0;
|
||||
if (!hasMoneyInPlay) return;
|
||||
@@ -754,8 +813,16 @@ const HoldemState = {
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
const boughtIn = seat ? Money.round(Number(seat.buyIns) || 0) : 0;
|
||||
const chipsNow = seat ? Money.round(Number(seat.stack) || 0) : 0;
|
||||
const gone = goneById.get(t.id);
|
||||
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
|
||||
// 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 —
|
||||
@@ -786,6 +853,10 @@ const HoldemState = {
|
||||
// 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),
|
||||
notes,
|
||||
};
|
||||
@@ -818,12 +889,273 @@ const HoldemState = {
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
// ---- The 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) {
|
||||
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(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Math.max(0, Number(p.stack) || 0) +
|
||||
this.chipsHeldBy(state, p) +
|
||||
Math.max(0, Number(p.committed) || 0) +
|
||||
Math.max(0, Number(p.amountOverride) || 0),
|
||||
0
|
||||
@@ -837,11 +1169,21 @@ const HoldemState = {
|
||||
// hunting for a miscount that never happened.
|
||||
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded - cashIn);
|
||||
// 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 {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
raked,
|
||||
paidOut,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
@@ -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,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,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);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,100 @@ describe("a felt wider than it is tall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -719,6 +719,26 @@ const LayoutAudit = {
|
||||
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 };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
// Every check this file knows how to make, every time.
|
||||
@@ -747,6 +767,7 @@ const LayoutAudit = {
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.hiddenButPainted(),
|
||||
this.contrast(options.contrast),
|
||||
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||
];
|
||||
|
||||
@@ -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