diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 75728c2..f73f0b1 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3272,10 +3272,16 @@ body.hasActionBar #holdemApp { 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 { color: var(--text-muted); display: flex; align-items: baseline; + gap: var(--sp-1); min-width: 0; 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 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 { flex: none; } +.actionWhoStack[hidden] { + display: none; +} + /* 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 { flex: none; min-width: 3.1rem; @@ -3305,6 +3324,10 @@ body.hasActionBar #holdemApp { transition: color 0.3s ease; } +.actionTank[hidden] { + display: none; +} + .actionTank.tankNudge { color: var(--gold); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 0c20df8..9fe74e6 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -244,6 +244,9 @@ const ActionBar = { } 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(); }, @@ -420,16 +423,23 @@ const ActionBar = { const whoStack = document.createElement("span"); 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); 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"); tank.className = "actionTank"; tank.id = "actionTank"; tank.title = "How long this player has been thinking"; tank.textContent = "0:00"; + tank.hidden = true; acting.appendChild(tank); // 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 // truncated *through* the keycap below it, which then sat squashed against // 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"); label.className = "actionUndoLabel"; 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 // buttons under the dealer's thumb. paintTank() { const el = document.getElementById("actionTank"); 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 // 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 @@ -641,39 +715,60 @@ const ActionBar = { if (left === 0 && !this.clockExpiredShown) { this.clockExpiredShown = true; this.render(); - return; + return false; } el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left); el.title = "Time left before the hand is decided"; el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S); el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S); - return; - } - - // 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; + return true; } if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) { 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.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.textContent = this.formatTank(seconds); el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && 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 diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js index 2a7da9e..33c513c 100644 --- a/tests/holdem/theRail.test.js +++ b/tests/holdem/theRail.test.js @@ -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", () => { // 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