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
+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, northsouth' : 'through track, eastwest';
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));