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:
+170
-16
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user