diff --git a/TODO.md b/TODO.md
index b10b397..1b0e39e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -93,6 +93,21 @@ target is settled and freight carries its intended share.
## Not yet built
+- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
+ Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
+ without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
+ is holding the game up — "Now waiting on player Alice to complete the Cargo phase."
+ - Further out, audio into the same call: a crash when a collision happens, a bell as the Stage
+ clock turns over.
+ - Further out still, a nudge on a timer — if a player has not moved within some interval, the
+ game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo
+ phase." That turns the turn chart's "waiting on" chip into something a distracted table
+ actually notices.
+- [ ] **Multiplayer train make-up is a round, not one player's job.** When a new train is built,
+ players take turns adding cars to the consist; in solitaire one player does all of it. The
+ engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
+ than wrong.
+
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
an opponent. Rejected with `NOT_IMPLEMENTED`.
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
diff --git a/scripts/deploy-web.ts b/scripts/deploy-web.ts
index cf49c64..12477d3 100644
--- a/scripts/deploy-web.ts
+++ b/scripts/deploy-web.ts
@@ -19,6 +19,7 @@
* FB_URL default https://phoenix.local:58157
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
+ * SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
* --dry-run list what would be sent, contact nothing
*/
@@ -28,6 +29,15 @@ import { dirname, join, posix, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/**
+ * Where Start9 Pages actually publishes the site — the address a player types, as opposed to the
+ * File Browser folder the files are uploaded INTO. The two are unrelated and the deploy output used
+ * to print only the second, which is the one nobody wants.
+ *
+ * Provisional: this is the playtesting host and it will change. Override with SITE_URL.
+ */
+const SITE_URL = process.env['SITE_URL'] ?? 'https://65.78.82.12:54697/';
const dist = join(root, 'dist');
const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
@@ -150,6 +160,6 @@ if (DRY) {
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
}
- console.log(`\ndeployed to ${URL_BASE}${DEST}`);
- console.log('Start9 Pages serves these files as the site; File Browser is only the store.');
+ console.log(`\nDeployed to FileBrowser at: ${URL_BASE}${DEST}`);
+ console.log(`Start9 Pages serves this website at: ${SITE_URL}`);
}
diff --git a/src/engine/apply.ts b/src/engine/apply.ts
index 3a90c20..7a2f8ef 100644
--- a/src/engine/apply.ts
+++ b/src/engine/apply.ts
@@ -604,9 +604,9 @@ function checkPlay(
case 'modifier': {
if (!placement) return 'NO_PLACEMENT';
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
- // §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby
- // spots) or it does nothing at all, so placing it anywhere else is not a legal play.
- return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
+ // §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
+ // the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
+ return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
if (!placement) return 'NO_PLACEMENT';
@@ -1423,15 +1423,33 @@ function buildFreightFacility(kind: FreightKind): Facility {
* Facility found instead. Modelling the per-Stage choice needs an extra decision point in the
* Load/Unload phase and is not worth it until the mechanic has been played.
*/
-function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null {
+/**
+ * The neighbouring Facility a Modifier would attach to — and, when a Modifier is named, only one it
+ * is actually ALLOWED to attach to.
+ *
+ * Every Modifier prints its host: a Waiting Area, a Restaurant and a Hotel go beside a Passenger
+ * Facility, Forklifts beside a Freight House or Packing Sheds, and so on. That was not checked. Any
+ * square touching ANY facility was offered — so a Waiting Area was legal beside a Mine Tipple and
+ * `applyModifier` then handed its extra Porter to whichever facility the scan reached first, which
+ * could be a different one again. The player saw three legal spots for a card that has one.
+ */
+function adjacentFacilityCoord(
+ area: OfficeArea,
+ coord: GridCoord,
+ modifier?: ModifierKind,
+): GridCoord | null {
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
if (coord.row > area.runningRow) return null;
+ const hosts = modifier ? modifierProfile(modifier).hosts : null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const c = { row: coord.row + dr, col: coord.col + dc };
if (c.row > area.runningRow) continue;
- if (area.grid.get(coordKey(c))?.facility) return c;
+ const f = area.grid.get(coordKey(c))?.facility;
+ if (!f) continue;
+ if (hosts && !hosts.includes(f.subtype)) continue;
+ return c;
}
}
return null;
@@ -1583,7 +1601,7 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
- const target = adjacentFacilityCoord(area, coord);
+ const target = adjacentFacilityCoord(area, coord, modifier);
if (!target) return;
const f = area.grid.get(coordKey(target))?.facility;
if (!f) return;
diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts
index 213ab77..f0bfff1 100644
--- a/src/sim/narrate.ts
+++ b/src/sim/narrate.ts
@@ -59,9 +59,9 @@ export function phaseLabel(phase: string): string {
case 'mainline':
return 'Mainline';
case 'loadUnload':
- return 'Load / Unload';
+ return 'Cargo';
case 'shiftChange':
- return 'Shift Change';
+ return 'Supervisor Shift';
default:
return phase;
}
@@ -74,7 +74,7 @@ export function phaseLabel(phase: string): string {
export type Narration = {
text: string;
/** Colours the line in the viewer. */
- tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
+ tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet' | 'phase';
/** Board cell to highlight, if the event happened somewhere. */
where?: GridCoord;
};
@@ -104,7 +104,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'stageBegan':
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
case 'phaseBegan':
- return { tone: 'quiet', text: `${phaseLabel(e.phase)}` };
+ // Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
+ // the log read as one undifferentiated column and you could not see where a phase began.
+ return { tone: 'phase', text: `▸ ${phaseLabel(e.phase)} phase` };
case 'actorChanged':
return {
tone: 'quiet',
diff --git a/src/sim/view.ts b/src/sim/view.ts
index 9cecf0e..96148ea 100644
--- a/src/sim/view.ts
+++ b/src/sim/view.ts
@@ -111,6 +111,8 @@ export type Frame = {
stage: number;
clock: string;
phase: string;
+ /** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */
+ phaseKey: string;
actor: number | null;
superintendent: number;
revenue: number;
@@ -332,11 +334,11 @@ export function describeIntent(s: GameState, i: Intent): string {
case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
case 'draw.end':
- return 'done drawing — end my turn';
+ return 'End my turn';
case 'switch.end':
- return 'done switching — end my turn';
+ return 'End my switching turn';
case 'loadUnload.end':
- return 'done working — end the Load/Unload phase';
+ return 'End my Cargo phase';
case 'newTrain.passCar':
return 'add no more cars to this train';
case 'newTrain.secondSection':
@@ -459,6 +461,7 @@ export function snapshot(
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
+ phaseKey: s.clock.phase,
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
diff --git a/src/web/game.ts b/src/web/game.ts
index a637e36..62b39f1 100644
--- a/src/web/game.ts
+++ b/src/web/game.ts
@@ -70,6 +70,15 @@ export type Game = {
history: Intent[];
/** Narrated lines, newest last. */
log: { text: string; tone: string }[];
+ /**
+ * Set when a card has been drawn this turn and not yet played or discarded.
+ *
+ * §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible,
+ * as a disabled button rather than a rejection after the click. UI state deliberately — putting
+ * it in the engine would change what the bot may do, and with it every balance figure measured
+ * against the current rules.
+ */
+ mustPlayCard: boolean;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -77,8 +86,14 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
{ prefix: 'switch.', title: 'Switching' },
- { prefix: 'draw.', title: 'Draw' },
- { prefix: 'card.', title: 'Cards' },
+ // Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
+ // three are different acts. Taking a face-up Department card is not the same decision as
+ // gambling on the Home Office deck, and neither is ending the turn.
+ { prefix: 'draw.fromHomeOffice', title: 'Draw a card from the Home Office deck' },
+ { prefix: 'draw.fromDepartment', title: 'Take a Department card (face up)' },
+ { prefix: 'card.play', title: 'Play a card from my hand' },
+ { prefix: 'card.discard', title: 'Discard a card from my hand' },
+ { prefix: 'draw.end', title: 'Finish' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
@@ -91,9 +106,19 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'redFlag.', title: 'Red flag' },
];
+/**
+ * The solitaire seat still has a NAME, because the history reads "Player Solitaire chose…" and a
+ * log that says "You" cannot be read back by anyone else — a save is meant to be sent around.
+ */
+export const SOLO_PLAYER = 'Solitaire';
+
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
- const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
- const game: Game = { state, seed, history: [], log: [] };
+ const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
+ const game: Game = { state, seed, history: [], log: [], mustPlayCard: false };
+ // A history that opens mid-Stage reads as though something was missed. Say what the game IS
+ // first, then let the clock take over.
+ game.log.push({ text: 'Game Begins', tone: 'start' });
+ game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
drain(game);
return game;
}
@@ -261,7 +286,14 @@ export function submit(game: Game, intent: Intent): boolean {
return false;
}
game.history.push(intent);
- record(game, result.events);
+ if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') {
+ game.mustPlayCard = true;
+ } else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') {
+ // Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all,
+ // so nothing can be left outstanding from them.
+ game.mustPlayCard = false;
+ }
+ record(game, result.events, actor);
drain(game);
return true;
}
@@ -290,7 +322,8 @@ export function view(game: Game): Frame {
return snapshot(game.state, [], null);
}
-function record(game: Game, events: GameEvent[]): void {
+function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
+ const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
for (const e of events) {
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
if (e.type === 'actorChanged') continue;
@@ -298,7 +331,11 @@ function record(game: Game, events: GameEvent[]): void {
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
});
- game.log.push({ text: n.text, tone: n.tone });
+ // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
+ // one seat. Only events the player caused are attributed; the Division running itself is not.
+ const mine = who !== null && 'player' in e;
+ const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
+ game.log.push({ text, tone: mine ? 'act' : n.tone });
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
diff --git a/src/web/index.html b/src/web/index.html
index 8b32669..9b28b68 100644
--- a/src/web/index.html
+++ b/src/web/index.html
@@ -33,7 +33,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
Station Master
-
Timetable-and-train-order railroading, 1840–1950.
+
TimeTable and Train Order (TT&TO) railroading, 1840–1950.
- You run a division: a Running Track between your Limits, a district of industries hanging beneath
- it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
- loads, and get every train away again — a train with nowhere to stand is a collision, and a
+ As Station Master in your line-side office, you manage the town's railroad connection. You are
+ responsible for all track between your Limits, a district of industries hanging beneath it, and
+ trains that arrive whether or not you are ready for them. Spot the right cars, work the loads,
+ and get every train away again — a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
@@ -68,7 +71,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
diff --git a/src/web/main.ts b/src/web/main.ts
index d29c0cf..ae08560 100644
--- a/src/web/main.ts
+++ b/src/web/main.ts
@@ -38,6 +38,76 @@ const esc = (s: string): string =>
// ---------------------------------------------------------------------------
+/**
+ * THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
+ *
+ * The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
+ * player could not see what had already happened this Stage or what was coming. The printed game
+ * has a turn chart for exactly this reason.
+ *
+ * Each phase carries a tooltip describing what actually happens in it — the first three are the
+ * rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
+ * sheet does not spell them out.
+ */
+const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
+ {
+ key: 'localOps',
+ label: 'Local Operations',
+ tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
+ // a turnout: the choice of three roads
+ icon: '',
+ },
+ {
+ key: 'newTrain',
+ label: 'New Train',
+ tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
+ // a locomotive being made up
+ icon: '',
+ },
+ {
+ key: 'mainline',
+ label: 'Mainline',
+ tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
+ // open road
+ icon: '',
+ },
+ {
+ key: 'loadUnload',
+ label: 'Cargo',
+ tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
+ // a loaded box on a flat
+ icon: '',
+ },
+ {
+ key: 'shiftChange',
+ label: 'Supervisor Shift',
+ tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
+ // a pocket watch
+ icon: '',
+ },
+];
+
+function renderTurnChart(f: ReturnType): void {
+ $('tc-day').textContent = `Day ${f.day}`;
+ $('tc-stage').textContent = `Stage ${f.stage} of 12`;
+ $('tc-clock').textContent = f.clock;
+ $('tc-phase').textContent = f.phase;
+ // WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
+ // multiplayer table asks constantly, and an automatic phase is waiting on nobody.
+ const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
+ $('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
+
+ const at = PHASES.findIndex((p) => p.key === f.phaseKey);
+ $('tc-phases').innerHTML = PHASES.map((p, i) => {
+ const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
+ return (
+ `
` +
+ `` +
+ `${esc(p.label)}
`
+ );
+ }).join('');
+}
+
function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
@@ -66,8 +136,7 @@ function render(): void {
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
- $('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
- $('phase').textContent = f.phase;
+ renderTurnChart(f);
$('revenue').textContent = String(f.revenue);
const obj = $('objective');
obj.textContent = f.objective.note;
@@ -150,16 +219,19 @@ function render(): void {
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
+ // The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
+ // rather than seen. The tooltip still explains WHY, which a hatch cannot.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`
`;
+ `${esc(name)}`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: 'empty';
+ // Reference, not choices: the Department cards are three FACE-UP CARDS, not three decks — there
+ // is nothing underneath them. Drawn like the hand so they read as cards, dashed and unlit so they
+ // do not read as clickable.
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: 'none';
@@ -243,11 +315,26 @@ function renderActions(menu: ReturnType): void {
);
};
- let html = menu.direct
+ // A heading over the buttons, so the panel says what it is before it says what is in it. The
+ // list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
+ // no worker able to act, the only thing offered is "End my Cargo phase".
+ let html = '
Actions
';
+
+ html += menu.direct
.map(
(g) =>
`
${esc(g.title)}
` +
- g.actions.map((a) => actionButton(a.label, a.index)).join('') +
+ g.actions
+ .map((a) => {
+ // §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
+ // the INTENT, not the label: matching button text would break the moment the wording
+ // changed, and would have caught `switch.end` too.
+ const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard;
+ return blocked
+ ? ``
+ : actionButton(a.label, a.index);
+ })
+ .join('') +
`
diff --git a/test/apply.test.ts b/test/apply.test.ts
index 0bf1eb5..c688962 100644
--- a/test/apply.test.ts
+++ b/test/apply.test.ts
@@ -635,3 +635,54 @@ describe('an industry handles every commodity its profile names (regression)', (
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
});
});
+
+describe('a Modifier only goes beside a host that can use it (regression)', () => {
+ it('offers a Waiting Area no square that is not beside a Passenger Facility', () => {
+ // REGRESSION. Every Modifier prints its host — a Waiting Area, Restaurant and Hotel go beside a
+ // Passenger Facility; Forklifts beside a Freight House or Packing Sheds — and the placement
+ // check ignored all of it, asking only "does this square touch ANY facility?". A Waiting Area
+ // was therefore legal beside a Mine Tipple, and `applyModifier` handed its extra Porter to
+ // whichever facility the scan happened to reach first. The player was shown three legal spots
+ // for a card that has one.
+ const s = game();
+ const area = areaOf(s, 0);
+
+ // A freight industry away from the Office, and nothing else nearby.
+ area.grid.set(coordKey({ row: -1, col: 4 }), {
+ geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ew' },
+ baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
+ facility: {
+ kind: 'freight', subtype: 'mineTipple',
+ allows: { outbound: true, inbound: false },
+ outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
+ menAtWork: [null, null, null],
+ industryTrack: { length: 2, cars: [] },
+ laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
+ },
+ } as never);
+
+ const waiting = [...s.cards.entries()].find(
+ ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
+ );
+ assert.ok(waiting, 'no Waiting Area card in the deck');
+ const [cardId] = waiting!;
+ s.decks.hands.set(0, [cardId]);
+ applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
+
+ const spots = legalActions(s, 0).filter(
+ (i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined,
+ );
+ assert.ok(spots.length > 0, 'a Waiting Area has nowhere legal at all — the Office is a host');
+
+ // Every offered square must touch the Office and none may touch only the Mine Tipple.
+ for (const i of spots) {
+ const p = (i as { placement: { row: number; col: number } }).placement;
+ const touchesOffice =
+ Math.abs(p.row - area.officeCoord.row) <= 1 && Math.abs(p.col - area.officeCoord.col) <= 1;
+ assert.ok(
+ touchesOffice,
+ `a Waiting Area was offered (${p.row}, ${p.col}), which is not beside a Passenger Facility`,
+ );
+ }
+ });
+});
diff --git a/test/replay.test.ts b/test/replay.test.ts
index c196c2c..4e279e7 100644
--- a/test/replay.test.ts
+++ b/test/replay.test.ts
@@ -78,7 +78,7 @@ describe('narration', () => {
const n = narrate(e);
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
- assert.ok(['plain', 'good', 'bad', 'clock', 'quiet'].includes(n.tone), `${e.type} bad tone`);
+ assert.ok(['plain', 'good', 'bad', 'clock', 'quiet', 'phase'].includes(n.tone), `${e.type} bad tone`);
}
});
@@ -98,8 +98,11 @@ describe('narration', () => {
});
it('names phases in words rather than identifiers', () => {
- assert.equal(phaseLabel('loadUnload'), 'Load / Unload');
+ assert.equal(phaseLabel('loadUnload'), 'Cargo');
assert.equal(phaseLabel('newTrain'), 'New Train');
+ assert.equal(phaseLabel('shiftChange'), 'Supervisor Shift');
+ assert.equal(phaseLabel('localOps'), 'Local Operations');
+ assert.equal(phaseLabel('mainline'), 'Mainline');
});
it('hides only the events with nothing to show', () => {
diff --git a/test/web.test.ts b/test/web.test.ts
index 7b41b24..c444de6 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -148,13 +148,21 @@ describe('the action menu presents choices the way they are made', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
- const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
- assert.ok(draw, 'no draw group');
- for (const a of draw!.actions) {
- assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
+ // The draw actions are split by SOURCE now — gambling on the deck, taking a named face-up
+ // card, and ending the turn are three different decisions — so gather every group that offers
+ // one rather than looking for a single group called "Draw".
+ const menu = actionMenu(game);
+ const draws = menu.direct.filter((g) =>
+ g.actions.some((a) => menu.options[a.index]?.type?.startsWith('draw.')),
+ );
+ assert.ok(draws.length > 0, 'no draw actions are offered at all');
+ for (const g of draws) {
+ for (const a of g.actions) {
+ assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
+ }
}
assert.ok(
- draw!.actions.some((a) => /face-up slot/.test(a.label)),
+ draws.flatMap((g) => g.actions).some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
);
});
@@ -743,8 +751,10 @@ describe('the static build', () => {
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
// would pass here and throw on load in a browser — the page would simply never start.
+ // `[\w-]` and not `[a-zA-Z]`: a hyphenated id is valid HTML, and the narrower pattern skipped
+ // those silently — which would have let a genuinely MISSING `#tc-day` pass this test too.
const served = new Set(
- [...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
+ [...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
@@ -867,9 +877,9 @@ describe('the static build', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
- const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
+ const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'play.html'), 'utf8');
- const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
+ const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
// `again` is created by the game-over screen before it is looked up.
present.add('again');
for (const id of asked) {
@@ -877,6 +887,29 @@ describe('the static build', () => {
}
});
+ it('gives every phase the engine can be in a pill on the turn chart', () => {
+ // The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
+ // exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
+ // one is added and the chart is not.
+ const engine = readFileSync(join(root, 'src/engine/state.ts'), 'utf8');
+ const decl = /export type Phase =([^;]+);/.exec(engine);
+ assert.ok(decl, 'the Phase union moved — this test cannot see it any more');
+ const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
+ assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
+
+ const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
+ for (const p of phases) {
+ assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
+ }
+ // And every pill must carry a tooltip: an icon alone does not explain a phase.
+ const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: '/g)];
+ assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
+
+ const html = readFileSync(join(dist, 'play.html'), 'utf8');
+ assert.match(html, /id="turnchart"/, 'the page has no turn chart');
+ assert.doesNotMatch(html, /phase: /, 'the old title-bar phase text is still there');
+ });
+
it('serves all three pages, each stamped and cache-busted', () => {
// The splash is the front door now; the game and the replay directory are separate pages.
for (const [name, entry] of [