Compare commits

..
2 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 d0e5091824 v0.8.0.7 — the Salvage Yard had nothing to say, and phases too little time to read
The Salvage Yard was face up all along; its tile just read "a card". apply.ts
pushes a synthetic train-<n> id on trainScheduled, nothing in s.cards matches it,
and cardName fell through to its default — and since a train is scheduled several
times a Day that id is on top most of the time. Measured before touching anything:
the tile read "a card" from the opening frame through 60 pushes while its depth
climbed from 2 to 8. cardName resolves it now, in sim/view.ts, because this is a
name.

The engine half is filed as Gitea#23 rather than fixed here. reshuffleIfDepleted
sweeps the Salvage Yard back into the draw deck, so that synthetic id can be
shuffled in and drawn into a hand as an id with no card behind it. Eight games
driven to 4000 moves across eight seeds produced zero reshuffles, so it is latent;
there are two defensible fixes and the choice turns on what the synthetic id is
for, which is not a call to make while fixing a label.

And phases scale with the speed control again, damped to a third of the rate. They
were pinned in v0.8.0.3 because scaling them walled off a player's own turn; pinned
turns out to be too short to read at 10x. Damped satisfies both: 1x unchanged, 10x
lands exactly on the four-times guess. Bounded because phase beats cluster rather
than accumulate — 1.0 per push on average, 4 at worst, so the wait after a move is
~2.4s typical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 06:58:09 -04:00
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
9 changed files with 293 additions and 22 deletions
+96
View File
@@ -19,6 +19,102 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.8.0.7 — 2026-09-10
### The Salvage Yard was face up and had nothing to say
*"Why is salvage deck not face up? I should see the card played onto salvage."* It always was — the
tile reads the top card. It just said **"a card"**.
`apply.ts` pushes a **synthetic id** on `trainScheduled`:
```ts
s.decks.salvageYard.push(`train-${e.trainNumber}`);
```
Nothing in `s.cards` matches that, so `cardName()` fell through to its "a card" default — and since a
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
before touching anything: a session's Salvage tile read `"a card"` from the opening frame through 60
pushes, never once changing, while its depth climbed from 2 to 8.
`cardName()` resolves `train-<n>` now, so the tile reads **Train 3**, **Train 2** as cards land.
Resolved in `sim/view.ts` because this is a NAME, which is that file's job.
**The engine half is filed, not fixed — Gitea#23.** `reshuffleIfDepleted()` sweeps the Salvage Yard
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
produced **zero** reshuffles. There are two defensible fixes and the choice turns on what that
synthetic id is *for*, which is not a call to make in passing while fixing a label.
### Phases scale with the speed control again — at a third of the rate
*"Phases displayed on the upper line go by too quickly still. Should be 4 times as long — at a guess.
Maybe use the speed multiplier for that too?"*
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 **pinned** phases at
their tabled beat because scaling them walled off a player's own turn — *"that makes no sense"*. At
10× that pinned beat is too short to read the sentence on it.
So they scale, damped to a third of the rate: **1× unchanged, 10× lands exactly on the four-times
guess**, 20× gives 4400ms. The cost stays bounded because phase beats cluster rather than accumulate
— measured over 60 pushes, a push carries **1.0 phase beat on average and 4 at worst**, so the wait
after a move is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would
have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that
matters.
---
## 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 ## 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 **Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0.5", "version": "0.8.0.7",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+32 -5
View File
@@ -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 * `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 * 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 * dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
* 3 are the ones actually asked for) and well short of unusable. * 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. * 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 * was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
* takes. * 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. * How long to show one step, in ms, at a given speed.
@@ -172,6 +177,28 @@ export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace))); return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
} }
/**
* How much of the speed control a PHASE gets — damped, not the full multiplier.
*
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
* long — at a guess. Maybe use the speed multiplier for that too?"*
*
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
* worst rather than the minutes a full multiplier would have cost.
*
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
* too.
*/
function phaseSpeed(pace: number): number {
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
}
/** /**
* How long to show one STEP — the form the queue actually uses. * How long to show one STEP — the form the queue actually uses.
* *
@@ -199,7 +226,7 @@ export function dwellForStep(
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted * The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
* to watch a bot shunt cars. * to watch a bot shunt cars.
*/ */
const speed = step.player === null ? 1 : pace; const speed = step.player === null ? phaseSpeed(pace) : pace;
if (step.lines.length > 0) return dwellFor(step.cause, speed); if (step.lines.length > 0) return dwellFor(step.cause, speed);
/** /**
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give * A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
+17
View File
@@ -1791,6 +1791,23 @@ export function snapshot(
/** A card id turned into something a person can read. */ /** A card id turned into something a person can read. */
export function cardName(s: GameState, id: string): string { export function cardName(s: GameState, id: string): string {
/**
* A SCHEDULED TRAIN IS IN THE SALVAGE YARD UNDER A SYNTHETIC ID, and without this the pile that is
* supposed to be face up reads "a card".
*
* `trainScheduled` pushes `train-<number>` rather than the id of the card that was played
* (`apply.ts`), so there is nothing in `s.cards` to look up — and since a train is scheduled
* several times a Day, that synthetic id is on top of the Salvage Yard most of the time. Reported
* by Jesse 2026-09-10 as "why is salvage deck not face up. I should see the card played onto
* salvage": it was face up all along and simply had nothing to say.
*
* Resolved here rather than in the engine because this is a NAME, which is this file's job. Whether
* the engine should be pushing a real card id instead is a separate question with a separate
* consequence — the Salvage Yard is swept back into the draw deck when it runs out — and is filed
* rather than answered in passing.
*/
const scheduled = /^train-(\d+)$/.exec(id);
if (scheduled) return `Train ${scheduled[1]}`;
const k = s.cards.get(id)?.kind; const k = s.cards.get(id)?.kind;
if (!k) return 'a card'; if (!k) return 'a card';
switch (k.kind) { switch (k.kind) {
+52 -2
View File
@@ -184,6 +184,20 @@ function drainIntoQueue(): void {
const reset = session.takeDisplayReset(); const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset); if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps()); 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(); if (stepQueue.busy()) startAnimationLoop();
} }
@@ -316,12 +330,25 @@ function startAnimationLoop(): void {
*/ */
console.error('display queue stopped:', err); console.error('display queue stopped:', err);
animating = false; 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; return;
} }
if (!stepQueue.busy()) { if (!stepQueue.busy()) {
animating = false; 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; return;
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
@@ -1987,6 +2014,29 @@ function renderActions(
renderEnding(el, f); renderEnding(el, f);
return; 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 // 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). // put its results up unasked again (Gitea#11).
resultsShown = false; resultsShown = false;
+14 -4
View File
@@ -723,10 +723,20 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit 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 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. */ element only while that step is the one being shown. */
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;animation:pileflash .45s ease-out 1} .handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
@keyframes pileflash{0%{background:#2f6b47;border-color:#c7f0d4;transform:scale(1.1)} animation:pilepulse 1.15s ease-in-out infinite}
100%{background:#1d3327;border-color:#8fd6a0;transform:scale(1)}} /* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
@media(prefers-reduced-motion:reduce){.handcard.pilelit{animation:none}} 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; .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)} 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 /* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
+34 -10
View File
@@ -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.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(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name'); 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) { 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.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)); assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
@@ -164,23 +170,41 @@ describe('pacing — dwell by kind', () => {
); );
}); });
it('the speed control stretches other people, not the clock', () => { it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
/** /**
* Jesse, from a real 5× game: *"after my turn, when I actually execute my turn, I'm still subject * Two complaints, one from each direction, and the answer is between them.
* to that same delay before it moves on. That makes no sense."* It was not his move being *
* replayed — it was the automatic phases behind it, which were scaling with `pace` along with * v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
* everything else. Measured over 40 turns, the waiting split almost evenly between other players * moves on. That makes no sense."* — phases were scaling with everything else and walling off a
* and phases turning over, so a 5× game spent 105 seconds on the clock alone. * player's own turn. So they were pinned at their tabled beat.
*
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
*
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
* the cost stays bounded because phase beats cluster rather than accumulate.
*/ */
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } }; const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } }; const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
for (const pace of [1, 3, 5, 7]) {
assert.equal(dwellForStep(phase, pace), DWELL.phase, `a phase beat grew at ${pace}x`); assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
assert.equal(dwellForStep(theirs, pace), DWELL.switching * pace); assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
const p = dwellForStep(phase, pace);
const t = dwellForStep(theirs, pace);
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
assert.ok(
p < DWELL.phase * pace,
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
);
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
} }
// Off still means off, for the clock as much as for anybody's move. // Off still means off, for the clock as much as for anybody's move; and below 1x the clock
// follows the multiplier straight, because "faster" should mean everything.
assert.equal(dwellForStep(phase, 0), 0); assert.equal(dwellForStep(phase, 0), 0);
assert.equal(dwellForStep(theirs, 0), 0); assert.equal(dwellForStep(theirs, 0), 0);
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
}); });
it('a real switching turn is watchable in a few seconds, not tens of them', () => { it('a real switching turn is watchable in a few seconds, not tens of them', () => {
+19
View File
@@ -251,6 +251,25 @@ describe('the step queue', () => {
assert.ok(q.showing() !== null, 'the caption should survive a reset'); 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', () => { it('draws nothing before a reset has arrived', () => {
const q = createStepQueue(); const q = createStepQueue();
assert.equal(q.current(), null); assert.equal(q.current(), null);
+28
View File
@@ -3647,6 +3647,34 @@ describe('the Day rolling over says so (Gitea#10)', () => {
); );
}); });
it('names the train on top of the Salvage Yard, instead of "a card"', () => {
/**
* The Salvage Yard is a FACE-UP pile and its tile reads the top card — but `trainScheduled`
* pushes a synthetic `train-<number>` id rather than the id of the card that was played
* (`apply.ts`), so there was nothing in `s.cards` to look up and the tile said "a card". A train
* is scheduled several times a Day, so that id is on top most of the time: the pile was face up
* and had nothing to say. Jesse, 2026-09-10: *"why is salvage deck not face up. I should see the
* card played onto salvage."*
*/
const s = createEngineGame({
id: 'salv',
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'],
});
s.decks.salvageYard.push('train-13');
const f = snapshot(s, [], null);
assert.equal(f.salvage.top, 'Train 13', 'a scheduled train on the pile must be named');
const html = pilesHtml(f);
assert.ok(html.includes('Train 13'), `the Salvage tile does not name the train:\n${html}`);
assert.equal(html.includes('>a card<'), false, 'the face-up pile still says "a card"');
});
it('lights only the pile a watched move touched', () => { it('lights only the pile a watched move touched', () => {
const s = createEngineGame({ const s = createEngineGame({
id: 'piles2', id: 'piles2',