Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fca325699 |
@@ -19,6 +19,53 @@ page as `v0.1.0 · <sha> · <date>`, 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.**
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -130,3 +130,46 @@ function need<T>(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;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
+54
-3
@@ -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 (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
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
|
||||
|
||||
+19
-1
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user