Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button
This commit is contained in:
+79
-30
@@ -178,9 +178,26 @@ export type Frame = {
|
||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||
handWhat: string[];
|
||||
deck: number;
|
||||
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
||||
departments: string[];
|
||||
/** What each face-up Department card does. */
|
||||
departmentsWhat: string[];
|
||||
/**
|
||||
* How deep each Department pile is.
|
||||
*
|
||||
* A discard goes on TOP, so a deep pile is a card a rival buried and a shallow one is a card
|
||||
* freshly offered. Without the depth the board cannot say which, and choosing where to discard is
|
||||
* the whole of the decision.
|
||||
*/
|
||||
departmentDepth: number[];
|
||||
/**
|
||||
* The Salvage Yard — face up (§2.6), so its top card and its depth are both public.
|
||||
*
|
||||
* It is where a played card goes when it does not stay on the board, and §6.2 sweeps it back into
|
||||
* the Home Office deck when that runs out. Watching it fill is watching the reshuffle approach,
|
||||
* which is the only warning a player gets that the deck is about to turn over.
|
||||
*/
|
||||
salvage: { top: string; depth: number };
|
||||
/**
|
||||
* The two yards, by car type.
|
||||
*
|
||||
@@ -203,12 +220,7 @@ export type Frame = {
|
||||
* had the score but no way to know whether it was good.
|
||||
*/
|
||||
objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
|
||||
/**
|
||||
* What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
|
||||
* each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
|
||||
* got left" is a real planning question the board could not answer.
|
||||
*/
|
||||
trackSupply: { piece: string; left: number }[];
|
||||
|
||||
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
|
||||
decision: Decision | null;
|
||||
/** A Local Operations turn that changed nothing — the frames worth your attention. */
|
||||
@@ -334,13 +346,20 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
: '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.discard':
|
||||
return `discard ${cardName(s, i.cardId)}`;
|
||||
case 'track.lay':
|
||||
return (
|
||||
`lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${geometryLabel(i.geometry)} ` +
|
||||
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant, i.hand)}`
|
||||
);
|
||||
case 'card.discard': {
|
||||
/**
|
||||
* NAME THE DEPARTMENT, and what the card would land on.
|
||||
*
|
||||
* All three discards described identically as "discard X", and the action list drops
|
||||
* duplicate labels — so the three choices collapsed into one button and the player could not
|
||||
* pick a Department at all. The choice IS the strategy: onto an empty-ish pile the card is an
|
||||
* offer a rival may take, and on top of a card a rival wants it puts that card out of reach.
|
||||
*/
|
||||
const pile = s.decks.departments[i.toSlot] ?? [];
|
||||
const top = pile[pile.length - 1];
|
||||
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
|
||||
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
|
||||
}
|
||||
case 'switch.move':
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
||||
case 'switch.dropCars':
|
||||
@@ -388,10 +407,14 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
|
||||
}
|
||||
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.
|
||||
const id = s.decks.departments[i.slot];
|
||||
return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
|
||||
// Naming the card is the whole point of a FACE-UP pile: "Department 2" tells a player nothing,
|
||||
// and the choice between a visible card and a blind draw is unmakeable without it.
|
||||
const pile = s.decks.departments[i.slot] ?? [];
|
||||
const id = pile[pile.length - 1];
|
||||
if (!id) return `Department ${i.slot + 1} (empty)`;
|
||||
const under = pile.length - 1;
|
||||
const buried = under === 0 ? '' : `, ${under} buried beneath it`;
|
||||
return `take ${cardName(s, id)} from Department ${i.slot + 1}${buried}`;
|
||||
}
|
||||
case 'draw.fromHomeOffice':
|
||||
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
||||
@@ -611,8 +634,21 @@ export function snapshot(
|
||||
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
|
||||
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
||||
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
return top ? cardName(s, top) : '—';
|
||||
}),
|
||||
departmentsWhat: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
return top ? cardDescription(s, top) : '';
|
||||
}),
|
||||
departmentDepth: s.decks.departments.map((pile) => pile.length),
|
||||
salvage: {
|
||||
top: s.decks.salvageYard.length
|
||||
? cardName(s, s.decks.salvageYard[s.decks.salvageYard.length - 1]!)
|
||||
: '—',
|
||||
depth: s.decks.salvageYard.length,
|
||||
},
|
||||
yards: {
|
||||
division: countStock(s.yards.divisionYard),
|
||||
classification: countStock(s.yards.classificationYard),
|
||||
@@ -623,12 +659,6 @@ export function snapshot(
|
||||
decision,
|
||||
wasted,
|
||||
objective: objectiveOf(s),
|
||||
trackSupply: [...area.trackSupply.entries()]
|
||||
.map(([key, left]) => {
|
||||
const [geometry, hand] = key.split(':');
|
||||
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
|
||||
})
|
||||
.sort((a, b) => a.piece.localeCompare(b.piece)),
|
||||
runningRow: area.runningRow,
|
||||
blocked: impediments(s, 0),
|
||||
trains: [...s.trays.values()].map((t) => ({
|
||||
@@ -662,7 +692,9 @@ export function cardName(s: GameState, id: string): string {
|
||||
case 'modifier':
|
||||
return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
|
||||
case 'track':
|
||||
return `${geometryLabel(k.geometry)} track`;
|
||||
// The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs
|
||||
// in the name: "curve" alone does not tell you what it can be joined to.
|
||||
return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'mainlineModifier':
|
||||
@@ -732,6 +764,24 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
|
||||
}
|
||||
case 'track': {
|
||||
// Track is the largest category in the deck, so a player holds it constantly — and what it
|
||||
// can be joined to is decided by the hand, which is not something the name alone conveys.
|
||||
const cost = k.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
|
||||
const stop = k.geometry === 'turnout' ? ' · a train may pass through but not stop on it' : '';
|
||||
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}`
|
||||
);
|
||||
}
|
||||
default: {
|
||||
// The recovered categories carry their own prose — effect plus where it may be played.
|
||||
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
|
||||
@@ -794,11 +844,10 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
: '';
|
||||
const p = industryProfile(g.facility as never);
|
||||
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
|
||||
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
|
||||
// block anything. It does inherit the Running Track's hazard: §10 makes cars left standing
|
||||
// between the Limits and the Office a collision when a train arrives.
|
||||
// An industry can no longer BE on the Running Track — the sheet puts every one of them on a
|
||||
// straight stub off it — so this reads as a leftover only if one is somehow there.
|
||||
const where = onRunning
|
||||
? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
|
||||
? ' · ON THE RUNNING TRACK — industries belong on a stub; a car left standing here is hit by the next arrival'
|
||||
: '';
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user