Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9c55af21 | ||
|
|
8b43614b1c | ||
|
|
42b9e83e8f | ||
|
|
470fbc0d26 | ||
|
|
c4e842f3e3 | ||
|
|
1d9353c94e | ||
|
|
8616636212 | ||
|
|
6174981cdc | ||
|
|
4db6f6e4d3 | ||
|
|
0ad5592687 | ||
|
|
236c52567e | ||
|
|
b8655be258 | ||
|
|
7e420ce747 | ||
|
|
c36d7f60bd | ||
|
|
15cec18256 | ||
|
|
9c8d981e2d | ||
|
|
907e0165db | ||
|
|
86e1570618 | ||
|
|
6df400322e | ||
|
|
ca9ac7b6af | ||
|
|
80dae56f0d | ||
|
|
6ca99fde6b | ||
|
|
b2262f8604 | ||
|
|
6ad9923aae | ||
|
|
168e8a380c | ||
|
|
edc08d7213 |
@@ -7,18 +7,23 @@
|
||||
|
||||
<div id="holdemApp">
|
||||
<header id="holdemHeader">
|
||||
@* Six equally-weighted buttons used to sit here in two rows, and on a 390px
|
||||
phone the felt did not start until 225px down — a quarter of the screen spent
|
||||
before the dealer saw the table. Only two of the six are per-hand controls.
|
||||
The rest have gone where they belong rather than into a "more" menu, which
|
||||
would have relocated the crowding rather than removed it: Reset Table to the
|
||||
Session panel beside Clear history and the save/load pair, Quick Split down
|
||||
with Common rulings where the other "open something else" link already lives,
|
||||
and the way back to the site up top where a back link goes. *@
|
||||
<div id="holdemHeaderLeft">
|
||||
<a href="/casino" class="holdemBackLink">← Casino</a>
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -37,6 +42,7 @@
|
||||
<p id="tableHint" class="tableHint"></p>
|
||||
<p class="tableHintActions">
|
||||
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
|
||||
<a href="/casino/texasholdem/quicksplit" class="linkBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -52,7 +58,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="sidebarPanel" open>
|
||||
@* Not `open` in the markup any more. Whether this panel starts open is a
|
||||
question about the table, so main.js answers it after the state has
|
||||
loaded: open on a table nobody has set up yet, shut on one already
|
||||
half-way through a night. *@
|
||||
<details class="sidebarPanel" id="tableSetupPanel">
|
||||
<summary>Table Setup</summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
@@ -209,6 +219,11 @@
|
||||
<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 />
|
||||
@* Down from the header, where it sat in the same weight as Resolve Pot
|
||||
— the most-pressed control in a session next to the one that throws
|
||||
the night away. Here it is beside Clear history, which is the other
|
||||
thing that destroys records, and behind a panel you have to open. *@
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -231,7 +231,17 @@ const ActionBar = {
|
||||
undo.type = "button";
|
||||
undo.className = "actionUndo";
|
||||
undo.title = `Undo ${undoLabel}`;
|
||||
undo.textContent = `↶ ${undoLabel}`;
|
||||
// The label goes in its own element rather than straight onto the button.
|
||||
// The pill is a fixed width — it names the last action, so its text changes
|
||||
// on every tap, and a control that resizes under the dealer's thumb reads
|
||||
// as the interface twitching. Fixed width means long labels have to
|
||||
// ellipsize, and only a real element can: as a bare text node the label
|
||||
// truncated *through* the keycap below it, which then sat squashed against
|
||||
// the cut-off text looking like a rendering fault.
|
||||
const label = document.createElement("span");
|
||||
label.className = "actionUndoLabel";
|
||||
label.textContent = `↶ ${undoLabel}`;
|
||||
undo.appendChild(label);
|
||||
// U undoes, and nothing said so — this control was the only place a dealer
|
||||
// could have learned it, and it was the one action bar control without its
|
||||
// key printed on it.
|
||||
|
||||
@@ -459,6 +459,66 @@ const Betting = {
|
||||
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
|
||||
},
|
||||
|
||||
// Are the blinds actually down? Moving the button or naming a blind means two
|
||||
// completely different things depending on the answer, and `onlyBlindsIn` alone
|
||||
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
|
||||
// committed, satisfies it just as well as a hand that has only just been dealt.
|
||||
//
|
||||
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
|
||||
// those chips back and post them again from the new positions, or the badges and
|
||||
// the money disagree.
|
||||
//
|
||||
// Before the deal it is a marker being slid round a table with nothing in the
|
||||
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
|
||||
// two stacks, action on the seat after the big blind — which is not what a dealer
|
||||
// means by "she's the big blind this one". Setting the positions is how a dealer
|
||||
// gets ready to deal; the cards come out when they press the button, and not a
|
||||
// moment before.
|
||||
blindsArePosted(state) {
|
||||
return !!state.bettingOpen && this.onlyBlindsIn(state);
|
||||
},
|
||||
|
||||
// Chips the pot has been priced with but nobody has actually paid.
|
||||
//
|
||||
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
|
||||
// bet leaves the stack, sits in front of the player, and is swept into their hand
|
||||
// total at the end of the street. The other way is the "without live betting" panel
|
||||
// — a base pot amount, or chips counted out on a seat — where the dealer is
|
||||
// recording a hand they are watching rather than driving, and nothing ever leaves
|
||||
// anybody's stack.
|
||||
//
|
||||
// The pot wizard prices both the same way, and awarding it credits the winner. So
|
||||
// in that second mode the table simply grew: measured at a three-handed table with
|
||||
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
|
||||
// left the bank check blaming a rebuy that never happened.
|
||||
//
|
||||
// So before the pot is pushed, whatever was assumed rather than taken comes off the
|
||||
// stacks that assumed it. Chips already committed or swept are left alone; they
|
||||
// have been paid once already, and taking them twice is the same bug the other way
|
||||
// round.
|
||||
collectAssumedContributions(state) {
|
||||
const taken = [];
|
||||
state.seats.forEach((player, index) => {
|
||||
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
|
||||
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
|
||||
const owed = this.round(priced - alreadyPaid);
|
||||
if (owed <= this.EPSILON_CHIPS) return;
|
||||
|
||||
// Never more than they are holding. A seat priced above its own stack is a
|
||||
// dealer's typo or a player who is short, and inventing the difference is
|
||||
// what this whole function exists to stop.
|
||||
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
|
||||
if (moved <= this.EPSILON_CHIPS) return;
|
||||
|
||||
player.stack = this.round(this.stackOf(state, player) - moved);
|
||||
// Recorded where a swept bet lives, so the money is in exactly the place
|
||||
// the rest of the tool looks for chips that are in the middle this hand.
|
||||
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
|
||||
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
|
||||
});
|
||||
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
|
||||
},
|
||||
|
||||
// A misdeal: the hand never happened, so every chip goes back where it came from.
|
||||
//
|
||||
// The rulings sheet in this tool has told dealers to redeal since the day it was
|
||||
|
||||
@@ -176,8 +176,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
// the dealer gets told rather than finding out later.
|
||||
const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) &&
|
||||
HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS;
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
|
||||
seatAction(index, willRepost
|
||||
? `Button on ${nameOf(index)} — blinds taken back and re-posted.`
|
||||
@@ -204,18 +203,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index);
|
||||
|
||||
const label = which === "sb" ? "small blind" : "big blind";
|
||||
seatAction(index, `${nameOf(index)} posts the ${label}.`, () => {
|
||||
// Only a hand that is already open has blinds to move. Before the deal this
|
||||
// is aiming the next hand, and re-posting would have dealt it — the dealer
|
||||
// says who the blind is well before they say "cards".
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(index, posted
|
||||
? `${nameOf(index)} posts the ${label}.`
|
||||
: `${nameOf(index)} has the ${label} next hand.`, () => {
|
||||
state.blindOverride = which === "sb"
|
||||
? { sbIndex: index, bbIndex: partner }
|
||||
: { sbIndex: partner, bbIndex: index };
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state);
|
||||
}),
|
||||
clearBlindOverride: () => {
|
||||
const posted = Betting.blindsArePosted(state);
|
||||
seatAction(state.dealerSeatIndex, posted
|
||||
? "Blinds follow the button again — taken back and re-posted."
|
||||
: "Blinds follow the button again.", () => {
|
||||
state.blindOverride = null;
|
||||
if (posted) Betting.repostBlinds(state);
|
||||
});
|
||||
},
|
||||
|
||||
editSeat: index => Render.toggleSeatEditor(index),
|
||||
|
||||
@@ -247,6 +257,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Render.mount(state, hooks);
|
||||
|
||||
// Table Setup used to ship open, so the tool's first screen was its most crowded
|
||||
// one: seat count, blind direction, dedicated dealer, both blinds, starting stack
|
||||
// and two sub-panels, all under a felt the dealer had come here to look at. It is
|
||||
// the right panel to have open on a table nobody has touched, and the wrong one to
|
||||
// have open on the fourth hand of a night.
|
||||
//
|
||||
// Decided once, here, rather than on every render — a panel that reopened itself
|
||||
// whenever the table emptied would fight the dealer who had just shut it.
|
||||
(() => {
|
||||
const panel = document.getElementById("tableSetupPanel");
|
||||
if (!panel) return;
|
||||
const fresh = (Number(state.handNumber) || 1) === 1 &&
|
||||
!state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||
panel.open = fresh;
|
||||
})();
|
||||
|
||||
const openPotWizard = () => {
|
||||
// Chips still in front of players belong in the pot being priced, so bank them
|
||||
// before the wizard reads anyone's contribution.
|
||||
@@ -261,6 +287,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (rake > 0) {
|
||||
state.rakeTaken = Money.round((Number(state.rakeTaken) || 0) + rake);
|
||||
}
|
||||
// Anything the pot was priced with but nobody actually paid — the
|
||||
// base pot and counted chips of the no-live-betting flow — comes off
|
||||
// the stacks that owe it before the winner is paid. Otherwise the
|
||||
// table gains the whole pot out of nowhere.
|
||||
Betting.collectAssumedContributions(state);
|
||||
if (payouts.length > 0) {
|
||||
HoldemState.recordHand(state, payouts, rake);
|
||||
// Winnings go straight back in front of the player, so the next
|
||||
|
||||
@@ -88,6 +88,19 @@ const Render = {
|
||||
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.";
|
||||
|
||||
// But only on the first hand. Two lines of instructions under the felt are worth
|
||||
// having on a table nobody has dealt at yet, and worth nothing on the fifth hand
|
||||
// of the night — by then the dealer either knows the gestures or is not going to
|
||||
// learn them from a caption they have scrolled past forty times.
|
||||
//
|
||||
// Keyed to the hand number rather than to whether a hand is currently out, on
|
||||
// purpose. `bettingOpen` was the obvious test and it is the wrong one: the hint
|
||||
// would vanish at every deal and come back at every payout, so a two-line block
|
||||
// under the felt would appear and disappear twice a hand, shunting the rulings
|
||||
// link and the whole panel stack up and down all night. This way it goes once,
|
||||
// at the first deal of the session, and stays gone.
|
||||
hint.hidden = (Number(this.state.handNumber) || 1) > 1 || !!this.state.bettingOpen;
|
||||
},
|
||||
|
||||
renderNotes() {
|
||||
@@ -408,6 +421,12 @@ const Render = {
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
const hasButton = index === dealerIndex;
|
||||
dealerChip.classList.toggle("active", hasButton);
|
||||
// Marked on the seat as well as the chip, so the puck's own text can keep
|
||||
// out from under it. A name is centred across the whole puck and the chip
|
||||
// sits in the corner on a layer above: measured, "Christopher" on the
|
||||
// button lost 20px of its 64px box to the chip and showed no ellipsis,
|
||||
// because the text believed it had the room it was painted out of.
|
||||
node.classList.toggle("seatHasButton", hasButton);
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// 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.
|
||||
@@ -547,8 +566,21 @@ const Render = {
|
||||
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||
fitAmount(el) {
|
||||
const length = (el.textContent || "").length;
|
||||
const formatted = el.textContent || "";
|
||||
// The separators go before the type size does. "$1,234,567" is ten characters,
|
||||
// which used to drop the puck to 7px — and 7px is not a size anybody reads a
|
||||
// stack off a felt at, so the shrink-rather-than-abbreviate rule above was
|
||||
// buying precision the dealer then couldn't see. Without its commas the same
|
||||
// number is eight characters, and eight fits at 10px with every digit intact.
|
||||
// Grouping is a reading aid for a column of figures; this is one number on a
|
||||
// 64px puck, and the aid costs more than it gives.
|
||||
const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted;
|
||||
if (text !== formatted) el.textContent = text;
|
||||
|
||||
const length = text.length;
|
||||
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||
// Only a stack of $100,000,000 or more still lands here. It is an overflow
|
||||
// guard rather than a size anybody is expected to read.
|
||||
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Saying who the blind is, before saying "cards".
|
||||
//
|
||||
// A dealer sets the table up out loud — button here, she's the small blind, he's the
|
||||
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
|
||||
// that, and it was answering by dealing the hand: betting opened, two stacks docked,
|
||||
// the action bar up and waiting on the seat after the big blind. The dealer had said
|
||||
// where the blinds go, and the tool heard "deal".
|
||||
//
|
||||
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
|
||||
// this hand except the blinds" — true of a hand one second old, and equally true of a
|
||||
// table in setup with no cards out and nothing committed. Those two need telling apart,
|
||||
// which is what `blindsArePosted` is for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("before the deal", () => {
|
||||
test("the blinds are not down, however much the table looks ready", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
assert.equal(state.bettingOpen, false);
|
||||
// The old condition, on its own, says yes here — this is the whole bug.
|
||||
assert.equal(Betting.onlyBlindsIn(state), true);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("a table straight out of setup, with nobody seated, is the same answer", () => {
|
||||
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
|
||||
});
|
||||
|
||||
test("re-posting would have dealt the hand, which is why it is guarded", () => {
|
||||
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
|
||||
// setup is the deal, and nothing about naming a blind should reach it.
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
Betting.repostBlinds(state);
|
||||
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
|
||||
assert.notEqual(state.actingSeatIndex, null);
|
||||
});
|
||||
|
||||
test("naming a blind moves a badge and no chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
// What the seat menu does: pin the override, repost only if the blinds are down.
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
|
||||
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
|
||||
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
|
||||
// And the badges say what the dealer just said.
|
||||
const blinds = HoldemState.getBlindSeats(state);
|
||||
assert.equal(blinds.sbIndex, 3);
|
||||
assert.equal(blinds.bbIndex, 0);
|
||||
});
|
||||
|
||||
test("and the hand that follows posts from where they were aimed", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
});
|
||||
|
||||
test("giving the button away is a marker moving too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
const willRepost = Betting.blindsArePosted(state);
|
||||
state.dealerSeatIndex = 2;
|
||||
if (willRepost) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(willRepost, false);
|
||||
assert.equal(state.bettingOpen, false);
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("once the hand is dealt", () => {
|
||||
test("the blinds are down, and re-aiming them moves real chips", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const before = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), true);
|
||||
assert.equal(Betting.committed(state.seats[1]), 1);
|
||||
|
||||
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
|
||||
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
|
||||
|
||||
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
|
||||
assert.equal(Betting.committed(state.seats[3]), 1);
|
||||
assert.equal(Betting.committed(state.seats[0]), 2);
|
||||
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
|
||||
});
|
||||
|
||||
test("but not once somebody has answered them", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, state.actingSeatIndex);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
|
||||
});
|
||||
|
||||
test("nor on a later street", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "flop";
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
|
||||
test("and a misdealt hand goes back to being a table in setup", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.takeHandBack(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
|
||||
});
|
||||
|
||||
test("as does a hand that has been paid out", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(Betting.blindsArePosted(state), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// Chips the pot was priced with that nobody actually paid.
|
||||
//
|
||||
// There are two ways a contribution gets into this tool. Tapping the action moves
|
||||
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
|
||||
// hand total at the end of the street. The other is the "without live betting" panel —
|
||||
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
|
||||
// hand they are watching rather than driving, and nothing ever leaves a stack.
|
||||
//
|
||||
// The pot wizard prices both the same way and awarding it credits the winner, so in
|
||||
// that second mode the table simply grew.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
|
||||
|
||||
const basePotTable = (base = 50, stack = 200) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 4);
|
||||
state.basePotAmount = base;
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].stack = stack;
|
||||
state.seats[i].buyIns = stack;
|
||||
});
|
||||
return state;
|
||||
};
|
||||
|
||||
const pricedPot = (state) => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
describe("what nobody paid", () => {
|
||||
test("comes off the stacks that owe it", () => {
|
||||
const state = basePotTable();
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 150);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
|
||||
});
|
||||
|
||||
test("and lands where the rest of the tool looks for chips in the middle", () => {
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
|
||||
assert.equal(HoldemState.unresolvedContributions(state), 150);
|
||||
});
|
||||
|
||||
test("chips counted out on a seat are money too", () => {
|
||||
const state = basePotTable(0);
|
||||
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 20);
|
||||
assert.equal(state.seats[0].stack, 180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what has already been paid", () => {
|
||||
test("a live bet is left exactly alone", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.equal(collected.total, 0, "the blinds are already off their stacks");
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("nor is a swept street taken a second time", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
Betting.beginHand(state);
|
||||
Betting.sweepToHandTotals(state);
|
||||
const before = state.seats.map(p => Betting.stackOf(state, p));
|
||||
|
||||
assert.equal(Betting.collectAssumedContributions(state).total, 0);
|
||||
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
|
||||
});
|
||||
|
||||
test("a base pot on top of a live bet only collects the difference", () => {
|
||||
// The base pot says "assume everyone is in for fifty". Somebody who has already
|
||||
// put twenty in owes thirty of it, not fifty.
|
||||
const state = basePotTable(50);
|
||||
state.seats[0].committed = 20;
|
||||
state.seats[0].stack = 180;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
const ann = collected.taken.find(row => row.name === "Ann");
|
||||
assert.equal(ann.amount, 30);
|
||||
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it will not do", () => {
|
||||
test("take more than a player is holding", () => {
|
||||
const state = basePotTable(50);
|
||||
state.seats[1].stack = 20;
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
|
||||
assert.equal(state.seats[1].amountOverride, 20);
|
||||
});
|
||||
|
||||
test("touch an empty chair", () => {
|
||||
const state = basePotTable();
|
||||
const before = { ...state.seats[3] };
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(state.seats[3].stack, before.stack);
|
||||
assert.equal(state.seats[3].amountOverride, before.amountOverride);
|
||||
});
|
||||
|
||||
test("or a seat that isn't in the hand", () => {
|
||||
// The base pot stands in for "everyone put the same in", and somebody sitting
|
||||
// out did not.
|
||||
const state = basePotTable();
|
||||
state.seats[2].inHand = false;
|
||||
const collected = Betting.collectAssumedContributions(state);
|
||||
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
|
||||
assert.equal(state.seats[2].stack, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the whole hand", () => {
|
||||
test("conserves the chips on the table, which it did not before", () => {
|
||||
const state = basePotTable();
|
||||
const total = chipsOnTable(state);
|
||||
const pot = pricedPot(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
|
||||
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("and the bank check stays balanced all the way through", () => {
|
||||
const state = basePotTable();
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
Betting.collectAssumedContributions(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("a live-betting hand is unchanged by any of this", () => {
|
||||
const state = basePotTable(0);
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
const total = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
const pot = HoldemState.unresolvedContributions(state);
|
||||
|
||||
Betting.collectAssumedContributions(state);
|
||||
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), total);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
|
||||
test("collecting twice takes nothing the second time", () => {
|
||||
// The pot wizard can be opened, cancelled and opened again.
|
||||
const state = basePotTable();
|
||||
Betting.collectAssumedContributions(state);
|
||||
const stacks = state.seats.map(p => p.stack);
|
||||
const again = Betting.collectAssumedContributions(state);
|
||||
assert.equal(again.total, 0);
|
||||
assert.deepEqual(state.seats.map(p => p.stack), stacks);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// The stylesheet has to keep picking from the scales.
|
||||
//
|
||||
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
|
||||
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
|
||||
// that was anybody's decision — each number was chosen at the moment it was needed, and
|
||||
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
|
||||
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
|
||||
//
|
||||
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
|
||||
// checked here instead: a new value has to be a step on one of them, or an exception
|
||||
// with a reason written next to it.
|
||||
//
|
||||
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
|
||||
// rest of this suite has no dependencies and runs in a second, and a regex over
|
||||
// declarations is enough to catch the thing that actually happens, which is somebody
|
||||
// (me) typing a number instead of reaching for a token.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
|
||||
const css = fs.readFileSync(CSS_PATH, "utf8");
|
||||
|
||||
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
|
||||
// and none of that is a declaration. Strip them before looking for values.
|
||||
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// Everything after the token block. The :root block is where the raw values are
|
||||
// supposed to be.
|
||||
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
|
||||
|
||||
const valuesIn = (properties, source = body) => {
|
||||
const found = [];
|
||||
const re = new RegExp(`\\b(${properties.join("|")})\\s*:([^;}]*)`, "g");
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
const [, prop, value] = m;
|
||||
// calc() and env() do their own arithmetic on tokens; the tokens inside them
|
||||
// are still checked, because the regex below looks at bare numbers only.
|
||||
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the spacing scale", () => {
|
||||
// Hairlines and structural clearances, each one deliberate:
|
||||
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
|
||||
// puck's name gives the dealer button beside it.
|
||||
// 5rem/9rem — how far the page has to scroll past the fixed action
|
||||
// bar. Not rhythm; clearance for a thing of a known size.
|
||||
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
|
||||
|
||||
test("every gap, margin and padding comes off the scale", () => {
|
||||
const offenders = valuesIn(
|
||||
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||
"gap", "row-gap", "column-gap"])
|
||||
.filter(v => !ALLOWED.has(v.value));
|
||||
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the type scale", () => {
|
||||
// 16px — the size below which iOS zooms the page when a field is focused, so
|
||||
// every text input states it. Not a typographic choice.
|
||||
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
|
||||
// a six-figure stack on a 58px seat puck.
|
||||
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
|
||||
|
||||
test("every font-size comes off the scale", () => {
|
||||
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
|
||||
test("and the scale is the one the tokens define", () => {
|
||||
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
|
||||
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the corner scale", () => {
|
||||
test("every border-radius comes off it", () => {
|
||||
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
|
||||
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tokens themselves", () => {
|
||||
test("every var() the stylesheet reaches for exists", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js.
|
||||
const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
const missing = [...used].filter(t =>
|
||||
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test("and the dark surfaces stay off pure black", () => {
|
||||
// Shadows are invisible on #000, so depth has to come from a lighter surface —
|
||||
// and white text on true black is the pairing that smears on an OLED phone,
|
||||
// which is what this tool is used on.
|
||||
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
|
||||
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
|
||||
surfaces.forEach(hex => {
|
||||
assert.notEqual(hex, "#000");
|
||||
assert.notEqual(hex, "#000000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("numbers that change while the dealer watches", () => {
|
||||
test("are set in tabular figures", () => {
|
||||
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
|
||||
// $18 to $21 shuffles its digits sideways and the eye reads movement where
|
||||
// there is only arithmetic.
|
||||
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
|
||||
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
|
||||
.forEach(sel => assert.ok(css.includes(sel),
|
||||
`${sel} should be in the tabular-figures list`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("controls that must not resize under a thumb", () => {
|
||||
test("the undo pill has a fixed width, not a content width", () => {
|
||||
// It names the last action, so its label changes on every tap. Measured across
|
||||
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
|
||||
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndo rule not found");
|
||||
assert.match(rule[0], /width:\s*[0-9.]+rem/);
|
||||
});
|
||||
|
||||
test("and its label is the part that ellipsizes", () => {
|
||||
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndoLabel rule not found");
|
||||
assert.match(rule[0], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
});
|
||||
@@ -719,6 +719,64 @@ const LayoutAudit = {
|
||||
return { name: "the felt shows one dealer button", offenders };
|
||||
},
|
||||
|
||||
// Anything that says it is hidden and is drawn anyway.
|
||||
//
|
||||
// `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
|
||||
// rule in the cascade, so any class that sets a display beats it — and in this tool
|
||||
// every button class does. The result is an element the code believes is gone,
|
||||
// sitting on the screen: found as a 21x44 empty box under the bank check, and this
|
||||
// file's own touch check had been calling it "could not be probed" rather than
|
||||
// saying the plainer thing, which is that it should not be there at all.
|
||||
hiddenButPainted() {
|
||||
const offenders = [...document.querySelectorAll("[hidden]")]
|
||||
.filter(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
const style = getComputedStyle(el);
|
||||
return style.display !== "none" && style.visibility !== "hidden";
|
||||
})
|
||||
.map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
|
||||
return { name: "hidden means hidden", offenders };
|
||||
},
|
||||
|
||||
// A seat's name, painted under the decoration on its own puck.
|
||||
//
|
||||
// The chip that marks the button sits in the puck's top-right corner on a layer
|
||||
// above the text, and the name is centred across the whole width. A long one
|
||||
// therefore ran underneath it and stopped — measured, "Christopher" on the button
|
||||
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
|
||||
// concerned it had all the room it needed. Nothing else here would have caught it:
|
||||
// the occlusion check is about controls a thumb has to reach, and this is a label
|
||||
// a dealer has to read.
|
||||
seatLabelsClear() {
|
||||
const offenders = [];
|
||||
document.querySelectorAll(".seat").forEach(seat => {
|
||||
const name = seat.querySelector(".seatPuckName");
|
||||
if (!name || !name.textContent.trim()) return;
|
||||
const box = name.getBoundingClientRect();
|
||||
if (!box.width || !box.height) return;
|
||||
// Padding is room the text has already been kept out of, so it is not
|
||||
// somewhere the text can be covered.
|
||||
const style = getComputedStyle(name);
|
||||
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
||||
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
||||
|
||||
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||
const over = mark.getBoundingClientRect();
|
||||
if (!over.width || !over.height) return;
|
||||
const marked = getComputedStyle(mark);
|
||||
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||
parseFloat(marked.opacity) < 0.05) return;
|
||||
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
|
||||
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||
if (covered > 1 && vertical > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { name: "seat names aren't painted under their own badges", offenders };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
// Every check this file knows how to make, every time.
|
||||
@@ -747,6 +805,8 @@ const LayoutAudit = {
|
||||
this.fieldFontSize(),
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.hiddenButPainted(),
|
||||
this.seatLabelsClear(),
|
||||
this.contrast(options.contrast),
|
||||
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user