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