Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6d63400a | ||
|
|
0d6e7e0b5a | ||
|
|
aeb02d023d | ||
|
|
60d192a9ec | ||
|
|
cc6a1902bc | ||
|
|
a488bbf832 |
@@ -104,16 +104,31 @@
|
||||
<summary>Session</summary>
|
||||
<div class="panelBody">
|
||||
<h3 class="panelSubhead">Standings</h3>
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
<span>Player</span>
|
||||
<span>In</span>
|
||||
<span>Won</span>
|
||||
<span>Net</span>
|
||||
<div class="standingsTableWrap">
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
<span>Player</span>
|
||||
<span class="standingsInHead">In</span>
|
||||
<span class="standingsWonHead">Won</span>
|
||||
<span>Net</span>
|
||||
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
|
||||
</div>
|
||||
<div id="standingsList"></div>
|
||||
</div>
|
||||
<div id="standingsList"></div>
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||
|
||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
<span id="bankCashIn"></span>
|
||||
</div>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Chips in play</span>
|
||||
<span id="bankChipsInPlay"></span>
|
||||
</div>
|
||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<div id="handHistoryList"></div>
|
||||
@@ -225,6 +240,7 @@
|
||||
<span class="standingsIn"></span>
|
||||
<span class="standingsWon"></span>
|
||||
<span class="standingsNet"></span>
|
||||
<span class="standingsCashOut"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -299,6 +315,41 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<h3>A short all-in doesn't reopen the betting</h3>
|
||||
<p>
|
||||
Somebody shoving in less than a full raise — say $3 more when the last
|
||||
raise was $8 — does not give the players behind them another go. Anyone
|
||||
who has already acted owes the difference or folds; they don't get to
|
||||
raise again because a short stack happened to land between them.
|
||||
</p>
|
||||
<p>
|
||||
A player who hadn't acted yet keeps full rights and may still raise, and
|
||||
the minimum raise stays measured off the last <em>full</em> raise, not
|
||||
off the short one. The action bar follows this for you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<h3>Chips nobody called come back</h3>
|
||||
<p>
|
||||
Bet more than anyone was able to call and the excess is returned to you
|
||||
before the showdown — it was never contested, so it isn't part of the
|
||||
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
|
||||
a side pot you could win.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="ruling">
|
||||
<h3>The odd chip in a split pot</h3>
|
||||
<p>
|
||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||
player left of the button, not to whoever the dealer happened to tick
|
||||
first. Pot payouts here order the winners that way, so the leftover lands
|
||||
on the right seat every time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="rulingsSource">
|
||||
Follows the Tournament Directors Association rules, which most rooms and
|
||||
home games take their lead from.
|
||||
|
||||
@@ -1040,6 +1040,13 @@ button,
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||
/* This sheet had no height limit and didn't scroll, so on a phone held sideways
|
||||
its first two actions — Fold and All-in, the two that matter — sat below the
|
||||
bottom of the screen with no way to reach them. A dealer in landscape could
|
||||
long-press a seat and then not fold it. */
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.seatMenuHeader {
|
||||
@@ -1465,9 +1472,22 @@ button,
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The sidebar is about 290px wide, and four numeric columns at a 4.5rem minimum
|
||||
plus gaps come to 320px — so the name column, being the only flexible one, was
|
||||
collapsing to *zero* and player names disappeared entirely. Names were already
|
||||
down to 50px with four columns; adding the cash-out column finished them off.
|
||||
Measured at 360, 390 and 1280 alike, since the sidebar is narrow at all of them.
|
||||
|
||||
So the table earns its columns instead of assuming them. Narrow: the player and
|
||||
the two numbers a banker settles up from. Wide enough: the per-hand detail as
|
||||
well. Everything is in the CSV export either way. */
|
||||
.standingsTableWrap {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.standingsRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
grid-template-columns: minmax(4rem, 1fr) auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
padding: 0.3rem 0.1rem;
|
||||
@@ -1478,7 +1498,78 @@ button,
|
||||
.standingsRow > span:not(:first-child) {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 4.5rem;
|
||||
min-width: 3.6rem;
|
||||
}
|
||||
|
||||
.standingsRow > .standingsName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The per-hand columns are detail: how the night's swings happened. They're the
|
||||
first thing to go when there isn't room, because Net and Cash out are what the
|
||||
banker is actually reading. */
|
||||
.standingsIn,
|
||||
.standingsWon,
|
||||
.standingsInHead,
|
||||
.standingsWonHead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (min-width: 30rem) {
|
||||
.standingsRow {
|
||||
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto;
|
||||
}
|
||||
|
||||
.standingsIn,
|
||||
.standingsWon,
|
||||
.standingsInHead,
|
||||
.standingsWonHead {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* The settle-up column is the one a banker reads while counting out cash, so it
|
||||
carries the weight the per-hand columns don't. */
|
||||
.standingsCashOut {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Bank reconciliation ----------
|
||||
Cash taken versus chips on the table. Every chip is a promise to pay cash for it
|
||||
later, and if the chips outrun the box that promise can't be kept — which a dealer
|
||||
otherwise discovers when the last two players cash out and there's nothing left. */
|
||||
.bankReconcile {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 8px;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.bankReconcileRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: #b8b8b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.12rem 0;
|
||||
}
|
||||
|
||||
.bankReconcileVerdict {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bankReconcileVerdict.bankBalanced {
|
||||
color: #7fc99a;
|
||||
}
|
||||
|
||||
.bankReconcileVerdict.bankOff {
|
||||
color: #f0b46a;
|
||||
}
|
||||
|
||||
.standingsHeader {
|
||||
@@ -2123,7 +2214,12 @@ body.hasActionBar #toastHost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
/* Every other sheet in here sits at 90-98; this one was at 50, underneath both
|
||||
the action bar (55) and the toast (80). So the bar drew on top of the dialog
|
||||
that pays people — in landscape one of its inputs was completely covered by
|
||||
the Fold button — and a toast could sit over "Confirm Winner(s)". It's the odd
|
||||
one out rather than a deliberate choice, and it joins the others. */
|
||||
z-index: 95;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -2137,8 +2233,13 @@ body.hasActionBar #toastHost {
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
/* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over
|
||||
the bottom of it — so the last of the wizard sat underneath the bar and its
|
||||
controls couldn't be tapped. Leaving the bar's own height out of the budget
|
||||
keeps the whole dialog above it. */
|
||||
max-height: calc(85vh - var(--actionBarHeight, 0px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 1.2rem 1.4rem 1.6rem;
|
||||
}
|
||||
|
||||
@@ -2732,3 +2833,33 @@ textarea:focus-visible {
|
||||
[role="alertdialog"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ---------- Fields on a tablet ----------
|
||||
The 16px minimum and the 44px touch targets were both written into the phone
|
||||
media query, so every iPad size missed them — measured at 768, 834 and 1024 wide,
|
||||
where all ten setup fields sat at 13.3px. iOS zooms the page whenever a field
|
||||
under 16px takes focus, so a dealer on an iPad was getting the table zoomed every
|
||||
time they touched the blind amount, then pinching back out. The reason for the
|
||||
rule was never "narrow screen", it was "iOS", and an iPad is iOS.
|
||||
|
||||
Font size applies everywhere now — 13.3px was small for a settings field on a
|
||||
desktop anyway. The touch sizing keys off `hover: none`, which is what actually
|
||||
distinguishes a finger from a mouse; a tablet gets thumb-sized controls and a
|
||||
desktop keeps its compact sidebar. */
|
||||
.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody select,
|
||||
.panelBody textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody select {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,14 +153,33 @@ const Betting = {
|
||||
const delta = this.round(target - this.committed(player));
|
||||
const moved = this.putIn(state, index, delta);
|
||||
const newBet = this.committed(player);
|
||||
const previousBet = state.currentBet || 0;
|
||||
|
||||
if (newBet > previousBet) {
|
||||
const increase = this.round(newBet - previousBet);
|
||||
// What a full raise would have had to be: at least as much again as the
|
||||
// last one, or the big blind if there hasn't been a raise yet.
|
||||
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
|
||||
const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS;
|
||||
|
||||
if (newBet > (state.currentBet || 0)) {
|
||||
state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
|
||||
state.currentBet = newBet;
|
||||
// A raise reopens the action: everyone else owes a response again.
|
||||
state.seats.forEach((p, i) => {
|
||||
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
|
||||
});
|
||||
|
||||
if (isFullRaise) {
|
||||
state.lastRaiseSize = increase;
|
||||
// A real raise reopens the action: everyone else owes a response again.
|
||||
state.seats.forEach((p, i) => {
|
||||
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
|
||||
});
|
||||
}
|
||||
// Otherwise this is a short all-in — somebody with less than a full raise
|
||||
// left shoving it in. The most misread rule in poker: it does *not* reopen
|
||||
// the betting to anyone who has already acted. They owe the difference or
|
||||
// they fold; they don't get to raise again because a short stack happened
|
||||
// to be between them. Players yet to act keep their full rights, which
|
||||
// they do here because their hasActed is already false.
|
||||
//
|
||||
// lastRaiseSize deliberately isn't lowered either, or the next legal raise
|
||||
// would be computed off the short amount and come out too small.
|
||||
}
|
||||
|
||||
player.hasActed = true;
|
||||
|
||||
@@ -318,15 +318,34 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("nextHandBtn").addEventListener("click", () => {
|
||||
document.getElementById("nextHandBtn").addEventListener("click", async () => {
|
||||
// Moving on wipes every seat's contribution, so an unresolved pot is simply
|
||||
// destroyed — measured at a four-handed table, pressing this with $8 in the
|
||||
// middle took the table from 800 chips to 792, and the toast said nothing
|
||||
// about it. Chips only ever leave this tool because a dealer meant them to.
|
||||
const unresolved = HoldemState.unresolvedContributions(state);
|
||||
|
||||
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||
const ok = await Dialog.confirm({
|
||||
title: `Throw away ${Money.format(unresolved)}?`,
|
||||
message: "There's still money in the middle from this hand. Starting the next one takes it off the table without paying anybody — use Resolve Pot if somebody won it.",
|
||||
confirmLabel: "Throw it away",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
// startNextHand moves the button on by one; say where it landed so the
|
||||
// dealer can confirm the rotation without hunting for the badge.
|
||||
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||
const where = state.dedicatedDealer
|
||||
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||
: `button on seat ${nextDealer + 1}`;
|
||||
const discarded = unresolved > Betting.EPSILON_CHIPS
|
||||
? ` ${Money.format(unresolved)} taken off the table.`
|
||||
: "";
|
||||
|
||||
runUndoable(`Next hand — ${where}. Blinds posted.`, () => {
|
||||
runUndoable(`Next hand — ${where}. Blinds posted.${discarded}`, () => {
|
||||
HoldemState.startNextHand(state);
|
||||
Betting.beginHand(state);
|
||||
});
|
||||
|
||||
@@ -272,11 +272,25 @@ const PotWizard = {
|
||||
});
|
||||
actions.appendChild(applyBtn);
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "holdemBtn holdemBtnGhost";
|
||||
closeBtn.textContent = "Close";
|
||||
closeBtn.addEventListener("click", () => this.close());
|
||||
actions.appendChild(closeBtn);
|
||||
// This button cancels — it does not pay anybody. Under a heading reading
|
||||
// "Final Payouts" with an amount beside a name, "Close" reads like dismissing
|
||||
// something already done, and a dealer who taps it walks away believing the
|
||||
// pot has been pushed. It hasn't: the chips are still in the middle and the
|
||||
// hand isn't logged. So it says what it does.
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "holdemBtn holdemBtnGhost";
|
||||
cancelBtn.textContent = "Cancel — don't pay yet";
|
||||
cancelBtn.addEventListener("click", () => this.close());
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
if (this.totals.size > 0) {
|
||||
const note = document.createElement("p");
|
||||
note.className = "fieldHint potWizardPendingNote";
|
||||
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(note);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(actions);
|
||||
return wrap;
|
||||
|
||||
@@ -16,6 +16,10 @@ const Render = {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.selectedSeatIndex = null;
|
||||
// A reset hands over a different table entirely, so nothing cached about the
|
||||
// last one still applies.
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
@@ -697,30 +701,101 @@ const Render = {
|
||||
netEl.classList.toggle("netUp", row.net > 0);
|
||||
netEl.classList.toggle("netDown", row.net < 0);
|
||||
|
||||
// What they'd actually be handed if the night ended now: chips in front of
|
||||
// them less the cash they gave the banker. This was computed and written to
|
||||
// the CSV all along, but never shown to the person doing the paying out.
|
||||
const cashEl = node.querySelector(".standingsCashOut");
|
||||
cashEl.textContent = Money.formatSigned(row.cashPosition);
|
||||
cashEl.classList.toggle("netUp", row.cashPosition > 0);
|
||||
cashEl.classList.toggle("netDown", row.cashPosition < 0);
|
||||
|
||||
list.appendChild(node);
|
||||
});
|
||||
|
||||
this.renderBankReconciliation();
|
||||
},
|
||||
|
||||
// Does the cash box cover the chips on the table? Nothing asked this before, and
|
||||
// it's the question that decides whether the last two players can be paid.
|
||||
renderBankReconciliation() {
|
||||
const host = document.getElementById("bankReconcile");
|
||||
if (!host) return;
|
||||
|
||||
const bank = HoldemState.bankReconciliation(this.state);
|
||||
// Nothing to reconcile before anybody has handed over money or taken a chip.
|
||||
host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0;
|
||||
if (host.hidden) return;
|
||||
|
||||
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
||||
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
||||
|
||||
const verdict = document.getElementById("bankReconcileVerdict");
|
||||
if (bank.balanced) {
|
||||
verdict.textContent = "Balanced — the cash covers every chip on the table.";
|
||||
} else if (bank.difference > 0) {
|
||||
verdict.textContent =
|
||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||
} else {
|
||||
verdict.textContent =
|
||||
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
|
||||
}
|
||||
verdict.classList.toggle("bankBalanced", bank.balanced);
|
||||
verdict.classList.toggle("bankOff", !bank.balanced);
|
||||
},
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
// toLocaleTimeString is the single most expensive call in the history panel, and a
|
||||
// logged hand's timestamp never changes, so the answer is worth keeping.
|
||||
handTimeCache: new Map(),
|
||||
|
||||
formatHandTime(timestamp) {
|
||||
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
|
||||
let formatted = "";
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
formatted = "";
|
||||
}
|
||||
this.handTimeCache.set(timestamp, formatted);
|
||||
return formatted;
|
||||
},
|
||||
|
||||
renderHandHistory() {
|
||||
// Cheap stand-in for "has the log changed". Entries are immutable once written and
|
||||
// only ever added at the front, so the length, the counter and the newest row
|
||||
// between them catch a recorded hand, a cleared history and an undo.
|
||||
historySignature() {
|
||||
const history = this.state.handHistory || [];
|
||||
const newest = history[0];
|
||||
return [
|
||||
history.length,
|
||||
this.state.handNumber || 1,
|
||||
newest ? newest.handNumber : "",
|
||||
newest ? newest.timestamp : "",
|
||||
newest ? newest.pot : "",
|
||||
].join("|");
|
||||
},
|
||||
|
||||
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
|
||||
// full re-render runs after every fold, call and raise. The log only changes when a
|
||||
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||
// is skipped unless it actually did change. Everything else on the table reflects
|
||||
// the current hand and still redraws every time.
|
||||
renderHandHistory(force = false) {
|
||||
const signature = this.historySignature();
|
||||
if (!force && signature === this.lastHistorySignature) return;
|
||||
this.lastHistorySignature = signature;
|
||||
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
list.innerHTML = "";
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
|
||||
// Built off-document so the panel is laid out once instead of once per row.
|
||||
const fragment = document.createDocumentFragment();
|
||||
const template = document.getElementById("handHistoryRowTemplate");
|
||||
history.forEach(entry => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -731,8 +806,10 @@ const Render = {
|
||||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
|
||||
list.appendChild(row);
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
list.innerHTML = "";
|
||||
list.appendChild(fragment);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -167,6 +167,38 @@ const HoldemState = {
|
||||
return !player.inHand;
|
||||
},
|
||||
|
||||
// What starting the next hand would take off the table for good: exactly the
|
||||
// fields startNextHand clears.
|
||||
//
|
||||
// Deliberately not totalPot(). The table-wide base pot is a standing ante — a
|
||||
// stand-in for "everyone puts the same in every hand", used by a dealer who
|
||||
// isn't tracking stacks — so it re-applies to the next hand rather than being
|
||||
// lost with this one. Counting it would mean warning about money that isn't
|
||||
// going anywhere, on every single hand, for exactly the dealers who chose the
|
||||
// simplest way to run the table.
|
||||
unresolvedContributions(state) {
|
||||
const total = state.seats.reduce((sum, player) => {
|
||||
const override = player.amountOverride;
|
||||
let banked = 0;
|
||||
|
||||
if (override !== null && override !== undefined && String(override).trim() !== "") {
|
||||
const parsed = Number(override);
|
||||
if (!Number.isNaN(parsed)) banked = Math.max(0, parsed);
|
||||
}
|
||||
|
||||
if (banked === 0) {
|
||||
banked = (state.chipLegend || []).reduce((chips, chip) => {
|
||||
const count = Number(player.chipCounts[chip.id]) || 0;
|
||||
return chips + count * Number(chip.value || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return sum + banked + Math.max(0, Number(player.committed) || 0);
|
||||
}, 0);
|
||||
|
||||
return Money.round(total);
|
||||
},
|
||||
|
||||
totalPot(state) {
|
||||
return state.seats.reduce(
|
||||
(sum, p) => sum + this.computeContribution(p, state.chipLegend, state.basePotAmount),
|
||||
@@ -352,6 +384,23 @@ const HoldemState = {
|
||||
// all, so they're read off the seat rather than summed — a rebuy isn't a hand.
|
||||
const seatById = new Map(state.seats.map(p => [p.id, p]));
|
||||
|
||||
// Anyone who has money in the game belongs in the settle-up, whether or not
|
||||
// they've been in a logged hand yet. Building the list from the hand log alone
|
||||
// left a player who bought in and then sat through a few hands folding —
|
||||
// or who just sat down — missing entirely, so the banker's own record didn't
|
||||
// mention the cash they'd taken. They turn up at cash-out holding chips the
|
||||
// list never accounted for.
|
||||
state.seats.forEach((player, index) => {
|
||||
if (totals.has(player.id)) return;
|
||||
const hasMoneyInPlay =
|
||||
(Number(player.buyIns) || 0) > 0 ||
|
||||
(Number(player.stack) || 0) > 0 ||
|
||||
(Number(player.amountOverride) || 0) > 0 ||
|
||||
(Number(player.committed) || 0) > 0;
|
||||
if (!hasMoneyInPlay) return;
|
||||
totals.set(player.id, { id: player.id, name: this.displayName(player, index), in: 0, won: 0 });
|
||||
});
|
||||
|
||||
return Array.from(totals.values())
|
||||
.map(t => {
|
||||
const seat = seatById.get(t.id);
|
||||
@@ -376,6 +425,37 @@ const HoldemState = {
|
||||
})
|
||||
.sort((a, b) => b.net - a.net);
|
||||
},
|
||||
|
||||
// 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
|
||||
// play add up to more than the cash that came in, that promise can't be kept —
|
||||
// and the way you find out is the last two players cashing out and the box being
|
||||
// empty. It's always someone taking chips without paying, a rebuy that never got
|
||||
// recorded, or a miscount, and it's silent until it's too late to work out which.
|
||||
//
|
||||
// Chips in play counts everything a player could hand back: what's in front of
|
||||
// them and what's in the middle waiting to be won.
|
||||
bankReconciliation(state) {
|
||||
const cashIn = state.seats.reduce((sum, p) => sum + Math.max(0, Number(p.buyIns) || 0), 0);
|
||||
const chipsInPlay = state.seats.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Math.max(0, Number(p.stack) || 0) +
|
||||
Math.max(0, Number(p.committed) || 0) +
|
||||
Math.max(0, Number(p.amountOverride) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const difference = Money.round(chipsInPlay - cashIn);
|
||||
return {
|
||||
cashIn: Money.round(cashIn),
|
||||
chipsInPlay: Money.round(chipsInPlay),
|
||||
difference,
|
||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
|
||||
@@ -22,6 +22,32 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
|
||||
The unit tests above cover the money. `uiAudit.js` covers the thing you can only see
|
||||
in a browser: whether a dealer can actually reach every control.
|
||||
|
||||
```bash
|
||||
node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||
```
|
||||
|
||||
It checks touch-action and tap highlights, that fields stay selectable and are large
|
||||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||
phones on their side.
|
||||
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||
this one needs a browser and a running site. Run it after any change that adds or
|
||||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||
call `LayoutAudit.runAll()`.
|
||||
|
||||
It exists because these checks were being re-typed into a console every time
|
||||
something changed, and each time they only covered whatever had just been touched.
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
and kept it for two rounds.
|
||||
|
||||
## Why the randomised suite exists
|
||||
|
||||
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// The banker's job, which is a different job from the dealer's: every chip on the
|
||||
// table is a promise to pay cash for it later, and somebody has to be able to check
|
||||
// that the promise can be kept before the last two players try to cash out.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, Money, makeTable } = require("./load");
|
||||
|
||||
const seat = (state, index, name, cash, chips) => {
|
||||
const p = state.seats[index];
|
||||
p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
|
||||
return p;
|
||||
};
|
||||
|
||||
describe("everyone with money in the game is accounted for", () => {
|
||||
test("a player who has bought in but played no logged hand still appears", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
seat(state, 2, "Cal", 200, 200); // just sat down
|
||||
|
||||
state.seats[0].amountOverride = 10;
|
||||
state.seats[1].amountOverride = 10;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
|
||||
state.seats[0].amountOverride = null;
|
||||
state.seats[1].amountOverride = null;
|
||||
|
||||
const names = HoldemState.sessionStandings(state).map(r => r.name);
|
||||
assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
test("their row reads as no hands played, not as a loss", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 200, 200);
|
||||
|
||||
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
|
||||
assert.equal(cal.in, 0);
|
||||
assert.equal(cal.won, 0);
|
||||
assert.equal(cal.net, 0);
|
||||
assert.equal(cal.boughtIn, 200);
|
||||
assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
|
||||
});
|
||||
|
||||
test("an empty chair is not dragged into the settle-up", () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
const rows = HoldemState.sessionStandings(state);
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test("somebody who has cashed out completely still shows what they took", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 0); // handed every chip back
|
||||
seat(state, 1, "Ben", 100, 200);
|
||||
const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
|
||||
assert.equal(ann.cashPosition, -100, "down the whole buy-in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("does the cash box cover the chips on the table", () => {
|
||||
test("a straight game balances", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 60);
|
||||
seat(state, 1, "Ben", 100, 140);
|
||||
seat(state, 2, "Cal", 100, 100);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.cashIn, 300);
|
||||
assert.equal(bank.chipsInPlay, 300);
|
||||
assert.equal(bank.difference, 0);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("chips outrunning the cash box is caught before cash-out", () => {
|
||||
// The 2am failure: the last players out can't be paid.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.cashIn, 300);
|
||||
assert.equal(bank.chipsInPlay, 460);
|
||||
assert.equal(bank.difference, 160);
|
||||
assert.equal(bank.balanced, false);
|
||||
});
|
||||
|
||||
test("cash taken for chips never handed over is caught too", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.difference, -100);
|
||||
assert.equal(bank.balanced, false);
|
||||
});
|
||||
|
||||
test("chips in the middle still count as chips in play", () => {
|
||||
// Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
|
||||
// would report a shortfall every time a hand was open — which is nearly always.
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
|
||||
|
||||
const before = HoldemState.bankReconciliation(state);
|
||||
assert.equal(before.balanced, true);
|
||||
|
||||
Betting.beginHand(state); // blinds go in
|
||||
Betting.raiseTo(state, 0, 25); // and a raise
|
||||
Betting.sweepToHandTotals(state); // banked into the pot
|
||||
|
||||
const during = HoldemState.bankReconciliation(state);
|
||||
assert.equal(during.balanced, true, "an open pot is not a discrepancy");
|
||||
assert.equal(during.chipsInPlay, before.chipsInPlay);
|
||||
});
|
||||
|
||||
test("a whole session of play never knocks the books out", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
|
||||
|
||||
for (let hand = 0; hand < 6; hand++) {
|
||||
Betting.beginHand(state);
|
||||
Betting.liveSeats(state).forEach(s => {
|
||||
if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index);
|
||||
});
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = HoldemState.totalPot(state);
|
||||
const winner = Betting.liveSeats(state)[0];
|
||||
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
|
||||
HoldemState.recordHand(state, payouts);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a rebuy keeps the books straight, a chip grab doesn't", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 100, 100);
|
||||
seat(state, 1, "Ben", 100, 100);
|
||||
|
||||
// A proper rebuy: cash to the banker, chips to the player.
|
||||
state.seats[0].buyIns += 100;
|
||||
state.seats[0].stack += 100;
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
|
||||
// Chips taken without paying.
|
||||
state.seats[1].stack += 50;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.balanced, false);
|
||||
assert.equal(bank.difference, 50);
|
||||
});
|
||||
|
||||
test("nothing to reconcile before anybody has bought in", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
assert.equal(bank.cashIn, 0);
|
||||
assert.equal(bank.chipsInPlay, 0);
|
||||
assert.equal(bank.balanced, true);
|
||||
});
|
||||
|
||||
test("float dust is not a discrepancy", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
|
||||
seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
|
||||
seat(state, 1, "Ben", 10, 10);
|
||||
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// The hand-history panel is only rebuilt when its signature changes, which takes a
|
||||
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
|
||||
// noticing every way the log can actually change — a stale panel would have the
|
||||
// dealer reading last hand's payouts while settling this one.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// historySignature() reads Render.state, so point it at a table for each case.
|
||||
const signatureOf = state => {
|
||||
Render.state = state;
|
||||
return Render.historySignature();
|
||||
};
|
||||
|
||||
const tableWithHands = (count) => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
for (let i = 0; i < count; i++) {
|
||||
state.seats[0].amountOverride = 2;
|
||||
state.seats[1].amountOverride = 2;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the history panel knows when to redraw", () => {
|
||||
test("nothing changes while a hand is being played", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
|
||||
// A whole street of betting touches no part of the log.
|
||||
state.seats[0].committed = 5;
|
||||
state.seats[1].folded = true;
|
||||
state.actingSeatIndex = 2;
|
||||
state.street = "flop";
|
||||
|
||||
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
|
||||
});
|
||||
|
||||
test("settling a hand changes it", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("clearing the history changes it", () => {
|
||||
const state = tableWithHands(5);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.clearHistory(state);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("undoing back to a shorter log changes it", () => {
|
||||
const state = tableWithHands(2);
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
|
||||
const afterRecord = signatureOf(state);
|
||||
|
||||
// The undo path swaps the state's contents in place.
|
||||
Object.keys(state).forEach(k => delete state[k]);
|
||||
Object.assign(state, snapshot);
|
||||
|
||||
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
|
||||
});
|
||||
|
||||
test("two hands that differ only in payout are told apart", () => {
|
||||
const a = tableWithHands(1);
|
||||
const b = tableWithHands(1);
|
||||
// Same count and hand number; different pot.
|
||||
b.handHistory[0].pot = a.handHistory[0].pot + 25;
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
});
|
||||
|
||||
test("the hand counter moving on changes it", () => {
|
||||
// The counter badge is drawn by the same function, so a new hand number has
|
||||
// to invalidate even when the log itself is untouched.
|
||||
const state = tableWithHands(2);
|
||||
const before = signatureOf(state);
|
||||
state.handNumber += 1;
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("an empty log has a stable signature of its own", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1] });
|
||||
const first = signatureOf(state);
|
||||
assert.equal(signatureOf(state), first);
|
||||
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand-time cache", () => {
|
||||
test("the same timestamp is only formatted once, and formats the same", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const stamp = Date.UTC(2026, 8, 1, 15, 4);
|
||||
const first = Render.formatHandTime(stamp);
|
||||
assert.equal(Render.handTimeCache.size, 1);
|
||||
|
||||
const second = Render.formatHandTime(stamp);
|
||||
assert.equal(second, first);
|
||||
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
|
||||
});
|
||||
|
||||
test("different timestamps still format differently", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
|
||||
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(Render.handTimeCache.size, 2);
|
||||
});
|
||||
|
||||
test("a nonsense timestamp doesn't throw", () => {
|
||||
Render.handTimeCache.clear();
|
||||
assert.doesNotThrow(() => Render.formatHandTime(undefined));
|
||||
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
const HandHistoryExport = load("handHistoryExport");
|
||||
|
||||
// render.js exports { Render } rather than the namespace directly.
|
||||
const Render = require(path.join(SRC, "render.js")).Render;
|
||||
globalThis.Render = Render;
|
||||
|
||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
globalThis.HoldemState = HoldemState;
|
||||
@@ -69,6 +73,7 @@ function contributions(state) {
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
SplitCalculator,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Chips only ever leave this tool because a dealer meant them to. Starting the next
|
||||
// hand clears every seat's contribution, so an unresolved pot is simply destroyed —
|
||||
// the UI now asks first, and the number it quotes has to be exactly what goes.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, Money, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
// The same measure the confirmation uses.
|
||||
const unresolvedPot = state => HoldemState.unresolvedContributions(state);
|
||||
|
||||
describe("what starting the next hand destroys", () => {
|
||||
test("the quoted amount is exactly what leaves the table", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3); Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const before = chipsOnTable(state);
|
||||
const quoted = unresolvedPot(state);
|
||||
assert.ok(quoted > 0, "there is a pot to warn about");
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(
|
||||
Money.toCents(chipsOnTable(state)),
|
||||
Money.toCents(before - quoted),
|
||||
"the dealer must be told the real number, not an approximation"
|
||||
);
|
||||
});
|
||||
|
||||
test("chips still in front of players on the current street are counted too", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state); // blinds are `committed`, not yet banked
|
||||
Betting.raiseTo(state, 0, 25); // and so is a raise mid-street
|
||||
|
||||
const before = chipsOnTable(state);
|
||||
const quoted = unresolvedPot(state);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(Money.toCents(chipsOnTable(state)), Money.toCents(before - quoted));
|
||||
assert.ok(quoted >= 25, `a live raise has to be included; quoted ${quoted}`);
|
||||
});
|
||||
|
||||
test("a hand that was paid out properly leaves nothing to warn about", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 0); Betting.call(state, 1); Betting.check(state, 2);
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = HoldemState.totalPot(state);
|
||||
const payouts = [{ id: state.seats[0].id, label: "Ann", amount: pot }];
|
||||
HoldemState.recordHand(state, payouts);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(unresolvedPot(state), 0, "nothing left over after a settled hand");
|
||||
});
|
||||
|
||||
test("settling then dealing on conserves every chip", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
const start = chipsOnTable(state);
|
||||
|
||||
for (let hand = 0; hand < 5; hand++) {
|
||||
Betting.beginHand(state);
|
||||
const live = Betting.liveSeats(state);
|
||||
live.forEach(s => { if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index); });
|
||||
Betting.sweepToHandTotals(state);
|
||||
|
||||
const pot = HoldemState.totalPot(state);
|
||||
const winner = Betting.liveSeats(state)[0];
|
||||
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
|
||||
HoldemState.recordHand(state, payouts);
|
||||
Betting.creditWinnings(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), start, `chips drifted on hand ${hand + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("an empty table has no phantom pot to warn about", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [] });
|
||||
assert.equal(unresolvedPot(state), 0);
|
||||
});
|
||||
|
||||
test("a standing ante is never warned about, because it isn't lost", () => {
|
||||
// The table default is a stand-in for "everyone puts the same in every hand",
|
||||
// used by a dealer not tracking stacks at all. It re-applies to the next hand
|
||||
// rather than going anywhere, so counting it would nag this dealer before
|
||||
// every single hand — which is why the warning measures the fields
|
||||
// startNextHand actually clears rather than the pot badge.
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1] });
|
||||
state.basePotAmount = 25;
|
||||
state.seats.forEach(p => { p.amountOverride = null; });
|
||||
|
||||
// The base pot carries into the next hand, so nothing is being thrown away
|
||||
// and the dealer must not be asked to confirm anything.
|
||||
assert.equal(HoldemState.totalPot(state), 50, "the badge still shows a pot");
|
||||
assert.equal(unresolvedPot(state), 0, "but none of it is at risk, so no warning");
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
assert.equal(HoldemState.totalPot(state), 50, "the standing ante applies to the new hand too");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// When does a bet reopen the action? The most misread rule in poker, and the one a
|
||||
// home game argues about longest: a player shoving in less than a full raise does not
|
||||
// give everybody behind them another go.
|
||||
//
|
||||
// TDA Rule 44 — an all-in wager of less than a full raise does not reopen the betting
|
||||
// to a player who has already acted and is not facing a full raise.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const actedFlags = state => state.seats
|
||||
.map((p, i) => ({ seat: i, acted: p.hasActed, live: p.inHand && !p.folded }))
|
||||
.filter(s => s.live);
|
||||
|
||||
describe("a short all-in does not reopen the betting", () => {
|
||||
// Blinds 1/2. Seat 4 raises to 10 (a full raise of 8). Seat 1 calls.
|
||||
// Seat 2 is all-in for 13 — three more, where a full raise needs eight.
|
||||
const shortAllInTable = () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 13;
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
Betting.call(state, 0);
|
||||
return state;
|
||||
};
|
||||
|
||||
test("players who already acted are not asked to act again", () => {
|
||||
const state = shortAllInTable();
|
||||
assert.equal(state.seats[0].hasActed, true, "seat 1 has called");
|
||||
assert.equal(state.seats[3].hasActed, true, "seat 4 has raised");
|
||||
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(state.seats[0].hasActed, true, "seat 1 must not be reopened");
|
||||
assert.equal(state.seats[3].hasActed, true, "seat 4 must not be reopened");
|
||||
});
|
||||
|
||||
test("but they do still owe the difference", () => {
|
||||
const state = shortAllInTable();
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(Betting.toCall(state, state.seats[0]), 3, "seat 1 owes the extra 3");
|
||||
assert.equal(Betting.isStreetComplete(state), false, "so the street isn't over");
|
||||
});
|
||||
|
||||
test("a player who had not acted yet keeps full rights", () => {
|
||||
const state = shortAllInTable();
|
||||
assert.equal(state.seats[2].hasActed, false, "seat 3 hasn't acted");
|
||||
Betting.allIn(state, 1);
|
||||
assert.equal(state.seats[2].hasActed, false, "and still hasn't — they may still raise");
|
||||
});
|
||||
|
||||
test("the minimum raise is not shrunk by the short all-in", () => {
|
||||
const state = shortAllInTable();
|
||||
assert.equal(state.lastRaiseSize, 8);
|
||||
|
||||
Betting.allIn(state, 1);
|
||||
|
||||
assert.equal(state.lastRaiseSize, 8, "still the last *full* raise");
|
||||
assert.equal(Betting.minRaiseTo(state), 21, "13 to call plus a full raise of 8");
|
||||
});
|
||||
|
||||
test("calling the short all-in ends the street", () => {
|
||||
const state = shortAllInTable();
|
||||
Betting.allIn(state, 1);
|
||||
Betting.call(state, 2); // seat 3, who had full rights, just calls
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(Betting.isStreetComplete(state), true);
|
||||
});
|
||||
|
||||
test("no chips are created or lost by any of it", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 13;
|
||||
const start = chipsOnTable(state);
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
Betting.call(state, 0);
|
||||
Betting.allIn(state, 1);
|
||||
Betting.call(state, 2);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(chipsOnTable(state), start);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a full raise still reopens the betting", () => {
|
||||
test("an all-in that is a full raise or more reopens everyone", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
state.seats[1].stack = 40;
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10);
|
||||
Betting.call(state, 0);
|
||||
|
||||
Betting.allIn(state, 1); // to 40: an increase of 30, well over the 8 needed
|
||||
|
||||
assert.equal(state.seats[0].hasActed, false, "seat 1 gets another go");
|
||||
assert.equal(state.seats[3].hasActed, false, "and so does seat 4");
|
||||
assert.equal(state.lastRaiseSize, 30);
|
||||
assert.equal(Betting.minRaiseTo(state), 70);
|
||||
});
|
||||
|
||||
test("an ordinary raise reopens everyone", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
assert.equal(state.seats[3].hasActed, true);
|
||||
|
||||
Betting.raiseTo(state, 1, 10);
|
||||
|
||||
assert.equal(state.seats[3].hasActed, false);
|
||||
assert.equal(state.seats[0].hasActed, false);
|
||||
});
|
||||
|
||||
test("a raise exactly the size of the last one counts as full", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.raiseTo(state, 3, 10); // raise of 8
|
||||
Betting.call(state, 0);
|
||||
|
||||
Betting.raiseTo(state, 1, 18); // exactly 8 more — a legal minimum raise
|
||||
|
||||
assert.equal(state.seats[0].hasActed, false, "a minimum raise still reopens");
|
||||
assert.equal(state.lastRaiseSize, 8);
|
||||
});
|
||||
|
||||
test("the first raise of a street is measured against the big blind", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
Betting.call(state, 0);
|
||||
Betting.call(state, 1);
|
||||
Betting.check(state, 2);
|
||||
Betting.closeStreet(state); // to the flop, no bet yet
|
||||
|
||||
const first = Betting.nextToAct(state, state.dealerSeatIndex);
|
||||
Betting.raiseTo(state, first, 2); // a bet of exactly one big blind
|
||||
|
||||
const others = state.seats.filter((p, i) => p.inHand && !p.folded && i !== first);
|
||||
assert.ok(others.every(p => p.hasActed === false), "a full-sized opening bet reopens everyone");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about.
|
||||
//
|
||||
// node tests/holdem/runUiAudit.js [baseUrl]
|
||||
//
|
||||
// Needs Playwright available (`npx playwright install chromium` once). It is
|
||||
// deliberately NOT wired into the unit-test suite or CI, because that suite is
|
||||
// dependency-free and runs in a second — this one needs a browser and a running
|
||||
// site. Run it after any change that adds or resizes a control.
|
||||
//
|
||||
// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools,
|
||||
// and call LayoutAudit.runAll(). Same checks, one viewport.
|
||||
const path = require("path");
|
||||
|
||||
const BASE = process.argv[2] || "http://localhost:5299";
|
||||
const AUDIT = path.join(__dirname, "uiAudit.js");
|
||||
|
||||
// Portrait phones, tablets, desktops, then the same phones on their side — landscape
|
||||
// is where this layout has broken most often, because the table is sized from width
|
||||
// and the action bar is fixed to a very short viewport.
|
||||
const VIEWPORTS = [
|
||||
[360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"],
|
||||
[412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"],
|
||||
[768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"],
|
||||
[1280, 900, "desktop"], [1600, 1000, "wide desktop"],
|
||||
[640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"],
|
||||
];
|
||||
|
||||
const OVERLAYS = [
|
||||
["rulings", () => document.getElementById("openRulingsBtn").click()],
|
||||
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
|
||||
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
|
||||
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
|
||||
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
|
||||
];
|
||||
|
||||
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
||||
function seedTable() {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
|
||||
.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
for (let hand = 1; hand <= 4; hand++) {
|
||||
state.handHistory.push({
|
||||
handNumber: hand, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require("playwright"));
|
||||
} catch {
|
||||
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
||||
console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll().");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
let checks = 0;
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
|
||||
warnings.forEach(w => console.log(` note: ${w}`));
|
||||
if (failures.length === 0) {
|
||||
console.log("clean");
|
||||
return;
|
||||
}
|
||||
failures.forEach(f => {
|
||||
console.log(`\nFAIL ${f.where}`);
|
||||
f.offenders.forEach(o => console.log(` ${o}`));
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,278 @@
|
||||
// Layout and touch audits for the dealer, written once so they stop being written
|
||||
// again every round.
|
||||
//
|
||||
// These checks have been re-typed into a browser console at least six times over
|
||||
// the course of building this thing, and each time they only covered whatever had
|
||||
// just been changed. That is exactly how the standings table shipped with a
|
||||
// zero-pixel player-name column and survived two rounds: the audit that would have
|
||||
// caught it existed only in a previous session's scrollback.
|
||||
//
|
||||
// This file is deliberately dependency-free and browser-side. It is injected into
|
||||
// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
|
||||
// package.json, and no browser automation of its own — anyone can also paste it
|
||||
// into devtools and call LayoutAudit.runAll().
|
||||
const LayoutAudit = {
|
||||
// Everything a dealer has to be able to hit.
|
||||
CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
|
||||
|
||||
describe(el) {
|
||||
if (!el) return "(nothing)";
|
||||
const id = el.id ? `#${el.id}` : "";
|
||||
const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
|
||||
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
|
||||
return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
|
||||
},
|
||||
|
||||
visibleControls() {
|
||||
return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
const style = getComputedStyle(el);
|
||||
return style.visibility !== "hidden" && style.display !== "none";
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Touch (see the mobile round) ----
|
||||
|
||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||
// deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
|
||||
// seat as its fold shortcut, so that wait is on the critical path.
|
||||
touchAction() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => getComputedStyle(el).touchAction === "auto")
|
||||
.map(el => this.describe(el));
|
||||
return { name: "touch-action", offenders, checked: this.visibleControls().length };
|
||||
},
|
||||
|
||||
tapHighlight() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => {
|
||||
const colour = getComputedStyle(el).webkitTapHighlightColor;
|
||||
return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
|
||||
})
|
||||
.map(el => this.describe(el));
|
||||
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
|
||||
},
|
||||
|
||||
// Text selection must stay ON for anything a dealer types into.
|
||||
textSelectable() {
|
||||
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
|
||||
.filter(el => el.offsetParent !== null)
|
||||
.filter(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return (style.webkitUserSelect || style.userSelect) === "none";
|
||||
})
|
||||
.map(el => this.describe(el));
|
||||
return { name: "fields stay selectable", offenders };
|
||||
},
|
||||
|
||||
// ---- Reachability (see the occlusion round) ----
|
||||
|
||||
// A control covered by something else is unreachable however good it looks.
|
||||
// Sampling several points matters: a button you can only hit in one corner is
|
||||
// covered for practical purposes.
|
||||
// Can this element be brought into view by scrolling? Something below the fold on
|
||||
// a page that scrolls is perfectly reachable; something below the fold in a panel
|
||||
// that *doesn't* scroll is lost. The first version of this check didn't make the
|
||||
// distinction and reported every link under the fold as a fault.
|
||||
reachableByScrolling(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (getComputedStyle(el).position === "fixed") return false;
|
||||
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
|
||||
if (!scrolls) continue;
|
||||
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
|
||||
const room = node === document.body
|
||||
? document.documentElement.scrollHeight - innerHeight - scrollY
|
||||
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
||||
if (rect.top >= box.bottom && room > 0) return true;
|
||||
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||
const offenders = [];
|
||||
const warnings = [];
|
||||
[...document.querySelectorAll(selector)].forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
|
||||
rect.right <= 0 || rect.left >= innerWidth;
|
||||
if (outsideViewport) {
|
||||
if (!this.reachableByScrolling(el)) {
|
||||
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const points = [
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
|
||||
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.3],
|
||||
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.7],
|
||||
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.7],
|
||||
].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight);
|
||||
if (points.length === 0) return;
|
||||
|
||||
let blocked = 0;
|
||||
let testable = 0;
|
||||
let blockedBy = null;
|
||||
points.forEach(([x, y]) => {
|
||||
const top = document.elementFromPoint(x, y);
|
||||
// A null result means the point isn't hit-testable — typically it sits
|
||||
// inside the element but outside a scrollable ancestor's clip, i.e. the
|
||||
// control is scrolled out of view rather than covered by anything.
|
||||
// Counting that as "covered by null" was reporting a perfectly
|
||||
// reachable button in a sheet that scrolls.
|
||||
if (!top) return;
|
||||
testable++;
|
||||
if (el.contains(top) || top === el || top.contains(el)) return;
|
||||
// Seats ring a round table and overlap each other at the rim by
|
||||
// design; that is crowding, not an overlay hiding a control.
|
||||
if (top.closest && top.closest(".seat")) return;
|
||||
blocked++;
|
||||
if (!blockedBy) blockedBy = this.describe(top);
|
||||
});
|
||||
|
||||
if (testable === 0) {
|
||||
if (!this.reachableByScrolling(el)) {
|
||||
offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (blocked === 0) return;
|
||||
|
||||
const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`;
|
||||
|
||||
// A toast is transient by design: it dismisses itself and carries a close
|
||||
// button, and on a desktop it necessarily lands over a table that fills the
|
||||
// content column. Partial overlap from one is a fact of the layout, not a
|
||||
// fault. Being *fully* covered by a toast is still a failure though — that
|
||||
// was a real bug once, where a seat sat exactly under the Undo button and a
|
||||
// tap meant for a player took back the last action instead.
|
||||
const transient = blockedBy && /toast/i.test(blockedBy);
|
||||
if (transient && blocked < testable) warnings.push(note);
|
||||
else offenders.push(note);
|
||||
});
|
||||
return { name: "nothing important is covered", offenders, warnings };
|
||||
},
|
||||
|
||||
// ---- Layout (the check that would have caught the standings regression) ----
|
||||
|
||||
// A grid column squeezed to nothing doesn't truncate its text, it erases it.
|
||||
// This is the one that was missing: the player-name column sat at 0px through
|
||||
// two rounds because nothing ever asked whether a column had survived.
|
||||
collapsedColumns() {
|
||||
const offenders = [];
|
||||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
||||
if (getComputedStyle(row).display !== "grid") return;
|
||||
[...row.children].forEach(cell => {
|
||||
if (getComputedStyle(cell).display === "none") return;
|
||||
const hasText = (cell.textContent || "").trim().length > 0;
|
||||
if (hasText && cell.getBoundingClientRect().width < 4) {
|
||||
offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { name: "no column collapsed to nothing", offenders };
|
||||
},
|
||||
|
||||
// Text actually painted outside the box meant to contain it — which on a table of
|
||||
// overlapping seats means painted on top of a neighbour.
|
||||
//
|
||||
// The clipping check is the whole subtlety, and the first version of this got it
|
||||
// wrong. A Range's bounding rect measures the *laid out* text, which happily runs
|
||||
// past the element when `overflow: hidden` is quietly clipping it — so an
|
||||
// ellipsised name was reported as spilling when nothing was drawn outside at all.
|
||||
// Only overflow that is actually visible can paint over a neighbour.
|
||||
textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") {
|
||||
const offenders = [];
|
||||
[...document.querySelectorAll(selector)].forEach(el => {
|
||||
const text = (el.textContent || "").trim();
|
||||
if (!text) return;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible";
|
||||
if (clipsItself) return;
|
||||
|
||||
const container = el.closest(".seat") || el.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const drawn = range.getBoundingClientRect();
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) {
|
||||
offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`);
|
||||
}
|
||||
});
|
||||
return { name: "no text painted outside its box", offenders };
|
||||
},
|
||||
|
||||
horizontalOverflow() {
|
||||
const offenders = document.documentElement.scrollWidth > innerWidth + 1
|
||||
? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`]
|
||||
: [];
|
||||
return { name: "page never scrolls sideways", offenders };
|
||||
},
|
||||
|
||||
// iOS zooms the whole page when a field under 16px takes focus, and the dealer
|
||||
// is then pinching back out mid-hand.
|
||||
fieldFontSize() {
|
||||
const offenders = [...document.querySelectorAll("input, textarea, select")]
|
||||
.filter(el => el.offsetParent !== null)
|
||||
.filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || ""))
|
||||
.filter(el => parseFloat(getComputedStyle(el).fontSize) < 16)
|
||||
.map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`);
|
||||
return { name: "no field small enough to trigger iOS zoom", offenders };
|
||||
},
|
||||
|
||||
// ---- Reach (touch target sizes) ----
|
||||
|
||||
touchTargets(minimum = 44) {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => el.type !== "color")
|
||||
.filter(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.height < minimum - 0.5 || rect.width < minimum - 0.5;
|
||||
})
|
||||
.map(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return `${this.describe(el)} :: ${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
});
|
||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
||||
},
|
||||
|
||||
// ---- Runner ----
|
||||
|
||||
runAll(options = {}) {
|
||||
const checks = [
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.textSelectable(),
|
||||
this.occlusion(options.occlusionSelector),
|
||||
this.collapsedColumns(),
|
||||
this.textSpill(),
|
||||
this.horizontalOverflow(),
|
||||
this.fieldFontSize(),
|
||||
];
|
||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
||||
|
||||
return {
|
||||
viewport: `${innerWidth}x${innerHeight}`,
|
||||
failed: checks.filter(c => c.offenders.length > 0),
|
||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||
warnings: checks.flatMap(c => c.warnings || []),
|
||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
||||
clean: checks.every(c => c.offenders.length === 0),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = LayoutAudit;
|
||||
}
|
||||
Reference in New Issue
Block a user