Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122

Open
jheaps wants to merge 27 commits from polish/holdem-seats-one-size into master
3 changed files with 467 additions and 7 deletions
Showing only changes of commit 53c99b2d91 - Show all commits
+158
View File
@@ -2460,6 +2460,18 @@ input[type="checkbox"] {
margin-bottom: var(--sp-2);
}
/* The two tiers. Upright this is the column it has always been — the wrappers stack in
the order they are written and gap exactly as the loose children used to, so nothing
about this sheet moves on a phone held the way phones are usually held. They are here
for the one held sideways, where the tiers become columns instead of rows. */
.seatMenuHand,
.seatMenuKeeping {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
/* What the group is for, said once, so the tiles under it don't each have to say it.
Small and unsaturated on purpose: a caption that competes with the actions has made
the sheet busier, which is the thing it is here to undo. */
@@ -2473,6 +2485,17 @@ input[type="checkbox"] {
}
.seatMenuGrid {
/* How narrow a tile in this grid is allowed to get before the row gives up a
column. Only read by the sideways layout, where the number of columns is a
function of the width there happens to be; upright there are two, always, because
a 360px phone has room for exactly two and counting them is not the dealer's job.
It lives here rather than there so the one grid of names can raise it.
7.5rem is not what the words need — the longest of these labels, "Small blind",
sets about 75px. It is what makes three columns fit the 397px the housekeeping
side has on a 640x360 phone, with the rest going to the tiles rather than to a
fourth column too narrow to hold anything. */
--tileMin: 7.5rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
@@ -5969,6 +5992,141 @@ body.hasActionBar #toastHost {
}
}
/* ---------- A phone on its side: the seat menu goes beside itself ----------
The block above moved the action bar off the bottom edge because a landscape phone is
short of height and swimming in width. The sheets never got the same news. Measured
on the fullest menu the tool can build, twelve-handed with somebody waiting:
640x360 545px of content in a 306px sheet — Cash out…, Move to…, Edit seat…,
Small blind, Big blind and Cancel below the fold, Cancel by 224px
667x375 545 in 319, Cancel 211px under
844x390 529 in 332, Cancel 183px under
and in all three the sheet was 420px wide in a window 640 to 844 wide. Two hundred
and forty-seven pixels of width held empty, on the one screen shape whose whole
problem is height. A dealer holding the phone sideways had to scroll a menu to find
the way out of it.
The sheet takes the width instead. The two tiers this menu has been sorted into since
round 7 — what you press with cards in the air, and housekeeping between hands — stop
being the top and bottom of a column and become the right and left of a row. Which
half goes right is not arbitrary: Fold and All-in sit under the same thumb the rail
above put Fold, Call and Raise under, so the urgent half of the screen is in the same
place whether the dealer is looking at the bar or at a seat.
Cancel goes under them, in the same column, because the way out belongs at the thumb
too — and because a full-width Cancel under both columns is 60px of the 306 there
are.
Scoped on height and width rather than orientation: a short desktop window has the
same shortage and the same answer, and a narrow one has neither the width to spend
nor a second column to spend it on. */
@media (max-height: 560px) and (min-width: 600px) {
#seatMenuSheet {
/* Not the whole window. A sheet with the scrim showing either side of it still
reads as a sheet over a table, and the edge is where a tap dismisses it. */
max-width: min(100% - var(--sp-5), 46rem);
/* 85vh of a 360px screen is 306px, and this is the one sheet whose content is
fixed by the game rather than by the dealer. The seven points buy 25px, which
is half a row of tiles. */
max-height: 92vh;
display: grid;
/* Wide enough for Fold and All-in to stay the bars they are, and no wider: the
right-hand column is two words, and every pixel over that comes out of the
left one, where it is a whole extra column of tiles. Measured at 640x360 —
at 11rem the housekeeping grid gets 381px and fits two columns; at 10rem it
gets 397 and fits three, which is a row of tiles saved for 16px. */
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem);
grid-template-areas:
"header header"
"keeping hand"
"keeping cancel";
align-content: start;
/* Nothing grows into the slack. The left column is the taller of the two, so
stretching would have left Cancel a 74px button on one phone and a 48px one
on the next — the way out should be the same size wherever it is read. */
align-items: start;
column-gap: var(--sp-3);
}
/* The move picker is a list of chairs and has no hand tier at all, so there is
nothing to put in the right-hand column and a column reserved for nothing would
narrow the names for no reason. One column, and the chairs spread across it. */
#seatMenuSheet:not(:has(.seatMenuHand)) {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"header"
"keeping"
"cancel";
}
/* The tiers are the grid's items, not this box's. It keeps its flex column for
upright phones and stands aside here. */
#seatMenuActions {
display: contents;
}
.seatMenuHeader {
grid-area: header;
}
.seatMenuHand {
grid-area: hand;
}
.seatMenuKeeping {
grid-area: keeping;
}
#seatMenuCancelBtn {
grid-area: cancel;
}
/* As many columns as the width holds, rather than the two a 360px phone has room
for. At 640x360 the left-hand column is ~397px, which is three tiles across: the
five under "This player" go from three rows to two, and the two under "Give this
seat" from two rows to one.
auto-fill, not auto-fit. auto-fit throws away the tracks nothing sits in, so the
two blinds under "Give this seat" stretched to half the sheet each while the five
above them were a third — two grids in a column, neither lining up with the
other, which reads as two different lists rather than one menu. auto-fill keeps
the tracks, so every tile in this sheet is the same width and sits on the same
column edges however many there happen to be in a group. */
.seatMenuGrid {
grid-template-columns: repeat(auto-fill, minmax(var(--tileMin), 1fr));
}
/* And the chairs are allowed to take two lines here. Twelve names three across is
four rows, which is the 30px that kept Back off the screen on a 640x360 phone;
four across is three rows, and the tile is 136px with 120 for the text, so the
longest of these — "Seat 2 — Mike from work", 115px wrapped — goes onto a second
line. It costs nothing: two lines of 15px sit inside the 44px floor the tile
already has, so the row is the same height it was. */
.seatMenuGrid.seatMenuGridNames {
--tileMin: 8.25rem;
}
/* Whose menu this is and what they have in front of them, on one line rather than
two. A second line of header is height every row of tiles below it pays for, and
this header is the part that cannot scroll away. It wraps back to two lines when
the name and the stack together need them — the move picker's sentence about
where the chips go still takes its own line on every phone here. */
.seatMenuHeading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
column-gap: var(--sp-2);
}
/* The captions lose their top margin: they are the top of their own column now,
not a break in the middle of a list. */
.seatMenuKeeping > .seatMenuCaption:first-child {
margin-top: 0;
}
}
/* ---------- Focus is brought to rest where it can be seen ----------
The ring below was the easy half. The hard half is that the bar is fixed over the
@@ -178,9 +178,23 @@ const SeatMenu = {
// A row of tiles. Two columns on a phone: a seat action's label is a verb and a
// short one, and stacking nine of them full width was most of the height the sheet
// was spending.
grid() {
grid(extra) {
const el = document.createElement("div");
el.className = "seatMenuGrid";
el.className = `seatMenuGrid ${extra || ""}`.trim();
return el;
},
// The two tiers, as boxes rather than as a run of siblings.
//
// Upright they are what they always were: one column, in the order they are
// written. The wrappers exist for the phone on its side, where the sheet has 306px
// of height and 616 of width and the tiers stop being two halves of a column and
// become two columns — see the `max-height: 560px` block in holdem.css. That is
// only expressible if each tier is one box the layout can address; as five loose
// children they can only ever stack.
tier(name) {
const el = document.createElement("div");
el.className = name;
return el;
},
@@ -219,7 +233,10 @@ const SeatMenu = {
// button already at the thumb end, so it says Back while there is somewhere
// to go back to.
cancel.textContent = "Back";
const grid = this.grid();
const keeping = this.tier("seatMenuKeeping");
// Chairs, not verbs: "Seat 3 — Alexandra" sets 116px where "Sit out" sets
// about 50, so this grid asks for a wider column than the tiles do.
const grid = this.grid("seatMenuGridNames");
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
// 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.
@@ -234,7 +251,8 @@ const SeatMenu = {
});
grid.appendChild(btn);
});
list.appendChild(grid);
keeping.appendChild(grid);
list.appendChild(keeping);
return;
}
@@ -267,18 +285,24 @@ const SeatMenu = {
});
};
const hand = this.tier("seatMenuHand");
actions.filter(a => a.group === "hand").forEach(action => {
const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim());
wire(btn, action);
list.appendChild(btn);
hand.appendChild(btn);
});
// An empty tier is left out rather than drawn empty — a folded player in a game
// with nobody waiting has nothing in the air to do, and the sideways layout
// reads the tier's absence to know it has one column rather than two.
if (hand.childElementCount) list.appendChild(hand);
const keeping = this.tier("seatMenuKeeping");
this.GROUPS.forEach(({ name, caption }) => {
const inGroup = actions.filter(a => a.group === name);
// A caption for nothing is worse than no caption. A group empties often —
// past the blinds there is nothing to give a seat but the button.
if (inGroup.length === 0) return;
list.appendChild(this.caption(caption));
keeping.appendChild(this.caption(caption));
const grid = this.grid();
inGroup.forEach(action => {
const btn = this.button(action.label,
@@ -287,8 +311,9 @@ const SeatMenu = {
wire(btn, action);
grid.appendChild(btn);
});
list.appendChild(grid);
keeping.appendChild(grid);
});
if (keeping.childElementCount) list.appendChild(keeping);
},
};
+277
View File
@@ -0,0 +1,277 @@
// The seat menu on a phone held sideways.
//
// The tool learned two rounds ago that a landscape phone is short of height and
// swimming in width: the action bar stops being a bar across the bottom and becomes a
// rail down the right. The sheets were never told. Measured on the fullest menu the
// tool can build — twelve seated, somebody on the waiting list, blinds still the only
// money in:
//
// 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…,
// Small blind, Big blind and Cancel all below the fold, Cancel by 224px.
// 667x375 545 in 319, Cancel 211px under.
// 844x390 529 in 332, Cancel 183px under.
//
// And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two
// hundred and forty-seven pixels of width held empty on the one screen shape whose
// whole problem is height, while the dealer scrolled a menu to find the way out of it.
//
// The two tiers this menu has been sorted into since round 7 — what you press with
// cards in the air, and housekeeping between hands — stop being the top and bottom of
// a column and become the right and left of a row. Fold and All-in land under the same
// thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured
// after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390.
// Nothing below the fold on any of them, and the move picker's twelve chairs and its
// Back went from four rows and a scroll to three rows and none.
//
// Upright nothing moved at all: 545px before, 545px after, the same two columns.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Betting, HoldemState, SeatMenu, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Everything inside one @media block, braces balanced, so a test asks what that block
// says rather than whether a string appears anywhere in six thousand lines.
const SHORT = "@media (max-height: 560px) and (min-width: 600px) {";
function shortViewportBlock() {
const at = CSS.indexOf(SHORT);
assert.ok(at >= 0, "there is no short-viewport block for the sheets");
let depth = 0;
for (let i = at + SHORT.length - 1; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
}
assert.fail("the short-viewport block is never closed");
}
// A document small enough to answer what paint() asks of one. It builds elements,
// appends them and reads their labels back; nothing here lays anything out, which is
// the stylesheet's half of the job and is checked as text below.
function fakeDom() {
const element = tag => ({
tag,
className: "",
type: "",
textContent: "",
children: [],
appendChild(child) { this.children.push(child); return child; },
addEventListener() {},
setAttribute() {},
focus() {},
get childElementCount() { return this.children.length; },
set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; },
get innerHTML() { return ""; },
});
const byId = {};
["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn",
"seatMenuOverlay"].forEach(id => { byId[id] = element("div"); });
return {
byId,
doc: {
createElement: element,
getElementById: id => byId[id] || null,
},
};
}
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try { return body(); } finally {
if (had) globalThis.document = before; else delete globalThis.document;
}
}
// The fullest menu the tool can build, aimed at a live seat that isn't the dealer with
// the blinds still the only money in and an override already set.
function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) {
const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }];
const { doc, byId } = fakeDom();
return withDocument(doc, () => {
SeatMenu.state = state;
SeatMenu.hooks = {};
SeatMenu.seatIndex = seatIndex;
SeatMenu.mode = move ? "move" : null;
SeatMenu.paint();
return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state };
});
}
const classes = el => el.className.split(/\s+/).filter(Boolean);
const labels = el => el.children.map(child => child.textContent);
// ---------------------------------------------------------------------------
describe("the two tiers are boxes, not a run of siblings", () => {
// Five loose children can only ever stack. Two boxes can be placed.
test("the sheet's actions are exactly the hand and the housekeeping", () => {
const { list } = painted();
assert.deepEqual(list.children.map(child => child.className),
["seatMenuHand", "seatMenuKeeping"]);
});
test("the hand tier holds the bars a dealer presses with cards in the air", () => {
const { list } = painted();
const hand = list.children[0];
assert.deepEqual(labels(hand), ["Fold", "All-in"]);
hand.children.forEach(btn => {
assert.ok(classes(btn).includes("seatMenuBtn"));
assert.ok(!classes(btn).includes("menuTile"),
"a bar is not a tile — the whole point of the tier is that it is a bar");
});
});
test("and the housekeeping tier holds its captions and grids in order", () => {
const { list } = painted();
const keeping = list.children[1];
assert.deepEqual(keeping.children.map(child => child.className),
["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]);
assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]);
});
// The sideways layout reads the tier's absence to know it has one column rather
// than two, so an empty tier has to be absent rather than empty.
test("a tier with nothing in it is left out rather than drawn empty", () => {
// A chair nobody is sitting in, with nobody waiting to take it: there is
// nothing at all to do to it while the cards are in the air.
const { list } = painted(7, { seated: [0, 1, 2], waiting: false });
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"],
"an empty chair still drew a hand tier with nothing in it");
});
});
describe("the move picker", () => {
test("its chairs go in the housekeeping tier too, so the sheet has one column", () => {
const { list } = painted(3, { move: true });
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]);
});
// "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a
// different column from the one the tiles use.
test("and they ask for a column of their own width", () => {
const { list } = painted(3, { move: true });
const grid = list.children[0].children[0];
assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]);
});
test("the way back out is still the sheet's own foot button", () => {
const { cancel } = painted(3, { move: true });
assert.equal(cancel.textContent, "Back");
assert.equal(painted(3).cancel.textContent, "Cancel");
});
});
describe("upright, nothing moved", () => {
// The wrappers gap exactly as the loose children used to, so the sheet a dealer
// holding a phone the usual way sees is the one it has been since round 7.
test("both tiers are the same column the sheet already was", () => {
const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping");
assert.match(rule, /flex-direction:\s*column/);
assert.match(rule, /gap:\s*var\(--sp-2\)/);
assert.match(rule, /min-width:\s*0/);
});
test("and the grid is still two columns wherever the screen is not short", () => {
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
});
});
describe("what the short screen does instead", () => {
const block = shortViewportBlock();
// Height and width, not orientation: a short desktop window has the same shortage
// and the same answer, and a narrow one has neither the width to spend nor a second
// column to spend it on.
test("it is scoped on the shortage rather than on which way up the phone is", () => {
assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is");
});
test("the sheet takes the width it has been leaving empty", () => {
assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/);
});
test("the tiers become the two columns, with the way out under the thumb", () => {
assert.match(block, /"header\s+header"/);
assert.match(block, /"keeping hand"/);
assert.match(block, /"keeping cancel"/);
});
test("which means the box that held them stands aside", () => {
const at = block.indexOf("#seatMenuActions {");
assert.ok(at >= 0, "#seatMenuActions is not addressed here");
assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/);
});
// A column reserved for nothing would narrow the names for no reason.
test("a sheet with no hand tier is one column, not two", () => {
const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))");
assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got");
const rule = block.slice(at, block.indexOf("}", at));
assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(rule, /"keeping"/);
});
test("the tiles spread into as many columns as the width holds", () => {
assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/);
});
// auto-fit throws away the tracks nothing sits in, so a group of two stretched to
// half the sheet each while the group above it was a third — two grids in a column
// lining up with nothing.
test("and they line up with each other, group to group", () => {
// The comment above the rule says the word it is here to rule out, so the code
// is read with the comments stripped out of it.
const code = block.replace(/\/\*[\s\S]*?\*\//g, "");
assert.ok(!/auto-fit/.test(code),
"auto-fit collapses the empty tracks and the grids stop sharing column edges");
});
// The left column is the taller of the two, and stretching left Cancel a 74px
// button on one phone and a 48px one on the next.
test("nothing grows into the slack the taller column leaves", () => {
assert.match(block, /align-items:\s*start/);
});
});
describe("the numbers the columns are chosen from", () => {
// The floor is what the longest of these labels needs, not a round figure that
// happened to work on the phone it was measured on.
test("a tile's narrowest is a token the grid of names can raise", () => {
assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/);
const block = shortViewportBlock();
const at = block.indexOf(".seatMenuGrid.seatMenuGridNames");
assert.ok(at >= 0, "the chairs never ask for more room than a verb");
assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/);
});
// Two words. Every pixel over that comes out of the left column, where it is a
// whole extra column of tiles.
test("the hand column is sized for Fold and All-in and no wider", () => {
const block = shortViewportBlock();
assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/);
});
test("what was measured is written beside the rules", () => {
const block = shortViewportBlock();
const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"),
CSS.indexOf(SHORT));
assert.match(note, /640x360/);
assert.match(note, /545px/);
assert.match(note, /224px/);
assert.match(block, /10rem it\s+gets 397 and fits three/);
});
});