Fix caching, Limits placement, and explain the board better

This commit is contained in:
Jesse
2026-07-31 23:34:18 -04:00
parent 2eca9de09f
commit dd300ac154
10 changed files with 466 additions and 26 deletions
+9
View File
@@ -341,6 +341,15 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
existing.standing.length === 0;
if (existing && !isMovableSign) return false;
// §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
// side of the sign and then planted a SECOND sign further out, leaving the board reading
// limits · Whistle Post · limits · straight · limits
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
if (coord.row === area.runningRow) {
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
}
const ports: Port[] = ['n', 's', 'e', 'w'];
for (const p of ports) {
if (!hasPort(card, p)) continue;
+14 -3
View File
@@ -87,10 +87,17 @@ export type Narration = {
export type NarrateContext = {
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
cardName?: (id: string) => string;
/**
* Resolves a Crew Tray id to the train riding it. Without this the §8.1 clearance question read
* "may tray2 follow tray3 into the next Subdivision?" — the most consequential decision in the
* game, phrased in internal identifiers.
*/
trainName?: (trayId: TrayId) => string;
};
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
switch (e.type) {
// -- clock
@@ -290,14 +297,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'clearanceRequested':
return {
tone: 'bad',
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`,
text:
`SUPERINTENDENT MUST RULE (§8.1): ${train(e.trainId)} wants to enter the Mainline card ` +
`that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` +
`into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` +
`waits where it is, losing time but safe.`,
};
case 'clearanceGiven':
return {
tone: e.allow ? 'bad' : 'plain',
text: e.allow
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
? `Clearance GRANTED — ${train(e.trainId)} follows into the occupied Subdivision`
: `Clearance DENIED — ${train(e.trainId)} holds where it is`,
};
// -- passengers
+5 -2
View File
@@ -35,7 +35,7 @@ export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip }
export { cardName, describeIntent, snapshot } from './view.ts';
import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
@@ -61,7 +61,10 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
};
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
const frames: Frame[] = [];
const narrateCtx = { cardName: (id: string) => cardName(s, id) };
const narrateCtx = {
cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id),
};
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
let didSomething = false;
+102 -7
View File
@@ -17,6 +17,7 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
modifierProfile,
@@ -25,7 +26,7 @@ import {
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState } from '../engine/state.ts';
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
@@ -46,6 +47,14 @@ export type CellView = {
tray: string | null;
cars: string[];
facility: FacilityView | null;
/**
* What this card DOES, now that it is on the board.
*
* A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House",
* "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly
* the moment it starts mattering.
*/
what: string;
};
export type FacilityView = {
@@ -168,7 +177,7 @@ function facilityView(
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
return {
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)),
commodity: facilityCarType(f) ?? '?',
flow: f.kind === 'passenger'
? 'passengers on and off'
@@ -285,8 +294,16 @@ export function describeIntent(s: GameState, i: Intent): string {
return `Red Flags on ${i.trayId}`;
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance':
return i.allow ? 'grant clearance' : 'refuse clearance';
case 'mainline.clearance': {
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
// that granting it risks a rear-ender, or that refusing merely costs time.
const pending = s.clock.pendingDecision;
const who = pending ? trainName(s, pending.train) : 'the train';
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
return i.allow
? `ALLOW — ${who} follows ${ahead} into the same Subdivision (risks a collision, −5 Revenue)`
: `HOLD — ${who} waits where it is: safe, but it loses the Stage`;
}
case 'draw.fromDepartment': {
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
// the choice between a visible card and a blind draw is unmakeable without it.
@@ -351,8 +368,11 @@ export function snapshot(
let label: string;
if (g.kind === 'office') label = officeProfile(area.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
// Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot
// guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key
// as the fallback put `refinery` and `viscosityBreakers` on the board.
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
@@ -365,6 +385,7 @@ export function snapshot(
kind,
label,
running: row === area.runningRow,
what: cellDescription(card, officeProfile(area.tier).name),
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
@@ -512,11 +533,16 @@ export function cardDescription(s: GameState, id: string): string {
}
case 'office': {
const p = officeProfile(k.tier);
// Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the
// Office is a Depot. Without saying so, the card looks playable and simply never is.
const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1];
const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : '';
return (
`upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
`${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
`${p.passengerOut} passenger out / ${p.passengerIn} in` +
(p.isControlPoint ? ' · a Control Point' : '')
(p.isControlPoint ? ' · a Control Point' : '') +
prereq
);
}
case 'freightFacility': {
@@ -544,6 +570,75 @@ export function cardDescription(s: GameState, id: string): string {
}
}
/** The train riding a Crew Tray, for anything that has to talk about it. */
export function trainName(s: GameState, trayId: string): string {
const tray = s.trays.get(trayId);
if (!tray) return trayId;
if (tray.trainNumber === null) return 'the local crew';
return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
}
/** What a card on the board does, in one short line. */
function cellDescription(card: TrackCard, officeName: string): string {
const g = card.geometry;
switch (g.kind) {
case 'limits':
return 'the edge of your control area — lay track HERE to extend the Running Track';
case 'office': {
const p = officeProfile(
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
);
return (
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
(p.isPassengerFacility
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
: 'not a Passenger Facility — no porters, no passenger boxes')
);
}
case 'modifier': {
const m = modifierProfile(g.modifier);
const adds: string[] = [];
if (m.addOut) adds.push(`+${m.addOut} outbound slot`);
if (m.addIn) adds.push(`+${m.addIn} inbound slot`);
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`;
}
case 'spaceUse':
return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space';
case 'facility': {
const f = card.facility;
if (!f) return 'a facility';
if (f.kind === 'passenger') return 'passengers board and detrain here';
const p = industryProfile(g.facility as never);
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load`;
}
case 'track':
switch (g.geometry) {
case 'straight':
return g.axis === 'ns' ? 'through track, north–south' : 'through track, east–west';
case 'curved':
case 'sharpCurved': {
const stem = g.hand === 'left' ? 'west' : 'east';
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
return `curve — joins the ${stem} end to a leg running south${cost}`;
}
case 'turnout': {
const t = g.turnout;
if (!t) return 'turnout';
const dir = (p: string): string =>
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
return (
`turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${dir(t.diverge)} · ` +
`a train may run stem↔through or stem↔diverge, but NEVER between ${dir(t.through)} and ${dir(t.diverge)}`
);
}
}
}
}
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
function facilityLabel(key: string): string {
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
+31 -2
View File
@@ -32,7 +32,14 @@ import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import { narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import { cardName, describeIntent, geometryLabel, snapshot, variantLabel } from '../sim/view.ts';
import {
cardName,
describeIntent,
geometryLabel,
snapshot,
trainName,
variantLabel,
} from '../sim/view.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Frame } from '../sim/view.ts';
@@ -258,6 +265,25 @@ export function submit(game: Game, intent: Intent): boolean {
return true;
}
/**
* Which cards in hand can be played RIGHT NOW, in hand order.
*
* A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
*/
export function handPlayable(game: Game): boolean[] {
const hand = game.state.decks.hands.get(0) ?? [];
const actor = currentActor(game);
if (actor === null) return hand.map(() => false);
const playable = new Set(
legalActions(game.state, actor)
.filter((i) => i.type === 'card.play')
.map((i) => (i as Extract<Intent, { type: 'card.play' }>).cardId),
);
return hand.map((id) => playable.has(id));
}
/** The board as the replay draws it, so the live game and the replay agree. */
export function view(game: Game): Frame {
return snapshot(game.state, [], null);
@@ -267,7 +293,10 @@ function record(game: Game, events: GameEvent[]): void {
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;
const n = narrate(e, { cardName: (id) => cardName(game.state, id) });
const n = narrate(e, {
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
});
game.log.push({ text: n.text, tone: n.tone });
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
+5 -1
View File
@@ -24,6 +24,8 @@ header b{font-size:16px}
.handcard{border-top:1px solid var(--line);padding:4px 0}
.handcard:first-child{border-top:0}
.handcard .what{font-size:11px;line-height:1.35}
.handcard.unplayable{opacity:.55}
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
@@ -50,6 +52,8 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
.cell .nm{font-size:12px;font-weight:600}
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
.cell{cursor:help}
.coord{font-size:10px}
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
@@ -107,7 +111,7 @@ ul.blocked li{padding:2px 0}
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>Your Office Area</h2><div id="grid"></div></section>
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
<section><h2>History</h2><div id="log"></div></section>
</div>
+17 -5
View File
@@ -8,6 +8,7 @@
import type { Game } from './game.ts';
import {
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
@@ -130,12 +131,15 @@ function render(): void {
: '<div></div>';
continue;
}
// The full explanation goes in `title` so hovering any card says what it does, with a short
// version printed on the card itself — a name alone tells a player nothing once it is played.
cells +=
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
`${spots ? ` data-at="${r},${c}"` : ''}>` +
`${spots ? ` data-at="${r},${c}"` : ''} title="${esc(cell.label)} — ${esc(cell.what)}">` +
`<div class="nm">${esc(cell.label)}</div>` +
`<div class="dim coord">(${r},${c})</div>` +
`<div class="dim what">${esc(short(cell.what))}</div>` +
(cell.enhancements.length
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
: '') +
@@ -194,16 +198,18 @@ function render(): void {
.join('');
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
const cardRow = (name: string, why: string): string =>
`<div class="handcard"><b>${esc(name)}</b>` +
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"><b>${esc(name)}</b>` +
(playable === false ? ' <span class="tag">not playable yet</span>' : '') +
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
`</div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '')).join('')
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '')).join('')
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: '<span class="dim">none</span>';
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
@@ -235,6 +241,12 @@ function render(): void {
save();
}
/** First clause only — the cards are small, and the full text is on the tooltip. */
function short(what: string): string {
const head = what.split(' · ')[0] ?? what;
return head.length > 64 ? head.slice(0, 61) + '…' : head;
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {