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
This commit is contained in:
Jesse.Markowitz
2026-09-10 04:35:59 -04:00
co-authored by Claude Opus 5
parent fc40fc39ed
commit 3fca325699
8 changed files with 301 additions and 8 deletions
+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)), []);
});
});
+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