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 307 additions and 19 deletions
Showing only changes of commit 42b36a1464 - Show all commits
+25 -2
View File
@@ -3272,10 +3272,16 @@ body.hasActionBar #holdemApp {
margin-left: auto; margin-left: auto;
} }
/* The gap is the space before the separator, and it is here rather than in the string
because it cannot be in the string: these are two flex items, and a flex item's own
leading whitespace is collapsed at its edge. The bar has been reading "Alexandra·
$1,500 behind" ever since the name and the stack were split into two spans to decide
which of them gives way. */
.actionWho { .actionWho {
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: var(--sp-1);
min-width: 0; min-width: 0;
white-space: nowrap; white-space: nowrap;
} }
@@ -3289,13 +3295,26 @@ body.hasActionBar #holdemApp {
/* And the half that doesn't. The same rule the seat pucks keep: a number read wrong is /* And the half that doesn't. The same rule the seat pucks keep: a number read wrong is
a worse failure than a name read short, so the stack keeps every digit and the name a worse failure than a name read short, so the stack keeps every digit and the name
is what loses characters. */ is what loses characters.
With a floor under the name, which is what this was missing: shrinking the only
shrinkable item until the line fits took "Christopher" to 0px on two of the three
phones in the sweep, and what the bar then read was "· $1,200 behind". So when the
name is being cut, this comes off the line instead — see ActionBar.fitActingLine.
The figure is on that player's seat puck either way; the name is the thing only the
bar is saying. */
.actionWhoStack { .actionWhoStack {
flex: none; flex: none;
} }
.actionWhoStack[hidden] {
display: none;
}
/* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does /* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does
nothing else. */ nothing else — and a prompt that is on the line from the first second of every
decision is not a prompt. It arrives when the button beside it does, half a minute
in, which is also 50px of a 349px line handed back to the name. */
.actionTank { .actionTank {
flex: none; flex: none;
min-width: 3.1rem; min-width: 3.1rem;
@@ -3305,6 +3324,10 @@ body.hasActionBar #holdemApp {
transition: color 0.3s ease; transition: color 0.3s ease;
} }
.actionTank[hidden] {
display: none;
}
.actionTank.tankNudge { .actionTank.tankNudge {
color: var(--gold); color: var(--gold);
} }
@@ -244,6 +244,9 @@ const ActionBar = {
} }
this.syncTankTimer(); this.syncTankTimer();
// After the tank timer, because whether the stopwatch and the clock button are
// on the acting line is what decides how much room the name has left.
this.fitActingLine();
this.announce(); this.announce();
}, },
@@ -420,16 +423,23 @@ const ActionBar = {
const whoStack = document.createElement("span"); const whoStack = document.createElement("span");
whoStack.className = "actionWhoStack"; whoStack.className = "actionWhoStack";
whoStack.textContent = ` · ${Money.format(Betting.stackOf(this.state, player))} behind`; // No leading space: this is a flex item and its own would be collapsed away
// at the edge. .actionWho holds the two of them apart instead.
whoStack.textContent = `· ${Money.format(Betting.stackOf(this.state, player))} behind`;
who.appendChild(whoStack); who.appendChild(whoStack);
acting.appendChild(who); acting.appendChild(who);
// Built every render and shown by the tick, the same way the clock button
// beside it is: a stopwatch reading "0:02" is 50px of the narrowest line in
// the tool spent on a number nobody acts on. Starts hidden so a decision
// that has only just landed on somebody never flashes one up for a frame.
const tank = document.createElement("span"); const tank = document.createElement("span");
tank.className = "actionTank"; tank.className = "actionTank";
tank.id = "actionTank"; tank.id = "actionTank";
tank.title = "How long this player has been thinking"; tank.title = "How long this player has been thinking";
tank.textContent = "0:00"; tank.textContent = "0:00";
tank.hidden = true;
acting.appendChild(tank); acting.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough // Rendered every time and shown by the tick once the wait is long enough
@@ -475,6 +485,23 @@ const ActionBar = {
// ellipsize, and only a real element can: as a bare text node the label // ellipsize, and only a real element can: as a bare text node the label
// truncated *through* the keycap below it, which then sat squashed against // truncated *through* the keycap below it, which then sat squashed against
// the cut-off text looking like a rendering fault. // the cut-off text looking like a rendering fault.
//
// Known, measured, and not fixed here: on a phone this label has 73px and
// four of the five labels a hand produces want more than that —
//
// ↶ dealing the hand 104 ↶ Christopher's fold 109
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
// ↶ Dee's fold 69 (the only one that fits)
//
// so the pill reads "↶ dealing …" most of the night. Widening it is not
// available — the room it would take is the room the name beside it needs,
// and that is what fitActingLine is spending. The answer is the same shape
// as the acting line's: the actor and the act as two spans, the actor
// shrinking first, so "Mike from work's fold" degrades to "Mike f…'s fold"
// rather than to "Mike from wor…" and the *act* — the half that says what
// would come back — survives. That needs the two halves to come from the
// call sites rather than be guessed out of a string with an apostrophe in
// it, which is a change to what commit() is handed, and a round of its own.
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "actionUndoLabel"; label.className = "actionUndoLabel";
label.textContent = `↶ ${undoLabel}`; label.textContent = `↶ ${undoLabel}`;
@@ -626,12 +653,59 @@ const ActionBar = {
} }
}, },
// Whether this decision has been going on long enough to be worth saying anything
// about — which is the one question behind both the stopwatch and the button beside
// it, so they ask it once and answer it together.
//
// The stopwatch used to be there from the first second of every decision, and for
// the first half-minute it reads "0:07" in grey: a number nobody acts on, holding
// 50px of a line that on a 390px phone has 349 to spend between a name, a stack, a
// clock button and the undo pill. It is not a readout the dealer is watching; it is
// a prompt, and a prompt that is always there is not one. See fitActingLine for
// what the room it gives back is spent on.
tankWorthShowing() {
if (this.clockRunning()) return true;
if (this.tankStartedAt === null) return false;
// Nothing to time from — the action was already here when the page loaded. The
// dash and its explanation are the answer to "why is there no clock on this
// one", so it has to be visible to be asked.
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) return true;
return this.tankSeconds() >= this.NUDGE_AFTER_S;
},
tankSeconds() {
return Math.floor((Date.now() - this.tankStartedAt) / 1000);
},
// Updates only the timer's own text, so the seconds ticking over never rebuilds the // Updates only the timer's own text, so the seconds ticking over never rebuilds the
// buttons under the dealer's thumb. // buttons under the dealer's thumb.
paintTank() { paintTank() {
const el = document.getElementById("actionTank"); const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return; if (!el || this.tankStartedAt === null) return;
const nudge = document.getElementById("actionClockBtn");
const wasTank = el.hidden;
const wasNudge = nudge ? nudge.hidden : null;
const worth = this.tankWorthShowing();
el.hidden = !worth;
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking. Before that it is a control nobody
// needs, and this bar is docked over the seats. While a clock is already running
// the button says "Stop the clock" and renderStatus decides whether it is there
// at all — once the count has expired there is nothing left to stop.
if (nudge && !this.clockRunning()) nudge.hidden = !worth;
if (!this.paintTankText(el)) return;
// Two things just left the line or joined it, so what is left has a different
// amount of room than it did a second ago.
if (el.hidden !== wasTank || (nudge && nudge.hidden !== wasNudge)) this.fitActingLine();
},
// The readout itself. Returns false when it has asked for a full render instead, so
// that nothing downstream measures a bar which is about to be rebuilt.
paintTankText(el) {
// A clock takes the readout over: the number the table is waiting on is no // A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to // longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one // change shape — a ruling and the button that carries it out — so that one
@@ -641,39 +715,60 @@ const ActionBar = {
if (left === 0 && !this.clockExpiredShown) { if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true; this.clockExpiredShown = true;
this.render(); this.render();
return; return false;
} }
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left); el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided"; el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S); el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S); el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return; return true;
}
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking — which is the same threshold the
// stopwatch already marks. Before that it is a control nobody needs, and this
// bar is docked over the seats.
const nudge = document.getElementById("actionClockBtn");
if (nudge && !this.clockRunning()) {
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
? false
: waited < this.NUDGE_AFTER_S;
} }
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) { if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—"; el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves."; el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
el.classList.remove("tankNudge", "tankSlow"); el.classList.remove("tankNudge", "tankSlow");
return; return true;
} }
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000); const seconds = this.tankSeconds();
el.title = "How long this player has been thinking"; el.title = "How long this player has been thinking";
el.textContent = this.formatTank(seconds); el.textContent = this.formatTank(seconds);
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S); el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S); el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
return true;
},
// Which half of "Christopher · $1,200 behind" survives when the line cannot hold
// both of them.
//
// The name was set up to be the half that gives way, on the rule the seat pucks
// keep: a number read wrong is a worse failure than a name read short. That rule is
// right and it stays. What was missing under it was a floor. Flex shrinks the only
// item that *can* shrink until the line fits, and with the clock button on the line
// there is no fitting to be had — measured at 360x640 and again at 390x844, both
// "Christopher" and "Alexandra" were drawn 0px wide, so the bar read
//
// · $1,200 behind
//
// a dangling separator and a figure, at the exact moment the dealer is looking at
// the bar to say whose turn it is out loud. The ellipsis never appeared: there were
// no characters left to put one after.
//
// So the stack comes off the line before the name gives up a single character. It is
// not lost — it is on that player's own seat puck, a couple of inches up the same
// screen, which is where a dealer counting somebody down is looking anyway. The name
// is the thing the bar alone is saying.
fitActingLine() {
const name = document.querySelector("#actionBar .actionWhoName");
const stack = document.querySelector("#actionBar .actionWhoStack");
if (!name || !stack) return;
// Always measured with the stack back on, so the answer is a property of the
// line rather than of whatever the last render happened to leave behind.
stack.hidden = false;
// What the name wants against what flex gave it. A cut name reads as an
// overflowing one even when the overflow is the whole word.
if (name.scrollWidth > name.clientWidth + 1) stack.hidden = true;
}, },
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left // "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
+170
View File
@@ -294,6 +294,176 @@ describe("the status block is the same number of lines whoever is acting", () =>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("and the name is the half that is still there at the end of it", () => {
// Shrinking the name had no floor under it. Flex takes the only item that can give
// way and takes it all the way, so with the clock button on the line there was
// nothing left to give: measured with five players nine-handed, mid-street, forcing
// the clock button on —
//
// 360x640 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 390x844 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 430x932 "Christopher" 62 -> 31.
//
// What the bar read at the first two was "· $1,200 behind" — the separator, the
// figure, and no player. The ellipsis the rule was written around never appeared,
// because there were no characters left to put one after.
//
// The rule itself is kept: a number read wrong is worse than a name read short, so
// the stack is never truncated. It is taken off the line whole instead. Measured
// again afterwards, with the same clock button on the same line:
//
// 360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
// 390x844 both whole
// 430x932 both whole
//
// and 0px of seat movement across the thirty-second mark at every one of them.
const withLine = (name, stack, run) => {
const el = (extra) => Object.assign({
hidden: false, scrollWidth: 0, clientWidth: 0,
}, extra);
const nameEl = el(name);
const stackEl = el(stack);
global.document = {
querySelector: sel => (sel.endsWith(".actionWhoName") ? nameEl
: sel.endsWith(".actionWhoStack") ? stackEl : null),
};
try { run(nameEl, stackEl); } finally { delete global.document; }
};
test("a name that fits keeps the stack beside it", () => {
withLine({ scrollWidth: 54, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a name being cut takes the stack off the line first", () => {
withLine({ scrollWidth: 62, clientWidth: 0 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, true,
"the stack is on that player's own seat puck; the name is only here");
});
});
test("and a stack already off it is put back before anything is measured", () => {
// Otherwise the answer is a property of whatever the last render left behind
// rather than of the line: a name that fits only *because* the stack is missing
// would keep it missing for the rest of the night.
withLine({ scrollWidth: 54, clientWidth: 54 }, { hidden: true }, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a hair of rounding is not a cut", () => {
withLine({ scrollWidth: 55, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("and a bar with nobody acting on it is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitActingLine()); }
finally { delete global.document; }
});
test("the stylesheet can actually take it off the line", () => {
// `hidden` is a UA-stylesheet default, and .actionWhoStack sits inside a flex
// container. Said out loud here so a later `display:` on the class can't quietly
// put the figure back.
assert.match(CSS, /\.actionWhoStack\[hidden\]\s*\{[^}]*display:\s*none/);
assert.match(CSS, /\.actionTank\[hidden\]\s*\{[^}]*display:\s*none/);
});
test("the space before the separator is in the stylesheet, not in the string", () => {
// These are two flex items, and a flex item's own leading whitespace is collapsed
// at its edge — so ` · $1,200 behind` drew as `Alexandra· $1,500 behind` from the
// day the sentence was split in two.
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
assert.match(who[1], /gap:\s*var\(--sp-1\)/);
assert.match(CSS_JS, /whoStack\.textContent = `· \$\{/,
"a leading space here is invisible and would double the gap if it weren't");
});
test("and the fit is taken after the timer has decided whether it is on the line", () => {
const render = CSS_JS.slice(CSS_JS.indexOf(" render() {"));
const body = render.slice(0, render.indexOf("\n },"));
const tank = body.indexOf("this.syncTankTimer()");
const fit = body.indexOf("this.fitActingLine()");
assert.ok(tank >= 0 && fit >= 0, "render() should do both");
assert.ok(fit > tank,
"measured before the stopwatch is hidden, the name is measured against 50px it has");
});
});
// ---------------------------------------------------------------------------
describe("a stopwatch that is only there when it is saying something", () => {
// 50px of a 349px line, from the first second of every decision, reading "0:07" in
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a
// prompt that is always on screen is not a prompt. It arrives when the button that
// acts on it arrives, which is the same threshold, and the room goes to the name.
const withClock = ({ seat = 0, started = null, unknown = null, clockAt = null }, run) => {
const saved = {
state: ActionBar.state, tankSeatIndex: ActionBar.tankSeatIndex,
tankStartedAt: ActionBar.tankStartedAt, tankUnknownFor: ActionBar.tankUnknownFor,
clockSeatIndex: ActionBar.clockSeatIndex, clockStartedAt: ActionBar.clockStartedAt,
};
ActionBar.state = { actingSeatIndex: seat };
ActionBar.tankSeatIndex = seat;
ActionBar.tankStartedAt = started;
ActionBar.tankUnknownFor = unknown;
ActionBar.clockSeatIndex = clockAt === null ? null : seat;
ActionBar.clockStartedAt = clockAt;
try { run(); } finally { Object.assign(ActionBar, saved); }
};
test("a decision that has only just landed on somebody shows neither", () => {
withClock({ started: Date.now() - 3000 }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("and both arrive together, at the threshold the stopwatch already marked", () => {
withClock({ started: Date.now() - (ActionBar.NUDGE_AFTER_S * 1000) }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("a clock already running is always worth watching", () => {
// The readout is a countdown by then, and the button beside it says "Stop the
// clock" — there is no second at which either of those is not the point.
withClock({ started: Date.now(), clockAt: Date.now() }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("so is the seat whose wait cannot be known", () => {
// The action was already on them when the page loaded. The dash and the sentence
// behind it are the answer to "why is there no time on this one", so the answer
// has to be visible for the question to get asked.
withClock({ started: Date.now(), unknown: 0 }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("and with nothing to time from at all, nothing is shown", () => {
withClock({ started: null }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("it is built hidden, so a fresh decision never flashes one up", () => {
assert.match(CSS_JS, /tank\.hidden = true;/);
});
test("and the tick re-fits the line only when one of them actually moved", () => {
// The line is measured by forcing a layout, and this runs once a second all
// night. Nothing has changed on the seconds in between.
const paint = CSS_JS.slice(CSS_JS.indexOf(" paintTank() {"));
const body = paint.slice(0, paint.indexOf("\n },"));
assert.match(body, /if \(el\.hidden !== wasTank \|\| \(nudge && nudge\.hidden !== wasNudge\)\) this\.fitActingLine\(\)/);
});
});
// ---------------------------------------------------------------------------
describe("how much of the bottom is spoken for", () => { describe("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different // feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is // answers depending on the layout. Under the bar it is the bar. Under the rail it is