Hold'em dealer: making the table and the bar fit the screen they are on #119
@@ -1075,6 +1075,33 @@ button,
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Neither row has anything in it most of the time, and both were holding their space
|
||||
anyway — the cards line on every seat of every table that isn't tracking hole cards,
|
||||
and the amount pill on everyone who hasn't put chips in yet.
|
||||
|
||||
The pill was worse than empty space. It has a background, a pill radius and side
|
||||
padding, so with no text it painted an 8x11px green lozenge in the middle of the puck
|
||||
— measured on seven of nine seats at an ordinary table, a coloured mark on almost
|
||||
every seat that stood for nothing. Nine of those is not a table, it is a rash.
|
||||
|
||||
The space they held was not free either. The ring is sized against the tallest puck
|
||||
on it, so two rows nobody had filled were setting how far apart twelve seats had to
|
||||
sit: on an iPhone 14 nine-handed the puck measured 78x88 with 25px of it reserved for
|
||||
nothing. Collapsed, the search settles on 78x63 — same width for the name, a quarter
|
||||
less height to fit on the ring.
|
||||
|
||||
`:empty` is exact here: both rows are written by updatePuckContent, which sets an
|
||||
empty string rather than a space when there is nothing to say. What the collapse
|
||||
needs from the layout is that the ring re-measures when one of them fills — see
|
||||
seatShapeSignature() in render.js, which now names both. */
|
||||
.seatPuckCards:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.seatPuckTotal:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dealerChip {
|
||||
position: absolute;
|
||||
/* Inside its own seat, not on the rim.
|
||||
|
||||
@@ -544,9 +544,26 @@ const Render = {
|
||||
// What the table has changed about the shape of its pucks. Anything that can make a
|
||||
// name wrap to a second line belongs here, because the height that comes back from
|
||||
// measuring is what the ring is sized against.
|
||||
//
|
||||
// The hole-card line and the amount pill belong here for the same reason, now that
|
||||
// an empty one collapses instead of holding its space: a puck with either of them
|
||||
// filled is about 15px taller than the same puck without, and a ring still sized
|
||||
// from the measurement taken before the first chips went in is sized for a puck
|
||||
// that no longer exists.
|
||||
//
|
||||
// Booleans over the whole table rather than a list per seat, because that is what
|
||||
// the measurement is — measuredSeatBox takes the tallest puck on the ring, so what
|
||||
// matters is whether *anybody* has one. A boolean also flips about twice a hand
|
||||
// rather than on every action, and each flip costs ten forced layouts, which is the
|
||||
// expense this cache exists to avoid.
|
||||
seatShapeSignature() {
|
||||
if (!this.state) return "";
|
||||
return `${this.state.seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}`;
|
||||
const seats = this.state.seats;
|
||||
const anyCards = seats.some(p => (p.cards || []).some(Boolean));
|
||||
const anyAmount = seats.some(p => Betting.handTotal(p) > 0 ||
|
||||
HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0);
|
||||
return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` +
|
||||
`|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`;
|
||||
},
|
||||
|
||||
// Which width the pucks get, and how wide a ring they sit on.
|
||||
|
||||
@@ -380,6 +380,68 @@ Three injected faults — the cap ignored, the summary carrying only the first e
|
||||
money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests
|
||||
respectively, and the audit check reports the original 64% when the cap is taken out.
|
||||
|
||||
## Two empty rows were setting how far apart twelve seats had to sit
|
||||
|
||||
Every seat puck carries four rows: the name, the hole cards, the amount that player has
|
||||
put in, and their stack. Two of those are empty most of the time, and both held their
|
||||
space anyway.
|
||||
|
||||
The amount pill was worse than empty space. It has a background, a pill radius and side
|
||||
padding, so with no text it painted an **8x11px green lozenge** — measured on **seven of
|
||||
nine seats** at an ordinary table. One of those is a component holding its place; nine is
|
||||
a rash, and a dealer has to learn to ignore a colour that on the other two seats means
|
||||
"this player has money out".
|
||||
|
||||
The space was not free either. `seatRingRadii` sizes the ring from the tallest puck on
|
||||
it, so two rows nobody had filled were setting the spacing for the whole table:
|
||||
|
||||
| | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPhone 14, 9 seats | 78x88 puck | **78x63** |
|
||||
| iPhone 14, 12 seats | 44x65 | **66x55** |
|
||||
| Pro Max, 9 seats | 84x88 | **96x63** |
|
||||
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
|
||||
|
||||
- **`emptyDecoration()`** — nothing inside `#pokerTableWrap` may paint a background or a
|
||||
border with no content in it. The rule is about paint, not space: an element may hold
|
||||
its place invisibly, and several do. With the old reservation restored it reports all
|
||||
seven pills; live it reports none.
|
||||
|
||||
The one exemption, `.betChipDisc`, is on the list because the check reported it, not
|
||||
because it was predicted — it is a 13x13px circle filled with the denomination colour of
|
||||
the chip beside it, so the colour *is* the content. Three other exemptions were written
|
||||
from memory first and matched no element on the page at all, which is the argument for
|
||||
building that list from a run.
|
||||
|
||||
### The collapse is only safe because the cache key moved
|
||||
|
||||
`resolveSeatLayout` measures ten candidate widths, each one a forced layout, and caches
|
||||
the result against `seatShapeSignature()`. That signature listed the names and the dealer
|
||||
seat — everything that could wrap a name onto a second line. It did not mention the two
|
||||
rows, because they always held their space and so could never change a puck's height.
|
||||
|
||||
Once they collapse, they can. `seatPuckShape.test.js` checks the key moves when the first
|
||||
chips go in and when a hole card is entered, and — just as important — that it does *not*
|
||||
move for the second player to bet, or when an amount merely gets wider. It is a boolean
|
||||
over the whole table because that is what the measurement is: `measuredSeatBox` takes the
|
||||
tallest puck on the ring, so what matters is whether anybody has one. A per-seat key would
|
||||
be correct and would throw away the cache on every single tap.
|
||||
|
||||
Measured on the running page: the ring settles by 24-26px when the blinds are posted —
|
||||
and it settled by **23-25px there before this round too**, because the action bar changes
|
||||
height at that moment and the felt is measured against it. The one place the ring moves
|
||||
is a place it already moved.
|
||||
|
||||
### A test that passed for the wrong reason
|
||||
|
||||
The signature joins names on a control character so that "ann" + "abel" and "anna" + "bel"
|
||||
stay distinguishable. The first version of that test used `Ann`/`Abel` against
|
||||
`Anna`/`Bel` — which can never collide whatever the separator, because the capitals land
|
||||
in different places. It passed with the separator deleted and proved nothing. Lower case
|
||||
is what makes it a test. Four faults were injected in total; the other three (dropping the
|
||||
two flags, letting the count into the key instead of a boolean, and counting only hand
|
||||
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// What the ring is allowed to be sized from.
|
||||
//
|
||||
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
|
||||
// shows what a player has put in. Both used to hold their space anyway, and the pill,
|
||||
// which has a background and a pill radius and side padding, painted an 8x11px green
|
||||
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
|
||||
// table, a coloured mark on almost every seat standing for nothing.
|
||||
//
|
||||
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on
|
||||
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On
|
||||
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
|
||||
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
|
||||
// the seats overlapped by 1556px² and now overlap by 87px².
|
||||
//
|
||||
// The collapse is only safe if the layout re-measures when one of those rows fills,
|
||||
// because resolveSeatLayout caches its ten forced layouts against this signature. That
|
||||
// is what is checked here: the cache key has to move when the puck's shape can, and
|
||||
// stay still when it cannot — a signature that changed on every action would throw the
|
||||
// cache away on every tap, which is the cost it exists to avoid.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
|
||||
const signatureOf = state => {
|
||||
const before = Render.state;
|
||||
Render.state = state;
|
||||
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
|
||||
};
|
||||
|
||||
const table = () => makeTable({ seatCount: 6, stack: 200 });
|
||||
|
||||
describe("the signature the ring layout is cached against", () => {
|
||||
test("moves when the first chips go in", () => {
|
||||
// Before the blinds every pill is empty and the pucks are short; after them two
|
||||
// seats are ~15px taller. A ring still sized from the first measurement is sized
|
||||
// for a puck that no longer exists.
|
||||
const state = table();
|
||||
const empty = signatureOf(state);
|
||||
state.seats[1].committed = 10;
|
||||
assert.notEqual(signatureOf(state), empty,
|
||||
"the ring would still be sized for pucks with no amount on them");
|
||||
});
|
||||
|
||||
test("and when a hole card is entered", () => {
|
||||
const state = table();
|
||||
const none = signatureOf(state);
|
||||
state.seats[0].cards = ["AS", "KC"];
|
||||
assert.notEqual(signatureOf(state), none);
|
||||
});
|
||||
|
||||
test("but not again for the second player to put chips in", () => {
|
||||
// The whole point of a boolean over the table. The ring is sized from the
|
||||
// tallest puck on it, so once anybody has an amount the answer cannot change —
|
||||
// and re-measuring here would mean ten forced layouts on every single action.
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
const first = signatureOf(state);
|
||||
state.seats[2].committed = 10;
|
||||
assert.equal(signatureOf(state), first);
|
||||
state.seats[3].committed = 250;
|
||||
assert.equal(signatureOf(state), first);
|
||||
});
|
||||
|
||||
test("nor when an amount merely changes size", () => {
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
const small = signatureOf(state);
|
||||
state.seats[1].committed = 999999;
|
||||
assert.equal(signatureOf(state), small,
|
||||
"a wider number is the amount fitter's problem, not the ring's");
|
||||
});
|
||||
|
||||
test("and it still moves for the things it always moved for", () => {
|
||||
// Renaming can wrap a name onto a second line, and the button's chip floats
|
||||
// into the name's first line, so both change how tall a puck measures.
|
||||
const state = table();
|
||||
const base = signatureOf(state);
|
||||
state.seats[0].name = "Bartholomew Fairweather";
|
||||
const renamed = signatureOf(state);
|
||||
assert.notEqual(renamed, base);
|
||||
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
||||
assert.notEqual(signatureOf(state), renamed);
|
||||
});
|
||||
|
||||
test("and two tables that differ only in where a name is split stay different", () => {
|
||||
// The names are joined on a control character no name can contain. Joined on
|
||||
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
|
||||
// tables would be laid out with the other's measurements — different names, so
|
||||
// different wrapping, so different puck heights.
|
||||
//
|
||||
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
|
||||
// never collide: the capital letters land in different places, so it passed with
|
||||
// the separator taken out and proved nothing at all. Lower case is what makes it
|
||||
// a real test.
|
||||
const a = table();
|
||||
const b = table();
|
||||
a.seats[0].name = "ann"; a.seats[1].name = "abel";
|
||||
b.seats[0].name = "anna"; b.seats[1].name = "bel";
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table nobody has sat down at", () => {
|
||||
test("has no amounts and no cards on it", () => {
|
||||
// The empty-table case is the one the collapse is most visible on, and it is
|
||||
// also the one where a stray default could quietly keep both rows open forever.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
assert.match(signatureOf(state), /\|$/,
|
||||
"an untouched table should carry neither flag");
|
||||
});
|
||||
|
||||
test("but a table with an ante on it does have an amount", () => {
|
||||
// computeContribution answers for a seat that has never been given a stack —
|
||||
// the table's base pot stands in for it — so a table dealt with "everyone antes
|
||||
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.basePotAmount = 5;
|
||||
state.seats.forEach(p => { p.inHand = true; });
|
||||
assert.match(signatureOf(state), /a$/);
|
||||
});
|
||||
});
|
||||
@@ -967,6 +967,57 @@ const LayoutAudit = {
|
||||
return { name: "hidden means hidden", offenders };
|
||||
},
|
||||
|
||||
// A mark on the felt that stands for nothing.
|
||||
//
|
||||
// The amount pill inside every seat puck has a background, a pill radius and side
|
||||
// padding, so a player who had not put chips in yet got an 8x11px green lozenge
|
||||
// where their bet would go — measured on seven of nine seats at an ordinary table.
|
||||
// One of those is a component holding its place; nine of them is a rash, and a
|
||||
// dealer reading the felt has to learn to ignore a colour that on three other seats
|
||||
// means "this player has money out".
|
||||
//
|
||||
// It cost room as well as attention. The ring is sized from the tallest puck on it,
|
||||
// so a row nobody had filled was setting how far apart the seats had to sit.
|
||||
//
|
||||
// The rule is about paint, not about space: an element may hold its place invisibly,
|
||||
// and several here do. What it may not do is draw a background or a border with
|
||||
// nothing inside it.
|
||||
//
|
||||
// One real exemption, and it is on the list because the check reported it rather
|
||||
// than because it was predicted: .betChipDisc is a 13x13px circle filled with the
|
||||
// denomination colour of the chip beside it, published per chip from render.js. It
|
||||
// has no text because the colour *is* the content. Anything carrying its own
|
||||
// picture in a background-image is exempt on the same grounds, without needing to be
|
||||
// named. Nothing else here needs an entry — three others were guessed at first and
|
||||
// matched no element on the page, which is its own warning about writing this list
|
||||
// from memory instead of from a run.
|
||||
emptyDecoration() {
|
||||
const DECORATIVE = new Set(["betChipDisc"]);
|
||||
const opaque = value => value && value !== "none" && !/rgba\([^)]*,\s*0\s*\)/.test(value)
|
||||
&& value !== "rgba(0, 0, 0, 0)" && value !== "transparent";
|
||||
|
||||
const offenders = [...document.querySelectorAll("#pokerTableWrap *")]
|
||||
.filter(el => {
|
||||
if (el.children.length || (el.textContent || "").trim()) return false;
|
||||
if ([...el.classList].some(c => DECORATIVE.has(c))) return false;
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width < 2 || box.height < 2) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
|
||||
if (opaque(cs.backgroundImage)) return false;
|
||||
return opaque(cs.backgroundColor) ||
|
||||
["Top", "Right", "Bottom", "Left"].some(side =>
|
||||
parseFloat(cs[`border${side}Width`]) > 0 && opaque(cs[`border${side}Color`]));
|
||||
})
|
||||
.map(el => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return `${this.describe(el)} :: nothing in it, painted ` +
|
||||
`${Math.round(box.width)}x${Math.round(box.height)}px`;
|
||||
});
|
||||
|
||||
return { name: "nothing on the felt is painted for nothing", offenders };
|
||||
},
|
||||
|
||||
// A seat's name, painted under the decoration on its own puck.
|
||||
//
|
||||
// The chip that marks the button sits in the puck's top-right corner on a layer
|
||||
@@ -1293,6 +1344,7 @@ const LayoutAudit = {
|
||||
this.seatOwnership(),
|
||||
this.oneDealerButton(),
|
||||
this.hiddenButPainted(),
|
||||
this.emptyDecoration(),
|
||||
this.seatLabelsClear(),
|
||||
this.seatsKeepTheirText(),
|
||||
this.chipsKeepTheirAmounts(),
|
||||
|
||||
Reference in New Issue
Block a user