From ec792dfc152bf38a9b308a38af73b504b0f80c32 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 4 Aug 2026 08:37:42 -0400 Subject: [PATCH] Say who is playing, what you can press, and where you are in the Stage Mostly presentation, plus one rules fix found on the way. --- TODO.md | 15 +++++++ scripts/deploy-web.ts | 14 +++++- src/engine/apply.ts | 30 ++++++++++--- src/sim/narrate.ts | 10 +++-- src/sim/view.ts | 9 ++-- src/web/game.ts | 51 ++++++++++++++++++--- src/web/index.html | 21 +++++---- src/web/main.ts | 101 +++++++++++++++++++++++++++++++++++++++--- src/web/play.html | 96 +++++++++++++++++++++++++++++++++------ test/apply.test.ts | 51 +++++++++++++++++++++ test/replay.test.ts | 7 ++- test/web.test.ts | 49 ++++++++++++++++---- 12 files changed, 392 insertions(+), 62 deletions(-) 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
build __BUILD__ - runs entirely in your browser — nothing is sent anywhere + runs entirely in your browser — no server code required
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)}` + - (playable === false ? ' not yet' : '') + - `
    `; + `${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('') + `
    `, ) .join(''); diff --git a/src/web/play.html b/src/web/play.html index 2df6fed..ff3a9f5 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -5,13 +5,15 @@ Station Master — play
    Station Master - — - phase: — Revenue 0 — seed — @@ -115,6 +174,15 @@ ul.blocked li{padding:2px 0} __BUILD__
    + +
    +
    Day —Stage ——
    +
    phase —
    +
    waiting on —
    +
      +
      +

      The Division — west to east

      @@ -123,12 +191,12 @@ ul.blocked li{padding:2px 0}
      -

      Your move

      -

      Cards

      -
      hand: —
      -
      face-up Department slots: —
      +

      Your Move

      +

      Cards Available

      +

      Cards in My Hand

      —
      +

      Department Cards (face up)

      —
      -

      Your track supply

      +

      Your Track Supply

      Blocked — why nothing is moving

        Facilities

        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 [