From 3fca325699db097c88d7f91bfcc7dda53826933e Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Thu, 10 Sep 2026 04:35:59 -0400 Subject: [PATCH] =?UTF-8?q?v0.8.0.5=20=E2=80=94=20the=20Home=20Office=20de?= =?UTF-8?q?ck,=20and=20lighting=20the=20pile=20a=20move=20touched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6 --- CHANGELOG.md | 47 +++++++++++++++++++++++++ package.json | 2 +- src/sim/public-delta.ts | 43 +++++++++++++++++++++++ src/web/main.ts | 4 ++- src/web/panels.ts | 57 +++++++++++++++++++++++++++++-- src/web/step-queue.ts | 20 ++++++++++- test/public-delta.test.ts | 72 ++++++++++++++++++++++++++++++++++++++- test/web.test.ts | 64 +++++++++++++++++++++++++++++++++- 8 files changed, 301 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6476a4e..8a489b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,53 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 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.** diff --git a/package.json b/package.json index 1340b0c..48d00cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.4", + "version": "0.8.0.5", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/sim/public-delta.ts b/src/sim/public-delta.ts index b142f5b..9df4976 100644 --- a/src/sim/public-delta.ts +++ b/src/sim/public-delta.ts @@ -130,3 +130,46 @@ function need(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; +} diff --git a/src/web/main.ts b/src/web/main.ts index 7760a46..4f99a05 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1511,7 +1511,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); diff --git a/src/web/panels.ts b/src/web/panels.ts index ad7ec75..f640987 100644 --- a/src/web/panels.ts +++ b/src/web/panels.ts @@ -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 ( - `
` + + `
` + `
${esc(label)}${depth}
` + `${esc(top)}
` ); }; 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,20 @@ 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;animation:pileflash .45s ease-out 1} +@keyframes pileflash{0%{background:#2f6b47;border-color:#c7f0d4;transform:scale(1.1)} + 100%{background:#1d3327;border-color:#8fd6a0;transform:scale(1)}} +@media(prefers-reduced-motion:reduce){.handcard.pilelit{animation: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)} /* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that diff --git a/src/web/step-queue.ts b/src/web/step-queue.ts index 0fcefae..42b927a 100644 --- a/src/web/step-queue.ts +++ b/src/web/step-queue.ts @@ -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. * diff --git a/test/public-delta.test.ts b/test/public-delta.test.ts index 724e62e..e3c6a06 100644 --- a/test/public-delta.test.ts +++ b/test/public-delta.test.ts @@ -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>(); + /** + * 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 => 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)), []); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index 2d631e3..ecaee3c 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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