Compare commits

..
22 changed files with 40 additions and 1615 deletions
@@ -130,12 +130,6 @@
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
<div id="settleUp" hidden>
<h3 class="panelSubhead">Settle up</h3>
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
<h3 class="panelSubhead">Hands</h3>
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
@@ -169,7 +163,7 @@
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<div class="seatPuckName"></div>
@@ -449,7 +443,6 @@
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
+9 -235
View File
@@ -212,20 +212,6 @@ button,
}
.holdemBtn {
/* Stated, because this class is worn by both <button> and <a> and the browser
does not give them the same box. Chromium's own stylesheet makes a button
border-box and leaves a link content-box, so `min-height: 46px` on the header
row came out as 46px on the buttons and 64px on the two links — and the row
stretches to its tallest item, so every button became 64px too. A 46px touch
target was quietly costing 64px of a phone screen, twice over, because the
header wraps to two rows. */
box-sizing: border-box;
/* And the touch floor is stated here rather than only under a width breakpoint.
It used to arrive by accident — the content-box links were 64px tall and the
row stretched everything to match — so once the boxes agree, a landscape phone
(wider than the phone breakpoint, still a thumb) would have been left with 42px
buttons. 44px is the floor everywhere; the narrow layout still asks for 46. */
min-height: 44px;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #eee;
border: 1px solid #3a3a3a;
@@ -322,22 +308,9 @@ button,
#pokerTableWrap {
position: relative;
/* The shape is stated once, and both the height and the cap below are derived from
it. It used to be a `padding-bottom: 72%` box, which has a trap in it: percentage
padding resolves against the *containing block's* width, not the element's own,
so narrowing the wrap left it exactly as tall as before. Capping the width was
therefore no way to shorten the table — measured, after trying it: the felt went
822px wide to 746px and stayed 617px tall, and the bottom seat sat as far into
the action bar as it had. aspect-ratio ties the two together properly. */
--feltAspect: 0.72;
width: 100%;
aspect-ratio: 1 / var(--feltAspect);
padding-bottom: 72%;
margin: 0 auto;
/* Capping the width is how the felt is told to stay above the action bar.
--feltRoomPx is published by actionBar.js with the bar's real height and the
bottom seat's overhang already taken out; the fallback leaves the table its full
size if that ever fails to arrive. */
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
}
@@ -618,6 +591,7 @@ button,
font-size: 0.68rem;
cursor: pointer;
z-index: 2;
opacity: 0.32;
transition: opacity 0.15s ease;
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
But tapping it hands the button to that seat and re-posts the blinds, and 22px
@@ -648,40 +622,16 @@ button,
left: -18px;
}
/* A poker table has one dealer button. This one drew nine.
Every seat carried a puck — the button's, and eight ghosts at 0.32 opacity that
were there to be pressed. Measured on a nine-seat table: eight pale discs, all
eight showing the letter D, one of them real. Finding the button meant comparing
nine identical shapes for the one that's gold, when it should be the only thing
of its kind on the felt. The ghosts also sat on top of their neighbours: every
remaining "something important is covered" offender on a crowded table was a
seat covered by the seat-next-door's ghost puck.
So a seat that doesn't have the button doesn't draw one. The affordance is still
there — hovering a seat fades its slot in, which is a preview of where the button
would go — and on touch, where there is no hover, "Give the button" has always
been in the seat's long-press menu. Keyboard reaches it by focus.
pointer-events matters as much as the opacity: an invisible puck that still takes
taps would be worse than a visible one. */
.dealerChip:not(.active) {
opacity: 0;
pointer-events: none;
.dealerChip:focus-visible {
opacity: 1;
}
@media (hover: hover) {
.seat:hover .dealerChip:not(.active) {
opacity: 0.5;
pointer-events: auto;
.seat:hover .dealerChip {
opacity: 1;
}
}
.dealerChip:not(.active):focus-visible {
opacity: 1;
pointer-events: auto;
}
.dealerChip.active {
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
border-color: #a9782f;
@@ -709,23 +659,6 @@ button,
display: inline-block;
}
/* Seats on the bottom of the ring wear their badge underneath, because that's the
edge the pot isn't on. Bets sit between a seat and the pot, so on the bottom arc
the chip comes down onto the seat's top edge — which is exactly where this badge
was, and the chip is a layer above it. See render.js for the measurement. */
.seat.seatBelowCentre .blindBadge {
top: auto;
bottom: -9px;
}
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
can't hide it again. Which blind a seat is posting is the one thing on that puck
nothing else on the felt repeats — a bet chip's amount is on the seat's own total
line as well. Measured across 12 table sizes: no chip loses its amount to this. */
.blindBadge {
z-index: 4;
}
/* Both badges failed WCAG AA against their own gradients — measured at the worst
stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
dealer is meant to read at a glance across a table.
@@ -1013,24 +946,9 @@ button,
text-align: center;
}
/* A bare checkbox ships at 13x13, which misses the 24px minimum with a mouse as well
as with a thumb. The row around it is the real target on touch, but the box itself
should be findable on any device. */
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
.linkBtn {
background: none;
border: none;
/* An underlined word was a 91x14 target, which misses the 24px minimum with a
mouse as well as with a thumb. The coarse-pointer block takes it to 44; this is
the floor everywhere else, and it changes nothing about how it looks. */
display: inline-flex;
align-items: center;
box-sizing: border-box;
min-height: 24px;
padding: 0;
color: #d8b46a;
font-family: inherit;
@@ -1485,16 +1403,7 @@ button,
.sidebarPanel summary {
cursor: pointer;
/* These headers are the sidebar's whole navigation and were 38px tall on every
device, phones included — the touch pass further down never reached them.
Padding rather than a flex box with min-height, because a <summary> only draws
its disclosure triangle while it is display: list-item. */
padding: 0.85rem 0;
/* A <summary> is content-box, so min-height would have been *added* to the padding
— measured at 71px before this line went in. Same trap the header buttons were
in, one file over. */
box-sizing: border-box;
min-height: 44px;
padding: 0.7rem 0;
font-size: 0.9rem;
color: #eee;
letter-spacing: 0.03em;
@@ -1710,46 +1619,6 @@ button,
color: #f0b46a;
}
/* The settle-up list is read out loud, one line per payment, so it's laid out like
the bank rows above it: name on the left, money hard right on tabular figures so a
column of amounts lines up on the decimal point. */
.settleUpList {
list-style: none;
margin: 0.35rem 0 0;
padding: 0;
}
.settleUpRow {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
font-size: 0.8rem;
padding: 0.18rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settleUpRow:last-child {
border-bottom: 0;
}
.settleUpWho {
color: #ddd;
}
.settleUpAmount {
color: #f2d489;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Same amber the bank check uses when the columns don't agree — it's the same fault
being reported twice, and it should look like it. */
.fieldHint.settleUpOff {
color: #f0b46a;
}
.standingsHeader {
color: #8a8a8a;
font-size: 0.7rem;
@@ -2234,23 +2103,6 @@ body.hasActionBar #holdemSidebar {
margin-top: 0.1rem;
}
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
letters and a warm colour separate it from the grey money above it at a glance —
which is the whole job, because the dealer is scanning the ring for who can still
be bet at. */
.seatPuckStack.seatPuckAllIn {
color: #ffcf6b;
font-weight: 700;
letter-spacing: 0.06em;
}
/* And the seat itself carries a hint of it, so the mark survives being read out of
the corner of an eye. Deliberately quieter than .seatActing — all-in is a standing
fact about a player, not a request for the dealer's attention. */
.seat.seatAllIn {
border-color: rgba(255, 207, 107, 0.45);
}
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
the seat next to it. These step the size down so every digit stays on its own
@@ -2629,16 +2481,6 @@ 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. */
#pokerTableWrap {
max-width: none;
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
.potWizardActions {
flex-direction: column-reverse;
@@ -2654,9 +2496,8 @@ body.hasActionBar #toastHost {
grid-template-columns: 1fr;
}
/* One column, so the table has the full width and can afford to be round. */
#pokerTableWrap {
--feltAspect: 1;
padding-bottom: 100%;
}
.seat {
@@ -2981,11 +2822,7 @@ body.hasActionBar #toastHost {
width: 100%;
height: var(--feltRoom);
min-height: 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;
padding-bottom: 0;
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
@@ -3209,66 +3046,3 @@ textarea:focus-visible {
font-size: 0.72rem;
color: #9a9a9a;
}
/* ---------- Touch targets on a wide touch screen ----------
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
never the question. A phone held sideways is 844px wide, so none of that work
applied to it. Measured on an emulated touch device at 844x390, with the sidebar
panels open: twelve controls under 44px, including the rulings link at 91x14 and
the dedicated-dealer checkbox at 13x13.
Restated here against the pointer instead of the viewport. The width-scoped block
keeps its layout changes — one column, stacked dialogs — because those really are
about how much room there is. This is only about what a thumb can hit, so it asks
about the thumb. */
@media (pointer: coarse) {
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
}
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn,
.toastAction {
min-height: 44px;
}
/* Destructive and tiny is the worst combination: these delete a chip colour or a
custom field, and are 28px wide. */
.removeChipColorBtn,
.removeFieldBtn {
min-width: 44px;
min-height: 44px;
}
/* flex-shrink would take a stated width straight back off again. */
.chipColorInput {
flex: none;
width: 44px;
height: 44px;
}
/* A bare checkbox can't grow into a target, but the row it sits in can — the
label is what a tap actually lands on. */
.panelBody .fieldRow {
min-height: 44px;
}
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
/* An underlined word is a 14px-tall target; give it room without making it look
like a button. */
.linkBtn {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 0.4rem;
}
}
@@ -29,7 +29,6 @@ const ActionBar = {
const visible = !host.hidden;
const height = visible ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
this.publishFeltRoom(height);
};
if (typeof ResizeObserver === "function") {
@@ -41,36 +40,6 @@ const ActionBar = {
publish();
},
// Half a seat, the gap left under the lowest one, and the smallest felt worth
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
// table-sized — so the overhang is a constant rather than something read back off
// the layout this is about to change. Measuring it would mean shrinking the felt,
// moving the seats, and getting a different answer on the next pass.
SEAT_OVERHANG_PX: 37,
FELT_GAP_PX: 8,
FELT_MIN_PX: 220,
// How tall the felt can be and still leave the whole ring of seats above the bar.
//
// Publishing the bar's height was only half the job. The wide layout never used it:
// the felt sizes itself from its own width — a box 72% as tall as it is wide — and
// so has no idea what is underneath it. The bar meanwhile grows with the hand, from
// 117px to 172px the moment there's a side pot to show, which is exactly when the
// dealer most needs to see who is still in. Measured with two side pots, the bottom
// seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024.
//
// The wrap's offset is taken in document space rather than viewport space, so
// scrolling the page doesn't resize the table under the dealer's hands.
publishFeltRoom(barHeight) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap) return;
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`);
},
// 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.
@@ -129,12 +98,8 @@ const ActionBar = {
row.appendChild(pot);
// How many are still in the hand — part of the standard per-street
// announcement, and the number a dealer is asked for most often. Before the
// deal it counts the table as it will be dealt, so a busted player waiting on a
// rebuy isn't announced as one of the players. Read-only: the sitting-out
// itself happens when the hand is dealt, not while the bar is being drawn.
const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state);
const remaining = Betting.liveSeats(counted).length;
// announcement, and the number a dealer is asked for most often.
const remaining = Betting.liveSeats(this.state).length;
const players = document.createElement("span");
players.className = "actionPlayers";
players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`;
@@ -345,10 +310,7 @@ const ActionBar = {
}
if (stack > owed) {
// Not "does this player owe anything" — the big blind owes nothing and is
// still raising. It's whether there is a live bet to raise.
const raising = Betting.wagerVerb(this.state) === "raise";
row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => {
row.appendChild(this.button(owed > 0 ? "Raise" : "Bet", "actionRaise", () => {
this.raiseOpen = true;
this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack);
this.render();
@@ -381,7 +343,6 @@ const ActionBar = {
// or a player whose whole stack is under a full raise never gets told that.
const legalMin = Betting.minRaiseTo(this.state);
const minTo = Math.min(legalMin, maxTo);
const raising = Betting.wagerVerb(this.state) === "raise";
const wrap = document.createElement("div");
wrap.className = "raisePad";
@@ -414,7 +375,7 @@ const ActionBar = {
input.inputMode = "decimal";
input.className = "raiseInput";
input.value = this.raiseDraft ?? minTo;
input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount");
input.setAttribute("aria-label", "Raise to amount");
input.addEventListener("input", () => {
this.raiseDraft = input.value.replace(/[^0-9.]/g, "");
this.updateRaiseNote(wrap, legalMin, maxTo);
@@ -426,16 +387,11 @@ const ActionBar = {
this.render();
}));
// "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing
// under it to raise past.
entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => {
entry.appendChild(this.button("Raise to", "raiseConfirm", () => {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
const label = raising
? `${who}'s raise to ${Money.format(target)}`
: `${who}'s bet of ${Money.format(target)}`;
this.commit(label, () => Betting.raiseTo(this.state, index, target));
this.commit(`${who}'s raise to ${Money.format(target)}`, () => Betting.raiseTo(this.state, index, target));
}));
wrap.appendChild(entry);
@@ -464,7 +420,6 @@ const ActionBar = {
const typed = Number(this.raiseDraft);
const current = this.state.currentBet || 0;
const raising = Betting.wagerVerb(this.state) === "raise";
if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; }
@@ -473,20 +428,16 @@ const ActionBar = {
const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS;
if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) {
// The way out of an under-minimum differs: with a bet in front of you the
// alternative is calling it, and with an empty pot there's nothing to call.
note.textContent = raising
? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`
: `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`;
note.textContent =
`${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.`;
note.className = "raiseNote raiseNoteWarn";
note.hidden = false;
return;
}
if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) {
note.textContent = raising
? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`
: `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`;
note.textContent =
`All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.`;
note.className = "raiseNote raiseNoteInfo";
note.hidden = false;
return;
@@ -521,35 +472,22 @@ const ActionBar = {
const preview = Betting.blindPreview(this.state);
const seated = Betting.liveSeats(this.state).length;
const broke = Betting.brokeSeats(this.state);
const name = i => HoldemState.displayName(this.state.seats[i], i);
const listNames = indexes => indexes.length === 1
? name(indexes[0])
: `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`;
const line = document.createElement("p");
line.className = "dealPromptLine";
if (!preview) {
// Two players is the least you can deal. Say what's missing rather than
// offering a button that would quietly do nothing — and say the *right*
// thing: "only one player is dealt in" is wrong when four are sitting there
// and three of them are just out of chips.
const withChips = seated - broke.length;
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.";
} else {
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
: "Only one player is dealt in — a hand needs two.";
}
// offering a button that would quietly do nothing.
line.textContent = seated === 0
? "Nobody is dealt in yet. Tap a seat to add a player."
: "Only one player is dealt in — a hand needs two.";
line.classList.add("dealPromptBlocked");
wrap.appendChild(line);
return wrap;
}
const name = i => HoldemState.displayName(this.state.seats[i], i);
line.textContent =
`${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
@@ -567,16 +505,6 @@ const ActionBar = {
wrap.appendChild(moved);
}
// Dealing sits out anyone with nothing behind. Said before it happens, for the
// same reason the button move is: a dealer shouldn't find out from the felt.
if (preview.satOut.length > 0) {
const out = document.createElement("p");
out.className = "dealPromptLine dealPromptAside";
out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` +
`${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`;
wrap.appendChild(out);
}
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
@@ -32,102 +32,28 @@ const Betting = {
// Who would post if the hand were dealt right now. Used to preview the blinds
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
//
// Not the same as all-in: an all-in player has money riding on the hand. This is
// somebody who busted and hasn't bought back in yet, which a home game produces
// about once an hour. A seat with no stack *recorded* isn't broke either —
// stackOf falls back to the table's starting stack for those — only an explicit
// zero counts.
brokeSeats(state) {
const broke = [];
state.seats.forEach((player, index) => {
if (!player.inHand) return;
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
broke.push(index);
});
return broke;
},
// The table as it will be once the seats that can't post are dealt around. A view,
// not a change: the preview has to ask the same question the deal will answer, and
// asking it of a different shape of table is how the two came apart last time.
tableAsDealt(state) {
const broke = this.brokeSeats(state);
if (broke.length === 0) return state;
const skip = new Set(broke);
return {
...state,
seats: state.seats.map((player, index) =>
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
};
},
blindPreview(state) {
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
// guessing this whole step exists to remove. Same reason the broke seats come
// out first: the preview once promised a small blind from a seat with no chips.
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
// guessing this whole step exists to remove.
const buttonIndex = HoldemState.buttonSeatForDeal(state);
const asDealt = buttonIndex === state.dealerSeatIndex
? state
: { ...state, dealerSeatIndex: buttonIndex };
const satOut = this.brokeSeats(state);
const { sbIndex, bbIndex, headsUp, manual } = HoldemState.getBlindSeats(asDealt);
if (sbIndex === null || bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual,
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
};
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
// their chips and stayed sat out, silently skipped hand after hand until somebody
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
// read "Ben added $100".
//
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
// somebody out on purpose and is topping them up, and dealing them back in would be
// the tool guessing at something it wasn't told. Only a seat with no stack of its
// own: busted at zero, or a chair being bought into for the first time.
//
// Deliberately reads player.stack rather than stackOf(), which substitutes the
// table's starting stack for a seat that has none — the question here is what this
// seat was actually recorded as holding.
buyInDealsThemIn(player) {
if (!player || player.inHand) return false;
const own = player.stack;
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
return !recorded || Number(own) <= this.EPSILON_CHIPS;
},
// "bet" or "raise" — what putting more in is actually called right now.
//
// A wager into a pot nobody has wagered into is a bet; everything after that is a
// raise. The bar decided this from what the acting player still owes, which gets
// the big blind's option backwards: they owe nothing, so it offered "Bet", but
// their own blind is a live bet and putting more in is a raise. It gets the other
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
// and wrote "Ann's raise to $10" into the undo control for it.
//
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet). This is only what it's called, which
// for a tool a dealer announces the action from is not a small thing.
wagerVerb(state) {
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
},
committed(player) {
return this.round(player.committed || 0);
},
@@ -191,20 +117,6 @@ const Betting = {
return null;
},
// A player still in the hand with chips in the pot and nothing left behind. This is
// the same stack test nextToAct uses to pass over them, which is the point: the
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
// act on again.
//
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
// anything in is a busted player who shouldn't have been dealt in, not a player
// who moved all-in, and calling them all-in on the felt would be a lie.
isAllIn(state, player) {
if (!player || !player.inHand || player.folded) return false;
return this.handTotal(player) > this.EPSILON_CHIPS &&
this.stackOf(state, player) <= this.EPSILON_CHIPS;
},
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
@@ -377,11 +289,6 @@ const Betting = {
// True when the hand is still live but nobody can bet again — the dealer is just
// running the board out.
isRunningOut(state) {
// Only while a hand is actually being played. Before the deal, a table of
// busted players who haven't rebought looks exactly like a table of all-ins to
// this test, and the bar was announcing a run-out over a felt with no cards and
// no chips on it.
if (!state.bettingOpen) return false;
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
},
@@ -419,21 +326,6 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
// Deal around anyone with no chips, before anything else reads the table.
//
// A busted player who hasn't rebought used to be dealt in anyway, and the
// result was a hand nobody could finish: the table promised a small blind from
// a seat with nothing to post it with, took it from nobody, put the action on
// the big blind with only their own money in front of them, and announced
// "3 players — all-in, running out the board" before a card was dealt.
//
// Sitting them out is what a dealer does. Seat, name and buy-in record are
// untouched, so dealing them back in is one tap when they buy chips.
this.brokeSeats(state).forEach(index => {
state.seats[index].inHand = false;
state.seats[index].folded = false;
});
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
// brand-new table starts on.
@@ -53,27 +53,6 @@ const HandHistoryExport = {
Money.forSheet(row.cashPosition || 0),
]);
});
// And the answer the standings are read to get to. Whoever opens this file
// wants the payment list, not to redo the arithmetic in a spreadsheet.
const plan = SettleUp.plan(standings);
if (plan.payments.length > 0) {
rows.push([]);
rows.push(["Settle Up"]);
rows.push(["Pays", "Receives", "Amount"]);
plan.payments.forEach(payment => {
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
});
if (!plan.balanced) {
// The file is the paper trail, so the caveat travels with it rather
// than living only in a sidebar nobody exported.
rows.push([
plan.shortBy > 0
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
]);
}
}
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
+3 -55
View File
@@ -96,19 +96,6 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
const movedName = nameOf(from);
const intoName = nameOf(to);
const wasEmpty = Render.isEmptySeat(state.seats[to]);
runUndoable(
wasEmpty
? `${movedName} moved to seat ${to + 1}.`
: `${movedName} and ${intoName} changed seats.`,
() => HoldemState.swapSeats(state, from, to));
},
makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
@@ -153,27 +140,11 @@ document.addEventListener("DOMContentLoaded", () => {
editSeat: index => Render.toggleSeatEditor(index),
addChips: index => AddChips.open(state, index, (seatIndex, amount) => {
// Buying chips puts you back in the game — see Betting.buyInDealsThemIn.
const dealBackIn = Betting.buyInDealsThemIn(state.seats[seatIndex]);
const handRunning = !Betting.onlyBlindsIn(state) || Betting.potSoFar(state) > 0;
const message = `${nameOf(seatIndex)} added ${Money.format(amount)}` +
(dealBackIn
? handRunning ? " — in from the next hand." : " — dealt in."
: ".");
seatAction(seatIndex, message, p => {
seatAction(seatIndex, `${nameOf(seatIndex)} added ${Money.format(amount)}.`, player => {
// Chips in front and cash in are different questions — the stack moves
// every hand, what they've handed over only moves when they buy.
p.stack = Money.round(Betting.stackOf(state, p) + amount);
p.buyIns = Money.round((Number(p.buyIns) || 0) + amount);
if (dealBackIn) {
p.inHand = true;
// Same rule "Deal in" follows: you can't buy into a pot you haven't
// been paying for, and `folded` is exactly that state for one hand.
p.folded = handRunning;
}
player.stack = Money.round(Betting.stackOf(state, player) + amount);
player.buyIns = Money.round((Number(player.buyIns) || 0) + amount);
});
}),
};
@@ -407,29 +378,6 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Table reset.", { duration: 2500 });
});
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
//
// Delegated from the document rather than wired per field: the raise pad, the
// add-chips sheet and the seat editor all build their inputs fresh every render,
// and a box added later should not have to remember to ask for this.
document.addEventListener("focusin", event => {
const field = event.target;
if (!Money.selectsOnFocus(field)) return;
if (!field.value) return;
// After the browser has finished placing its own caret, or it wins the race
// and the selection collapses again the moment the tap completes.
setTimeout(() => {
if (document.activeElement !== field) return;
try {
field.select();
} catch {
// A number input refuses setSelectionRange in some browsers; select()
// is the one that works on both, and if neither does the field is
// simply left as it was.
}
}, 0);
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
@@ -48,24 +48,6 @@ const Money = {
return (this.toCents(amount) / 100).toFixed(2);
},
// Should focusing this field select what's already in it?
//
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
// it already has. A dealer taps the box because they want a *different* number,
// and a tap puts the caret after the suggestion instead of over it. Measured on a
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
// showing 200, tap, type "50" — 20050.
//
// Only number boxes. A name or a note is a thing you edit, and selecting all of
// it on focus would be the same mistake pointing the other way.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
@@ -49,21 +49,11 @@ const Render = {
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
},
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
// written for. Touch gets told about the menu route instead, which works.
canDragSeats() {
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
},
renderTableHint() {
const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = this.canDragSeats()
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
},
renderNotes() {
@@ -181,31 +171,11 @@ const Render = {
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
// Which side of the seat the pot is on, which is the side its bet chip
// arrives from — the chip sits on the same spoke, closer in. For a seat on
// the top of the ring that's from below; on the bottom, from above.
//
// The blind badge has to be on the other side or the chip lands on top of
// it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
// the badge's 288 square pixels, and it sits a layer higher, so the marker
// telling the dealer who the big blind is was simply gone.
node.classList.toggle("seatBelowCentre", pos.top > 50);
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
const chipLabel = hasButton
? `${who} has the dealer button`
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
// Tapping the puck is the same act as "Give the button" in the seat menu,
// so it goes through the same hook — otherwise this one path would move
// the button without moving the blinds that follow it.
@@ -299,18 +269,11 @@ const Render = {
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
// A player with nothing behind used to leave this line blank, so the felt drew
// an all-in exactly like a seat whose stack nobody had entered. On a table with
// side pots that's the one distinction the dealer is working from — who can
// still be bet at, and who is already committed for everything they have.
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
const allIn = Betting.isAllIn(this.state, player);
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
stackEl.classList.toggle("seatPuckAllIn", allIn);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
this.fitAmount(stackEl);
node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
@@ -750,58 +713,6 @@ const Render = {
});
this.renderBankReconciliation();
this.renderSettleUp(standings);
},
// The last step the standings stopped short of. Six signed numbers at 2am is not
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
// and it's the part of the night people actually get wrong.
renderSettleUp(standings) {
const host = document.getElementById("settleUp");
if (!host) return;
const plan = SettleUp.plan(standings);
const note = document.getElementById("settleUpNote");
const list = document.getElementById("settleUpList");
// Nothing to show before anyone is up or down. A panel that says "no payments"
// to a table that hasn't played a hand is just noise in the sidebar.
host.hidden = plan.payments.length === 0 && plan.balanced;
if (host.hidden) return;
list.innerHTML = "";
plan.payments.forEach(payment => {
const row = document.createElement("li");
row.className = "settleUpRow";
const who = document.createElement("span");
who.className = "settleUpWho";
// An arrow rather than "pays": the direction is the whole content of the
// line, and it survives being read at a glance from across the table.
who.textContent = `${payment.from} → ${payment.to}`;
row.appendChild(who);
const amount = document.createElement("span");
amount.className = "settleUpAmount";
amount.textContent = Money.format(payment.amount);
row.appendChild(amount);
list.appendChild(row);
});
if (plan.balanced) {
const count = plan.payments.length;
note.textContent = count === 1
? "One payment and the night is square."
: `${count} payments and the night is square.`;
} else if (plan.shortBy > 0) {
note.textContent =
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
} else {
note.textContent =
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
}
note.classList.toggle("settleUpOff", !plan.balanced);
},
// Does the cash box cover the chips on the table? Nothing asked this before, and
@@ -6,16 +6,10 @@ const SeatMenu = {
hooks: null,
seatIndex: null,
mode: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
// Always the action list, never whatever the sheet was showing when it last
// closed — a seat menu that opens on a list of chairs is a menu that lost the
// dealer's place.
this.mode = null;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
@@ -69,81 +63,29 @@ const SeatMenu = {
}
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
// The chairs this player could move to. Every other chair: swapping into an empty
// one is how a player moves, and swapping with an occupied one is two players
// changing places, which is the other half of the same job.
destinationsFor(index) {
return this.state.seats
.map((player, seat) => ({ player, seat }))
.filter(entry => entry.seat !== index);
},
openMovePicker() {
this.mode = "move";
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
if (this.mode === "move") {
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "seatMenuBtn";
// displayName falls back to "Seat 5" for a chair with nobody in it,
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
const named = (other.name || "").trim();
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
btn.addEventListener("click", () => {
const from = this.seatIndex;
this.close();
this.hooks.swapSeats(from, seat);
});
list.appendChild(btn);
});
return;
}
title.textContent = HoldemState.displayName(player, this.seatIndex);
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
subtitle.textContent = bits.join(" · ");
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
// "Move to…" stays on the sheet and swaps it for the seat list; every
// other action is the end of the interaction.
if (action.label === "Move to…") {
action.run();
return;
}
this.close();
action.run();
});
@@ -1,66 +0,0 @@
// Who hands cash to whom at the end of the night.
//
// The standings panel already works out what everyone is up or down. It then stopped,
// which leaves the dealer with six signed numbers at 2am and the actual job still to
// do: turning them into payments. That's the step people get wrong — not the poker.
//
// Everything is done in cents. A settlement that has to come out exactly right is the
// last place to let a float land on 41.99999999.
const SettleUp = {
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
// and for this problem that's the right trade: finding the true minimum number of
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
// than there are people, and each payment it does emit clears somebody out of the
// list entirely. A dealer wants a short list they can read down, not a proof.
//
// Payers are the outer loop so that everything one person owes is printed together
// — the list is read out loud, one player at a time.
plan(standings) {
const owed = [];
const owing = [];
(standings || []).forEach(row => {
const cents = Money.toCents(row.cashPosition);
if (cents > 0) owed.push({ name: row.name, cents });
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
});
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
const payments = [];
let creditor = 0;
owing.forEach(payer => {
while (payer.cents > 0 && creditor < owed.length) {
const taker = owed[creditor];
const amount = Math.min(payer.cents, taker.cents);
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
payer.cents -= amount;
taker.cents -= amount;
if (taker.cents === 0) creditor++;
}
});
// What's left over when the chips on the table don't match the cash that came
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
// is always the thing the bank check is already warning about: a rebuy nobody
// wrote down, or a miscount. Reported rather than quietly spread across the
// payments, because inventing a transfer to make the columns agree is how a
// real person ends up short.
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
return {
payments,
// Only one of these can be non-zero: either there are more chips than cash
// or less, never both.
shortBy: unpaid,
overBy: unspent,
balanced: unpaid === 0 && unspent === 0,
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SettleUp;
}
@@ -40,22 +40,6 @@ const Shortcuts = {
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
// Enter and Space are how a keyboard presses the control it is already sitting on.
// Claiming them for "advance" didn't just block that press — it ran a different
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
// the next hand. Space on the same button worked, because "advance" happened to
// decline that time and the browser's own activation got through. A control that
// does something else every other press is worse than one that does nothing.
//
// Only these two keys are handed back. The letter shortcuts stay live with a
// button focused, because a button does nothing with "f" and the dealer's hand
// shouldn't have to leave them.
focusedControlOwnsKey(key) {
if (key !== "enter" && key !== " ") return false;
const el = document.activeElement;
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
},
onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -63,8 +47,6 @@ const Shortcuts = {
const action = this.KEYS[key];
if (!action) return;
if (this.focusedControlOwnsKey(key)) return;
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
@@ -467,21 +467,6 @@ const HoldemState = {
.sort((a, b) => b.net - a.net);
},
// Two players change chairs.
//
// The whole seat moves — name, stack, cards, what they have in the pot — because
// that is what changing chairs is. What stays behind is everything that belongs to
// the *position*: the button, the blinds and the acting index all name a chair, and
// a player who moves into the big blind's chair is the big blind. That is the same
// answer a card room gives.
swapSeats(state, a, b) {
const seats = state.seats || [];
if (a === b) return false;
if (!seats[a] || !seats[b]) return false;
[seats[a], seats[b]] = [seats[b], seats[a]];
return true;
},
// The one check a banker actually needs, and the one nothing here did.
//
// Every chip on the table is a promise to pay cash for it later. If the chips in
-98
View File
@@ -1,98 +0,0 @@
// Who the felt marks "ALL IN".
//
// The mark's whole value is that a dealer can scan the ring and see who can still be
// bet at, so the test that matters most is the last one: the seats it marks are
// exactly the seats the betting round refuses to ask for another chip.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("a player who moved all-in is marked", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
assert.equal(state.seats[3].stack, 0);
});
test("a player with chips behind is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("a busted player who was dealt in but has put nothing in is not", () => {
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("a folded player with an empty stack is not", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[1].committed = 5;
state.seats[1].stack = 0;
state.seats[1].folded = true;
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("an empty chair is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
});
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
state.seats[2].committed = 10;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("the seats marked are exactly the ones the action passes over", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[1].stack = 8;
state.seats[3].stack = 30;
Betting.beginHand(state);
// Everyone in, two of them for less than the others can cover.
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
const marked = state.seats
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
.filter(index => index !== null);
const skipped = state.seats
.map((player, index) =>
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
.filter(index => index !== null);
assert.deepEqual(marked, skipped);
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
});
test("nobody is marked once the next hand starts", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
// The short stack got there by winning nothing; give them chips back the way a
// dealer would before the next deal.
HoldemState.startNextHand(state);
state.seats[2].stack = 50;
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
});
test("marking nobody doesn't move a chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
const before = chipsOnTable(state);
state.seats.forEach(player => Betting.isAllIn(state, player));
assert.equal(chipsOnTable(state), before);
});
-148
View File
@@ -1,148 +0,0 @@
// Dealing around a player who busted and hasn't bought back in.
//
// A home game produces one of these about once an hour. The table used to deal them
// in anyway, promise a blind from a seat with nothing to post it with, take it from
// nobody, and then sit on a hand that could never finish.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const postedBy = state =>
state.seats
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
.filter(Boolean);
test("a seat with no chips and nothing in the pot is broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.deepEqual(Betting.brokeSeats(state), [2]);
});
test("an all-in player is not broke — they have money riding on the hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, 2);
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a chair nobody is sitting in isn't broke, it's empty", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].name = "Cal";
state.seats[2].buyIns = 100;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
assert.equal(state.seats[2].name, "Cal");
assert.equal(state.seats[2].buyIns, 100);
});
test("the blinds skip the broke seat and land on players who can post", () => {
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
Betting.beginHand(state);
assert.deepEqual(postedBy(state), [
{ seat: 3, amount: state.smallBlind },
{ seat: 4, amount: state.bigBlind },
]);
});
test("the preview promises what the deal produces", () => {
// The fault this whole step exists to prevent: the preview once read a table the
// deal was about to change underneath it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
state.bettingOpen = false;
const preview = Betting.blindPreview(state);
assert.deepEqual(preview.satOut, [1]);
Betting.beginHand(state);
const posted = postedBy(state);
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
});
test("asking for the preview doesn't sit anybody out on its own", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.blindPreview(state);
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
});
test("with only one player left holding chips there is nothing to preview", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
assert.equal(Betting.blindPreview(state), null);
});
test("no chips move when the broke seats are dealt around", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
test("the board isn't running out before the hand is dealt", () => {
// Two busted players and one with chips looks exactly like a table of all-ins to
// the run-out test, and the bar was announcing one over an empty felt.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
state.bettingOpen = false;
assert.equal(Betting.isRunningOut(state), false);
});
test("buying back in puts them in the next hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
// What the dealer does when they hand over cash: chips, and back in.
state.seats[2].stack = 100;
state.seats[2].inHand = true;
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.liveSeats(state).length, 3);
});
test("the table dealt around them counts the right number of players", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 0;
assert.equal(Betting.liveSeats(state).length, 4);
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
});
-76
View File
@@ -1,76 +0,0 @@
// Whether handing a seat chips also puts them back in the game.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
test("a busted player who has been sat out comes back in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.beginHand(state); // the deal sits them out
assert.equal(state.seats[1].inHand, false);
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
});
test("a chair being bought into for the first time comes in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("a seat sat out while holding chips stays out", () => {
// A dealer sat them out on purpose and is topping them up. Dealing them back in
// would be the tool guessing at something it wasn't told.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].inHand = false;
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("somebody already in the hand is left exactly where they are", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
// Including a player who is in but out of chips — they're all-in, not sat out.
state.seats[2].stack = 0;
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("a blank stack field reads as nothing recorded, not as a number", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[3].name = "Dee";
state.seats[3].stack = "";
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
});
test("it doesn't ask the table what a missing stack would have been", () => {
// stackOf substitutes the table's starting stack for a seat that has none, which
// would make a seat with nothing recorded look like it was holding chips.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.defaultStack = 500;
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("asking doesn't change anything", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].inHand = false;
const before = JSON.stringify(state);
Betting.buyInDealsThemIn(state.seats[2]);
assert.equal(JSON.stringify(state), before);
});
test("nothing to decide about a seat that isn't there", () => {
assert.equal(Betting.buyInDealsThemIn(null), false);
assert.equal(Betting.buyInDealsThemIn(undefined), false);
});
-2
View File
@@ -18,7 +18,6 @@ const Money = load("money");
const PotCalculator = load("potCalculator");
const SplitCalculator = load("splitCalculator");
const Betting = load("betting");
const SettleUp = load("settleUp");
const HandHistoryExport = load("handHistoryExport");
// render.js exports { Render } rather than the namespace directly.
@@ -78,7 +77,6 @@ module.exports = {
HandHistoryExport,
PotCalculator,
SplitCalculator,
SettleUp,
Betting,
HoldemState,
makeTable,
-53
View File
@@ -1,53 +0,0 @@
// Which fields replace their contents when a dealer reaches for them.
const test = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// Enough of an input to answer the question, without a DOM.
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
test("a number box selects", () => {
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
});
test("a decimal keypad box selects", () => {
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
// a number pad without the browser's spinner arrows.
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
});
test("a name does not", () => {
// Selecting all of a name on focus is the same mistake pointing the other way:
// a name is a thing you edit, not a suggestion you replace.
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
});
test("a colour swatch does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
});
test("a checkbox does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
});
test("a textarea does not, whatever it is asked", () => {
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
});
test("a select does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
});
test("case doesn't matter", () => {
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
});
test("nothing at all is not a field", () => {
assert.equal(Money.selectsOnFocus(null), false);
assert.equal(Money.selectsOnFocus(undefined), false);
});
test("a field with no type stated is a text box, so it does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
});
-124
View File
@@ -1,124 +0,0 @@
// Turning the standings into "who hands cash to whom".
const test = require("node:test");
const assert = require("node:assert");
const { Money, HoldemState, SettleUp, makeTable } = require("./load");
const standings = pairs => pairs.map(([name, cashPosition]) => ({ name, cashPosition }));
const lines = plan => plan.payments.map(p => `${p.from} -> ${p.to} ${Money.format(p.amount)}`);
const paidTo = (plan, name) =>
plan.payments.filter(p => p.to === name).reduce((sum, p) => sum + p.amount, 0);
const paidBy = (plan, name) =>
plan.payments.filter(p => p.from === name).reduce((sum, p) => sum + p.amount, 0);
test("one loser, one winner", () => {
const plan = SettleUp.plan(standings([["Ann", 40], ["Ben", -40]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $40"]);
assert.equal(plan.balanced, true);
});
test("everybody flat means nothing to do", () => {
const plan = SettleUp.plan(standings([["Ann", 0], ["Ben", 0]]));
assert.deepEqual(plan.payments, []);
assert.equal(plan.balanced, true);
});
test("nobody at all means nothing to do", () => {
const plan = SettleUp.plan([]);
assert.deepEqual(plan.payments, []);
assert.equal(plan.balanced, true);
});
test("each player ends up square", () => {
const table = standings([
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
]);
const plan = SettleUp.plan(table);
table.forEach(row => {
const settled = paidTo(plan, row.name) - paidBy(plan, row.name);
assert.equal(settled, row.cashPosition, `${row.name} should end up square`);
});
assert.equal(plan.balanced, true);
});
test("at most one payment fewer than there are people", () => {
const table = standings([
["Ann", 120], ["Ben", -75], ["Cal", -60], ["Dee", 30], ["Eve", -15],
]);
const plan = SettleUp.plan(table);
assert.ok(plan.payments.length <= table.length - 1,
`${plan.payments.length} payments for ${table.length} players`);
});
test("a player who owes two people has both lines together", () => {
// The list gets read out one player at a time, so a payer's obligations must not
// be split up by somebody else's.
const plan = SettleUp.plan(standings([["Ann", 50], ["Cal", 30], ["Ben", -80]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $50", "Ben -> Cal $30"]);
});
test("cents survive", () => {
const plan = SettleUp.plan(standings([["Ann", 0.1], ["Ben", 0.2], ["Cal", -0.3]]));
assert.deepEqual(lines(plan), ["Cal -> Ben $0.20", "Cal -> Ann $0.10"]);
assert.equal(plan.balanced, true);
});
test("more chips than cash leaves somebody unpaid, and says so", () => {
// The table is $25 up on the bank — an unrecorded rebuy or a miscount. The winners
// cannot all be paid, and pretending otherwise is how a real person ends up short.
const plan = SettleUp.plan(standings([["Ann", 100], ["Ben", -75]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $75"]);
assert.equal(plan.shortBy, 25);
assert.equal(plan.overBy, 0);
assert.equal(plan.balanced, false);
});
test("less chips than cash leaves a payer with nowhere to send it", () => {
const plan = SettleUp.plan(standings([["Ann", 50], ["Ben", -75]]));
assert.deepEqual(lines(plan), ["Ben -> Ann $50"]);
assert.equal(plan.overBy, 25);
assert.equal(plan.shortBy, 0);
assert.equal(plan.balanced, false);
});
test("no payment is ever for nothing", () => {
const plan = SettleUp.plan(standings([
["Ann", 60], ["Ben", -60], ["Cal", 0], ["Dee", 0],
]));
assert.ok(plan.payments.every(p => p.amount > 0));
assert.equal(plan.payments.length, 1);
});
test("nobody pays themselves", () => {
const plan = SettleUp.plan(standings([
["Ann", 90], ["Ben", -30], ["Cal", -30], ["Dee", -30],
]));
assert.ok(plan.payments.every(p => p.from !== p.to));
});
test("the payments total what the winners are owed", () => {
const table = standings([["Ann", 42.5], ["Ben", 17.5], ["Cal", -60]]);
const plan = SettleUp.plan(table);
const moved = plan.payments.reduce((sum, p) => sum + p.amount, 0);
assert.equal(Money.round(moved), 60);
});
test("it settles a real table's standings, not just hand-made numbers", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].buyIns = 100;
state.seats[1].buyIns = 100;
state.seats[2].buyIns = 100;
state.seats[0].stack = 175;
state.seats[1].stack = 90;
state.seats[2].stack = 35;
const rows = HoldemState.sessionStandings(state);
const plan = SettleUp.plan(rows);
// Bank balances — 300 in, 300 on the table — so everyone can be paid.
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
assert.equal(plan.balanced, true);
rows.forEach(row => {
assert.equal(paidTo(plan, row.name) - paidBy(plan, row.name), row.cashPosition);
});
});
-110
View File
@@ -1,110 +0,0 @@
// The keyboard shortcuts, and the one rule that keeps them from stealing a press.
//
// Shortcuts talks to `document` directly, so these tests stand a small fake one up
// rather than a whole DOM: the only things it asks about are what has focus and
// whether an overlay is open.
const test = require("node:test");
const assert = require("node:assert");
const path = require("path");
const Shortcuts = require(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "shortcuts.js"));
// A stand-in for a focused element. `matches` answers the same selector list the
// real one would, which is the whole of what focusedControlOwnsKey looks at.
function element(tagName, { selectors = [], isContentEditable = false } = {}) {
return {
tagName,
isContentEditable,
matches: selector => selectors.some(s => selector.includes(s)),
};
}
const BUTTON = element("BUTTON", { selectors: ["button"] });
const SUMMARY = element("SUMMARY", { selectors: ["summary"] });
const LINK = element("A", { selectors: ["a[href]"] });
const DIV = element("DIV");
const TEXT_FIELD = element("INPUT");
// Runs one keypress against a fresh Shortcuts and reports which action fired.
function press(key, { focused = DIV, overlays = [] } = {}) {
const fired = [];
let defaultPrevented = false;
globalThis.document = {
activeElement: focused,
addEventListener() {},
querySelectorAll: () => overlays,
};
const handlers = {};
for (const action of Object.values(Shortcuts.KEYS)) {
handlers[action] = () => { fired.push(action); return true; };
}
Shortcuts.handlers = handlers;
Shortcuts.onKey({ key, preventDefault: () => { defaultPrevented = true; } });
return { fired: fired[0] || null, defaultPrevented };
}
test.after(() => { delete globalThis.document; });
test("Enter on a focused button is left to the button", () => {
assert.equal(press("Enter", { focused: BUTTON }).fired, null);
});
test("Space on a focused button is left to the button", () => {
assert.equal(press(" ", { focused: BUTTON }).fired, null);
});
test("a key left to the control is not swallowed either", () => {
// preventDefault would stop the browser activating the button, which is the
// half of the bug that turned "wrong action" into "no action".
assert.equal(press("Enter", { focused: BUTTON }).defaultPrevented, false);
});
test("summary and links own Enter and Space too", () => {
assert.equal(press("Enter", { focused: SUMMARY }).fired, null);
assert.equal(press(" ", { focused: LINK }).fired, null);
});
test("Space still advances when nothing pressable has focus", () => {
assert.equal(press(" ", { focused: DIV }).fired, "advance");
});
test("Enter still advances when nothing pressable has focus", () => {
assert.equal(press("Enter", { focused: DIV }).fired, "advance");
});
test("the letter shortcuts stay live with a button focused", () => {
// A button does nothing with "f", so there is no press to hand back — and a
// dealer whose hand is on the keys shouldn't lose them to a stray focus ring.
assert.equal(press("f", { focused: BUTTON }).fired, "fold");
assert.equal(press("c", { focused: BUTTON }).fired, "checkOrCall");
assert.equal(press("r", { focused: BUTTON }).fired, "raise");
assert.equal(press("a", { focused: BUTTON }).fired, "allIn");
assert.equal(press("u", { focused: BUTTON }).fired, "undo");
});
test("typing still silences everything but Escape", () => {
assert.equal(press("f", { focused: TEXT_FIELD }).fired, null);
assert.equal(press("Escape", { focused: TEXT_FIELD }).fired, "cancel");
});
test("an open overlay still owns the keyboard", () => {
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: false }] }).fired, null);
assert.equal(press("f", { focused: DIV, overlays: [{ hidden: true }] }).fired, "fold");
});
test("a focused control wins before the overlay check, not after", () => {
// Both would decline here; the point is that neither path fires an action.
assert.equal(press("Enter", { focused: BUTTON, overlays: [{ hidden: false }] }).fired, null);
});
test("modified keys are never shortcuts", () => {
globalThis.document = { activeElement: DIV, addEventListener() {}, querySelectorAll: () => [] };
const fired = [];
Shortcuts.handlers = { advance: () => { fired.push("advance"); return true; } };
Shortcuts.onKey({ key: " ", ctrlKey: true, preventDefault() {} });
assert.deepEqual(fired, []);
});
-95
View File
@@ -1,95 +0,0 @@
// Two players changing chairs.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("the whole seat moves", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].name = "Ann";
state.seats[0].cards = ["AS", "KD"];
state.seats[0].stack = 175;
state.seats[4].name = "";
HoldemState.swapSeats(state, 0, 4);
assert.equal(state.seats[4].name, "Ann");
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
assert.equal(state.seats[4].stack, 175);
assert.equal(state.seats[0].name, "");
});
test("what they have in the pot goes with them", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
const posted = state.seats.map(p => p.committed);
const before = chipsOnTable(state);
HoldemState.swapSeats(state, 1, 4);
assert.equal(state.seats[4].committed, posted[1]);
assert.equal(state.seats[1].committed, posted[4]);
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
});
test("the button stays on the chair, not on the player", () => {
// A card room moves the button around the table, not around the people. Somebody
// who sits down in the big blind's chair is the big blind.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
state.seats[0].name = "Ann";
HoldemState.swapSeats(state, 0, 2);
assert.equal(state.dealerSeatIndex, 0);
assert.notEqual(state.seats[0].name, "Ann");
});
test("the blinds follow the chairs too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const before = HoldemState.getBlindSeats(state);
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
const after = HoldemState.getBlindSeats(state);
assert.equal(after.sbIndex, before.sbIndex);
assert.equal(after.bbIndex, before.bbIndex);
});
test("swapping a seat with itself does nothing and says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
assert.equal(JSON.stringify(state), before);
});
test("a chair that isn't there is refused rather than swallowed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
assert.equal(JSON.stringify(state), before);
});
test("swapping twice puts everything back", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
const before = JSON.stringify(state);
HoldemState.swapSeats(state, 1, 3);
HoldemState.swapSeats(state, 1, 3);
assert.equal(JSON.stringify(state), before);
});
test("moving into an empty chair leaves an empty chair behind", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[1].name = "Ben";
HoldemState.swapSeats(state, 1, 5);
assert.equal(state.seats[5].name, "Ben");
assert.equal(state.seats[5].inHand, true);
assert.equal(state.seats[1].inHand, false);
});
-36
View File
@@ -514,41 +514,6 @@ const LayoutAudit = {
return { name: "a seat's centre belongs to that seat", offenders };
},
// A poker table has one dealer button. Nine pucks all showing "D", one of them
// gold, turns the most-read marker on the felt into a spot-the-difference — and
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
// Both halves are checked here: what's drawn, and what it announces itself as.
oneDealerButton() {
const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => {
const style = getComputedStyle(chip);
if (style.display === "none" || style.visibility === "hidden") return false;
if (parseFloat(style.opacity) <= 0.05) return false;
// A hovered seat deliberately fades its empty slot in — that's the "put the
// button here" preview, and it is on screen because a pointer is sitting on
// that seat. Counting it reports two buttons whenever the mouse happens to
// rest on the table, which is most of the time a mouse exists.
const seat = chip.closest(".seat");
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
return true;
});
const offenders = [];
if (shown.length > 1) {
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
}
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
if (duplicated.length) {
offenders.push(
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
);
}
return { name: "the felt shows one dealer button", offenders };
},
// ---- Runner ----
runAll(options = {}) {
@@ -562,7 +527,6 @@ const LayoutAudit = {
this.horizontalOverflow(),
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
-83
View File
@@ -1,83 +0,0 @@
// Whether putting more in is called a bet or a raise.
//
// The arithmetic was always right — minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet. This is only what it's called, which for
// a tool a dealer announces the action from is not a small thing.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
// Walks the preflop action round to the big blind, who is owed nothing and gets to
// choose between checking and putting more in.
function toTheBigBlindsOption() {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const bigBlind = state.seats.findIndex(player => Betting.committed(player) === state.bigBlind);
while (state.actingSeatIndex !== null && state.actingSeatIndex !== bigBlind) {
Betting.call(state, state.actingSeatIndex);
}
return { state, bigBlind };
}
test("the big blind's option is a raise, not a bet", () => {
const { state, bigBlind } = toTheBigBlindsOption();
assert.equal(state.actingSeatIndex, bigBlind);
assert.equal(Betting.toCall(state, state.seats[bigBlind]), 0, "they owe nothing");
assert.equal(Betting.wagerVerb(state), "raise", "but their own blind is a live bet");
});
test("and the minimum there is a real raise, not a call of the blind", () => {
const { state } = toTheBigBlindsOption();
assert.equal(Betting.minRaiseTo(state), state.bigBlind * 2);
});
test("facing the blinds preflop is a raise", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.wagerVerb(state), "raise");
});
test("first in on the flop is a bet", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
assert.equal(state.street, "flop");
assert.equal(Betting.wagerVerb(state), "bet");
assert.equal(Betting.minRaiseTo(state), state.bigBlind, "a first bet is one big blind");
});
test("after that bet it's a raise again", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
Betting.raiseTo(state, state.actingSeatIndex, 10);
assert.equal(Betting.wagerVerb(state), "raise");
});
test("and a bet again on the next street once it's checked around", () => {
const { state, bigBlind } = toTheBigBlindsOption();
Betting.check(state, bigBlind);
Betting.closeStreet(state);
while (state.actingSeatIndex !== null) Betting.check(state, state.actingSeatIndex);
Betting.closeStreet(state);
assert.equal(state.street, "turn");
assert.equal(Betting.wagerVerb(state), "bet");
});
test("a table playing without blinds opens with a bet", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, smallBlind: 0, bigBlind: 0 });
Betting.beginHand(state);
assert.equal(state.currentBet, 0);
assert.equal(Betting.wagerVerb(state), "bet");
});
test("asking doesn't change anything", () => {
const { state } = toTheBigBlindsOption();
const before = JSON.stringify(state);
Betting.wagerVerb(state);
assert.equal(JSON.stringify(state), before);
});