Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 f0994a8aed Get to the right ruling in one tap, and cover the string bet
The rulings sheet is opened mid-argument, with players waiting. It was eight rulings
of prose in a row with no way in. Reaching the last one meant scrolling:

  1.6 screens on a desktop
  2.1 on a phone
  3.5 on a phone held sideways

A dealer looking for the odd-chip rule had to read past seven others to find it.

There is an index across the top now, one chip per ruling, and tapping one scrolls its
section into view and puts the keyboard and a screen reader on its heading. Built from
the sections rather than written out beside them, so a ruling added later turns up in
the index without anyone having to remember to add it twice.

And the missing ruling

I went looking for what home games actually argue about rather than guessing, and the
answer that kept coming back alongside misdeals -- which were already here -- was the
string bet. It was the one common dispute the sheet had nothing to say about.

  Betting in one motion - the string bet

  A bet is whatever goes in on the first trip to the stack, unless the amount was
  said out loud first. Reaching back for more chips without having said "raise" is a
  string bet, and only the first motion stands. Saying the number is always enough on
  its own: "raise to sixty" binds whatever order the chips arrive in.

  It's the rule home games invoke most and enforce least, and both are fine -- the
  point is that a player can't watch the table react and then decide how much to bet.
  Say it, or push it all at once.

That second paragraph is deliberate. The sheet's whole stance is that house rules win,
so a ruling that tells a home game it must enforce something would be the wrong voice.

What it costs: the index is 342px at the top of a 390px-wide sheet, so a dealer who
wants to read straight through now scrolls past it first. That is the trade -- it is a
jump table you use once and then read, and the alternative was scrolling blind.

Driven through at four sizes across touch and pointer contexts: nine rulings, nine
index entries, every one of them 44px on touch, tapping the last one lands it on screen
and focused. Focus stays inside the sheet across fourteen tabs, Enter on a chip jumps,
Escape closes, and opening it three times still builds nine entries rather than
twenty-seven. Contrast clean, no console errors, 244 tests still passing.

Sources: cardplayerlifestyle.com and pokernews on string bets; the sheet still follows
the TDA rules it cites.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:21:40 -06:00
jheapsandClaude Opus 5 ddb3907ace Say the payout in chips, not just in money
"Ann - $135" is the answer to the arithmetic. The dealer's actual job at that moment
is working out that $135 is a black, a green and two reds, while four people watch
them do it.

The chip legend has known the denominations all along. It was only being used to read
chips *into* an amount -- counting a seat's stack -- and never to turn an amount back
into chips.

  Final Payouts
    Ann - $135
    1x$100, 1x$25, 2x$5

Largest first, which is greedy and so not optimal for an arbitrary set of values. Poker
chips are not arbitrary: every real set is canonical -- each denomination divides the
next, 1/5/25/100/500 -- and greedy is exactly optimal on those. A dealer who invents a
3-and-7 chip set still gets a valid answer, possibly one chip longer than it needed to
be, which is a fair trade for not solving a knapsack in a modal.

Two cases where saying nothing is the right answer, and one where it is not.

A table with no chip legend gets the amount on its own -- it has not told us its
denominations, so there is no advice to give. But a legend that exists and cannot reach
the amount does say so: "$3 no chip covers" when the smallest chip is $5. Half a dollar
in a dollar-chip game is the dealer's problem to solve and they can only solve it if
they are told, so it is never rounded away.

12 tests (244 total): the breakdown adds up to the amount across eight amounts, cent
chips make cent amounts exactly, a jumbled legend still pays largest first, the colour
comes along for a future caller, and the two no-advice cases are told apart from the
cannot-reach one.

Driven through a real all-in hand at four sizes: $135 reads "1x$100, 1x$25, 2x$5" and
a tournament-sized $1,350 reads "2x$500, 3x$100, 2x$25", fitting without spill down to
360px. With the legend emptied the line disappears and the payout stands alone.
Contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:08:49 -06:00
jheapsandClaude Opus 5 3fe16fa7f2 Let a phone move a player to another seat
The hint under the table has said this on every screen since the gestures went in:

  Tap a seat to edit - double-tap to fold - press and hold for quick actions -
  drag to swap seats.

Three of those work on a phone. The fourth never has. Swapping seats is HTML5
drag-and-drop -- draggable="true", dragstart, drop -- and a touch screen does not
generate drag events at all. So the one clause written in the same breath as "tap"
and "press and hold" was the one thing a thumb could never do.

I found it by dragging seat 1 onto seat 4 with real touch pointer events and watching
nothing happen: no swap, no menu, no error.

Moving a seat is in the long-press menu now, which is the route a thumb already takes
for everything else. "Move to..." replaces the action list with the other chairs, by
number and name, and picking one makes the move:

  Move Ben to...
  Their chips, cards and bet go with them. The button and the blinds stay on the chair.
    Seat 1 - Ann     Seat 3 - Cal     Seat 4 - Dee     Seat 5 - empty ...

That subtitle is the rule, and it is the one a card room uses: the whole seat travels,
while everything naming a position stays put. Somebody who sits down in the big
blind's chair is the big blind.

Driven through on a phone: Ben, small blind, $109 behind with $1 posted, moves to
seat 5 with both numbers; seat 2 goes empty; the button stays on seat 1; the toast
reads "Ben moved to seat 5" and undo puts it all back. Reopening the menu on another
seat shows actions again rather than the chair list it closed on.

The hint tells the truth per device now -- dragging is only mentioned where a mouse
exists. Dragging still works there, checked.

8 tests (232 total): the whole seat moves, the bet moves with it, chips are conserved,
the button and the blinds stay on their chairs, swapping twice is a no-op, and a chair
that is not there is refused rather than swallowed.

One audit fix rides along. The "one dealer button" check counted the empty slot that a
hovered seat deliberately fades in, so it reported two buttons whenever a mouse
happened to rest on the table -- which is most of the time a mouse exists. It ignores
the hovered seat now, and still catches the real fault: with the pre-#62 behaviour put
back it reports six, or five while hovering.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:54:03 -06:00
jheapsandClaude Opus 5 f199148046 A pre-filled amount is a suggestion, so reaching for the box replaces it
Every amount box in this tool opens pre-filled: the raise pad offers the minimum, Add
chips offers a buy-in, the seat editor offers the stack a player already has. A dealer
taps one because they want a different number -- and a tap puts the caret after the
suggestion rather than over it.

Driven on an emulated phone:

  raise pad shows 4      tap, type "60"    -> 460
  add chips shows 200    tap, type "50"    -> 20050
  seat stack shows 200   tap, type "175"   -> 200175

The raise pad is the most time-critical control in the tool, and 460 is a number the
dealer never said. The all-in warning happens to catch that one against a $200 stack;
against a deeper stack it is simply accepted, and the pot is wrong from then on.

Number boxes now select what is in them when they take focus, so the first digit
typed replaces the suggestion.

  raise pad     4 -> "60"
  add chips   200 -> "50"
  seat stack  200 -> "175"
  a name      Cal -> "CalX"

That last line is the point of the rule. Only number boxes: type=number, or the
type=text/inputmode=decimal pair the raise pad uses to get a keypad without spinner
arrows. A name or a note is a thing you edit, and selecting all of it on focus would
be the same mistake pointing the other way.

Delegated from the document rather than wired per field. The raise pad, the add-chips
sheet and the seat editor all rebuild their inputs on every render, and a box added
later should not have to remember to ask for this.

The select is deferred a tick. Doing it inside the focus handler loses the race with
the browser placing its own caret, and the selection collapses again the moment the
tap completes.

10 tests (224 total) on the predicate, including the ones that must stay false --
name, note, colour swatch, checkbox, select, and an input with no type stated.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:40:04 -06:00
12 changed files with 555 additions and 8 deletions
@@ -294,6 +294,8 @@
not during it.
</p>
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
<section class="ruling">
<h3>Redeal it — misdeal</h3>
<ul>
@@ -383,6 +385,22 @@
</p>
</section>
<section class="ruling">
<h3>Betting in one motion — the string bet</h3>
<p>
A bet is whatever goes in on the first trip to the stack, unless the
amount was said out loud first. Reaching back for more chips without
having said "raise" is a string bet, and only the first motion stands.
Saying the number is always enough on its own: "raise to sixty" binds
whatever order the chips arrive in.
</p>
<p>
It's the rule home games invoke most and enforce least, and both are
fine — the point is that a player can't watch the table react and then
decide how much to bet. Say it, or push it all at once.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
@@ -1095,6 +1095,51 @@ button,
line-height: 1.5;
}
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
a dealer scanning this is looking for the words somebody at the table just said. */
.rulingsIndex {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 1rem;
}
.rulingsIndexBtn {
box-sizing: border-box;
min-height: 32px;
padding: 0.3rem 0.6rem;
border-radius: 999px;
border: 1px solid #3a3a3a;
background: #232323;
color: #d8b46a;
font-family: inherit;
font-size: 0.72rem;
line-height: 1.25;
text-align: left;
cursor: pointer;
}
@media (hover: hover) {
.rulingsIndexBtn:hover {
background: #2e2e2e;
border-color: #4a4a4a;
}
}
/* A thumb needs the same 44px here as everywhere else. */
@media (pointer: coarse) {
.rulingsIndexBtn {
min-height: 44px;
font-size: 0.78rem;
}
}
/* The heading the index jumps to takes focus, and a focus ring on a heading that the
dealer just asked for reads as noise rather than as help. */
.ruling h3:focus {
outline: none;
}
.ruling {
margin-bottom: 1rem;
padding-left: 0.7rem;
@@ -2603,6 +2648,16 @@ body.hasActionBar #toastHost {
color: #d6ffe0;
}
/* The chips to push, under the amount they add up to. Quieter than the payout it
belongs to — the dealer reads the name and the money first, then counts. */
.payoutChips {
display: block;
margin-top: 0.15rem;
font-size: 0.76rem;
color: #9fb8a6;
font-variant-numeric: tabular-nums;
}
.potWizardActions {
display: flex;
gap: 0.6rem;
+72 -1
View File
@@ -96,6 +96,19 @@ document.addEventListener("DOMContentLoaded", () => {
});
},
// Two players change chairs. Undoable like every other seat action, because a
// swap is easy to aim wrong and the seats look alike at a glance.
swapSeats: (from, to) => {
const movedName = nameOf(from);
const intoName = nameOf(to);
const wasEmpty = Render.isEmptySeat(state.seats[to]);
runUndoable(
wasEmpty
? `${movedName} moved to seat ${to + 1}.`
: `${movedName} and ${intoName} changed seats.`,
() => HoldemState.swapSeats(state, from, to));
},
makeDealer: index => {
// Before the deal there's nothing posted, so this is just moving a marker.
// After it, the blinds have to follow the button or the badges and the
@@ -394,6 +407,29 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show("Table reset.", { duration: 2500 });
});
// A pre-filled amount is a suggestion, so reaching for the box replaces it.
//
// Delegated from the document rather than wired per field: the raise pad, the
// add-chips sheet and the seat editor all build their inputs fresh every render,
// and a box added later should not have to remember to ask for this.
document.addEventListener("focusin", event => {
const field = event.target;
if (!Money.selectsOnFocus(field)) return;
if (!field.value) return;
// After the browser has finished placing its own caret, or it wins the race
// and the selection collapses again the moment the tap completes.
setTimeout(() => {
if (document.activeElement !== field) return;
try {
field.select();
} catch {
// A number input refuses setSelectionRange in some browsers; select()
// is the one that works on both, and if neither does the field is
// simply left as it was.
}
}, 0);
});
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
[
@@ -422,7 +458,42 @@ document.addEventListener("DOMContentLoaded", () => {
// Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
// An index across the top of the rulings sheet, one chip per ruling.
//
// This is opened mid-argument with players waiting, and it was nine rulings of
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
// the odd-chip rule had to read past seven others to find it.
//
// Built from the sections rather than written out beside them, so a ruling added
// later turns up in the index without anyone having to remember to add it twice.
const buildRulingsIndex = () => {
const index = document.getElementById("rulingsIndex");
if (!index || index.childElementCount > 0) return;
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
const heading = section.querySelector("h3");
if (!heading) return;
if (!section.id) section.id = `ruling${i + 1}`;
const jump = document.createElement("button");
jump.type = "button";
jump.className = "rulingsIndexBtn";
jump.textContent = heading.textContent;
jump.addEventListener("click", () => {
section.scrollIntoView({ block: "start", behavior: "smooth" });
// The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
});
index.appendChild(jump);
});
};
document.getElementById("openRulingsBtn").addEventListener("click", () => {
buildRulingsIndex();
rulingsOverlay.hidden = false;
});
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
@@ -48,6 +48,76 @@ const Money = {
return (this.toCents(amount) / 100).toFixed(2);
},
// The chips to actually push, for an amount.
//
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
// still have to work out that $132 is two blacks, a green, a red and two whites
// while six people watch. The chip legend already knows the denominations.
//
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
// values — but poker chips are not arbitrary. Every real set is canonical (each
// denomination divides the next: 1, 5, 25, 100, 500), and greedy is exactly optimal
// on those. A dealer who invents a 3-and-7 chip set gets a valid answer that might
// use one chip more than it had to.
//
// Anything the smallest chip can't make comes back as `remainder` rather than being
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
// problem for the dealer to solve, not a rounding error to hide.
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
const values = (legend || [])
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
.filter(entry => entry.cents > 0)
.sort((a, b) => b.cents - a.cents);
const chips = [];
values.forEach(entry => {
const count = Math.floor(cents / entry.cents);
if (count <= 0) return;
cents -= count * entry.cents;
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
});
return { chips, remainder: this.fromCents(cents) };
},
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
//
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
// hasn't told us its denominations, so there is no advice to give and the amount
// stands on its own. A legend that exists but can't reach the amount is different,
// and does say so.
formatChipBreakdown(amount, legend) {
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
if (!hasDenominations) return "";
const { chips, remainder } = this.chipBreakdown(amount, legend);
if (chips.length === 0 && remainder === 0) return "";
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
return parts.join(", ");
},
// Should focusing this field select what's already in it?
//
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
// it already has. A dealer taps the box because they want a *different* number,
// and a tap puts the caret after the suggestion instead of over it. Measured on a
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
// showing 200, tap, type "50" — 20050.
//
// Only number boxes. A name or a note is a thing you edit, and selecting all of
// it on focus would be the same mistake pointing the other way.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
@@ -246,10 +246,23 @@ const PotWizard = {
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
wrap.appendChild(empty);
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
// The amount is the answer to the arithmetic; the chips are the answer
// to the dealer's job. Only when the legend has been filled in — a
// table that never set up chip colours gets the number on its own,
// which is what it had before.
const chips = Money.formatChipBreakdown(amount, legend);
if (chips) {
const note = document.createElement("span");
note.className = "payoutChips";
note.textContent = chips;
li.appendChild(note);
}
list.appendChild(li);
}
wrap.appendChild(list);
@@ -49,11 +49,21 @@ const Render = {
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
},
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
// written for. Touch gets told about the menu route instead, which works.
canDragSeats() {
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
},
renderTableHint() {
const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
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.";
},
renderNotes() {
@@ -6,10 +6,16 @@ const SeatMenu = {
hooks: null,
seatIndex: null,
mode: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
// Always the action list, never whatever the sheet was showing when it last
// closed — a seat menu that opens on a list of chairs is a menu that lost the
// dealer's place.
this.mode = null;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
@@ -63,29 +69,81 @@ const SeatMenu = {
}
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
// The chairs this player could move to. Every other chair: swapping into an empty
// one is how a player moves, and swapping with an occupied one is two players
// changing places, which is the other half of the same job.
destinationsFor(index) {
return this.state.seats
.map((player, seat) => ({ player, seat }))
.filter(entry => entry.seat !== index);
},
openMovePicker() {
this.mode = "move";
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
if (this.mode === "move") {
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "seatMenuBtn";
// displayName falls back to "Seat 5" for a chair with nobody in it,
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
const named = (other.name || "").trim();
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
btn.addEventListener("click", () => {
const from = this.seatIndex;
this.close();
this.hooks.swapSeats(from, seat);
});
list.appendChild(btn);
});
return;
}
title.textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
subtitle.textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
// "Move to…" stays on the sheet and swaps it for the seat list; every
// other action is the end of the interaction.
if (action.label === "Move to…") {
action.run();
return;
}
this.close();
action.run();
});
@@ -467,6 +467,21 @@ const HoldemState = {
.sort((a, b) => b.net - a.net);
},
// Two players change chairs.
//
// The whole seat moves — name, stack, cards, what they have in the pot — because
// that is what changing chairs is. What stays behind is everything that belongs to
// the *position*: the button, the blinds and the acting index all name a chair, and
// a player who moves into the big blind's chair is the big blind. That is the same
// answer a card room gives.
swapSeats(state, a, b) {
const seats = state.seats || [];
if (a === b) return false;
if (!seats[a] || !seats[b]) return false;
[seats[a], seats[b]] = [seats[b], seats[a]];
return true;
},
// The one check a banker actually needs, and the one nothing here did.
//
// Every chip on the table is a promise to pay cash for it later. If the chips in
+82
View File
@@ -0,0 +1,82 @@
// The chips to actually push, for an amount.
const test = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// A standard house set.
const LEGEND = [
{ value: 1, color: "#fff" },
{ value: 5, color: "#c0392b" },
{ value: 25, color: "#27ae60" },
{ value: 100, color: "#2c3e50" },
{ value: 500, color: "#8e44ad" },
];
const say = (amount, legend = LEGEND) => Money.formatChipBreakdown(amount, legend);
test("a payout comes back as chips, largest first", () => {
assert.equal(say(132), "1×$100, 1×$25, 1×$5, 2×$1");
});
test("it uses the fewest chips on a normal set", () => {
const { chips } = Money.chipBreakdown(500, LEGEND);
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["1×500"]);
});
test("a denomination that doesn't fit is skipped rather than listed as none", () => {
assert.equal(say(7), "1×$5, 2×$1");
});
test("nothing to pay is nothing to say", () => {
assert.equal(say(0), "");
assert.equal(say(-5), "");
});
test("no legend means no advice, not a wrong answer", () => {
// A table that never set chip colours up gets the amount on its own.
assert.equal(say(132, []), "");
assert.equal(say(132, null), "");
});
test("a legend with no values in it yet is the same as none", () => {
assert.equal(say(132, [{ value: "", color: "#fff" }, { value: 0, color: "#000" }]), "");
});
test("what the smallest chip can't make is reported, not rounded away", () => {
// 50c in a game whose smallest chip is a dollar is the dealer's problem to solve,
// and they can only solve it if they're told.
const { chips, remainder } = Money.chipBreakdown(10.5, LEGEND);
assert.deepEqual(chips.map(c => `${c.count}×${c.value}`), ["2×5"]);
assert.equal(remainder, 0.5);
assert.equal(say(10.5), "2×$5, $0.50 no chip covers");
});
test("cent chips make cent amounts exactly", () => {
const cents = [{ value: 0.25 }, { value: 0.05 }, { value: 0.01 }];
assert.equal(say(0.41, cents), "1×$0.25, 3×$0.05, 1×$0.01");
assert.equal(Money.chipBreakdown(0.41, cents).remainder, 0);
});
test("the chips add up to the amount", () => {
for (const amount of [1, 7, 33, 132, 499, 500, 1234, 9999]) {
const { chips, remainder } = Money.chipBreakdown(amount, LEGEND);
const total = chips.reduce((sum, c) => sum + c.count * c.value, 0) + remainder;
assert.equal(Money.round(total), amount, `${amount} should be accounted for`);
}
});
test("a legend in any order still pays largest first", () => {
const jumbled = [{ value: 25 }, { value: 1 }, { value: 500 }, { value: 5 }, { value: 100 }];
assert.equal(say(631, jumbled), "1×$500, 1×$100, 1×$25, 1×$5, 1×$1");
});
test("the colour comes along, so the felt can say it in chips", () => {
const { chips } = Money.chipBreakdown(25, LEGEND);
assert.equal(chips[0].color, "#27ae60");
});
test("a legend that exists but can't reach the amount does say so", () => {
// Different from having no legend at all: this table has told us its chips, and
// the answer is that they don't go that low.
assert.equal(say(3, [{ value: 5 }, { value: 25 }]), "$3 no chip covers");
});
+53
View File
@@ -0,0 +1,53 @@
// Which fields replace their contents when a dealer reaches for them.
const test = require("node:test");
const assert = require("node:assert");
const { Money } = require("./load");
// Enough of an input to answer the question, without a DOM.
const field = (props = {}) => ({ tagName: "INPUT", type: "text", inputMode: "", ...props });
test("a number box selects", () => {
assert.equal(Money.selectsOnFocus(field({ type: "number" })), true);
});
test("a decimal keypad box selects", () => {
// The raise pad's amount is type=text with inputmode=decimal, so the phone shows
// a number pad without the browser's spinner arrows.
assert.equal(Money.selectsOnFocus(field({ type: "text", inputMode: "decimal" })), true);
});
test("a name does not", () => {
// Selecting all of a name on focus is the same mistake pointing the other way:
// a name is a thing you edit, not a suggestion you replace.
assert.equal(Money.selectsOnFocus(field({ type: "text" })), false);
});
test("a colour swatch does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "color" })), false);
});
test("a checkbox does not", () => {
assert.equal(Money.selectsOnFocus(field({ type: "checkbox" })), false);
});
test("a textarea does not, whatever it is asked", () => {
assert.equal(Money.selectsOnFocus({ tagName: "TEXTAREA", type: "text", inputMode: "decimal" }), false);
});
test("a select does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "SELECT", type: "select-one" }), false);
});
test("case doesn't matter", () => {
assert.equal(Money.selectsOnFocus(field({ type: "NUMBER" })), true);
assert.equal(Money.selectsOnFocus(field({ type: "TEXT", inputMode: "DECIMAL" })), true);
});
test("nothing at all is not a field", () => {
assert.equal(Money.selectsOnFocus(null), false);
assert.equal(Money.selectsOnFocus(undefined), false);
});
test("a field with no type stated is a text box, so it does not", () => {
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
});
+95
View File
@@ -0,0 +1,95 @@
// Two players changing chairs.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
test("the whole seat moves", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[0].name = "Ann";
state.seats[0].cards = ["AS", "KD"];
state.seats[0].stack = 175;
state.seats[4].name = "";
HoldemState.swapSeats(state, 0, 4);
assert.equal(state.seats[4].name, "Ann");
assert.deepEqual(state.seats[4].cards, ["AS", "KD"]);
assert.equal(state.seats[4].stack, 175);
assert.equal(state.seats[0].name, "");
});
test("what they have in the pot goes with them", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
const posted = state.seats.map(p => p.committed);
const before = chipsOnTable(state);
HoldemState.swapSeats(state, 1, 4);
assert.equal(state.seats[4].committed, posted[1]);
assert.equal(state.seats[1].committed, posted[4]);
assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip");
});
test("the button stays on the chair, not on the player", () => {
// A card room moves the button around the table, not around the people. Somebody
// who sits down in the big blind's chair is the big blind.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 });
state.seats[0].name = "Ann";
HoldemState.swapSeats(state, 0, 2);
assert.equal(state.dealerSeatIndex, 0);
assert.notEqual(state.seats[0].name, "Ann");
});
test("the blinds follow the chairs too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
Betting.beginHand(state);
const before = HoldemState.getBlindSeats(state);
HoldemState.swapSeats(state, before.sbIndex, before.bbIndex);
const after = HoldemState.getBlindSeats(state);
assert.equal(after.sbIndex, before.sbIndex);
assert.equal(after.bbIndex, before.bbIndex);
});
test("swapping a seat with itself does nothing and says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 1, 1), false);
assert.equal(JSON.stringify(state), before);
});
test("a chair that isn't there is refused rather than swallowed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
const before = JSON.stringify(state);
assert.equal(HoldemState.swapSeats(state, 0, 99), false);
assert.equal(HoldemState.swapSeats(state, -1, 0), false);
assert.equal(JSON.stringify(state), before);
});
test("swapping twice puts everything back", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
Betting.beginHand(state);
const before = JSON.stringify(state);
HoldemState.swapSeats(state, 1, 3);
HoldemState.swapSeats(state, 1, 3);
assert.equal(JSON.stringify(state), before);
});
test("moving into an empty chair leaves an empty chair behind", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[1].name = "Ben";
HoldemState.swapSeats(state, 1, 5);
assert.equal(state.seats[5].name, "Ben");
assert.equal(state.seats[5].inHand, true);
assert.equal(state.seats[1].inHand, false);
});
+9 -2
View File
@@ -522,8 +522,15 @@ const LayoutAudit = {
const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => {
const style = getComputedStyle(chip);
return style.display !== "none" && style.visibility !== "hidden" &&
parseFloat(style.opacity) > 0.05;
if (style.display === "none" || style.visibility === "hidden") return false;
if (parseFloat(style.opacity) <= 0.05) return false;
// A hovered seat deliberately fades its empty slot in — that's the "put the
// button here" preview, and it is on screen because a pointer is sitting on
// that seat. Counting it reports two buttons whenever the mouse happens to
// rest on the table, which is most of the time a mouse exists.
const seat = chip.closest(".seat");
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
return true;
});
const offenders = [];