Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user