From d0e509182467af81cdb6ff6991df49259635c431 Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Thu, 10 Sep 2026 06:58:09 -0400 Subject: [PATCH] =?UTF-8?q?v0.8.0.7=20=E2=80=94=20the=20Salvage=20Yard=20h?= =?UTF-8?q?ad=20nothing=20to=20say,=20and=20phases=20too=20little=20time?= =?UTF-8?q?=20to=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Salvage Yard was face up all along; its tile just read "a card". apply.ts pushes a synthetic train- 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 Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/sim/pacing.ts | 24 +++++++++++++++++++++++- src/sim/view.ts | 17 +++++++++++++++++ test/pacing.test.ts | 38 ++++++++++++++++++++++++++++---------- test/web.test.ts | 28 ++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167be11..2ced78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,51 @@ page as `v0.1.0 · · `, 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-` 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 diff --git a/package.json b/package.json index f474aeb..88b38e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.6", + "version": "0.8.0.7", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/sim/pacing.ts b/src/sim/pacing.ts index 970a04e..411ea12 100644 --- a/src/sim/pacing.ts +++ b/src/sim/pacing.ts @@ -177,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))); } +/** + * 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. * @@ -204,7 +226,7 @@ export function dwellForStep( * The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted * 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); /** * A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give diff --git a/src/sim/view.ts b/src/sim/view.ts index d378716..0eed25b 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -1791,6 +1791,23 @@ export function snapshot( /** A card id turned into something a person can read. */ 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-` 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; if (!k) return 'a card'; switch (k.kind) { diff --git a/test/pacing.test.ts b/test/pacing.test.ts index a1a1b62..156dab4 100644 --- a/test/pacing.test.ts +++ b/test/pacing.test.ts @@ -170,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 - * 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 - * everything else. Measured over 40 turns, the waiting split almost evenly between other players - * and phases turning over, so a 5× game spent 105 seconds on the clock alone. + * Two complaints, one from each direction, and the answer is between them. + * + * v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it + * moves on. That makes no sense."* — phases were scaling with everything else and walling off a + * 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 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(theirs, pace), DWELL.switching * pace); + + assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat'); + 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(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', () => { diff --git a/test/web.test.ts b/test/web.test.ts index ecaee3c..cfda119 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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-` 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', () => { const s = createEngineGame({ id: 'piles2',