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
|
## 0.8.0.4 — 2026-09-09
|
||||||
|
|
||||||
**Housekeeping: the test server's name is out of the ten places this session put it.**
|
**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",
|
"name": "station-master",
|
||||||
"version": "0.8.0.4",
|
"version": "0.8.0.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
@@ -130,3 +130,46 @@ function need<T>(value: T | undefined, what: string): T {
|
|||||||
}
|
}
|
||||||
return value;
|
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
|
* 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.
|
* 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);
|
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
|
* 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.
|
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||||
*/
|
*/
|
||||||
export function pilesHtml(f: Frame): string {
|
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): 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(' · ');
|
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
// 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
|
// 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.
|
// viewer draws exactly the same markup and nothing there is clickable.
|
||||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
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 (
|
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>` +
|
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||||
`<b>${esc(top)}</b></div>`
|
`<b>${esc(top)}</b></div>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
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
|
f.departments
|
||||||
.map((d, i) => {
|
.map((d, i) => {
|
||||||
const depth = f.departmentDepth[i] ?? 0;
|
const depth = f.departmentDepth[i] ?? 0;
|
||||||
const under = depth - 1;
|
const under = depth - 1;
|
||||||
return pile(
|
return pile(
|
||||||
|
`dept${i}`,
|
||||||
`Dept ${i + 1}`,
|
`Dept ${i + 1}`,
|
||||||
d,
|
d,
|
||||||
depth,
|
depth,
|
||||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
|||||||
})
|
})
|
||||||
.join('') +
|
.join('') +
|
||||||
pile(
|
pile(
|
||||||
|
'salvage',
|
||||||
'Salvage',
|
'Salvage',
|
||||||
f.salvage.top,
|
f.salvage.top,
|
||||||
f.salvage.depth,
|
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}
|
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
.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;
|
.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)}
|
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
|
/* 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 { PublicFrame } from '../sim/view.ts';
|
||||||
import type { DisplayStep } from '../sim/display-step.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';
|
import { dwellForStep } from '../sim/pacing.ts';
|
||||||
|
|
||||||
export type StepQueue = {
|
export type StepQueue = {
|
||||||
@@ -43,6 +44,13 @@ export type StepQueue = {
|
|||||||
behind(): number;
|
behind(): number;
|
||||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||||
showing(): DisplayStep | null;
|
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. */
|
/** True while there is anything left to show. */
|
||||||
busy(): boolean;
|
busy(): boolean;
|
||||||
};
|
};
|
||||||
@@ -65,6 +73,7 @@ export function createStepQueue(
|
|||||||
): StepQueue {
|
): StepQueue {
|
||||||
let shown: PublicFrame | null = null;
|
let shown: PublicFrame | null = null;
|
||||||
let last: DisplayStep | null = null;
|
let last: DisplayStep | null = null;
|
||||||
|
let litPiles: readonly PileKey[] = [];
|
||||||
let pending: DisplayStep[] = [];
|
let pending: DisplayStep[] = [];
|
||||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||||
let dueAt: number | null = null;
|
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. */
|
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||||
const show = (step: DisplayStep): void => {
|
const show = (step: DisplayStep): void => {
|
||||||
|
const before = shown;
|
||||||
shown = applyPublicDelta(shown, step.frame);
|
shown = applyPublicDelta(shown, step.frame);
|
||||||
last = step;
|
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 {
|
return {
|
||||||
@@ -84,6 +99,8 @@ export function createStepQueue(
|
|||||||
shown = frame;
|
shown = frame;
|
||||||
pending = [];
|
pending = [];
|
||||||
dueAt = null;
|
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
|
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||||
// sentence describing the most recent action is still true.
|
// sentence describing the most recent action is still true.
|
||||||
},
|
},
|
||||||
@@ -136,6 +153,7 @@ export function createStepQueue(
|
|||||||
current: () => shown,
|
current: () => shown,
|
||||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||||
showing: () => last,
|
showing: () => last,
|
||||||
|
lit: () => litPiles,
|
||||||
/**
|
/**
|
||||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
* 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 { applyIntent } from '../src/engine/apply.ts';
|
||||||
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
||||||
import type { PublicFrame } 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 = {
|
const config: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
@@ -184,3 +184,73 @@ describe('public frame delta', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
|
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 { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import type { DivisionView } from '../src/sim/view.ts';
|
import type { DivisionView } from '../src/sim/view.ts';
|
||||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.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 { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.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}`);
|
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', () => {
|
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
|
* 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