user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.

This commit is contained in:
Jesse
2026-08-05 19:18:26 -04:00
parent 1c321d4cea
commit 543584a2ca
20 changed files with 1851 additions and 320 deletions
+170 -16
View File
@@ -17,10 +17,12 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
@@ -32,7 +34,7 @@ import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
import { carLabel, carsLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
export type CellView = {
row: number;
@@ -47,6 +49,25 @@ export type CellView = {
*/
enhancements: string[];
tray: string | null;
/**
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
*
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is the port it points at on this card.
*/
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
/**
* Office card only: A/D tracks taken and how many the tier has.
*
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
* extra rails — there is no room on the card for more track, and the count is what matters.
*/
ad: { used: number; of: number } | null;
cars: string[];
facility: FacilityView | null;
/**
@@ -79,6 +100,17 @@ export type FacilityView = {
trackCap: number;
laborers: string;
porters: string;
/**
* What the INDUSTRY CARD itself prints, before any Modifier beside it.
*
* A modifier's whole effect is a number going up, and the panel showed only the number — so an Ice
* House raising outbound capacity from 0 to 1 and laborers from 1 to 2 looked like nothing had
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number };
/** The Modifier cards standing beside it, by name. */
modifiers: string[];
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
@@ -173,6 +205,14 @@ export type Frame = {
cells: CellView[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
/**
* Moves left in this Local Operations turn, or null outside a switching turn.
*
* It was reported only in the history, which is the one panel a player is NOT looking at while
* switching — the count that decides whether a run-around is still possible belongs next to the
* moves themselves.
*/
movesLeft: number | null;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -256,7 +296,7 @@ const FACILITY_NAMES: Record<string, string> = {
};
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown },
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
@@ -282,9 +322,42 @@ function facilityView(
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
base: baseOf(card),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
/**
* What the card itself prints, before any Modifier beside it.
*
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
*/
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
const g = card.geometry;
if (g.kind === 'facility' && g.facility) {
const p = industryProfile(g.facility as never);
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
}
return { out: 0, in: 0, laborers: 0 };
}
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
function trainOnCard(s: GameState, key: string): CellView['train'] {
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid') continue;
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
void id;
}
return null;
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
@@ -344,8 +417,22 @@ export function describeIntent(s: GameState, i: Intent): string {
: i.option === 'draw'
? 'DRAW — take a card and play one, and you may lay a piece of track'
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.play': {
/**
* NAME THE ROTATION, or the choice disappears.
*
* The action list drops duplicate labels, and this said only "play X at (0, 1)" — so a
* turnout's two orientations produced one identical label each and the second was silently
* discarded before the menu ever saw it. The rotation is the entire decision for a turnout or
* a curve, and it could not be made.
*/
const kind = s.cards.get(i.cardId)?.kind;
const turn =
i.placement && kind?.kind === 'track'
? variantLabel(kind.geometry, i.variant, kind.hand)
: '';
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
}
case 'card.discard': {
/**
* NAME THE DEPARTMENT, and what the card would land on.
@@ -362,8 +449,23 @@ export function describeIntent(s: GameState, i: Intent): string {
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.dropCars': {
/**
* NAME THE CARS AND THE END THEY COME OFF.
*
* This said "drop 1 car(s)", which is two failures at once. It never said WHICH car, so a
* player who knew the caboose was on the back still had to guess; and it read identically for
* a nose drop and a tail drop, so — the action list dropping duplicate labels — setting out
* from the front of the train was silently discarded and could not be chosen at all.
*/
const tray = s.trays.get(i.trayId);
if (!tray) return `drop ${i.count} car(s)`;
const cut = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const end = i.fromNose ? 'off the front' : 'off the back';
return `set out ${carsLabel(cut)} ${end}`;
}
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
@@ -386,8 +488,35 @@ export function describeIntent(s: GameState, i: Intent): string {
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
// "add loaded caboose" on a button.
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'mainline.modify': {
/**
* NAME THE CARD, NOT ITS INDEX, AND SAY WHAT HAPPENS TO IT.
*
* This read "Realignment on Mainline card 3" — a raw node index, which tells a player nothing
* about which stretch of the Division it means, and no clue what the card would do to it.
* Worse, the action list attaches a tooltip only when a label carries an em-dash, so this one
* silently had none at all while the same card in hand did.
*/
const node = s.division.nodes[i.node];
const key = (s.cards.get(i.cardId)?.kind as { key?: string } | undefined)?.key;
const shortWhere = node?.kind === 'mainline' ? `the ${mainlineProfile(node.card).name}` : `Mainline card ${i.node}`;
const where =
node?.kind === 'mainline'
? `the ${ordinal(mainlineIndex(s, i.node))} Mainline card west to east`
: `node ${i.node}`;
let effect = '';
if (key === 'realignment' && node?.kind === 'mainline') {
const to = REALIGNMENTS.find((r) => r.from === node.card);
effect = to ? `converts it to ${mainlineProfile(to.to).name}` : 'nothing here to convert';
} else if (key === 'brakeman' || key === 'airbrakes') {
effect = 'one Stage off the descent for a train running downhill';
} else if (key === 'helpers') {
effect = 'one Stage off the climb for a train running uphill';
}
// Short head, full detail after the em-dash — `actionButton` puts the first on the button and
// the second in the tooltip, so the list stays narrow without losing the explanation.
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
}
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
case 'maneuver.flyingSwitch':
@@ -495,6 +624,11 @@ export function snapshot(
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
train: trainOnCard(s, key),
ad:
card.geometry.kind === 'office'
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
: null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
@@ -660,6 +794,7 @@ export function snapshot(
wasted,
objective: objectiveOf(s),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -772,15 +907,24 @@ export function cardDescription(s: GameState, id: string): string {
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const arcs = variantsFor(k.geometry, k.hand)
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
.join(' or ');
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
return (
`east-west track with a 45° leg through the middle of the north or south edge · ` +
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
`same one${stop}${cost}`
);
const ways = variantsFor(k.geometry, k.hand)
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
.join(', or turned about, ');
/**
* A CURVE IS NOT A TURNOUT, and it used to be described as one.
*
* Both said "east-west track with a 45° leg", which is a turnout: a road straight across the
* card plus a leg off it. A curve has ONE road and no choice to make — it comes in from an
* east or west edge, runs along the centre line to the frog, and leaves at 45° through the
* middle of a north or south edge. Nothing runs past it, which is exactly why it may not be
* laid in the Running Track.
*/
const what =
k.geometry === 'turnout'
? 'east-west track with a 45° leg through the middle of the north or south edge'
: 'a single road: in from the east or west edge, then out at 45° through the middle of the north or south edge — no track runs past it';
return `${what} · lay it so it ${ways} · its 45° leg is on the ${diagonal} diagonal and only meets a card on the same one${stop}${cost}`;
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
@@ -876,6 +1020,16 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
}
}
/** Which Mainline card this is, counting only the Mainline cards from the west end. */
function mainlineIndex(s: GameState, node: number): number {
let n = 0;
for (let k = 0; k <= node; k++) if (s.division.nodes[k]?.kind === 'mainline') n++;
return n;
}
const ORDINALS = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'];
const ordinal = (n: number): string => ORDINALS[n] ?? `${n}th`;
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
/**