Compare commits

...
3 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 64e8ce584f v0.8.0.6 — your move waits its turn, the lit pile keeps asking to be looked at
Your actions are put away while the board is catching up. The board on screen is
behind the game, so a move offered there is a move against a position that has
already moved on — and the screen had grown to four things competing at once: the
district, the history, the catching-up row, and a lit pile. Skip is one click away,
so the wait stays voluntary.

That could have locked a player out of their own game. Hiding actions behind busy()
makes that flag the thing standing between a player and their turn, and without
requestAnimationFrame nothing ever advances the queue — so busy() would never
clear. Caught by the DOM-stub test that has been proving this page still starts
since long before any of this existed. No rAF now means draw everything at once,
which is what pace 0 does deliberately, and a queue that throws empties itself
rather than stranding anyone.

The lit pile was never brief: measured, it stays lit for 6997ms at 10x. It was a
single flash over a dark fill, easy to miss while watching the district — a state
that settles stops asking to be looked at. It pulses now for as long as the move is
up.

And the pace ceiling was not theoretical. 10x was the top of the ladder and was
reported still a bit fast; it runs to 20 now. A control whose limit is reached in
ordinary use has the wrong limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 05:49:01 -04:00
Jesse.MarkowitzandClaude Opus 5 3fca325699 v0.8.0.5 — the Home Office deck, and lighting the pile a move touched
"Many operations still occurred too fast for me to see", at 10x — where an action
already holds the screen for seven seconds. So it was never duration: a bot drawing
a card changes one number in a panel nobody is watching, and the board sits
unchanged. Raising the dwell was the wrong lever and it had been pulled three
times.

f.deck has carried the face-down count since the Frame existed and nothing drew it
— the display gap test/display-gaps.test.ts sweeps for, surviving in the one panel
that draws every other pile. It is a tile now, first in the row, face down, because
that is the order a card travels and not knowing what is on top is the point.

And the piles a move touched are lit for as long as that move is on screen. Derived
from the frames either side of a step rather than sent, so nothing joins the
protocol and the 0.8.1 board gets it free. What lights follows what is public, and
was measured across four seeds rather than reasoned about: a Home Office draw
lights the deck and never names the card; a Department draw lights that pile, and
the deck too when it refills; a discard lights the Department it lands on; a played
card lights the Salvage Yard. Switching and new trains light nothing here — they
move the board, which the district panel already follows.

A state rather than a flash: the timetable's fixed 1.5s animation would be over
long before a seven-second pause. Not for your own moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 04:35:59 -04:00
Jesse.MarkowitzandClaude Opus 5 fc40fc39ed v0.8.0.4 — take the test server's name back out of the tracked files
Both repositories allow anonymous clone — checked rather than assumed: info/refs
for git-upload-pack answers 200 for each, git-receive-pack answers 401. So
everything committed here is public, and tracked files are supposed to carry
placeholders rather than real hosts.

Ten mentions added while building v0.8.0 are now "the test server" or "the target
hardware", across CHANGELOG.md, the common-board plan's three deferral banners,
sim/pacing.ts, and two test files. Prose and comments only, no behaviour; the
quotes are untouched, because what was said about bot pacing is the part worth
keeping.

Left alone deliberately: nineteen older mentions in entries about v0.7.5, v0.7.6
and v0.7.8 and in TODO.md, since rewriting a changelog after the fact makes the
record less true; and scripts/deploy-web.ts, where the host is the functional
default for FB_URL rather than prose — turning that into a required variable
changes how deploying works and wants deciding on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 21:43:06 -04:00
12 changed files with 485 additions and 23 deletions
+128 -2
View File
@@ -19,6 +19,132 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.8.0.6 — 2026-09-10
Playing v0.8.0.5: *"saw bot's office area now — much better."* Three things still wrong, and one of
them was mine hiding inside the fix for another.
### Your move is put away while the board is catching up
*"Your actions should be hidden while catching up."* Two reasons, and the second is the one that
changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is
behind the game, so a move offered there is a move against a position that has already moved on — the
menu is computed from the CURRENT state and would be acted on while looking at an older one. And the
screen had grown to four things competing at once: the district, the history, the catching-up row,
and now a lit pile. Taking the action list out of that competition, while there is nothing to decide
anyway, is the cheapest way to quieten it.
Not a block: Skip is one click away at the left of the row, so the wait stays voluntary. The buttons
are replaced by the reason they are gone.
### …which could have locked a player out of their own game
Hiding actions behind `busy()` makes that flag the thing standing between a player and their turn —
and **without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would never
clear.** The action list would have been hidden permanently, with Skip the only way to play.
Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same stub that has been proving this
page still starts since long before any of this existed. Two fallbacks now: no `rAF` means draw
everything at once (exactly what `pace = 0` does deliberately), and a queue that throws empties
itself rather than stranding the player. `test/step-queue.test.ts` pins that a never-advanced queue
is still skippable.
### The lit pile was never brief — it was too quiet
*"Never saw decks lighting up… caught one flash deck light up for just a very brief moment, but
couldn't see that with what bot was doing in office area and history and catch up area all at same
time."*
Measured before changing anything: at 10× a pile stays lit for **6997ms**, just under seven seconds.
So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to
miss entirely while looking at the district — a state that settles stops asking to be looked at. It
pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud
in a different way rather than simply still, since motion is the whole point here.
### The ceiling was not theoretical
*"At 10× — still a bit fast, but followable."* 10× was the top of the ladder, so the control's
slowest setting was not slow enough for the person using it. `PACE_LEVELS` now runs to 20 and
`MAX_PACE` with it. A control whose limit is reached in ordinary use has the wrong limit, not the
right one held firmly.
---
## 0.8.0.5 — 2026-09-10
**Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
me to see."* At 10× an action holds the screen for seven seconds, so this was never about duration —
it was that a bot drawing a card changes one number in a panel nobody is watching, and the board sits
unchanged for those seven seconds. **Raising the dwell was the wrong lever, and it had been pulled
three times.** His diagnosis was the right one: mark WHERE, not longer.
### The Home Office deck was never drawn
`f.deck` has carried the face-down count since the Frame existed and **nothing in `src/web/` read
it** — the exact display gap `test/display-gaps.test.ts` was written to sweep for, surviving in the
one panel that draws every other pile. It is a tile now, first in the row, because that is the order
a card travels: out of the deck, into a hand, then onto a Department or the Salvage Yard. Face down,
so its card slot says so rather than naming one — not knowing what is on top is the point of the
pile.
### What lights, and why that is exactly what is public
The piles a move touched are now lit for as long as that move is on screen. **Derived, never sent**:
the client already holds the frame before a step and the frame after it, so `changedPiles()` is a
diff. Nothing is added to the protocol, nothing can drift out of step with the projection, and the
0.8.1 seatless board gets it for free.
Measured across four seeds rather than reasoned about, and pinned by a test that requires each case
to have actually occurred rather than passing on whichever the bot happened to play:
| action | lights | why that is public |
| --- | --- | --- |
| `draw.fromHomeOffice` | the deck | the count, never the card — a blind draw stays the drawer's |
| `draw.fromDepartment` | that Department, and the deck when it refills | the pile is face up, so the card taken is public |
| `card.discard` | that Department | face up, and which pile it went on is the point |
| `card.play` | the Salvage Yard | where a played card that did not stay on the board lands |
| switching, new trains | nothing here | they move the board, which the district panel already follows |
| `*.end`, `localOps.choose` | nothing | no card moved |
**It is a state, not a flash**, and that distinction is the whole reason it works. The timetable's
existing `.tt-slot.fresh` animates for a fixed 1.5s — right for a die roll nobody is waiting on, and
wrong here, because a step can hold for seven seconds and the animation would be long over before
the pause it belongs to. A brief flash-in marks the moment; the lit border and background stay for
exactly as long as the step is up.
**Not for your own moves.** You drew that card — the same rule that already gives your own steps no
dwell.
---
## 0.8.0.4 — 2026-09-09
**Housekeeping: the test server's name is out of the ten places this session put it.**
Both of this project's repositories allow anonymous clone — checked, not assumed: `info/refs` for
`git-upload-pack` answers 200 for `station-master` and for `station-master-startos` alike, while
`git-receive-pack` answers 401. So everything committed here is public, and the standing rule is that
tracked files carry placeholders rather than real hosts.
Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware":
`CHANGELOG.md`, `docs/plans/jitsi-common-board.md` (three identical deferral banners), `sim/pacing.ts`,
`test/pacing.test.ts` and `test/step-queue.test.ts`. Prose and comments only — no behaviour, and the
quotes they carry are unchanged, because what a player said about bot pacing is the part worth
keeping.
**What is deliberately left, and why it is not an oversight:**
- **Nineteen older mentions**, in entries about v0.7.5, v0.7.6 and v0.7.8 and in `TODO.md`. Rewriting
a changelog after the fact makes the record less true, and these describe verification that
genuinely happened on that machine.
- **`scripts/deploy-web.ts` is FUNCTIONAL, not prose.** It carries the host as the default for
`FB_URL`, so a placeholder there would break the deploy for the person the default exists to serve.
Same for the public address it publishes to. If those should move to required environment variables
with no default, that is a change to how deploying works and wants deciding on its own rather than
being smuggled in beside a comment sweep.
---
## 0.8.0.3 — 2026-09-09
Three things from playing v0.8.0.2, all of them about the row rather than the mechanism.
@@ -64,7 +190,7 @@ as how long another player's move is held, and that is now what it does. A phase
## 0.8.0.2 — 2026-09-09
Two things found by playing v0.8.0.1 on `phoenix.local`, neither of them in the mechanism itself.
Two things found by playing v0.8.0.1 on the test server, neither of them in the mechanism itself.
### `?pace=` never worked, and a whole game was played at the wrong speed
@@ -136,7 +262,7 @@ the suite until it was declared out loud.
## 0.8.0.1 — 2026-09-09
**Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour;
**Bot play was way too fast.** v0.8.0 was installed on the test server and played within the hour;
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
+3 -3
View File
@@ -888,7 +888,7 @@ Cover:
## Step 5 — Minimal visual-only Jitsi engine
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1028,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
## Step 6 — Chromium publisher supervisor
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1161,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
## Step 7 — Configuration, lifecycle, packaging, and observability
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.8.0.3",
"version": "0.8.0.6",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+10 -5
View File
@@ -54,7 +54,7 @@ export const DWELL: Record<StepKind, number> = {
*
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
* Jesse, from the first real play on `phoenix.local`: *"bot play was way too fast. I briefly saw
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
* that it was the bot's office area then their turn was done."* His instruction had been "start at
* 1s and tune down", and that was applied only to switching while this number was invented.
*/
@@ -143,10 +143,15 @@ export function kindOf(cause: StepCause): StepKind {
*
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
* 3 are the ones actually asked for) and well short of unusable.
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
* well short of unusable.
*
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
* held firmly.
*/
export const MAX_PACE = 10;
export const MAX_PACE = 20;
/**
* The speeds the on-screen control offers, slowest last.
@@ -158,7 +163,7 @@ export const MAX_PACE = 10;
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
* takes.
*/
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const;
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
/**
* How long to show one step, in ms, at a given speed.
+43
View File
@@ -130,3 +130,46 @@ function need<T>(value: T | undefined, what: string): T {
}
return value;
}
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
export type PileKey = 'home' | 'salvage' | `dept${number}`;
/**
* WHICH PILES A STEP MOVED — derived, never sent.
*
* The receiver already holds the frame before a step and the frame after it, so which pile changed
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
* hint would.
*
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
*
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
*
* | intent | piles |
* | ----------------------- | -------------------------------------------------------- |
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
* | `card.discard` | that `dept` |
* | `card.play` | `salvage`, or nothing here when it lands on the board |
* | switching, new trains | nothing here — those show on the board itself |
*/
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
if (before === null) return [];
const out: PileKey[] = [];
if (before.deck !== after.deck) out.push('home');
after.departmentDepth.forEach((depth, i) => {
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
// and changes the card everybody can see, which is the half that matters to a watcher.
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
out.push(`dept${i}`);
}
});
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
out.push('salvage');
}
return out;
}
+55 -3
View File
@@ -184,6 +184,20 @@ function drainIntoQueue(): void {
const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps());
/**
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
*
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
* stub that has been proving this page still starts since long before any of this existed.
*/
if (typeof requestAnimationFrame !== 'function') {
stepQueue.skip();
return;
}
if (stepQueue.busy()) startAnimationLoop();
}
@@ -316,12 +330,25 @@ function startAnimationLoop(): void {
*/
console.error('display queue stopped:', err);
animating = false;
// Do not strand the player behind a queue that can no longer advance: jump the board to the
// live position, which brings "Your Move" back with it.
try {
stepQueue.skip();
} catch {
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
}
render();
return;
}
if (!stepQueue.busy()) {
animating = false;
// One last render so the "N behind" row collapses the moment the board is level.
renderWatching();
/**
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
* action list hidden until something else happened to trigger a render — which, when the game
* is waiting on this player, is nothing at all.
*/
render();
return;
}
requestAnimationFrame(tick);
@@ -1511,7 +1538,9 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
$('depts').innerHTML = pilesHtml(f);
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
// whenever the board is level with the game, or when the move was this player's own.
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
renderYards(f);
@@ -1985,6 +2014,29 @@ function renderActions(
renderEnding(el, f);
return;
}
/**
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
* be hidden while catching up."*
*
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
* behind the game, so a move offered here is a move against a position that has already moved on —
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
* And the display had grown to four things demanding attention at once — the district, the history,
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
* Taking the action list out of that competition while there is nothing to decide anyway is the
* cheapest way to quieten it.
*
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
* menu over a stale board.
*/
if (stepQueue.busy()) {
el.innerHTML =
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
'level with the game. <b>Skip</b> jumps straight to it.</div>';
return;
}
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
// put its results up unasked again (Gitea#11).
resultsShown = false;
+64 -3
View File
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there.
*/
export function pilesHtml(f: Frame): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
const pile = (
key: string,
label: string,
top: string,
depth: number,
why: string,
extra = '',
slot = -1,
faceDown = false,
): string => {
const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
return (
/**
* THE HOME OFFICE DECK, which the screen had never drawn.
*
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
*
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
* — the whole point of this pile is that nobody knows what is on top.
*/
pile(
'home',
'Home Office',
'face down',
f.deck,
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
'into it.',
'',
-1,
true,
) +
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pile(
`dept${i}`,
`Dept ${i + 1}`,
d,
depth,
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
})
.join('') +
pile(
'salvage',
'Salvage',
f.salvage.top,
f.salvage.depth,
@@ -676,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e}
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
dimmed voice the rest of the panel uses for "nothing to read here". */
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
border and background stay for exactly as long as the step is up, because the class is on the
element only while that step is the one being shown. */
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
animation:pilepulse 1.15s ease-in-out infinite}
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
office area and history and catch up area all at same time." Something still moving keeps drawing
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
than simply not moving: a solid ring and a brighter fill, held. */
@media(prefers-reduced-motion:reduce){
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
+19 -1
View File
@@ -19,7 +19,8 @@
import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts';
import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = {
@@ -43,6 +44,13 @@ export type StepQueue = {
behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null;
/**
* The piles the step now on screen moved, for the display to light.
*
* Here because this is the only place that holds both the frame before a step and the frame after
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
*/
lit(): readonly PileKey[];
/** True while there is anything left to show. */
busy(): boolean;
};
@@ -65,6 +73,7 @@ export function createStepQueue(
): StepQueue {
let shown: PublicFrame | null = null;
let last: DisplayStep | null = null;
let litPiles: readonly PileKey[] = [];
let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null;
@@ -75,8 +84,14 @@ export function createStepQueue(
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => {
const before = shown;
shown = applyPublicDelta(shown, step.frame);
last = step;
/**
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
* that gives your own steps no dwell — the display is for watching everybody else.
*/
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
};
return {
@@ -84,6 +99,8 @@ export function createStepQueue(
shown = frame;
pending = [];
dueAt = null;
// Nothing was watched arriving at this board, so nothing on it is lit.
litPiles = [];
// `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true.
},
@@ -136,6 +153,7 @@ export function createStepQueue(
current: () => shown,
behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last,
lit: () => litPiles,
/**
* STILL SHOWING SOMETHING, not just still holding something back.
*
+8 -2
View File
@@ -52,7 +52,7 @@ describe('pacing — dwell by kind', () => {
assert.equal(kindOf('switch.end'), 'bookkeeping');
/**
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
* play on `phoenix.local`. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
* to do.
*/
@@ -131,6 +131,12 @@ describe('pacing — dwell by kind', () => {
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
/**
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
* somebody actually reached for and found insufficient.
*/
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
for (const p of PACE_LEVELS) {
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
@@ -199,7 +205,7 @@ describe('pacing — dwell by kind', () => {
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
/**
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on `phoenix.local`:
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
* was done."* This is the shape that turn actually had — no switching in it at all, because
* switching is not legal until there is track down — and under the original values it came to
+71 -1
View File
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
import { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = {
mode: 'competitive',
@@ -184,3 +184,73 @@ describe('public frame delta', () => {
});
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
describe('which piles a step moved', () => {
/**
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
* and asserts the mapping holds, action by action.
*/
it('maps each action to the piles it actually touches', () => {
const seen = new Map<string, Set<string>>();
/**
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
* never takes a Department card, and a driver that prefers one then never draws from the deck —
* so each preference is played out separately and the assertions below require BOTH to have
* been observed rather than passing on whichever happened to occur.
*/
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
for (const seed of [1917398, 191056, 4242]) {
const s = newState(seed);
for (let i = 0; i < 400; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen =
options.find((o) => o.type === prefer) ??
options.find((o) => o.type === 'card.discard') ??
options[i % options.length]!;
const before = publicSnapshot(s);
const r = applyIntent(s, actor, chosen);
if (!r.ok) break;
pump(s);
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
for (const p of piles) seen.get(chosen.type)!.add(p);
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
}
}
}
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
}
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
// getting shorter is not, and it is the only thing a watcher can be shown.
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
// it. Both are public, so both may light.
for (const p of of('draw.fromDepartment')) {
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
}
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
// A DISCARD lands face up on a Department, and which one is public.
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
}
});
it('lights nothing without a previous frame to compare against', () => {
// A reset has nothing to have watched arriving, so nothing on it is lit.
const s = newState(4242);
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
});
});
+20 -1
View File
@@ -179,7 +179,7 @@ describe('the step queue', () => {
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
* play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
* play on the test server: *"I briefly saw that it was the bot's office area then their turn was
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
*
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
@@ -251,6 +251,25 @@ describe('the step queue', () => {
assert.ok(q.showing() !== null, 'the caption should survive a reset');
});
it('can always be emptied, so a player is never stranded behind it', () => {
/**
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
* thing standing between a player and their own turn. So the ways it can be cleared matter more
* than they did: `skip()` must always work, from any state, including one where the clock has
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
* and how this was found.
*/
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
// Never advanced at all: no frame has been shown, and the queue is full.
assert.equal(q.busy(), true);
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
assert.deepEqual(q.current(), final);
});
it('draws nothing before a reset has arrived', () => {
const q = createStepQueue();
assert.equal(q.current(), null);
+63 -1
View File
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
@@ -3614,6 +3614,68 @@ describe('the Day rolling over says so (Gitea#10)', () => {
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
});
it('draws the Home Office deck, face down, with its count', () => {
/**
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
* flash: taking a card off this deck is the commonest move nobody can see.
*/
const s = createEngineGame({
id: 'piles',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
const html = pilesHtml(f);
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
assert.ok(
html.indexOf('Home Office') < html.indexOf('Dept 1'),
'the draw deck should be read before the piles cards land on',
);
});
it('lights only the pile a watched move touched', () => {
const s = createEngineGame({
id: 'piles2',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
const home = pilesHtml(f, ['home']);
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
assert.ok(
home.indexOf('pilelit') < home.indexOf('Dept 1'),
'a Home Office draw must light the Home Office pile, not a Department',
);
const dept2 = pilesHtml(f, ['dept1']);
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
// Two piles can move at once — a Department draw that refills from the deck.
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
});
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
/**
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two