Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19575ab10 | ||
|
|
8aa90e90a4 | ||
|
|
dc5db16ffd | ||
|
|
b9b2237dbe | ||
|
|
fc866597eb | ||
|
|
b97453fba1 | ||
|
|
419852a1f4 | ||
|
|
8a2fc3e540 | ||
|
|
b64da7a787 | ||
|
|
3be93cdadf | ||
|
|
40fa16920b | ||
|
|
09eac536b5 | ||
|
|
8efc14f61f | ||
|
|
c70cb150db | ||
|
|
e9bc9afcdb |
@@ -86,6 +86,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">
|
||||
@@ -110,8 +136,9 @@
|
||||
<span>Player</span>
|
||||
<span class="standingsInHead">In</span>
|
||||
<span class="standingsWonHead">Won</span>
|
||||
<span>Net</span>
|
||||
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
|
||||
<span class="standingsNetHead">Net</span>
|
||||
<span class="standingsChipsHead" title="What they are holding — the cash to count out if they leave now">Chips</span>
|
||||
<span title="Those chips, less the cash they handed over">Up/down</span>
|
||||
</div>
|
||||
<div id="standingsList"></div>
|
||||
</div>
|
||||
@@ -131,6 +158,10 @@
|
||||
<span>Taken off the table</span>
|
||||
<span id="bankDiscarded"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow" id="bankRakedRow" hidden>
|
||||
<span>House take</span>
|
||||
<span id="bankRaked"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
@@ -154,6 +185,17 @@
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">The table itself</h3>
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -196,6 +238,7 @@
|
||||
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
|
||||
<span class="blindBadge sbBadge">SB</span>
|
||||
<span class="blindBadge bbBadge">BB</span>
|
||||
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
|
||||
<div class="seatPuckName"></div>
|
||||
<div class="seatPuckCards"></div>
|
||||
<div class="seatPuckTotal"></div>
|
||||
@@ -270,6 +313,7 @@
|
||||
<span class="standingsIn"></span>
|
||||
<span class="standingsWon"></span>
|
||||
<span class="standingsNet"></span>
|
||||
<span class="standingsChips"></span>
|
||||
<span class="standingsCashOut"></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -347,6 +391,23 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="Straddle">
|
||||
<h3>A straddle is a blind raise</h3>
|
||||
<p>
|
||||
The player left of the big blind may put out twice it before the cards,
|
||||
without looking. It is a blind bet, not a raise made in turn, so it does
|
||||
three things at once: everyone else acts first, the minimum raise becomes
|
||||
twice the straddle, and the straddler still has the option when the
|
||||
action comes back — they may raise even if nobody raised into them.
|
||||
</p>
|
||||
<p>
|
||||
Re-straddles carry on to the left, each doubling again, and stop before
|
||||
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
|
||||
posts it, puts the right player on the clock and prices the raise;
|
||||
the seat wears an <em>STR</em> badge for the hand.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling" data-short="Short all-in">
|
||||
<h3>A short all-in doesn't reopen the betting</h3>
|
||||
<p>
|
||||
|
||||
@@ -586,12 +586,16 @@ button,
|
||||
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a
|
||||
came out at 3.39:1 once the fade was composited, for the label a dealer reads to
|
||||
answer "which seat is seat 6?". Lifted until it passes with a little to spare —
|
||||
measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
|
||||
.seatChairNumber {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 6px;
|
||||
font-size: 0.58rem;
|
||||
color: #8a8a8a;
|
||||
color: #aeaeae;
|
||||
}
|
||||
|
||||
.seatPuckName {
|
||||
@@ -796,6 +800,19 @@ button,
|
||||
color: #241a05;
|
||||
}
|
||||
|
||||
/* The straddle. It takes the big blind's slot on the puck, which is free by
|
||||
construction: the chain starts left of the big blind and stops short of the small
|
||||
one, so no seat ever wears two of these.
|
||||
|
||||
Violet rather than a third shade of gold or blue, because the badge a dealer is
|
||||
looking for mid-hand is the one telling them where the action comes back to.
|
||||
Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
|
||||
.strBadge {
|
||||
left: 22px;
|
||||
background: linear-gradient(180deg, #cbaaf0, #a97fd6);
|
||||
color: #1b1030;
|
||||
}
|
||||
|
||||
/* A blind the dealer named by hand rather than one the button handed out. Without
|
||||
the ring the table just looks like it's put the blinds in the wrong place. */
|
||||
.blindBadge.blindManual {
|
||||
@@ -803,6 +820,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 {
|
||||
@@ -1816,31 +1870,44 @@ button,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The per-hand columns are detail: how the night's swings happened. They're the
|
||||
first thing to go when there isn't room, because Net and Cash out are what the
|
||||
banker is actually reading. */
|
||||
/* The per-hand columns are detail: how the night's swings happened. They're the first
|
||||
to go when there isn't room, and Net goes with them — it only moves when a pot is
|
||||
resolved through the wizard, so on a table where the dealer settles pots by hand it
|
||||
reads $0 all night beside two columns that don't.
|
||||
|
||||
What survives at every width is the pair the banker actually works from: what the
|
||||
player is holding, and whether that leaves them up or down. */
|
||||
.standingsIn,
|
||||
.standingsWon,
|
||||
.standingsNet,
|
||||
.standingsInHead,
|
||||
.standingsWonHead {
|
||||
.standingsWonHead,
|
||||
.standingsNetHead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (min-width: 30rem) {
|
||||
.standingsRow {
|
||||
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto;
|
||||
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto auto;
|
||||
}
|
||||
|
||||
.standingsIn,
|
||||
.standingsWon,
|
||||
.standingsNet,
|
||||
.standingsInHead,
|
||||
.standingsWonHead {
|
||||
.standingsWonHead,
|
||||
.standingsNetHead {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* The settle-up column is the one a banker reads while counting out cash, so it
|
||||
carries the weight the per-hand columns don't. */
|
||||
/* The chips column is the one a banker's hands follow while counting money onto the
|
||||
table, so it carries the weight. Up/down is read once, at the end. */
|
||||
.standingsChips {
|
||||
font-weight: 600;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.standingsCashOut {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -2831,6 +2898,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;
|
||||
@@ -2880,14 +2961,31 @@ body.hasActionBar #toastHost {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
/* The narrow layout already had an answer for the bar: it reserves the bar's height
|
||||
as scroll room below the table, and a phone is a thing you scroll. Capping the
|
||||
felt here as well was measured and rejected — at 360x640 it took the table from
|
||||
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
|
||||
covered seats traded for two seats whose centre landed on a neighbour. The cap is
|
||||
for the wide layout, which never consulted the bar's height at all. */
|
||||
/* The cap lives here now, and the reason it didn't is worth keeping.
|
||||
|
||||
It was measured and rejected once: at 360x640 it took the table from 321px to
|
||||
226px, which fixed the six-seat ring but broke the nine-seat one — two covered
|
||||
seats traded for two seats whose centre landed on a neighbour. The narrow layout
|
||||
fell back on reserving the bar's height as scroll room, on the grounds that a
|
||||
phone is a thing you scroll.
|
||||
|
||||
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: 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: none;
|
||||
max-height: var(--feltRoomPx, 100vh);
|
||||
}
|
||||
|
||||
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
|
||||
@@ -2959,10 +3057,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
|
||||
@@ -3125,7 +3231,18 @@ body.hasActionBar #toastHost {
|
||||
}
|
||||
|
||||
.panelBody .holdemBtnSmall,
|
||||
.handHistoryActions .holdemBtn {
|
||||
.handHistoryActions .holdemBtn,
|
||||
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
||||
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
||||
player's cards, which is not a thing to make a dealer aim at. */
|
||||
.cardPickerHeader .holdemBtn,
|
||||
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
||||
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
||||
short of a fingertip. Growing the box costs bar height, which comes out of the
|
||||
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
||||
is room for it. A hit-area pad was tried first and does not work here: the pill
|
||||
clips its overflow to keep a long label on one line, which clips the pad too. */
|
||||
.actionUndo {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@@ -3223,28 +3340,79 @@ body.hasActionBar #toastHost {
|
||||
why a seat's centre belonged to its neighbour.
|
||||
|
||||
Landscape has width and no height, so the table takes the width it has and
|
||||
is only as tall as the room left over. */
|
||||
--feltRoom: calc(100vh - var(--actionBarHeight, 0px) - 7rem);
|
||||
is only as tall as the room left over.
|
||||
|
||||
The height comes from --feltRoomPx, the same measured value the wide layout
|
||||
caps its width by: the bar's real height, the wrap's real offset and the
|
||||
bottom seat's overhang, all taken off the window. This branch used to work
|
||||
it out itself as `100vh - bar - 7rem`, guessing that everything above the
|
||||
felt came to 7rem. It comes to 108px at 640x360, which the guess covers, and
|
||||
119px at 844x390, which it doesn't — so on an iPhone on its side the bottom
|
||||
three seats sat up to 26px inside the action bar. Nothing here needs to
|
||||
guess; the measurement was already being published. */
|
||||
width: 100%;
|
||||
height: var(--feltRoom);
|
||||
min-height: 7rem;
|
||||
height: var(--feltRoomPx, calc(100vh - var(--actionBarHeight, 0px) - 7rem));
|
||||
/* This branch sets its own height outright, so neither the aspect ratio nor the
|
||||
cap derived from it has anything to say here — and the cap would only narrow
|
||||
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
|
||||
to spare. */
|
||||
#holdemApp {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* The header ate 148px of a 390px-tall window, which was most of the reason the
|
||||
table started below the fold. Here it only needs to be a title bar. */
|
||||
table started below the fold. Here it only needs to be a title bar.
|
||||
|
||||
It still wrapped to two rows, and that second row is 44px of the 390 — the title
|
||||
block and the five buttons came to 829px inside a 765px header at 844x390, so
|
||||
they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
|
||||
the page title: the one thing on this screen a dealer already knows. It is hidden
|
||||
from the eye rather than removed, so the page keeps its heading for a screen
|
||||
reader and for the browser's own outline. The hand number stays — nothing else on
|
||||
the screen says which hand this is. */
|
||||
#holdemHeader {
|
||||
margin-bottom: 0.4rem;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
font-size: 1.05rem;
|
||||
padding-bottom: 0.15rem;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.handCounterBadge {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Tightened across, never down: the buttons keep the 44px a thumb needs. */
|
||||
#holdemHeaderActions {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
#holdemHeaderActions .holdemBtn {
|
||||
padding-left: 0.55rem;
|
||||
padding-right: 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.holdemSubtitle {
|
||||
@@ -3269,6 +3437,16 @@ body.hasActionBar #toastHost {
|
||||
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 ----------
|
||||
@@ -3450,6 +3628,38 @@ textarea:focus-visible {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is
|
||||
flex: 1 and would have split the row evenly with the button the dealer presses every
|
||||
hand. It sizes to its own words instead, and stays quiet next to the green one —
|
||||
choosing a straddle is the exception, dealing is the rule. */
|
||||
.actionBtn.actionStraddle {
|
||||
flex: 0 1 auto;
|
||||
background: linear-gradient(180deg, #33304a, #24223a);
|
||||
border-color: #4b4770;
|
||||
color: #e6e1ff;
|
||||
font-size: 0.82rem;
|
||||
padding-left: 0.7rem;
|
||||
padding-right: 0.7rem;
|
||||
}
|
||||
|
||||
.actionBtn.actionStraddleOff {
|
||||
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||
border-color: #4a4a4a;
|
||||
color: #dcdcdc;
|
||||
}
|
||||
|
||||
/* Three buttons across a 360px phone is a squeeze, and the two ways out are both
|
||||
worse than this one. Letting the row wrap put the deal button on its own line and
|
||||
made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths
|
||||
behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the
|
||||
max-width: none override, where capping it was tried and rejected). Letting flex
|
||||
squash freely took the deal button to 84px with its label over three lines.
|
||||
A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle
|
||||
costs no felt at all. */
|
||||
.dealPromptButtons .actionDeal {
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
|
||||
would otherwise only discover afterwards. */
|
||||
.dealPromptLine.dealPromptAside {
|
||||
@@ -3512,7 +3722,9 @@ textarea:focus-visible {
|
||||
|
||||
.panelBody .holdemBtnSmall,
|
||||
.handHistoryActions .holdemBtn,
|
||||
.toastAction {
|
||||
.toastAction,
|
||||
.cardPickerHeader .holdemBtn,
|
||||
.actionUndo {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,12 +143,18 @@ button,
|
||||
}
|
||||
|
||||
.unevenCurrency {
|
||||
color: #777;
|
||||
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
|
||||
against every amount a dealer types, so it is read constantly. */
|
||||
color: #8f8f8f;
|
||||
}
|
||||
|
||||
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
|
||||
44px everywhere else since the round that set it; this page never got measured for
|
||||
it, because the audit only ran the target check when asked and nothing asked. */
|
||||
.unevenAmount {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -178,13 +184,17 @@ button,
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
/* 30px across, and it deletes a player's entry — the one control on the row where a
|
||||
near miss costs something. The amount field beside it gives up the 16px. */
|
||||
.unevenRemoveBtn {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6a6a6a;
|
||||
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
|
||||
player's entry. A destructive action should not be the faintest thing in view. */
|
||||
color: #9a9a9a;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -209,11 +219,86 @@ button,
|
||||
.unevenPot {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
|
||||
eligible-players list and pushing it off the row. */
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.unevenChopToggle {
|
||||
flex: none;
|
||||
order: 3;
|
||||
/* 44px on touch: it moves money, like everything else that does. */
|
||||
min-height: 32px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid rgba(216, 180, 106, 0.45);
|
||||
border-radius: 999px;
|
||||
background: rgba(216, 180, 106, 0.1);
|
||||
color: #e3c887;
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unevenChopToggle:hover {
|
||||
background: rgba(216, 180, 106, 0.18);
|
||||
}
|
||||
|
||||
.unevenChopToggle:focus-visible {
|
||||
outline: 2px solid #d8b46a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.unevenChopDetail {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
/* Or the padding is added on top of the 100% basis and the panel sits wider than
|
||||
the row it belongs to — measured 328px inside a 309px row on a phone. */
|
||||
box-sizing: border-box;
|
||||
margin: 0.15rem 0 0.2rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.unevenChopWays {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.unevenChopWaysLabel {
|
||||
color: #cfcfcf;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.unevenChopEach {
|
||||
margin: 0;
|
||||
color: #f2f2f2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unevenChopNote {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9a9a9a;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.unevenChopDust {
|
||||
color: #e0b169;
|
||||
}
|
||||
|
||||
@media (max-width: 700px), (pointer: coarse) {
|
||||
.unevenChopToggle {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.unevenPot + .unevenPot {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -510,7 +595,9 @@ button,
|
||||
|
||||
.splitFootnote {
|
||||
margin-top: 1.5rem;
|
||||
color: #6f6f6f;
|
||||
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
|
||||
list, which is not a decorative line. */
|
||||
color: #8d8d8d;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
|
||||
@@ -53,7 +53,14 @@ const ActionBar = {
|
||||
// moving the seats, and getting a different answer on the next pass.
|
||||
SEAT_OVERHANG_PX: 37,
|
||||
FELT_GAP_PX: 8,
|
||||
FELT_MIN_PX: 220,
|
||||
// The floor is a guard against a measurement that has gone wrong — not a size the
|
||||
// window has agreed to hand over. It used to be 220px, which a phone on its side
|
||||
// hasn't got: at 844x390 there are 99px between the header and the bar, and holding
|
||||
// out for 220 is how the bottom row of seats came to sit 26px inside the Fold
|
||||
// button. A floor that puts seats behind the bar defeats the one thing the floor is
|
||||
// for, so it drops to the smallest felt still worth drawing and the measurement
|
||||
// wins above it.
|
||||
FELT_FLOOR_PX: 96,
|
||||
|
||||
// How tall the felt can be and still leave the whole ring of seats above the bar.
|
||||
//
|
||||
@@ -66,21 +73,39 @@ const ActionBar = {
|
||||
//
|
||||
// The wrap's offset is taken in document space rather than viewport space, so
|
||||
// scrolling the page doesn't resize the table under the dealer's hands.
|
||||
feltRoomFor(windowHeight, barHeight, topInDocument) {
|
||||
const room = windowHeight - barHeight - topInDocument -
|
||||
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
||||
return Math.max(this.FELT_FLOOR_PX, Math.round(room));
|
||||
},
|
||||
|
||||
publishFeltRoom(barHeight) {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
if (!wrap) return;
|
||||
// Taking the top before the height is set matters: the wrap sits below the
|
||||
// header in normal flow, so its own height never moves its top and this can't
|
||||
// chase itself around the screen.
|
||||
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
||||
const room = window.innerHeight - barHeight - topInDocument -
|
||||
this.SEAT_OVERHANG_PX - this.FELT_GAP_PX;
|
||||
document.documentElement.style.setProperty(
|
||||
"--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`);
|
||||
"--feltRoomPx",
|
||||
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
|
||||
// The felt has just changed size, so how much rim each seat gets has changed
|
||||
// with it. Told rather than left to be noticed on the next render, which on a
|
||||
// bar that grew mid-hand could be several actions away.
|
||||
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||
// still holds during setup, because a seated player is a live seat whether or not
|
||||
// the betting has been opened. An empty table stays uncluttered.
|
||||
//
|
||||
// A *named* table is not an empty one, though, and that was the hole: name six
|
||||
// people without dealing any of them in and the bar hid itself, leaving a screen
|
||||
// with six players on it, no way forward and nothing saying why. Anyone with a
|
||||
// name on the felt is somebody the dealer has to be told about.
|
||||
isRunning() {
|
||||
return Betting.liveSeats(this.state).length > 0;
|
||||
return Betting.liveSeats(this.state).length > 0 ||
|
||||
this.state.seats.some(p => (p.name || "").trim());
|
||||
},
|
||||
|
||||
render() {
|
||||
@@ -424,7 +449,6 @@ const ActionBar = {
|
||||
const player = this.state.seats[index];
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const maxTo = Betting.round(Betting.committed(player) + stack);
|
||||
const pot = Betting.potSoFar(this.state);
|
||||
// Two different minimums, and conflating them was a bug. `minTo` is clamped to
|
||||
// what this player can actually put in, which is what the Min button should
|
||||
// offer. `legalMin` is what the rules require — for a short stack the two are
|
||||
@@ -440,10 +464,16 @@ const ActionBar = {
|
||||
const quick = document.createElement("div");
|
||||
quick.className = "raiseQuick";
|
||||
|
||||
// The pot amounts come from Betting, which knows that a pot raise is measured
|
||||
// off the pot as it stands *after* the caller's own call goes in. The pad used
|
||||
// to work it out here as "the bet plus the pot", which left the call out and
|
||||
// made both amounts short: pre-flop at 1/2 it offered "Pot $5" where a pot
|
||||
// raise is $7, and a half-pot that clamped up to the minimum and sat next to
|
||||
// the Min button showing the same $4 twice.
|
||||
const options = [
|
||||
{ label: "Min", value: minTo },
|
||||
{ label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) },
|
||||
{ label: "Pot", value: Betting.round(this.state.currentBet + pot) },
|
||||
{ label: "½ pot", value: Betting.potRaiseTo(this.state, index, 0.5) },
|
||||
{ label: "Pot", value: Betting.potRaiseTo(this.state, index, 1) },
|
||||
{ label: "All-in", value: maxTo },
|
||||
];
|
||||
options.forEach(option => {
|
||||
@@ -566,6 +596,43 @@ const ActionBar = {
|
||||
// by itself the instant the previous hand ended, which meant blinds were committed
|
||||
// while people were still sitting down — and the blind badges would drift away from
|
||||
// the money already posted as seats changed. Now the dealer says when.
|
||||
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
|
||||
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
|
||||
// name doesn't need the verb again, and the bar has no room for it.
|
||||
straddleClause(preview) {
|
||||
const straddles = preview.straddles || [];
|
||||
if (straddles.length === 0) return "";
|
||||
const name = i => HoldemState.displayName(this.state.seats[i], i);
|
||||
const [first, ...rest] = straddles;
|
||||
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
|
||||
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
|
||||
},
|
||||
|
||||
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
|
||||
// the big blind to straddle from, so nothing is offered rather than a button that
|
||||
// would do nothing.
|
||||
straddleButtons(preview) {
|
||||
const on = (preview.straddles || []).length;
|
||||
const max = preview.maxStraddles || 0;
|
||||
if (max <= 0) return [];
|
||||
|
||||
const buttons = [];
|
||||
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
|
||||
if (on < max) {
|
||||
buttons.push(this.button(
|
||||
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
|
||||
"actionStraddle",
|
||||
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
|
||||
() => { this.state.straddleCount = on + 1; })));
|
||||
}
|
||||
if (on > 0) {
|
||||
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
|
||||
() => this.commit("calling the straddle off",
|
||||
() => { this.state.straddleCount = 0; })));
|
||||
}
|
||||
return buttons;
|
||||
},
|
||||
|
||||
renderDealPrompt() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "dealPrompt";
|
||||
@@ -587,10 +654,19 @@ const ActionBar = {
|
||||
// thing: "only one player is dealt in" is wrong when four are sitting there
|
||||
// and three of them are just out of chips.
|
||||
const withChips = seated - broke.length;
|
||||
// "Nobody has chips left" is the wrong story for a table that never had
|
||||
// any. A dealer who has just named six people and not yet said what a
|
||||
// buy-in is gets told their players are busted, which sends them looking
|
||||
// for a rebuy button instead of the one field that fixes it.
|
||||
const nobodyHasEverBoughtIn = this.state.seats.every(p =>
|
||||
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
|
||||
|
||||
if (broke.length > 0 && withChips < 2) {
|
||||
line.textContent = withChips === 1
|
||||
? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.`
|
||||
: "Nobody at the table has chips left. Add a buy-in to deal.";
|
||||
: nobodyHasEverBoughtIn
|
||||
? "Nobody has bought in yet. Set what everyone starts with, or give each seat their chips."
|
||||
: "Nobody at the table has chips left. Add a buy-in to deal.";
|
||||
} else {
|
||||
line.textContent = seated === 0
|
||||
? "Nobody is dealt in yet. Tap a seat to add a player."
|
||||
@@ -598,17 +674,70 @@ const ActionBar = {
|
||||
}
|
||||
line.classList.add("dealPromptBlocked");
|
||||
wrap.appendChild(line);
|
||||
|
||||
// A blocked prompt with nothing to press is a dead end, and this was the
|
||||
// one a first-time dealer hits: six players named, a sentence saying they
|
||||
// have no chips, and no button anywhere on the bar. The starting stack is a
|
||||
// single field in the sidebar and the message never pointed at it.
|
||||
if (nobodyHasEverBoughtIn && seated > 0) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button("Set starting stack", "actionDeal", () => {
|
||||
const field = document.getElementById("defaultStackInput");
|
||||
if (!field) return;
|
||||
const panel = field.closest("details");
|
||||
if (panel) panel.open = true;
|
||||
field.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
field.focus();
|
||||
field.select();
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Named seats that aren't in the hand. Naming a chair now seats whoever is
|
||||
// in it, so reaching this takes something else — a table where everyone was
|
||||
// sat out, or a save from before that was true — but the way out of it
|
||||
// should still be one tap rather than a hunt through six seat editors.
|
||||
const benched = this.state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
|
||||
.map(({ index }) => index);
|
||||
|
||||
if (benched.length > 0) {
|
||||
const aside = document.createElement("p");
|
||||
aside.className = "dealPromptLine dealPromptAside dealPromptDebt";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = benched.length === 1
|
||||
? `${listNames(benched)} is sitting out.`
|
||||
: `${listNames(benched)} are sitting out.`;
|
||||
aside.appendChild(text);
|
||||
aside.appendChild(this.button(
|
||||
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
|
||||
"dealPromptDebtBtn",
|
||||
() => this.commit("dealing everybody in", () => {
|
||||
benched.forEach(index => {
|
||||
this.state.seats[index].inHand = true;
|
||||
this.state.seats[index].folded = false;
|
||||
});
|
||||
})
|
||||
));
|
||||
wrap.appendChild(aside);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
|
||||
// the small blind" out loud is the whole point — a dealer who reads a preview
|
||||
// with a blind missing and isn't told why will assume the tool has lost track.
|
||||
line.textContent = preview.deadSmallBlind
|
||||
const blinds = document.createElement("span");
|
||||
blinds.textContent = (preview.deadSmallBlind
|
||||
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
|
||||
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
|
||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
|
||||
this.straddleClause(preview) +
|
||||
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
|
||||
line.appendChild(blinds);
|
||||
wrap.appendChild(line);
|
||||
|
||||
// Why a position is missing. Both of these are the dead button rule doing its
|
||||
@@ -682,7 +811,12 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
row.className = "actionButtons dealPromptButtons";
|
||||
// Beside the deal button rather than on a line of its own. Measured at 390x844:
|
||||
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
|
||||
// and this row is already there and already 44px tall. It reads in the order it
|
||||
// happens, too — say the straddle, then deal.
|
||||
this.straddleButtons(preview).forEach(button => row.appendChild(button));
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||
}));
|
||||
|
||||
@@ -93,9 +93,60 @@ const Betting = {
|
||||
satOut,
|
||||
smallBlind: this.round(state.smallBlind || 0),
|
||||
bigBlind: this.round(state.bigBlind || 0),
|
||||
// What the straddles will be if the hand is dealt as it stands, and how
|
||||
// many more this table could take.
|
||||
straddles: this.straddleSeats(state),
|
||||
maxStraddles: this.maxStraddles(state),
|
||||
};
|
||||
},
|
||||
|
||||
// ---- Straddles ----
|
||||
//
|
||||
// A straddle is a blind raise put out before the cards, by the player left of the
|
||||
// big blind, for twice it. It buys the last word before the flop: everyone else
|
||||
// acts first, and the straddler still has the option when it comes back round even
|
||||
// if nobody raised. Re-straddles carry on left, each doubling again.
|
||||
//
|
||||
// Home games run them constantly and a dealer has to get three things right at
|
||||
// once — the amount, who is first to act, and what the minimum raise becomes. The
|
||||
// tool used to do none of them, so a dealer's only way through was to deal, have
|
||||
// the straddler "raise" to $4, and hand the action to the wrong player.
|
||||
|
||||
// How many straddles this table could take. Everyone but the two blinds can put
|
||||
// one out; heads-up that is nobody, which is the right answer — the button is
|
||||
// already the small blind and there is no seat left of the big blind to straddle
|
||||
// from.
|
||||
maxStraddles(state) {
|
||||
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
|
||||
},
|
||||
|
||||
// Who straddles and for how much, walking left from the big blind and doubling.
|
||||
// Asked of the table as it will be dealt, so a seat about to be sat out for having
|
||||
// no chips is never handed a straddle it can't post.
|
||||
straddleSeats(state, count = state.straddleCount) {
|
||||
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
|
||||
if (wanted <= 0) return [];
|
||||
|
||||
const dealt = this.tableAsDealt(state);
|
||||
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
|
||||
const asDealt = buttonIndex === dealt.dealerSeatIndex
|
||||
? dealt
|
||||
: { ...dealt, dealerSeatIndex: buttonIndex };
|
||||
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
|
||||
if (bbIndex === null) return [];
|
||||
|
||||
const straddles = [];
|
||||
let index = bbIndex;
|
||||
let amount = this.round((Number(state.bigBlind) || 0) * 2);
|
||||
for (let n = 0; n < wanted; n++) {
|
||||
index = this.nextToAct(asDealt, index);
|
||||
if (index === null || index === bbIndex) break;
|
||||
straddles.push({ index, amount });
|
||||
amount = this.round(amount * 2);
|
||||
}
|
||||
return straddles;
|
||||
},
|
||||
|
||||
// Does handing this seat chips also put them back in the game?
|
||||
//
|
||||
// At a real table those are one event, and here they were two: a busted player got
|
||||
@@ -475,6 +526,7 @@ const Betting = {
|
||||
state.currentBet = 0;
|
||||
state.actingSeatIndex = null;
|
||||
state.postedBlinds = null;
|
||||
state.postedStraddles = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -505,17 +557,71 @@ const Betting = {
|
||||
// from one who has just walked up and had the blinds go past them.
|
||||
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
|
||||
|
||||
state.currentBet = this.round(Math.max(
|
||||
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
|
||||
this.committed(state.seats[bbIndex])));
|
||||
state.lastRaiseSize = bb || 0;
|
||||
// Straddles go out after the blinds and before the cards, each one left of the
|
||||
// last. Posted here rather than left to the dealer to fake as a raise, because
|
||||
// a raise from that seat would hand the action to the wrong player.
|
||||
const straddles = this.straddleSeats(state);
|
||||
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
|
||||
state.postedStraddles = straddles.map(({ index }) => index);
|
||||
|
||||
state.currentBet = this.round(state.seats.reduce(
|
||||
(most, player) => Math.max(most, this.committed(player)), 0));
|
||||
// A straddle is a blind raise, so what has to be matched to raise again is the
|
||||
// straddle itself, not the big blind underneath it: $2 blinds with a $4
|
||||
// straddle make the next raise $8, the same way the $2 blind makes it $4.
|
||||
// Left alone, the minimum would come out at $6 and the dealer would be talked
|
||||
// out of it by every player at the table.
|
||||
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
|
||||
|
||||
// Heads-up the button is the small blind and acts first before the flop; with
|
||||
// three or more it's the seat after the big blind. Either way, a blind big
|
||||
// enough to put that player all-in leaves them nothing to decide.
|
||||
//
|
||||
// A straddle moves that seat along: first to act is left of the last straddler,
|
||||
// and the straddler keeps the option, which falls out of nobody having acted
|
||||
// yet.
|
||||
const lastPosted = straddles.length > 0
|
||||
? straddles[straddles.length - 1].index
|
||||
: bbIndex;
|
||||
state.actingSeatIndex = headsUp
|
||||
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
|
||||
: this.nextToAct(state, bbIndex);
|
||||
: this.nextToAct(state, lastPosted);
|
||||
},
|
||||
|
||||
// What a pot-sized raise actually is, as a "raise to" total.
|
||||
//
|
||||
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
|
||||
// is in the pot before the raise is measured off it, so the pot being matched is
|
||||
// the one that exists after the call:
|
||||
//
|
||||
// raise to = the current bet + (pot + what they owe) × fraction
|
||||
//
|
||||
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
|
||||
// raise $5. Leaving the call out understates every one of these, and players say
|
||||
// "pot" out loud meaning a definite number.
|
||||
//
|
||||
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
|
||||
// zero, so it comes out as the pot, which is the right answer there too.
|
||||
// Rounded up to something the table's chips can actually build. Half the pot is
|
||||
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
|
||||
// chip in the default legend is a dollar, so the button offered a number the
|
||||
// dealer would have to round themselves every single time.
|
||||
//
|
||||
// Only when the legend describes this game. A smallest chip *larger* than the big
|
||||
// blind means the denominations were never set for these stakes, and rounding a
|
||||
// 5c/10c table up to whole dollars would be the tool doing real damage on the
|
||||
// strength of a field somebody forgot to fill in. Left exact there, and the wrong
|
||||
// legend stays visible rather than being quietly applied.
|
||||
potRaiseTo(state, index, fraction = 1) {
|
||||
const player = state.seats[index];
|
||||
if (!player) return 0;
|
||||
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
|
||||
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
|
||||
|
||||
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
|
||||
const bigBlind = Number(state.bigBlind) || 0;
|
||||
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
|
||||
return Money.upToChip(exact, state.chipLegend);
|
||||
},
|
||||
|
||||
// Who turns their hand over first.
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -192,11 +192,15 @@ 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);
|
||||
}
|
||||
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);
|
||||
@@ -462,6 +466,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());
|
||||
@@ -598,6 +621,72 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("saveTableBtn").addEventListener("click", () => {
|
||||
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = HoldemState.backupFileName();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
|
||||
});
|
||||
|
||||
document.getElementById("loadTableBtn").addEventListener("click", () => {
|
||||
document.getElementById("loadTableInput").click();
|
||||
});
|
||||
|
||||
document.getElementById("loadTableInput").addEventListener("change", async e => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
// Cleared straight away so picking the same file twice still fires a change.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
Toast.show("That file couldn't be read.", { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = HoldemState.fromBackup(text);
|
||||
if (result.error) {
|
||||
Toast.show(result.error, { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
|
||||
// what is about to be lost is the difference between a dealer choosing that
|
||||
// and discovering it — and the current table might be the live one.
|
||||
const here = HoldemState.sessionStandings(state).length;
|
||||
const incoming = HoldemState.sessionStandings(result.table).length;
|
||||
const when = result.savedAt ? new Date(result.savedAt) : null;
|
||||
const saidWhen = when && !Number.isNaN(when.getTime())
|
||||
? ` It was saved ${when.toLocaleString()}.`
|
||||
: "";
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Replace this table?",
|
||||
message:
|
||||
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
|
||||
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
|
||||
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
|
||||
`Undo will bring it back for a few seconds; after that it is gone.`,
|
||||
confirmLabel: "Load it",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable("loading a saved table", () => {
|
||||
// Replace the contents rather than the reference: everything on the page
|
||||
// holds a pointer to this object.
|
||||
Object.keys(state).forEach(key => { delete state[key]; });
|
||||
Object.assign(state, result.table);
|
||||
});
|
||||
});
|
||||
|
||||
// The filter is how the dealer looks at the log, not something about the table, so
|
||||
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
||||
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||
|
||||
@@ -63,6 +63,44 @@ const Money = {
|
||||
// Anything the smallest chip can't make comes back as `remainder` rather than being
|
||||
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
|
||||
// problem for the dealer to solve, not a rounding error to hide.
|
||||
// The smallest chip the table has said it plays with, in cents. Zero when no
|
||||
// denominations are set, which means the tool has been told nothing about what
|
||||
// chips exist and shouldn't pretend otherwise.
|
||||
chipStepCents(legend) {
|
||||
const values = (legend || [])
|
||||
.map(chip => this.toCents(chip.value))
|
||||
.filter(cents => cents > 0);
|
||||
return values.length > 0 ? Math.min(...values) : 0;
|
||||
},
|
||||
|
||||
// Rounds an amount up to something the table can actually build out of its chips.
|
||||
//
|
||||
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
|
||||
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
|
||||
// amount below a legal minimum, which rounding the other way can.
|
||||
upToChip(amount, legend) {
|
||||
const step = this.chipStepCents(legend);
|
||||
if (step <= 0) return this.round(amount);
|
||||
const cents = this.toCents(amount);
|
||||
if (cents <= 0) return this.round(amount);
|
||||
return this.fromCents(Math.ceil(cents / step) * step);
|
||||
},
|
||||
|
||||
// And down, for money coming *out* of a pot rather than going into it.
|
||||
//
|
||||
// The rake is the one amount in this tool that leaves the table, so the direction
|
||||
// of the rounding is a decision about whose money it is. Down: a house that can't
|
||||
// make 40c out of its chips takes nothing rather than a dollar, and the players
|
||||
// keep the difference. Being wrong in the players' favour is the only way to be
|
||||
// wrong about this that nobody has to argue about.
|
||||
downToChip(amount, legend) {
|
||||
const step = this.chipStepCents(legend);
|
||||
if (step <= 0) return this.round(amount);
|
||||
const cents = this.toCents(amount);
|
||||
if (cents <= 0) return 0;
|
||||
return this.fromCents(Math.floor(cents / step) * step);
|
||||
},
|
||||
|
||||
chipBreakdown(amount, legend) {
|
||||
let cents = this.toCents(amount);
|
||||
if (cents <= 0) return { chips: [], remainder: 0 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -197,6 +197,80 @@ const QuickSplit = {
|
||||
});
|
||||
},
|
||||
|
||||
// Chopping one of the pots listed above, without leaving the screen that worked it
|
||||
// out. Starts closed and at as many ways as there are players who can win the pot,
|
||||
// which is the common case; the stepper covers a chop between fewer of them, which
|
||||
// is the other one.
|
||||
//
|
||||
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
|
||||
// the main pot doesn't change what the side pot below it is saying.
|
||||
addChopControl(row, amount, contenderCount) {
|
||||
let ways = contenderCount;
|
||||
let open = false;
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "unevenChopToggle";
|
||||
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "unevenChopDetail";
|
||||
detail.hidden = true;
|
||||
|
||||
const draw = () => {
|
||||
toggle.textContent = open ? "Hide chop" : "Chop";
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
detail.hidden = !open;
|
||||
if (!open) return;
|
||||
|
||||
const result = SplitCalculator.split(amount, ways, this.chip);
|
||||
detail.innerHTML = "";
|
||||
|
||||
const stepper = document.createElement("div");
|
||||
stepper.className = "unevenChopWays";
|
||||
stepper.appendChild(this.makeStepButton("−", () => {
|
||||
if (ways > 2) { ways -= 1; draw(); }
|
||||
}));
|
||||
const label = document.createElement("span");
|
||||
label.className = "unevenChopWaysLabel";
|
||||
label.textContent = `${ways} ways`;
|
||||
stepper.appendChild(label);
|
||||
stepper.appendChild(this.makeStepButton("+", () => {
|
||||
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
|
||||
}));
|
||||
detail.appendChild(stepper);
|
||||
|
||||
const each = document.createElement("p");
|
||||
each.className = "unevenChopEach";
|
||||
each.textContent = `${Money.format(result.each)} each`;
|
||||
detail.appendChild(each);
|
||||
|
||||
// Same wording as the main split, because it is the same rule and a dealer
|
||||
// shouldn't have to notice they're reading a different screen.
|
||||
if (result.valid && result.oddChips > 0) {
|
||||
const odd = document.createElement("p");
|
||||
odd.className = "unevenChopNote";
|
||||
odd.textContent = result.oddChips === 1
|
||||
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||
`first ${result.oddChips} players left of the button`;
|
||||
detail.appendChild(odd);
|
||||
}
|
||||
if (result.valid && result.dust > 0) {
|
||||
const dust = document.createElement("p");
|
||||
dust.className = "unevenChopNote unevenChopDust";
|
||||
dust.textContent =
|
||||
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||
`pick a smaller denomination.`;
|
||||
detail.appendChild(dust);
|
||||
}
|
||||
};
|
||||
|
||||
toggle.addEventListener("click", () => { open = !open; draw(); });
|
||||
draw();
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(detail);
|
||||
},
|
||||
|
||||
updateUneven() {
|
||||
const players = this.entries
|
||||
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
|
||||
@@ -218,7 +292,7 @@ const QuickSplit = {
|
||||
}
|
||||
empty.hidden = true;
|
||||
|
||||
const addRow = (label, amount, who, className) => {
|
||||
const addRow = (label, amount, who, className, chopWays) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `unevenPot ${className || ""}`;
|
||||
const name = document.createElement("span");
|
||||
@@ -234,9 +308,19 @@ const QuickSplit = {
|
||||
row.appendChild(value);
|
||||
row.appendChild(eligible);
|
||||
host.appendChild(row);
|
||||
|
||||
// Two players tying for a side pot is ordinary, and working out each one's
|
||||
// share meant copying the amount into the other mode by hand — retyping a
|
||||
// number the screen had just worked out, which is a transcription error
|
||||
// waiting to happen with money on it. The chop happens here instead.
|
||||
//
|
||||
// Only offered where it means something: a pot with at least two people
|
||||
// who can win it. Not the uncalled row, which goes back to one player, and
|
||||
// not the total.
|
||||
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
|
||||
};
|
||||
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", ")));
|
||||
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
|
||||
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
|
||||
|
||||
const total = players.reduce((sum, p) => sum + p.contribution, 0);
|
||||
|
||||
@@ -47,6 +47,19 @@ const Render = {
|
||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
|
||||
const rake = this.state.rake || {};
|
||||
document.getElementById("rakePercentInput").value = rake.percent || "";
|
||||
document.getElementById("rakeCapInput").value = rake.cap ?? "";
|
||||
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
|
||||
|
||||
// What the house has actually taken tonight, said where the rake is set rather
|
||||
// than only in the bank check — a dealer who changes the percentage mid-session
|
||||
// wants to see the running total right there.
|
||||
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
|
||||
const hint = document.getElementById("rakeTakenHint");
|
||||
hint.hidden = taken <= 0;
|
||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
||||
},
|
||||
|
||||
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||
@@ -82,24 +95,205 @@ const Render = {
|
||||
|
||||
// ---- Seats around the table (compact pucks) ----
|
||||
|
||||
seatPosition(index, count) {
|
||||
return this.positionOnRing(index, count, 44, 42);
|
||||
seatPosition(index, count, aspect = this.feltAspect()) {
|
||||
return this.positionOnRing(index, count, 44, 42, aspect);
|
||||
},
|
||||
|
||||
// How many times wider than tall the felt actually is, which is the one thing the
|
||||
// ring needs from the layout and can't work out from percentages. Falls back to a
|
||||
// circle when there's nothing to measure — the same answer the ring gave before it
|
||||
// asked.
|
||||
feltAspect() {
|
||||
const box = this.ringBox();
|
||||
return box ? box.width / box.height : 1;
|
||||
},
|
||||
|
||||
// The box a seat's left/top percentages actually resolve against. Not the felt: on
|
||||
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
|
||||
// off the screen, and measuring the felt instead put the ring's shape out by that
|
||||
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
|
||||
// which is the difference between "fine" and "overlapping".
|
||||
ringBox() {
|
||||
if (typeof document === "undefined") return null;
|
||||
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
|
||||
if (!layer) return null;
|
||||
const box = layer.getBoundingClientRect();
|
||||
return box.width && box.height ? box : null;
|
||||
},
|
||||
|
||||
// Bets sit on the felt between their player and the pot, the way chips actually
|
||||
// do — closer in than the seats, on the same spoke. The bets layer is inset to
|
||||
// match the seat ring (see the CSS), so this one radius holds at every size.
|
||||
betPosition(index, count) {
|
||||
return this.positionOnRing(index, count, 27, 25);
|
||||
//
|
||||
// The angle is the seat's own rather than one walked again round a tighter ellipse.
|
||||
// The two answers are within half a degree of each other, so this is not a fix for
|
||||
// anything visible — it is just the honest way to say "on that player's spoke",
|
||||
// and it saves the second walk.
|
||||
betPosition(index, count, aspect = this.feltAspect()) {
|
||||
const seat = this.seatPosition(index, count, aspect);
|
||||
return {
|
||||
left: 50 + (seat.left - 50) * (27 / 44),
|
||||
top: 50 + (seat.top - 50) * (25 / 42),
|
||||
};
|
||||
},
|
||||
|
||||
positionOnRing(index, count, rx, ry) {
|
||||
const angle = -90 + (360 / count) * index;
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
left: 50 + rx * Math.cos(radians),
|
||||
top: 50 + ry * Math.sin(radians),
|
||||
};
|
||||
// Seats spaced evenly around the rim — by the distance between them, not by the
|
||||
// angle they sit at.
|
||||
//
|
||||
// Equal angles are only equal spacing on a circle. The felt is an ellipse, and a
|
||||
// phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36°
|
||||
// apart then put four of them down each steep side, where 36° of turn is barely any
|
||||
// distance — seats 3 and 4 came out at the same x and 20px apart vertically, each
|
||||
// puck 72px tall. Their centres landed on each other, so tapping a player got their
|
||||
// neighbour, and the layout audit had been calling it at 640x360 and 667x375.
|
||||
//
|
||||
// Walking the perimeter instead puts the seats where a real table has them: along
|
||||
// the two long rails, one at each end. The arc length is summed numerically because
|
||||
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
|
||||
// few hundred pixels long, and it runs once per render.
|
||||
positionOnRing(index, count, rx, ry, aspect = 1) {
|
||||
return this.ringPositions(count, rx, ry, aspect)[index];
|
||||
},
|
||||
|
||||
// Past this much wider than tall, the rim stops being an ellipse and becomes a
|
||||
// stadium — two straight rails with a semicircular cap at each end.
|
||||
//
|
||||
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
|
||||
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
|
||||
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
|
||||
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
|
||||
// an end holds one seat and the rest of the table is rail — which is how a real
|
||||
// poker table is built, and why it is shaped that way.
|
||||
//
|
||||
// Only where it is needed. Anything rounder than this keeps the ellipse it has
|
||||
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
|
||||
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
|
||||
// upright), so no currently-working table changes shape.
|
||||
STADIUM_RATIO: 2.5,
|
||||
|
||||
// Seats spaced evenly around whichever rim this table has, as percentages.
|
||||
ringPositions(count, rx, ry, aspect = 1) {
|
||||
if (count <= 0) return [];
|
||||
const halfWidth = rx * aspect;
|
||||
const halfHeight = ry;
|
||||
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
|
||||
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
|
||||
left: 50 + rx * Math.cos(angle),
|
||||
top: 50 + ry * Math.sin(angle),
|
||||
}));
|
||||
}
|
||||
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
|
||||
},
|
||||
|
||||
// The same walk, around a stadium instead. Its perimeter has a closed form, so
|
||||
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
|
||||
stadiumPositions(count, halfWidth, halfHeight, aspect) {
|
||||
const rail = halfWidth - halfHeight;
|
||||
const cap = Math.PI * halfHeight;
|
||||
const perimeter = 4 * rail + 2 * cap;
|
||||
|
||||
return Array.from({ length: count }, (_, seat) => {
|
||||
// Clockwise from the top centre, so seat one sits where it always has.
|
||||
let walked = (perimeter * seat) / count;
|
||||
let x;
|
||||
let y;
|
||||
if (walked <= rail) { // top rail, going right
|
||||
x = walked;
|
||||
y = -halfHeight;
|
||||
} else if (walked <= rail + cap) { // right cap
|
||||
const turned = (walked - rail) / halfHeight;
|
||||
x = rail + halfHeight * Math.sin(turned);
|
||||
y = -halfHeight * Math.cos(turned);
|
||||
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
|
||||
x = rail - (walked - rail - cap);
|
||||
y = halfHeight;
|
||||
} else if (walked <= 3 * rail + 2 * cap) { // left cap
|
||||
const turned = (walked - 3 * rail - cap) / halfHeight;
|
||||
x = -rail - halfHeight * Math.sin(turned);
|
||||
y = halfHeight * Math.cos(turned);
|
||||
} else { // top rail, back to the start
|
||||
x = -rail + (walked - 3 * rail - 2 * cap);
|
||||
y = -halfHeight;
|
||||
}
|
||||
return { left: 50 + x / aspect, top: 50 + y };
|
||||
});
|
||||
},
|
||||
|
||||
RING_STEPS: 720,
|
||||
ringCache: {},
|
||||
|
||||
// The closest two neighbouring seats come to each other, straight line, in pixels.
|
||||
//
|
||||
// Rim length divided by seat count was the first answer and it is the wrong one:
|
||||
// it measures the walk between two seats rather than the gap between them, and on
|
||||
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
|
||||
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
|
||||
// and two seats a long walk apart are stacked one above the other.
|
||||
closestSeatGapPx(count, box = this.ringBox()) {
|
||||
if (count < 2 || !box || !box.width || !box.height) return Infinity;
|
||||
|
||||
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
|
||||
x: (seat.left / 100) * box.width,
|
||||
y: (seat.top / 100) * box.height,
|
||||
}));
|
||||
return points.reduce((closest, point, i) => {
|
||||
const next = points[(i + 1) % points.length];
|
||||
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
|
||||
}, Infinity);
|
||||
},
|
||||
|
||||
// A puck is 76px across, so neighbours whose centres are closer than that overlap
|
||||
// by definition; a few pixels more keeps them from touching. Under it the pucks
|
||||
// come down a size rather than the table pretending it has room it hasn't.
|
||||
SEAT_ROOM_PX: 84,
|
||||
|
||||
// Called on every render and again whenever the felt is resized — the action bar
|
||||
// growing mid-hand shortens the table, and the seats have to answer that without
|
||||
// waiting for the next render to come along.
|
||||
updateSeatDensity() {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
if (!layer || !this.state) return;
|
||||
layer.classList.toggle("seatsTight",
|
||||
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
|
||||
},
|
||||
|
||||
// The angles that cut the rim into `count` equal lengths, starting at the top.
|
||||
// Every seat on a ring wants the same answer, so it is worked out once per shape
|
||||
// and kept — the ring only changes when the table is resized or the window is.
|
||||
ringAngles(count, rx, ry, aspect) {
|
||||
if (count <= 0) return [];
|
||||
const key = `${count}:${rx}:${ry}:${aspect.toFixed(3)}`;
|
||||
if (this.ringCache[key]) return this.ringCache[key];
|
||||
|
||||
const start = -Math.PI / 2;
|
||||
// Percentages of the width and of the height are not the same distance, so the
|
||||
// horizontal radius is scaled into the vertical one's units before measuring.
|
||||
const a = rx * aspect;
|
||||
const b = ry;
|
||||
const step = (2 * Math.PI) / this.RING_STEPS;
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i <= this.RING_STEPS; i++) {
|
||||
const from = start + (i - 1) * step;
|
||||
const to = start + i * step;
|
||||
const dx = a * (Math.cos(to) - Math.cos(from));
|
||||
const dy = b * (Math.sin(to) - Math.sin(from));
|
||||
cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy));
|
||||
}
|
||||
|
||||
const perimeter = cumulative[this.RING_STEPS];
|
||||
const angles = [];
|
||||
let walked = 0;
|
||||
for (let seat = 0; seat < count; seat++) {
|
||||
const wanted = (perimeter * seat) / count;
|
||||
while (walked < this.RING_STEPS && cumulative[walked + 1] < wanted) walked++;
|
||||
// Interpolate inside the step the seat falls in, so the answer doesn't
|
||||
// jump in 0.5° increments.
|
||||
const spanned = cumulative[walked + 1] - cumulative[walked];
|
||||
const into = spanned > 0 ? (wanted - cumulative[walked]) / spanned : 0;
|
||||
angles.push(start + (walked + into) * step);
|
||||
}
|
||||
this.ringCache[key] = angles;
|
||||
return angles;
|
||||
},
|
||||
|
||||
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
|
||||
@@ -171,6 +365,7 @@ const Render = {
|
||||
renderSeats() {
|
||||
const layer = document.getElementById("seatsLayer");
|
||||
layer.innerHTML = "";
|
||||
this.updateSeatDensity();
|
||||
const template = document.getElementById("seatTemplate");
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
// A button on an empty chair normally means the table has drifted and wants
|
||||
@@ -236,6 +431,14 @@ const Render = {
|
||||
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
|
||||
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
|
||||
|
||||
// A straddle is a blind too, and the seat wearing it is the one the action
|
||||
// comes back to. Marked from what the deal actually posted rather than from
|
||||
// the count, so the badge can't outlive the chips it stands for — before
|
||||
// the cards there is nothing out there yet.
|
||||
const straddleBadge = node.querySelector(".strBadge");
|
||||
const straddled = (this.state.postedStraddles || []).includes(index);
|
||||
straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
|
||||
|
||||
this.updatePuckContent(node, player, index);
|
||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||
|
||||
@@ -524,13 +727,41 @@ const Render = {
|
||||
this.updatePotBadge();
|
||||
this.renderSeatTotal(form, player);
|
||||
this.refreshPuck(index);
|
||||
// The editor deliberately refreshes narrowly rather than redrawing everything —
|
||||
// a full render on every keystroke would take the name field out from under the
|
||||
// dealer mid-word. But the action bar answers "is there a table here at all",
|
||||
// and seating somebody is exactly what changes that answer. Without this it
|
||||
// stayed hidden until something else happened to redraw: two players seated, a
|
||||
// dealable hand, and a blank bar until the page was reloaded.
|
||||
//
|
||||
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
||||
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
const nameInput = form.querySelector(".seatNameInput");
|
||||
nameInput.value = player.name;
|
||||
nameInput.addEventListener("input", () => {
|
||||
// Putting a name on an empty chair is somebody sitting down, so they get
|
||||
// dealt in. It used to be two separate acts, and the second one was easy to
|
||||
// miss: a dealer setting up typed six names, got six seats that still
|
||||
// weren't in the hand, and the action bar hid itself because by its reckoning
|
||||
// nobody was playing. Six people at the table and a blank screen.
|
||||
//
|
||||
// Only for a chair that was empty — no name and not dealt in. Renaming
|
||||
// somebody who is sitting out is a correction, not an arrival, and dealing
|
||||
// them back in over a typo would be the tool overruling the dealer.
|
||||
const wasEmpty = !player.name.trim() && !player.inHand;
|
||||
player.name = nameInput.value;
|
||||
if (wasEmpty && player.name.trim()) {
|
||||
player.inHand = true;
|
||||
player.folded = false;
|
||||
const inHandBox = form.querySelector(".inHandCheckbox");
|
||||
if (inHandBox) inHandBox.checked = true;
|
||||
const foldBox = form.querySelector(".foldCheckbox");
|
||||
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
|
||||
}
|
||||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
@@ -790,9 +1021,15 @@ const Render = {
|
||||
netEl.classList.toggle("netUp", row.net > 0);
|
||||
netEl.classList.toggle("netDown", row.net < 0);
|
||||
|
||||
// What they'd actually be handed if the night ended now: chips in front of
|
||||
// them less the cash they gave the banker. This was computed and written to
|
||||
// the CSV all along, but never shown to the person doing the paying out.
|
||||
// The cash to count out, which is a plain amount and never signed — this
|
||||
// is the one the banker's hands follow. It sat only in the CSV: on screen
|
||||
// the column headed "Cash out" held the difference against their buy-in
|
||||
// instead, so a player up $140 on a $340 stack read as "$140" at the exact
|
||||
// moment somebody was counting money onto the table.
|
||||
node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
|
||||
|
||||
// And whether that leaves them up or down, which is the question the rest
|
||||
// of the table asks and the one the old column was actually answering.
|
||||
const cashEl = node.querySelector(".standingsCashOut");
|
||||
cashEl.textContent = Money.formatSigned(row.cashPosition);
|
||||
cashEl.classList.toggle("netUp", row.cashPosition > 0);
|
||||
@@ -921,10 +1158,26 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
// What has left the table on purpose, whichever way it went.
|
||||
const accountedFor = [
|
||||
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
|
||||
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
|
||||
].filter(Boolean);
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = 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 =
|
||||
|
||||
@@ -100,6 +100,20 @@ const HoldemState = {
|
||||
// the big blind rather than waiting for it. Cleared every hand: it's a
|
||||
// decision about one deal, not a standing arrangement.
|
||||
postingIn: [],
|
||||
// How many straddles are on for the hand about to be dealt. A straddle is
|
||||
// announced hand by hand, so this is cleared with every other one-deal
|
||||
// decision rather than standing until somebody turns it off.
|
||||
straddleCount: 0,
|
||||
// What the house takes, if it takes anything. Percent zero is a game that
|
||||
// doesn't rake, which is nearly all of them and the default.
|
||||
rake: { percent: 0, cap: null, noFlopNoDrop: true },
|
||||
// What it has taken so far tonight. A session total, not a per-hand one:
|
||||
// it is the number the box is checked against at the end.
|
||||
rakeTaken: 0,
|
||||
// Which seats actually put a straddle out when the hand was dealt, so the
|
||||
// felt can badge them. Read off the deal rather than worked out again
|
||||
// later, the same way the posted blinds are.
|
||||
postedStraddles: [],
|
||||
// Set when somebody leaves out of the blinds and the rotation has to hold
|
||||
// to the seats to keep the big blind moving one player at a time. The
|
||||
// button then sits on the chair they vacated, and no small blind is posted.
|
||||
@@ -164,13 +178,18 @@ const HoldemState = {
|
||||
return this.createDefault();
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
// Everything a table has to be put through before it can be trusted, whichever
|
||||
// direction it arrived from.
|
||||
//
|
||||
// Pulled out of load() so a table read from a file goes through exactly the same
|
||||
// checks as one read from storage. A second, looser path for imports is how a
|
||||
// format grows two meanings of the same field.
|
||||
//
|
||||
// Returns null for anything that isn't a table at all; every other repair is
|
||||
// silent and forward, the way a save from an older version is treated.
|
||||
migrate(parsed) {
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return null;
|
||||
{
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||
@@ -196,6 +215,20 @@ const HoldemState = {
|
||||
// asking the whole table to post.
|
||||
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
|
||||
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
|
||||
// None on is the right reading of a save from before straddles existed:
|
||||
// whatever is in front of those players is already in their committed
|
||||
// chips, and nothing is owed on top of it.
|
||||
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
|
||||
// A table saved before the house took anything takes nothing, which is
|
||||
// also the right answer for one that never will.
|
||||
const rake = parsed.rake && typeof parsed.rake === "object" ? parsed.rake : {};
|
||||
parsed.rake = {
|
||||
percent: Math.min(100, Math.max(0, Number(rake.percent) || 0)),
|
||||
cap: Number(rake.cap) > 0 ? Money.round(Number(rake.cap)) : null,
|
||||
noFlopNoDrop: rake.noFlopNoDrop !== false,
|
||||
};
|
||||
parsed.rakeTaken = Math.max(0, Money.round(Number(parsed.rakeTaken) || 0));
|
||||
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
|
||||
|
||||
// A table saved before dealing and betting were separate has no flag, and
|
||||
// guessing false would show "deal the hand" over a hand already in
|
||||
@@ -215,12 +248,85 @@ const HoldemState = {
|
||||
});
|
||||
|
||||
return parsed;
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const migrated = this.migrate(JSON.parse(raw));
|
||||
if (!migrated) return this.failedLoad(raw);
|
||||
return migrated;
|
||||
} catch {
|
||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||
return this.failedLoad(raw);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Taking the table off this device ------------------------------------
|
||||
//
|
||||
// A night lives in one browser's localStorage and nowhere else. A dealer whose
|
||||
// phone dies, or who wants to carry on from the tablet after the break, loses the
|
||||
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
|
||||
// export can't bring any of that back, because it records what happened rather
|
||||
// than where the table stands.
|
||||
//
|
||||
// So the table can be written out as a file and read back in. Same shape that goes
|
||||
// into storage, wrapped in enough to recognise it later.
|
||||
BACKUP_KIND: "casino.holdem.table",
|
||||
BACKUP_VERSION: 1,
|
||||
|
||||
toBackup(state) {
|
||||
return JSON.stringify({
|
||||
kind: this.BACKUP_KIND,
|
||||
version: this.BACKUP_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
table: state,
|
||||
}, null, 2);
|
||||
},
|
||||
|
||||
// Reads a file back. Returns { table } on success, or { error } with something a
|
||||
// dealer can act on — never a half-loaded table, because the failure mode there is
|
||||
// a night that looks restored and isn't.
|
||||
fromBackup(text) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { error: "That file isn't a saved table." };
|
||||
}
|
||||
// A bare table, from someone who copied the storage value out by hand, is
|
||||
// still a table. The envelope is a convenience, not a password.
|
||||
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
|
||||
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
|
||||
return { error: "That file is a save from something else, not a Hold'em table." };
|
||||
}
|
||||
const migrated = this.migrate(table);
|
||||
if (!migrated) {
|
||||
return { error: "That file doesn't have a table in it — no seats were found." };
|
||||
}
|
||||
if (parsed.version > this.BACKUP_VERSION) {
|
||||
// Newer than this build understands. Loading it anyway risks reading a
|
||||
// field that has changed meaning, and the dealer would find out from the
|
||||
// money, so say so instead.
|
||||
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
|
||||
}
|
||||
return { table: migrated, savedAt: parsed.savedAt || null };
|
||||
},
|
||||
|
||||
// What the file is called. Dated, because a dealer who saves twice in a night
|
||||
// should not have to guess which is which.
|
||||
backupFileName(now = new Date()) {
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
||||
},
|
||||
|
||||
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||
// toast is a moment and this is a fact about the rest of the night.
|
||||
saveFailed: false,
|
||||
@@ -565,6 +671,10 @@ const HoldemState = {
|
||||
// Posting to come in is a decision about one deal. Carrying it forward would
|
||||
// charge somebody an extra big blind every hand for the rest of the night.
|
||||
state.postingIn = [];
|
||||
// So is a straddle. It is announced hand by hand at a table, and a straddle
|
||||
// that stayed on would take chips off somebody who never said the word.
|
||||
state.straddleCount = 0;
|
||||
state.postedStraddles = [];
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -584,11 +694,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) => ({
|
||||
@@ -603,6 +717,7 @@ const HoldemState = {
|
||||
handNumber: state.handNumber,
|
||||
timestamp: Date.now(),
|
||||
pot,
|
||||
rake: taken,
|
||||
payouts,
|
||||
ledger,
|
||||
});
|
||||
@@ -683,6 +798,14 @@ const HoldemState = {
|
||||
net: Money.round(t.won - t.in),
|
||||
boughtIn,
|
||||
chipsNow,
|
||||
// Everything still theirs: the stack in front of them plus whatever
|
||||
// is out in the middle for the hand in progress. This is the number
|
||||
// a banker counts out when somebody leaves, and it was the one
|
||||
// figure the standings never showed — the column called "Cash out"
|
||||
// held the difference against their buy-in, so a player up $140 on
|
||||
// a $340 stack read as "$140" at the exact moment cash changed
|
||||
// hands.
|
||||
chipsInFront: Money.round(chipsNow + inFrontOfThem),
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
notes,
|
||||
};
|
||||
@@ -715,6 +838,67 @@ const HoldemState = {
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
// ---- The house's cut ----
|
||||
//
|
||||
// A game that takes a rake or a time charge has chips leaving the table every hand,
|
||||
// and until now the bank check called them missing: it added up the buy-ins,
|
||||
// added up the chips on the felt, and told the dealer somebody had been paid for
|
||||
// chips they never got. The house's cut is the one thing on this table that is
|
||||
// supposed to go and not come back, so it is counted rather than mourned.
|
||||
//
|
||||
// Percentage with a cap, which is what nearly every room and home game runs. Zero
|
||||
// percent is a game that doesn't rake, which is the default and the common case.
|
||||
|
||||
// What the house takes from a pot of this size.
|
||||
//
|
||||
// "No flop, no drop" is the rule almost everyone plays: a hand that ends before the
|
||||
// flop pays nothing, because the players never got a hand out of it. It is on by
|
||||
// default and can be turned off for a house that rakes every pot.
|
||||
rakeOn(state, pot, sawFlop = true) {
|
||||
const rake = state.rake || {};
|
||||
const percent = Number(rake.percent) || 0;
|
||||
const amount = Money.round(Number(pot) || 0);
|
||||
if (percent <= 0 || amount <= 0) return 0;
|
||||
if (rake.noFlopNoDrop !== false && !sawFlop) return 0;
|
||||
|
||||
const uncapped = (amount * percent) / 100;
|
||||
const cap = Number(rake.cap);
|
||||
const capped = cap > 0 ? Math.min(uncapped, cap) : uncapped;
|
||||
// Never more than the pot, however the numbers were typed in.
|
||||
return Money.downToChip(Math.min(capped, amount), state.chipLegend);
|
||||
},
|
||||
|
||||
// The same payouts with the house's cut taken out of them.
|
||||
//
|
||||
// Off the top of the whole pot, then shared out in proportion — which for the hand
|
||||
// a dealer actually deals, with one winner, is just "winner gets the pot less the
|
||||
// rake". Split pots take it proportionally, and whatever the rounding leaves over
|
||||
// goes back to the winners in turn rather than to the house: the house asked for a
|
||||
// number, and it gets that number, not that number plus dust.
|
||||
applyRake(payouts, rake) {
|
||||
const total = payouts.reduce((sum, p) => sum + p.amount, 0);
|
||||
const taken = Money.round(Math.min(Math.max(0, rake), total));
|
||||
if (taken <= 0) return { payouts: payouts.map(p => ({ ...p })), rake: 0 };
|
||||
|
||||
const kept = Money.toCents(total) - Money.toCents(taken);
|
||||
const shares = payouts.map(p => {
|
||||
const exact = (Money.toCents(p.amount) * kept) / Money.toCents(total);
|
||||
return { payout: p, cents: Math.floor(exact) };
|
||||
});
|
||||
|
||||
// Floor everybody, then hand the leftover cents out one at a time, biggest
|
||||
// share first — the same shape as the odd-chip rule the split calculator uses.
|
||||
let spare = kept - shares.reduce((sum, s) => sum + s.cents, 0);
|
||||
[...shares]
|
||||
.sort((a, b) => b.payout.amount - a.payout.amount)
|
||||
.forEach(share => { if (spare > 0) { share.cents += 1; spare -= 1; } });
|
||||
|
||||
return {
|
||||
payouts: shares.map(share => ({ ...share.payout, amount: Money.fromCents(share.cents) })),
|
||||
rake: taken,
|
||||
};
|
||||
},
|
||||
|
||||
bankReconciliation(state) {
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
@@ -734,11 +918,16 @@ 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));
|
||||
|
||||
const difference = Money.round(chipsInPlay + discarded + raked - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
discarded,
|
||||
raked,
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
|
||||
+16
-1
@@ -36,7 +36,22 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||
phones on their side.
|
||||
phones on their side — each viewport twice, once with a mouse and once with a thumb.
|
||||
|
||||
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
|
||||
eleven for a long run of rounds. Both were written and tested and simply never called.
|
||||
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
|
||||
where `hover: hover` says there is a pointer, and a headless desktop browser is a
|
||||
pointer, so a single pass had never measured a touch target at any viewport. Between
|
||||
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
|
||||
button on Quick Split.
|
||||
|
||||
A check that decides it doesn't apply says so in the output rather than reporting no
|
||||
offenders, which reads identically to a pass. The same goes for the two exemptions
|
||||
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||||
sentence, whose size is set by the line it sits in.
|
||||
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||
this one needs a browser and a running site. Run it after any change that adds or
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// The sums the contrast check runs on, tested away from a browser.
|
||||
//
|
||||
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
|
||||
// and the arithmetic is the part that decides whether a real fault is reported or
|
||||
// waved through. Faded text used to be filed as "not checked" rather than measured;
|
||||
// this is the compositing that replaced that.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const LayoutAudit = require("./uiAudit");
|
||||
|
||||
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
||||
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
|
||||
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
|
||||
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
|
||||
|
||||
describe("the ratio itself", () => {
|
||||
test("black on white is the full 21:1, either way round", () => {
|
||||
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
|
||||
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
|
||||
});
|
||||
|
||||
test("a colour against itself is 1:1", () => {
|
||||
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text on a box that is being faded", () => {
|
||||
// opacity on a box fades the box and its text together onto whatever is behind
|
||||
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
|
||||
// faded text both landing on the page.
|
||||
const fade = alpha => ({ alpha, behind: PAGE });
|
||||
|
||||
test("at full strength nothing is composited", () => {
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
|
||||
assert.deepEqual(painted.fg, WHITE);
|
||||
assert.deepEqual(painted.bg, PUCK);
|
||||
});
|
||||
|
||||
test("fading moves both the text and its background, not just the text", () => {
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||
assert.ok(painted.fg.r < WHITE.r, "the text dims");
|
||||
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
|
||||
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
|
||||
});
|
||||
|
||||
test("which is what stops a fade being reported as a fault it isn't", () => {
|
||||
// Dimming light text on a dark puck over a dark page costs contrast, but far
|
||||
// less than measuring the faded text against the *unfaded* puck would claim.
|
||||
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
|
||||
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
|
||||
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
|
||||
});
|
||||
|
||||
test("a fade all the way out lands everything on the page behind", () => {
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
|
||||
assert.deepEqual(painted.fg, PAGE);
|
||||
assert.deepEqual(painted.bg, PAGE);
|
||||
});
|
||||
|
||||
test("with nothing known to be behind it, the fade is left alone", () => {
|
||||
// Better to measure the unfaded pair than to invent a backdrop.
|
||||
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
|
||||
assert.deepEqual(painted.fg, WHITE);
|
||||
assert.deepEqual(painted.bg, PUCK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
|
||||
// Deliberately not asserting a particular CSS colour here: the page's own greys are
|
||||
// measured in the browser, against the real gradients, where they live. What this
|
||||
// pins is the direction — that dimming a box costs its text contrast, which is why
|
||||
// filing faded text as "not checked" hid faults rather than avoiding false ones.
|
||||
test("the same grey scores lower once its box is dimmed", () => {
|
||||
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
|
||||
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
|
||||
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
|
||||
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
|
||||
LayoutAudit.contrastRatio(solid.fg, solid.bg));
|
||||
});
|
||||
|
||||
test("and a lighter grey scores higher at the same fade", () => {
|
||||
const at = colour => {
|
||||
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
|
||||
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
|
||||
};
|
||||
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading a colour off the page", () => {
|
||||
test("rgb and rgba both come back as numbers", () => {
|
||||
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
|
||||
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
|
||||
});
|
||||
|
||||
test("a gradient gives up every stop, because text could be over any of them", () => {
|
||||
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
|
||||
assert.equal(stops.length, 2);
|
||||
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
|
||||
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
|
||||
});
|
||||
|
||||
test("no background image, no stops", () => {
|
||||
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
|
||||
});
|
||||
});
|
||||
@@ -167,3 +167,58 @@ describe("does the cash box cover the chips on the table", () => {
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number the banker counts out", () => {
|
||||
// The standings had two signed columns and neither was an amount to pay. A player
|
||||
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
|
||||
// exact moment somebody was counting money onto the table.
|
||||
test("chips in front is what they are holding, not what they are up", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].buyIns = 200;
|
||||
state.seats[1].buyIns = 200;
|
||||
state.seats[1].stack = 60;
|
||||
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
const ann = rows.find(r => r.name === "Ann");
|
||||
assert.equal(ann.chipsInFront, 340, "the cash to count out");
|
||||
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
|
||||
});
|
||||
|
||||
test("a player who is down still has chips to be handed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
|
||||
state.seats[0].name = "Cal";
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
|
||||
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
|
||||
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
|
||||
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
|
||||
});
|
||||
|
||||
test("chips still out in the middle are counted as theirs", () => {
|
||||
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
|
||||
// and a stack that drops by the size of a bet looks like money going missing.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
|
||||
state.seats[0].name = "Dee";
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 0, 50);
|
||||
|
||||
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
|
||||
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
|
||||
assert.equal(dee.cashPosition, 0, "so they are still level");
|
||||
});
|
||||
|
||||
test("chips and up/down always differ by exactly the buy-in", () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
|
||||
[340, 150, 60, 250].forEach((chips, i) => {
|
||||
state.seats[i].stack = chips;
|
||||
state.seats[i].buyIns = 200 + i * 50;
|
||||
});
|
||||
|
||||
HoldemState.sessionStandings(state).forEach(row => {
|
||||
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
|
||||
`${row.name}'s two columns should tell one story`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// How tall the felt is allowed to be.
|
||||
//
|
||||
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
|
||||
// has to be told what is left. Everything below is the arithmetic that decides it —
|
||||
// the part that can be wrong without anyone noticing until a seat is behind the Fold
|
||||
// button.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { ActionBar } = require("./load");
|
||||
|
||||
// window height, bar height, and how far down the page the felt starts.
|
||||
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
|
||||
|
||||
describe("the room left for the felt", () => {
|
||||
test("is what the window has after the bar, the header and the bottom seat", () => {
|
||||
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
|
||||
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
|
||||
});
|
||||
|
||||
test("shrinks when the bar grows mid-hand", () => {
|
||||
// Side pots make the bar taller exactly when the dealer most needs to see who
|
||||
// is still in.
|
||||
const quiet = room(900, 117, 146);
|
||||
const sidePots = room(900, 172, 146);
|
||||
assert.equal(quiet - sidePots, 55);
|
||||
});
|
||||
|
||||
test("leaves the bottom seat's overhang out of it", () => {
|
||||
// The seat pucks hang below the felt's edge; the felt has to stop short by that
|
||||
// much or the ring ends up in the bar even though the felt doesn't.
|
||||
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
|
||||
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a window too short to give what was asked", () => {
|
||||
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
|
||||
// everything above the felt. What's left is under 100.
|
||||
const landscape = () => room(390, 127, 119);
|
||||
|
||||
test("gets the measurement, not a comfortable number", () => {
|
||||
// The floor used to be 220px, which this window hasn't got. Holding out for it
|
||||
// put the bottom row of seats 26px inside the action bar — the exact fault the
|
||||
// measurement exists to prevent.
|
||||
assert.ok(landscape() < 150, `${landscape()}px`);
|
||||
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
|
||||
});
|
||||
|
||||
test("still draws a table rather than a line", () => {
|
||||
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
|
||||
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
|
||||
});
|
||||
|
||||
test("never returns something a browser can't lay out", () => {
|
||||
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
|
||||
const answer = ActionBar.feltRoomFor(h, bar, top);
|
||||
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a window with room to spare", () => {
|
||||
test("hands over everything that's left, floor or no floor", () => {
|
||||
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
|
||||
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
|
||||
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
|
||||
});
|
||||
|
||||
test("gives a whole number of pixels", () => {
|
||||
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,10 @@ globalThis.Render = Render;
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
globalThis.HoldemState = HoldemState;
|
||||
|
||||
// actionBar.js after state, since its own module-level code reaches for neither but
|
||||
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||
const ActionBar = load("actionBar");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -81,6 +85,7 @@ module.exports = {
|
||||
SettleUp,
|
||||
Betting,
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// What a pot-sized raise actually is.
|
||||
//
|
||||
// The raise pad worked it out as "the current bet plus the pot as it stands", which
|
||||
// leaves out the fact that the raiser calls first and their call is in the pot before
|
||||
// the raise is measured off it. Every quick amount came out short — pre-flop at 1/2 it
|
||||
// offered "Pot $5" where a pot raise is $7. Players say "pot" out loud meaning a
|
||||
// definite number, and a dealer tapping the button that claims to be it should record
|
||||
// what was actually said.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, Money, makeTable } = require("./load");
|
||||
|
||||
describe("pre-flop at 1/2", () => {
|
||||
const dealt = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a pot raise from the first player to act is $7", () => {
|
||||
// Call the $2, which makes the pot $5, then raise $5.
|
||||
const state = dealt();
|
||||
assert.equal(Betting.potSoFar(state), 3, "the blinds");
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7);
|
||||
});
|
||||
|
||||
test("half pot is $5 — $4.50 rounded up to something the chips can make", () => {
|
||||
// The old arithmetic gave $3.50, which clamped up to the minimum and put the
|
||||
// same $4 on two buttons side by side. The honest half-pot is $4.50, and the
|
||||
// smallest chip in the default legend is a dollar, so the button would have
|
||||
// offered an amount the dealer had to round themselves every single time.
|
||||
const state = dealt();
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||
assert.notEqual(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5),
|
||||
Betting.minRaiseTo(state), "the two quick buttons say different things");
|
||||
});
|
||||
|
||||
test("the small blind owes less, so their pot raise is smaller", () => {
|
||||
// They have $1 in already: call $1, pot becomes $4, raise $4, total $6.
|
||||
const state = dealt();
|
||||
const { sbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||
assert.equal(Betting.toCall(state, state.seats[sbIndex]), 1);
|
||||
assert.equal(Betting.potRaiseTo(state, sbIndex), 6);
|
||||
});
|
||||
|
||||
test("the big blind owes nothing, so it is just the pot", () => {
|
||||
const state = dealt();
|
||||
const { bbIndex } = require("./load").HoldemState.getBlindSeats(state);
|
||||
assert.equal(Betting.toCall(state, state.seats[bbIndex]), 0);
|
||||
assert.equal(Betting.potRaiseTo(state, bbIndex), 5, "the $2 bet plus the $3 pot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("after the flop", () => {
|
||||
const flopWithBet = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
while (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex);
|
||||
Betting.closeStreet(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a pot raise facing a bet is three times the bet plus the dead money", () => {
|
||||
const state = flopWithBet();
|
||||
const potBefore = Betting.potSoFar(state); // $6 carried over
|
||||
const bettor = state.actingSeatIndex;
|
||||
Betting.raiseTo(state, bettor, 10);
|
||||
|
||||
const raiser = state.actingSeatIndex;
|
||||
// Call $10, pot becomes $26, raise $26 → $36. Which is 3×10 + 6.
|
||||
assert.equal(Betting.potRaiseTo(state, raiser), 36);
|
||||
assert.equal(Betting.potRaiseTo(state, raiser), 3 * 10 + potBefore);
|
||||
});
|
||||
|
||||
test("half pot facing that bet is $23", () => {
|
||||
const state = flopWithBet();
|
||||
Betting.raiseTo(state, state.actingSeatIndex, 10);
|
||||
// Call $10, pot becomes $26, raise $13 → $23.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 23);
|
||||
});
|
||||
|
||||
test("facing no bet at all it is a plain pot-sized bet", () => {
|
||||
const state = flopWithBet();
|
||||
assert.equal(state.currentBet, 0, "nobody has bet the flop");
|
||||
assert.equal(Betting.toCall(state, state.seats[state.actingSeatIndex]), 0);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), Betting.potSoFar(state));
|
||||
});
|
||||
});
|
||||
|
||||
describe("it stays sane at the edges", () => {
|
||||
test("a seat that doesn't exist gets zero rather than throwing", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
assert.equal(Betting.potRaiseTo(state, 99), 0);
|
||||
});
|
||||
|
||||
test("the amount is always at least a call", () => {
|
||||
// A raise-to below the current bet would be nonsense to offer. Every fraction
|
||||
// from a tenth up lands at or above what the player owes.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||
Betting.beginHand(state);
|
||||
const index = state.actingSeatIndex;
|
||||
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||
assert.ok(Betting.potRaiseTo(state, index, fraction) >= state.currentBet,
|
||||
`${fraction} of the pot should still cover the bet`);
|
||||
});
|
||||
});
|
||||
|
||||
test("cent-sized blinds don't drift", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 20, smallBlind: 0.05, bigBlind: 0.1 });
|
||||
Betting.beginHand(state);
|
||||
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the amounts are ones the table can actually build", () => {
|
||||
const CENTS = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
|
||||
const DOLLARS = [{ value: 1 }, { value: 5 }, { value: 25 }, { value: 100 }];
|
||||
|
||||
const table = (legend, smallBlind, bigBlind, stack = 200) => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack, smallBlind, bigBlind });
|
||||
state.chipLegend = legend;
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a half-pot that lands on a half chip is rounded up, not offered as it is", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
// Honestly $4.50. No dollar chip makes fifty cents.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 5);
|
||||
assert.equal(Money.chipBreakdown(5, DOLLARS).remainder, 0);
|
||||
});
|
||||
|
||||
test("up rather than down — a shaved pot bet is not a pot bet", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
assert.ok(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5) > 4.5);
|
||||
});
|
||||
|
||||
test("an amount the chips already make is left exactly alone", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 7, "the pot raise was already whole");
|
||||
});
|
||||
|
||||
test("a cent table keeps its cents", () => {
|
||||
const state = table(CENTS, 0.05, 0.1);
|
||||
// Pot $0.15, owes $0.10: call to $0.25, raise $0.25 → $0.35, which cent chips make.
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||
assert.equal(Money.chipBreakdown(0.35, CENTS).remainder, 0);
|
||||
});
|
||||
|
||||
test("a legend that plainly isn't for these stakes is not applied", () => {
|
||||
// Dollar chips on a 5c/10c game means somebody never filled the legend in.
|
||||
// Rounding those blinds up to whole dollars would be the tool doing real
|
||||
// damage on the strength of a stale field, so the amounts stay exact and the
|
||||
// wrong legend stays visible.
|
||||
const state = table(DOLLARS, 0.05, 0.1);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex), 0.35);
|
||||
});
|
||||
|
||||
test("a table with no legend at all gets exact amounts", () => {
|
||||
const state = table([], 1, 2);
|
||||
assert.equal(Betting.potRaiseTo(state, state.actingSeatIndex, 0.5), 4.5);
|
||||
});
|
||||
|
||||
test("every fraction comes out buildable, and never shaved", () => {
|
||||
const state = table(DOLLARS, 1, 2);
|
||||
const index = state.actingSeatIndex;
|
||||
const potAfterCall = Betting.potSoFar(state) + Betting.toCall(state, state.seats[index]);
|
||||
|
||||
[0.1, 0.25, 0.5, 0.75, 1].forEach(fraction => {
|
||||
const value = Betting.potRaiseTo(state, index, fraction);
|
||||
const exact = state.currentBet + potAfterCall * fraction;
|
||||
assert.ok(value >= exact, `${fraction} of the pot should never be rounded down`);
|
||||
assert.ok(value - exact < 1, `${fraction} of the pot should round by less than a chip`);
|
||||
assert.equal(Money.chipBreakdown(value, DOLLARS).remainder, 0,
|
||||
`${fraction} of the pot should be buildable from the table's chips`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// The house's cut.
|
||||
//
|
||||
// A game that rakes has chips leaving the table every hand, and the bank check used to
|
||||
// call them missing: buy-ins in, chips on the felt, and a dealer told somebody had been
|
||||
// paid for chips they never got. The cut is the one thing here that is supposed to go
|
||||
// and not come back.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, HoldemState, Betting, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const raking = (rake, extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, ...extra });
|
||||
state.rake = { percent: 0, cap: null, noFlopNoDrop: true, ...rake };
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what the house takes", () => {
|
||||
test("nothing, unless somebody says otherwise", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
assert.equal(state.rake.percent, 0);
|
||||
assert.equal(HoldemState.rakeOn(state, 100), 0);
|
||||
});
|
||||
|
||||
test("a percentage of the pot", () => {
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 100), 5);
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 10 }), 60), 6);
|
||||
});
|
||||
|
||||
test("no more than the cap", () => {
|
||||
const state = raking({ percent: 5, cap: 5 });
|
||||
assert.equal(HoldemState.rakeOn(state, 100), 5);
|
||||
assert.equal(HoldemState.rakeOn(state, 400), 5, "still five on a pot four times the size");
|
||||
assert.equal(HoldemState.rakeOn(state, 40), 2, "under the cap it is just the percentage");
|
||||
});
|
||||
|
||||
test("no flop, no drop", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
assert.equal(HoldemState.rakeOn(state, 100, false), 0, "hand ended before the flop");
|
||||
assert.equal(HoldemState.rakeOn(state, 100, true), 5);
|
||||
});
|
||||
|
||||
test("unless the house rakes every pot", () => {
|
||||
const state = raking({ percent: 5, noFlopNoDrop: false });
|
||||
assert.equal(HoldemState.rakeOn(state, 100, false), 5);
|
||||
});
|
||||
|
||||
test("nothing it can't make out of the table's chips, rounded the players' way", () => {
|
||||
// 5% of $30 is $1.50, and this table's smallest chip is a dollar.
|
||||
const state = raking({ percent: 5 });
|
||||
assert.equal(HoldemState.rakeOn(state, 30), 1);
|
||||
// 5% of $10 is 50c — under a chip, so the house takes nothing at all.
|
||||
assert.equal(HoldemState.rakeOn(state, 10), 0);
|
||||
});
|
||||
|
||||
test("never more than the pot, whatever gets typed in", () => {
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 100 }), 20), 20);
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 5, cap: 500 }), 20), 1);
|
||||
});
|
||||
|
||||
test("nothing out of an empty pot", () => {
|
||||
assert.equal(HoldemState.rakeOn(raking({ percent: 5 }), 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taking it out of the pot", () => {
|
||||
const winner = amount => [{ id: "a", label: "Ann", amount }];
|
||||
|
||||
test("one winner just gets the pot less the cut", () => {
|
||||
const { payouts, rake } = HoldemState.applyRake(winner(100), 5);
|
||||
assert.equal(rake, 5);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [95]);
|
||||
});
|
||||
|
||||
test("a split pot shares what's left, in proportion", () => {
|
||||
const { payouts } = HoldemState.applyRake(
|
||||
[{ id: "a", label: "Ann", amount: 60 }, { id: "b", label: "Ben", amount: 40 }], 10);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [54, 36]);
|
||||
});
|
||||
|
||||
test("the rounding leftovers go back to the players, not to the house", () => {
|
||||
// $100 split three ways less $7: 93 doesn't divide evenly, and every cent of
|
||||
// it has to land on somebody.
|
||||
const { payouts, rake } = HoldemState.applyRake([
|
||||
{ id: "a", label: "Ann", amount: 34 },
|
||||
{ id: "b", label: "Ben", amount: 33 },
|
||||
{ id: "c", label: "Cal", amount: 33 },
|
||||
], 7);
|
||||
assert.equal(rake, 7);
|
||||
assert.equal(Money.round(payouts.reduce((sum, p) => sum + p.amount, 0)), 93);
|
||||
});
|
||||
|
||||
test("what goes out plus what the house keeps is what was in the middle", () => {
|
||||
[[100, 5], [37.5, 1.25], [1000, 0], [9, 9]].forEach(([pot, rake]) => {
|
||||
const result = HoldemState.applyRake(winner(pot), rake);
|
||||
assert.equal(Money.round(result.payouts[0].amount + result.rake), pot, `${pot} less ${rake}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("asking for more than the pot takes the pot, not more", () => {
|
||||
const { payouts, rake } = HoldemState.applyRake(winner(20), 50);
|
||||
assert.equal(rake, 20);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [0]);
|
||||
});
|
||||
|
||||
test("no rake, no change — the same payouts come back", () => {
|
||||
const { payouts, rake } = HoldemState.applyRake(winner(80), 0);
|
||||
assert.equal(rake, 0);
|
||||
assert.deepEqual(payouts.map(p => p.amount), [80]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the box is checked against", () => {
|
||||
test("raked chips are gone but not missing", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
const before = HoldemState.bankReconciliation(state);
|
||||
assert.equal(before.balanced, true);
|
||||
|
||||
// The house takes $10 out of the middle: somebody's stack is $10 lighter.
|
||||
state.seats[0].stack = 190;
|
||||
state.rakeTaken = 10;
|
||||
const after = HoldemState.bankReconciliation(state);
|
||||
assert.equal(after.raked, 10);
|
||||
assert.equal(after.balanced, true, "the check still balances");
|
||||
});
|
||||
|
||||
test("and are told apart from the ones that went astray", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
state.seats[0].stack = 180;
|
||||
state.rakeTaken = 10;
|
||||
const check = HoldemState.bankReconciliation(state);
|
||||
assert.equal(check.raked, 10);
|
||||
assert.equal(check.difference, -10, "ten dollars really are missing");
|
||||
assert.equal(check.balanced, false);
|
||||
});
|
||||
|
||||
test("a table that has never raked reads exactly as it did before", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
state.seats.forEach(p => { p.buyIns = 100; p.stack = 100; });
|
||||
const check = HoldemState.bankReconciliation(state);
|
||||
assert.equal(check.raked, 0);
|
||||
assert.equal(check.balanced, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record of it", () => {
|
||||
test("the hand log keeps the pot as the table saw it, and the cut beside it", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 95 }], 5);
|
||||
const [entry] = state.handHistory;
|
||||
assert.equal(entry.pot, 100, "the pot in the middle");
|
||||
assert.equal(entry.rake, 5);
|
||||
assert.equal(entry.payouts[0].amount, 95, "what the winner actually took");
|
||||
});
|
||||
|
||||
test("a hand with no rake records a zero rather than nothing", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "P1", amount: 40 }]);
|
||||
assert.equal(state.handHistory[0].rake, 0);
|
||||
assert.equal(state.handHistory[0].pot, 40);
|
||||
});
|
||||
|
||||
test("a table saved before the house took anything takes nothing", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
|
||||
delete parsed.rake;
|
||||
delete parsed.rakeTaken;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.rake.percent, 0);
|
||||
assert.equal(migrated.rake.noFlopNoDrop, true);
|
||||
assert.equal(migrated.rakeTaken, 0);
|
||||
});
|
||||
|
||||
test("nonsense in a saved rake is cleaned up rather than trusted", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
|
||||
parsed.rake = { percent: -5, cap: -1, noFlopNoDrop: false };
|
||||
parsed.rakeTaken = -20;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.rake.percent, 0);
|
||||
assert.equal(migrated.rake.cap, null);
|
||||
assert.equal(migrated.rake.noFlopNoDrop, false, "that one is a real choice");
|
||||
assert.equal(migrated.rakeTaken, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file the dealer hands over", () => {
|
||||
const { HandHistoryExport } = require("./load");
|
||||
|
||||
test("has a column for what the house took", () => {
|
||||
const state = raking({ percent: 5 });
|
||||
HoldemState.recordHand(state, [{ id: "a", label: "Ann", amount: 95 }], 5);
|
||||
HoldemState.recordHand(state, [{ id: "b", label: "Ben", amount: 40 }], 0);
|
||||
|
||||
// Oldest hand first, and money as bare numbers so a spreadsheet can total it.
|
||||
const csv = HandHistoryExport.toCsv(state.handHistory);
|
||||
const [header, raked, unraked] = csv.split("\n");
|
||||
assert.match(header, /Pot,House take,Payouts/);
|
||||
assert.match(raked, /,100\.00,5\.00,/, "the raked hand says how much was taken");
|
||||
assert.match(unraked, /,40\.00,0\.00,/, "and the unraked one says nothing was");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a raked hand, end to end", () => {
|
||||
test("the chips add up: stacks plus the house's cut equal what was bought in", () => {
|
||||
// 5/10 blinds, so six callers make a pot big enough for 5% to be a whole chip.
|
||||
// At 1/2 the same hand makes $12, 5% of which is 60c and rounds away to
|
||||
// nothing — which is the rule working, not a hand worth testing the bank on.
|
||||
const state = raking({ percent: 5, cap: 5 }, { smallBlind: 5, bigBlind: 10 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; });
|
||||
Betting.beginHand(state);
|
||||
// Everyone calls the big blind, then it is checked down.
|
||||
state.seats.forEach((p, i) => { if (p.inHand) Betting.putIn(state, i, Betting.toCall(state, p)); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = state.seats.reduce((sum, p) => sum + (Number(p.amountOverride) || 0), 0);
|
||||
const rake = HoldemState.rakeOn(state, pot, true);
|
||||
assert.equal(pot, 60);
|
||||
assert.equal(rake, 3);
|
||||
|
||||
const { payouts } = HoldemState.applyRake([{ id: state.seats[0].id, label: "P1", amount: pot }], rake);
|
||||
state.rakeTaken = rake;
|
||||
HoldemState.recordHand(state, payouts, rake);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(Money.round(chipsOnTable(state) + state.rakeTaken), 1200);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
+55
-38
@@ -68,57 +68,74 @@ async function main() {
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
let checks = 0;
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
|
||||
// down where `hover: hover` says there's a pointer, and a headless desktop browser
|
||||
// is a pointer — so a single pass here has never once measured a touch target,
|
||||
// however many viewports it visited. The phone-sized ones are exactly the pass that
|
||||
// needs it.
|
||||
for (const touch of [false, true]) {
|
||||
const context = await browser.newContext(touch
|
||||
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
|
||||
: {});
|
||||
const page = await context.newPage();
|
||||
const hand = touch ? "touch" : "mouse";
|
||||
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
const label = `${rawLabel} · ${hand}`;
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
}
|
||||
}
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||
const label = `${rawLabel} · ${hand}`;
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
|
||||
}
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
|
||||
warnings.forEach(w => console.log(` note: ${w}`));
|
||||
if (failures.length === 0) {
|
||||
console.log("clean");
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Where the seats sit on the rim.
|
||||
//
|
||||
// The ring used to space seats by angle, which is only the same as spacing them evenly
|
||||
// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
|
||||
// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
|
||||
// seat's centre belonged to its neighbour and tapping a player got the wrong one.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// The seats as a dealer sees them: pixels on a felt of a given size.
|
||||
const ringPixels = (count, width, height, rx = 44, ry = 42) => {
|
||||
const aspect = width / height;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const pos = Render.positionOnRing(i, count, rx, ry, aspect);
|
||||
return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
|
||||
});
|
||||
};
|
||||
|
||||
const gaps = (points) => points.map((p, i) => {
|
||||
const next = points[(i + 1) % points.length];
|
||||
return Math.hypot(next.x - p.x, next.y - p.y);
|
||||
});
|
||||
|
||||
// What the ring did before: one turn cut into equal angles.
|
||||
const byAngle = (index, count, rx, ry) => {
|
||||
const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
|
||||
return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
|
||||
};
|
||||
|
||||
describe("a round table", () => {
|
||||
test("still spaces seats by angle, because on a circle that is the same thing", () => {
|
||||
for (let seat = 0; seat < 9; seat++) {
|
||||
const now = Render.positionOnRing(seat, 9, 40, 40, 1);
|
||||
const before = byAngle(seat, 9, 40, 40);
|
||||
assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
|
||||
assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
|
||||
}
|
||||
});
|
||||
|
||||
test("seat one is at the top, whatever shape the felt is", () => {
|
||||
[[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
|
||||
const first = Render.positionOnRing(0, 10, 44, 42, w / h);
|
||||
assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
|
||||
assert.ok(first.top < 50, `${w}x${h} is above the middle`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a felt wider than it is tall", () => {
|
||||
// 640x360 landscape: the size the audit was failing at.
|
||||
const wide = () => ringPixels(10, 601, 126);
|
||||
|
||||
const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
|
||||
const pos = byAngle(i, 10, 44, 42);
|
||||
return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
|
||||
});
|
||||
|
||||
// The two seats at the right-hand end, which is where they crowd.
|
||||
const endPairApart = (seats) => {
|
||||
const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
|
||||
return Math.abs(rightmost[0].y - rightmost[1].y);
|
||||
};
|
||||
|
||||
test("pulls the pair at each end apart", () => {
|
||||
// They still share the end — ten seats on a rim this flat have nowhere else to
|
||||
// be — but they stop sitting on top of each other.
|
||||
const now = endPairApart(wide());
|
||||
const before = endPairApart(byAnglePixels());
|
||||
assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
|
||||
});
|
||||
|
||||
test("nearly doubles the room between the closest two neighbours", () => {
|
||||
// Not equal spacing in a straight line — at the ends the rim turns back on
|
||||
// itself, so the same walk along it covers less ground across. Even there the
|
||||
// gap goes from a third of a puck to most of one.
|
||||
const now = Math.min(...gaps(wide()));
|
||||
const before = Math.min(...gaps(byAnglePixels()));
|
||||
assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
|
||||
assert.ok(now > 55, `${now.toFixed(1)}px`);
|
||||
});
|
||||
|
||||
test("never sits two seats on the same spot", () => {
|
||||
const seats = wide();
|
||||
seats.forEach((seat, i) => {
|
||||
seats.slice(i + 1).forEach((other, j) => {
|
||||
const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
|
||||
assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("how close the closest two seats come", () => {
|
||||
// What decides whether the pucks shrink. Rim length per seat was the first answer
|
||||
// and it is the wrong question: it measures the walk between two seats, not the
|
||||
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
|
||||
// apart end up stacked one above the other.
|
||||
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
|
||||
|
||||
test("a flat table no longer stacks two seats at each end", () => {
|
||||
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
|
||||
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
|
||||
// is a stadium at this shape, so an end holds one seat.
|
||||
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
|
||||
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
|
||||
});
|
||||
|
||||
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
|
||||
// Same felt, forced through the ellipse the tool uses for rounder tables.
|
||||
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
|
||||
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
|
||||
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
|
||||
}));
|
||||
const closest = asEllipse.reduce((least, point, i) => {
|
||||
const next = asEllipse[(i + 1) % asEllipse.length];
|
||||
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
|
||||
}, Infinity);
|
||||
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
|
||||
assert.ok(gap(6, 616, 96) > closest * 1.3,
|
||||
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
|
||||
});
|
||||
|
||||
test("a round table's neighbours are about as far apart as the rim says", () => {
|
||||
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
|
||||
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
|
||||
});
|
||||
|
||||
test("more seats, less room, in that order", () => {
|
||||
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
|
||||
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
|
||||
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
|
||||
});
|
||||
|
||||
test("nothing to measure between fewer than two seats", () => {
|
||||
assert.equal(gap(1, 400, 400), Infinity);
|
||||
assert.equal(gap(9, 0, 0), Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which rim a table gets", () => {
|
||||
// An ellipse until the table is much wider than tall, then a stadium: two rails
|
||||
// and a half-round cap at each end, which is how a real poker table is built.
|
||||
const shape = (width, height) => {
|
||||
const aspect = width / height;
|
||||
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
|
||||
};
|
||||
|
||||
test("every layout the tool had keeps the ellipse", () => {
|
||||
assert.equal(shape(844, 608), "ellipse", "desktop");
|
||||
assert.equal(shape(600, 600), "ellipse", "tablet");
|
||||
assert.equal(shape(278, 314), "ellipse", "phone upright");
|
||||
assert.equal(shape(336, 240), "ellipse", "short phone upright");
|
||||
});
|
||||
|
||||
test("a phone on its side gets the stadium", () => {
|
||||
assert.equal(shape(528, 73), "stadium");
|
||||
assert.equal(shape(780, 113), "stadium");
|
||||
});
|
||||
|
||||
test("the cap holds one seat instead of stacking two", () => {
|
||||
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
|
||||
// the cap, or two of them end up on the same half-circle.
|
||||
const halfWidth = 44 * (616 / 96), halfHeight = 42;
|
||||
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
|
||||
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
|
||||
});
|
||||
|
||||
test("seat one is still at the top centre, and the ring still mirrors", () => {
|
||||
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
|
||||
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
|
||||
assert.ok(seats[0].top < 50);
|
||||
for (let seat = 1; seat < 4; seat++) {
|
||||
const mirrored = seats[8 - seat];
|
||||
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
|
||||
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
|
||||
}
|
||||
});
|
||||
|
||||
test("nobody is drawn off the felt", () => {
|
||||
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
|
||||
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
|
||||
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shape of the ring", () => {
|
||||
test("is a mirror image left to right", () => {
|
||||
const seats = ringPixels(10, 601, 126);
|
||||
for (let seat = 1; seat < 5; seat++) {
|
||||
const mirrored = seats[10 - seat];
|
||||
assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
|
||||
assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
|
||||
}
|
||||
});
|
||||
|
||||
test("holds for the smallest and largest tables the seat count allows", () => {
|
||||
[2, 3, 4, 9, 12, 24].forEach(count => {
|
||||
const seats = ringPixels(count, 601, 126);
|
||||
assert.equal(seats.length, count);
|
||||
seats.forEach((seat, i) => {
|
||||
assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
|
||||
assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
|
||||
});
|
||||
assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
|
||||
});
|
||||
});
|
||||
|
||||
test("shares the rim out equally on a round table at any size", () => {
|
||||
[3, 6, 9, 10, 24].forEach(count => {
|
||||
const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
|
||||
const shortest = Math.min(...spacing);
|
||||
const longest = Math.max(...spacing);
|
||||
assert.ok(longest - shortest < 0.5,
|
||||
`${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
|
||||
});
|
||||
});
|
||||
|
||||
test("asked for no seats, offers no positions", () => {
|
||||
assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
|
||||
});
|
||||
|
||||
test("puts each bet between its own player and the pot", () => {
|
||||
// Where a dealer pushes the chips: on that player's line to the middle, short
|
||||
// of the seat.
|
||||
const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
|
||||
const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
|
||||
|
||||
[[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
|
||||
for (let seat = 0; seat < 9; seat++) {
|
||||
const player = Render.seatPosition(seat, 9, w / h);
|
||||
const bet = Render.betPosition(seat, 9, w / h);
|
||||
const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
|
||||
assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
|
||||
assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Sitting people down, which is the first thing anybody does with the tool.
|
||||
//
|
||||
// Naming a chair and dealing that chair in were two separate acts and the second was
|
||||
// easy to miss. A dealer setting up typed six names, got six seats that still weren't
|
||||
// in the hand, and the action bar hid itself because by its reckoning nobody was
|
||||
// playing: six people at the table and a blank screen with no way forward.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, ActionBar, makeTable } = require("./load");
|
||||
|
||||
const emptyTable = (seatCount = 10) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, seatCount);
|
||||
return state;
|
||||
};
|
||||
|
||||
const bar = state => Object.assign(Object.create(ActionBar), { state });
|
||||
|
||||
describe("the bar knows the difference between an empty table and a named one", () => {
|
||||
test("a table nobody has touched gets no bar", () => {
|
||||
assert.equal(bar(emptyTable()).isRunning(), false, "an empty felt stays uncluttered");
|
||||
});
|
||||
|
||||
test("a table with names on it gets one, dealt in or not", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[1].name = "Ben";
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 0, "nobody is in the hand");
|
||||
assert.equal(bar(state).isRunning(), true, "but there are people at the table to say something to");
|
||||
});
|
||||
|
||||
test("whitespace isn't a player", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = " ";
|
||||
assert.equal(bar(state).isRunning(), false);
|
||||
});
|
||||
|
||||
test("a table that is playing still gets one", () => {
|
||||
assert.equal(bar(makeTable({ seatCount: 6, seated: [0, 1] })).isRunning(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naming an empty chair sits somebody down", () => {
|
||||
// What the seat editor's name field does, as the rule it applies rather than
|
||||
// through the DOM: a chair with no name and nobody in it is empty, and putting a
|
||||
// name on it is somebody arriving.
|
||||
const nameSeat = (player, name) => {
|
||||
const wasEmpty = !player.name.trim() && !player.inHand;
|
||||
player.name = name;
|
||||
if (wasEmpty && player.name.trim()) { player.inHand = true; player.folded = false; }
|
||||
};
|
||||
|
||||
test("an empty chair gets dealt in", () => {
|
||||
const state = emptyTable();
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
assert.equal(state.seats[0].inHand, true);
|
||||
assert.equal(state.seats[0].folded, false);
|
||||
});
|
||||
|
||||
test("six names is six players, not six benched seats", () => {
|
||||
const state = emptyTable();
|
||||
state.defaultStack = 200; // a table that has told the tool what a buy-in is
|
||||
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => nameSeat(state.seats[i], name));
|
||||
|
||||
assert.equal(state.seats.filter(p => p.inHand).length, 6);
|
||||
assert.equal(bar(state).isRunning(), true);
|
||||
assert.ok(Betting.blindPreview(state), "and there's a hand to deal");
|
||||
});
|
||||
|
||||
test("renaming somebody who is sitting out doesn't deal them back in", () => {
|
||||
// A correction, not an arrival. Dealing them in over a typo would be the tool
|
||||
// overruling a dealer who sat them out on purpose.
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats[0].inHand = false;
|
||||
|
||||
nameSeat(state.seats[0], "Anne");
|
||||
assert.equal(state.seats[0].inHand, false, "still sitting out, now spelled right");
|
||||
});
|
||||
|
||||
test("clearing a name doesn't sit anybody down", () => {
|
||||
const state = emptyTable();
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
nameSeat(state.seats[0], "");
|
||||
assert.equal(state.seats[0].name, "");
|
||||
});
|
||||
|
||||
test("a chair already in the hand is left alone", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0] });
|
||||
state.seats[0].folded = true;
|
||||
nameSeat(state.seats[0], "Ann");
|
||||
assert.equal(state.seats[0].folded, true, "mid-hand a fold is not undone by typing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the way back from a table where everybody is sat out", () => {
|
||||
test("there are named seats to offer", () => {
|
||||
// Can't be reached by naming any more, but a table where everyone was sat out
|
||||
// — or a save from before naming seated people — still lands here.
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; });
|
||||
|
||||
const benched = state.seats
|
||||
.map((player, index) => ({ player, index }))
|
||||
.filter(({ player }) => (player.name || "").trim() && !player.inHand)
|
||||
.map(({ index }) => index);
|
||||
|
||||
assert.deepEqual(benched, [0, 1, 2]);
|
||||
assert.equal(Betting.blindPreview(state), null, "no hand to deal until they're in");
|
||||
});
|
||||
|
||||
test("dealing them all in makes a hand possible", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].stack = 100; });
|
||||
|
||||
state.seats.forEach(player => {
|
||||
if ((player.name || "").trim()) { player.inHand = true; player.folded = false; }
|
||||
});
|
||||
|
||||
assert.equal(Betting.liveSeats(state).length, 3);
|
||||
assert.ok(Betting.blindPreview(state), "the table can be dealt");
|
||||
});
|
||||
|
||||
test("an unnamed chair is never swept in by it", () => {
|
||||
const state = emptyTable();
|
||||
state.seats[0].name = "Ann";
|
||||
state.seats.forEach(player => {
|
||||
if ((player.name || "").trim()) { player.inHand = true; }
|
||||
});
|
||||
assert.equal(state.seats.filter(p => p.inHand).length, 1, "empty chairs stay empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table where nobody has bought in", () => {
|
||||
// Different from a table that has busted out, and the difference is the whole
|
||||
// point: one needs a starting stack named, the other needs a rebuy. Telling a
|
||||
// dealer their brand-new players have "no chips left" sends them hunting for the
|
||||
// wrong thing.
|
||||
const neverBoughtIn = state => state.seats.every(p =>
|
||||
!(Number(p.buyIns) > 0) && !(Number(p.stack) > 0));
|
||||
|
||||
test("six freshly named players have never bought in", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal", "Dee", "Eve", "Fay"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
});
|
||||
|
||||
assert.equal(neverBoughtIn(state), true);
|
||||
assert.equal(Betting.blindPreview(state), null, "and there is no hand to deal");
|
||||
});
|
||||
|
||||
test("a table that has busted out is not the same thing", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
|
||||
state.seats.forEach(p => { p.buyIns = 200; p.stack = 0; });
|
||||
|
||||
assert.equal(neverBoughtIn(state), false, "they had chips — they lost them");
|
||||
});
|
||||
|
||||
test("one player still holding chips is not a never-bought-in table either", () => {
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
|
||||
state.seats[0].stack = 50;
|
||||
|
||||
assert.equal(neverBoughtIn(state), false);
|
||||
});
|
||||
|
||||
test("naming a starting stack is enough to deal", () => {
|
||||
// The one field the message now points at. A seat with no stack of its own
|
||||
// falls back to the table default, so setting it seats everybody's chips at
|
||||
// once — which is why it's the right thing to send the dealer to.
|
||||
const state = emptyTable();
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => { state.seats[i].name = name; state.seats[i].inHand = true; });
|
||||
assert.equal(Betting.blindPreview(state), null);
|
||||
|
||||
state.defaultStack = 200;
|
||||
assert.ok(Betting.blindPreview(state), "one number and the table can play");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// The straddle: a blind raise put out before the cards by the player left of the big
|
||||
// blind, for twice it.
|
||||
//
|
||||
// Home games run them constantly, and it is three rules at once — the amount, who is
|
||||
// first to act, and what the minimum raise becomes. Faking one by dealing and having
|
||||
// the straddler "raise" gets the last of those right and the other two wrong.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
|
||||
// straddle seat 3.
|
||||
const table = (straddles = 0, extra = {}) => {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
|
||||
state.straddleCount = straddles;
|
||||
return state;
|
||||
};
|
||||
|
||||
const dealt = (straddles = 0, extra = {}) => {
|
||||
const state = table(straddles, extra);
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("what goes in the middle", () => {
|
||||
test("the seat left of the big blind puts out twice it", () => {
|
||||
const state = dealt(1);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
|
||||
});
|
||||
|
||||
test("a re-straddle doubles again, one seat further round", () => {
|
||||
const state = dealt(2);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
|
||||
});
|
||||
|
||||
test("none of it comes from anywhere but the straddler's stack", () => {
|
||||
const before = chipsOnTable(table(2));
|
||||
assert.equal(chipsOnTable(dealt(2)), before);
|
||||
});
|
||||
|
||||
test("the felt is told who straddled, not left to work it out", () => {
|
||||
assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
|
||||
assert.deepEqual(dealt(0).postedStraddles, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who is on the clock", () => {
|
||||
test("action starts left of the straddler, not left of the big blind", () => {
|
||||
assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
|
||||
assert.equal(dealt(1).actingSeatIndex, 4);
|
||||
assert.equal(dealt(2).actingSeatIndex, 5);
|
||||
});
|
||||
|
||||
test("the straddler still has the option when it comes back to them", () => {
|
||||
const state = dealt(1);
|
||||
// Everyone calls the straddle round to the big blind.
|
||||
[4, 5, 0, 1, 2].forEach(seat => {
|
||||
assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
|
||||
Betting.call(state, seat);
|
||||
});
|
||||
assert.equal(state.actingSeatIndex, 3, "back to the straddler");
|
||||
assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
|
||||
assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
|
||||
});
|
||||
|
||||
test("the straddler checking closes it", () => {
|
||||
const state = dealt(1);
|
||||
[4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
|
||||
Betting.check(state, 3);
|
||||
assert.equal(Betting.isStreetComplete(state), true);
|
||||
assert.equal(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("the big blind acts in turn, not last", () => {
|
||||
const state = dealt(1);
|
||||
Betting.call(state, 4);
|
||||
Betting.call(state, 5);
|
||||
Betting.call(state, 0);
|
||||
Betting.call(state, 1);
|
||||
assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
|
||||
assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it costs to raise", () => {
|
||||
test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
|
||||
// $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
|
||||
// instead it comes out at $6, and every player at the table would argue.
|
||||
assert.equal(Betting.minRaiseTo(dealt(1)), 8);
|
||||
assert.equal(Betting.minRaiseTo(dealt(2)), 16);
|
||||
assert.equal(Betting.minRaiseTo(dealt(0)), 4);
|
||||
});
|
||||
|
||||
test("what the table owes is measured off the straddle", () => {
|
||||
const state = dealt(1);
|
||||
assert.equal(state.currentBet, 4);
|
||||
assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
|
||||
assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
|
||||
assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
|
||||
assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
|
||||
});
|
||||
|
||||
test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
|
||||
const state = dealt(1);
|
||||
Betting.raiseTo(state, 4, 12);
|
||||
assert.equal(state.currentBet, 12);
|
||||
assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
|
||||
assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many a table can take", () => {
|
||||
test("everyone but the two blinds", () => {
|
||||
assert.equal(Betting.maxStraddles(table(0)), 4);
|
||||
assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
|
||||
});
|
||||
|
||||
test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
|
||||
const heads = table(1, { seatCount: 6, seated: [0, 1] });
|
||||
assert.equal(Betting.maxStraddles(heads), 0);
|
||||
assert.deepEqual(Betting.straddleSeats(heads), []);
|
||||
Betting.beginHand(heads);
|
||||
assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("asking for more than the table has stops at the last seat", () => {
|
||||
const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
|
||||
state.straddleCount = 99;
|
||||
assert.equal(Betting.straddleSeats(state).length, 2);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
|
||||
assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
|
||||
});
|
||||
|
||||
test("a seat with no chips is never handed one", () => {
|
||||
const state = table(1);
|
||||
state.seats[3].stack = 0;
|
||||
// Seat 3 is sat out for the deal, so the straddle passes to seat 4.
|
||||
assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it doesn't outlive", () => {
|
||||
test("the hand it was called for", () => {
|
||||
const state = dealt(1);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
|
||||
assert.deepEqual(state.postedStraddles, []);
|
||||
});
|
||||
|
||||
test("but it survives the button being pushed around before the deal", () => {
|
||||
// repostBlinds takes every posted chip back and deals again; a straddle that
|
||||
// vanished there would have been paid once and never returned.
|
||||
const state = dealt(1);
|
||||
const before = chipsOnTable(state);
|
||||
Betting.repostBlinds(state);
|
||||
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("a table saved before straddles existed reads as having none", () => {
|
||||
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
|
||||
delete parsed.straddleCount;
|
||||
delete parsed.postedStraddles;
|
||||
const migrated = HoldemState.migrate(parsed);
|
||||
assert.equal(migrated.straddleCount, 0);
|
||||
assert.deepEqual(migrated.postedStraddles, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the dealer is shown before dealing", () => {
|
||||
test("the straddles that are coming, and how many more it could take", () => {
|
||||
const preview = Betting.blindPreview(table(1));
|
||||
assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
|
||||
assert.equal(preview.maxStraddles, 4);
|
||||
});
|
||||
|
||||
test("and the preview matches what dealing actually does", () => {
|
||||
const state = table(2);
|
||||
const preview = Betting.blindPreview(state);
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
|
||||
preview.straddles.forEach(({ index, amount }) => {
|
||||
assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Taking the table off this device.
|
||||
//
|
||||
// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
|
||||
// dies, or who wants to carry on from the tablet after the break, loses the stacks,
|
||||
// the buy-ins, where the button is and the whole hand log — and the CSV export can't
|
||||
// bring any of that back, because it records what happened rather than where the
|
||||
// table stands.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const aNight = () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
state.seats[2].stack = 340;
|
||||
state.notes = "Cal owes the bar $12";
|
||||
Betting.beginHand(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a table written out and read back", () => {
|
||||
test("comes back the same table", () => {
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
|
||||
assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
|
||||
assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
|
||||
assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
|
||||
assert.equal(after.handNumber, before.handNumber);
|
||||
assert.equal(after.notes, before.notes);
|
||||
assert.equal(after.handHistory.length, before.handHistory.length);
|
||||
});
|
||||
|
||||
test("keeps the things a CSV can't carry", () => {
|
||||
// The whole reason this exists: an export records what happened, not where the
|
||||
// table stands.
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
|
||||
assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
|
||||
assert.equal(after.street, before.street);
|
||||
assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
|
||||
assert.deepEqual(after.chipLegend, before.chipLegend);
|
||||
});
|
||||
|
||||
test("the standings and the bank check agree across the trip", () => {
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
|
||||
assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
|
||||
});
|
||||
|
||||
test("it can be dealt from straight away", () => {
|
||||
const before = aNight();
|
||||
HoldemState.startNextHand(before);
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
Betting.beginHand(after);
|
||||
assert.equal(after.bettingOpen, true);
|
||||
assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
const refuses = (text, because) => {
|
||||
const result = HoldemState.fromBackup(text);
|
||||
assert.ok(result.error, because);
|
||||
assert.equal(result.table, undefined, "and never hands back half a table");
|
||||
};
|
||||
|
||||
test("something that isn't JSON", () => {
|
||||
refuses("not a file", "unreadable");
|
||||
refuses("", "empty");
|
||||
});
|
||||
|
||||
test("JSON that isn't a table", () => {
|
||||
refuses('{"hello":"world"}', "no seats in it");
|
||||
refuses("[1,2,3]", "an array of nothing");
|
||||
refuses("null", "null");
|
||||
});
|
||||
|
||||
test("a save from something else that happens to be JSON", () => {
|
||||
refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
|
||||
});
|
||||
|
||||
test("a file from a newer version, rather than guessing at it", () => {
|
||||
// Loading it risks reading a field whose meaning has changed, and the dealer
|
||||
// would find out from the money.
|
||||
const backup = JSON.parse(HoldemState.toBackup(aNight()));
|
||||
backup.version = HoldemState.BACKUP_VERSION + 1;
|
||||
const result = HoldemState.fromBackup(JSON.stringify(backup));
|
||||
assert.match(result.error, /newer version/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it accepts anyway", () => {
|
||||
test("a bare table, with no envelope around it", () => {
|
||||
// Somebody who copied the storage value out by hand. The envelope is a
|
||||
// convenience, not a password.
|
||||
const state = aNight();
|
||||
const result = HoldemState.fromBackup(JSON.stringify(state));
|
||||
assert.ok(!result.error);
|
||||
assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
|
||||
});
|
||||
|
||||
test("a table from before fields that exist now, migrated on the way in", () => {
|
||||
// Same repairs a save from an older version gets, because it is the same
|
||||
// function — a second, looser path for imports is how a format grows two
|
||||
// meanings of the same field.
|
||||
const state = aNight();
|
||||
const raw = JSON.parse(JSON.stringify(state));
|
||||
delete raw.deadButton;
|
||||
delete raw.postedBlinds;
|
||||
delete raw.dealtLastHand;
|
||||
delete raw.discarded;
|
||||
raw.seats.forEach(p => { delete p.buyIns; });
|
||||
|
||||
const { table, error } = HoldemState.fromBackup(JSON.stringify({
|
||||
kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
|
||||
}));
|
||||
assert.ok(!error);
|
||||
assert.equal(table.deadButton, false);
|
||||
assert.deepEqual(table.dealtLastHand, []);
|
||||
assert.equal(table.discarded, 0);
|
||||
assert.equal(table.seats[0].buyIns, 0);
|
||||
});
|
||||
|
||||
test("the time it was saved comes back with it", () => {
|
||||
const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
|
||||
assert.ok(result.savedAt, "so the dealer can tell two files apart");
|
||||
assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file itself", () => {
|
||||
test("is named with the date and time, so two saves in a night are told apart", () => {
|
||||
const at = new Date(2026, 8, 2, 21, 5);
|
||||
assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
|
||||
});
|
||||
|
||||
test("says what it is, for anyone finding it in a downloads folder", () => {
|
||||
const parsed = JSON.parse(HoldemState.toBackup(aNight()));
|
||||
assert.equal(parsed.kind, "casino.holdem.table");
|
||||
assert.equal(parsed.version, 1);
|
||||
assert.ok(parsed.table.seats.length > 0);
|
||||
});
|
||||
});
|
||||
+159
-14
@@ -93,10 +93,44 @@ const LayoutAudit = {
|
||||
return false;
|
||||
},
|
||||
|
||||
// The modal that is currently on top, if any.
|
||||
//
|
||||
// Every check below asks "is this thing reachable", and while a sheet or dialog is
|
||||
// open the answer for everything behind it is legitimately no — that is what a
|
||||
// modal is for. Without knowing that, the audit reported the pot wizard covering
|
||||
// the seats as ten faults and the rulings sheet as seven, which is the noise that
|
||||
// made it useless in exactly the states nobody had audited. Now it scopes to what
|
||||
// is actually on top and says so.
|
||||
MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
|
||||
"#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
|
||||
|
||||
openModal() {
|
||||
const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
|
||||
.filter(el => !el.hidden && getComputedStyle(el).display !== "none");
|
||||
// Last in the DOM wins, which is how they stack.
|
||||
return open.length > 0 ? open[open.length - 1] : null;
|
||||
},
|
||||
|
||||
// A seat sheet on a phone isn't an overlay element, it's a body class — but it
|
||||
// covers the felt just the same.
|
||||
modalScope() {
|
||||
const overlay = this.openModal();
|
||||
if (overlay) return overlay;
|
||||
if (document.body.classList.contains("seatSheetOpen")) {
|
||||
return document.getElementById("seatEditorPanel") || null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||
const offenders = [];
|
||||
const warnings = [];
|
||||
[...document.querySelectorAll(selector)].forEach(el => {
|
||||
// While something is open on top, only what is inside it can be judged. The
|
||||
// rest of the page is meant to be unreachable.
|
||||
const scope = this.modalScope();
|
||||
const candidates = [...document.querySelectorAll(selector)]
|
||||
.filter(el => !scope || scope.contains(el));
|
||||
candidates.forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
@@ -359,6 +393,37 @@ const LayoutAudit = {
|
||||
}
|
||||
},
|
||||
|
||||
// How much of this element actually reaches the screen, and what is behind the
|
||||
// thing doing the fading.
|
||||
//
|
||||
// `opacity` on a box fades the whole box — its text and its own background together
|
||||
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
|
||||
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
|
||||
// halves have to be composited the same way or the number means nothing.
|
||||
fading(el) {
|
||||
let alpha = 1;
|
||||
let outermost = null;
|
||||
for (let node = el; node; node = node.parentElement) {
|
||||
const own = parseFloat(getComputedStyle(node).opacity);
|
||||
if (own < 0.99) {
|
||||
alpha *= own;
|
||||
outermost = node;
|
||||
}
|
||||
}
|
||||
if (!outermost) return { alpha: 1, behind: null };
|
||||
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
|
||||
},
|
||||
|
||||
// The two colours as the screen actually shows them, once the fading is applied.
|
||||
asPainted(fgRaw, bg, fade) {
|
||||
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
||||
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
|
||||
return {
|
||||
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
|
||||
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
|
||||
};
|
||||
},
|
||||
|
||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||
const offenders = [];
|
||||
const unmeasured = [];
|
||||
@@ -392,12 +457,17 @@ const LayoutAudit = {
|
||||
// exempt — there's nothing to read at zero — but anything in between is
|
||||
// reported as unmeasured rather than passed over in silence, because a
|
||||
// number nobody checked is not the same as a number that's fine.
|
||||
const opacity = parseFloat(style.opacity);
|
||||
if (opacity <= 0.02) return;
|
||||
const faded = opacity < 0.99 ||
|
||||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
|
||||
if (faded) {
|
||||
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
|
||||
const fade = this.fading(el);
|
||||
if (fade.alpha <= 0.02) return;
|
||||
|
||||
// A control that is switched off is the one exemption WCAG itself grants —
|
||||
// an inactive component has no contrast requirement, which is why every
|
||||
// toolkit dims them. Said out loud rather than skipped in silence: this is
|
||||
// the only exemption in here, and the moment a dimmed thing turns out to be
|
||||
// live it stops applying.
|
||||
const off = el.closest("[disabled], [aria-disabled='true']");
|
||||
if (off) {
|
||||
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
|
||||
return;
|
||||
}
|
||||
// aria-hidden used to exempt text here, on the grounds that decoration
|
||||
@@ -425,8 +495,8 @@ const LayoutAudit = {
|
||||
// The kindest reading available: best contrast across every candidate.
|
||||
let best = 0;
|
||||
candidates.forEach(bg => {
|
||||
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
||||
best = Math.max(best, this.contrastRatio(fg, bg));
|
||||
const painted = this.asPainted(fgRaw, bg, fade);
|
||||
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
|
||||
});
|
||||
|
||||
const sizePx = parseFloat(style.fontSize);
|
||||
@@ -504,10 +574,36 @@ const LayoutAudit = {
|
||||
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
||||
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
||||
}
|
||||
const unmeasured = [];
|
||||
this.visibleControls()
|
||||
.filter(el => el.type !== "color")
|
||||
.filter(el => !(exempt && el.matches(exempt)))
|
||||
.forEach(el => {
|
||||
// A link inside a sentence is the one target WCAG itself carves out:
|
||||
// its size is set by the line it sits in, and padding a word out to
|
||||
// 44px would break the paragraph around it. The rule is about controls
|
||||
// a thumb goes hunting for, not words in prose. Said out loud, and
|
||||
// narrow — display:inline, and real text either side of it.
|
||||
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
|
||||
el.parentElement &&
|
||||
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
|
||||
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
|
||||
return;
|
||||
}
|
||||
// A control inside a scrolling sheet can sit below its container's
|
||||
// visible area while still having a viewport rect. elementFromPoint at
|
||||
// its centre then returns the sheet, effectiveTarget gives up, and the
|
||||
// check used to pass it in silence — which is how "Confirm Winner(s)",
|
||||
// the button that pays the pot out, went unmeasured for 36 rounds.
|
||||
// Bring it into view before probing.
|
||||
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
|
||||
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
|
||||
const box = scroller.getBoundingClientRect();
|
||||
const own = el.getBoundingClientRect();
|
||||
if (own.bottom > box.bottom || own.top < box.top) {
|
||||
el.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Drawn big enough is big enough — no need to probe, and probing wide
|
||||
// elements is where the cap above used to produce nonsense. Seats sit
|
||||
@@ -515,15 +611,35 @@ const LayoutAudit = {
|
||||
// measuring their reach would report the crowding as a fault.
|
||||
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
||||
|
||||
// A checkbox inside a label is tapped by tapping the label, so the
|
||||
// label is the target. Measured rather than probed, because probing
|
||||
// fails on anything scrolled off the viewport — which is how a 24px
|
||||
// checkbox with a 335x44 label around it came up as unprobeable
|
||||
// instead of as the perfectly good target it is.
|
||||
const label = el.closest("label");
|
||||
if (label) {
|
||||
const lr = label.getBoundingClientRect();
|
||||
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
|
||||
}
|
||||
|
||||
const size = this.effectiveTarget(el);
|
||||
if (!size) return;
|
||||
// Still can't measure it. That is not a pass — it is the check failing
|
||||
// to run, and quietly returning here is exactly the kind of exemption
|
||||
// that hides real faults. Say so instead.
|
||||
if (!size) {
|
||||
unmeasured.push(
|
||||
`${this.describe(el)} :: could not be probed` +
|
||||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
||||
offenders.push(
|
||||
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
||||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||
);
|
||||
});
|
||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
||||
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
|
||||
},
|
||||
|
||||
// Tapping the middle of a seat must give you that seat.
|
||||
@@ -537,6 +653,13 @@ const LayoutAudit = {
|
||||
// Reported separately from occlusion because the cause is different: not an
|
||||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||
seatOwnership() {
|
||||
// Seats behind an open sheet aren't fighting each other for taps — nothing is
|
||||
// meant to reach them at all. Asking the question there produced six offenders
|
||||
// per modal, every one of them the modal doing its job.
|
||||
if (this.modalScope()) {
|
||||
return { name: "a seat's centre belongs to that seat", offenders: [],
|
||||
skipped: "a sheet is open over the table" };
|
||||
}
|
||||
const seats = [...document.querySelectorAll(".seat")];
|
||||
const offenders = [];
|
||||
|
||||
@@ -598,6 +721,20 @@ const LayoutAudit = {
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
// Every check this file knows how to make, every time.
|
||||
//
|
||||
// Two of them used not to be here. Contrast was simply missing from the list, and
|
||||
// touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
|
||||
// has meant nine checks rather than eleven, and the two left out are the two whose
|
||||
// faults a dealer meets in a dim room with their thumb. Both were written, tested
|
||||
// and then never run: the contrast checker was built across two rounds and every
|
||||
// sweep since has been silent about colour.
|
||||
//
|
||||
// A check that decides it can't apply here says so out loud rather than reporting
|
||||
// an empty offenders list, which reads as a pass. touchTargets stands itself down
|
||||
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
|
||||
// on it — but "not measured" and "measured and fine" are different answers and the
|
||||
// runner now prints which one it got.
|
||||
runAll(options = {}) {
|
||||
const checks = [
|
||||
this.touchAction(),
|
||||
@@ -610,15 +747,23 @@ const LayoutAudit = {
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.contrast(options.contrast),
|
||||
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||
];
|
||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
||||
|
||||
return {
|
||||
viewport: `${innerWidth}x${innerHeight}`,
|
||||
failed: checks.filter(c => c.offenders.length > 0),
|
||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||
warnings: checks.flatMap(c => c.warnings || []),
|
||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
||||
// Controls a check couldn't probe go here too: not a fault, but not a pass
|
||||
// either, and a silent skip is how faults hide.
|
||||
warnings: checks.flatMap(c => [
|
||||
...(c.warnings || []),
|
||||
...(c.unmeasured || []),
|
||||
...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
|
||||
]),
|
||||
passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
|
||||
skipped: checks.filter(c => c.skipped).map(c => c.name),
|
||||
clean: checks.every(c => c.offenders.length === 0),
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user