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:
+44
-2
@@ -464,9 +464,36 @@ export function officeSvg(
|
||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||
const filled = i < cell.cars.length;
|
||||
out += `<rect class="bs-slot${filled ? ' bs-occ' : ''}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
|
||||
/**
|
||||
* WHICH CAR IS STANDING HERE, not just that one is.
|
||||
*
|
||||
* The switching game is entirely about getting the RIGHT car to the right industry, so "a car
|
||||
* is here" is not enough — a Grocer's Warehouse wants a boxcar or a reefer and nothing else
|
||||
* will do. This printed the first three characters of the label, which for "loaded hopper" and
|
||||
* "loaded boxcar" alike is `loa`: every car on the board looked the same.
|
||||
*
|
||||
* Type by colour and by three letters, loaded by a filled slot and empty by an outlined one —
|
||||
* the same distinction the printed game makes with coloured tokens.
|
||||
*/
|
||||
const car = cell.cars[i] ?? '';
|
||||
const type = /caboose/.test(car)
|
||||
? 'cab'
|
||||
: /hopper/.test(car)
|
||||
? 'hop'
|
||||
: /boxcar/.test(car)
|
||||
? 'box'
|
||||
: /reefer/.test(car)
|
||||
? 'rfr'
|
||||
: /tank/.test(car)
|
||||
? 'tnk'
|
||||
: /coach/.test(car)
|
||||
? 'cch'
|
||||
: '';
|
||||
const loaded = /^loaded/.test(car);
|
||||
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
|
||||
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
|
||||
if (filled) {
|
||||
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc((cell.cars[i] ?? '').slice(0, 3))}</text>`;
|
||||
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc(type)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +562,21 @@ export const BOARD_CSS = `
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
|
||||
type has to be readable at a glance; and a loaded car and an empty one of the same type are
|
||||
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
|
||||
.bs-slot.bs-car-box{stroke:#c08a4a}
|
||||
.bs-slot.bs-car-hop{stroke:#8d97a5}
|
||||
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
|
||||
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
|
||||
.bs-slot.bs-car-cch{stroke:#5aa9e6}
|
||||
.bs-slot.bs-car-cab{stroke:#c05a5a}
|
||||
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
|
||||
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
|
||||
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
|
||||
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
|
||||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
|
||||
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
|
||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||
|
||||
+151
-21
@@ -21,7 +21,7 @@
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { Hand } from '../engine/content.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
@@ -219,9 +219,47 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
return because('no option has a clear purpose this turn', can('switch') ?? can('freightAgent') ?? choices[0] ?? options[0]!);
|
||||
}
|
||||
|
||||
/** The face-up card on top of a Department pile — the only one that may be taken. */
|
||||
function topOfDepartment(s: GameState, slot: number): string | undefined {
|
||||
const pile = s.decks.departments[slot] ?? [];
|
||||
return pile[pile.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE TO DISCARD, now that the Departments are shared piles and a discard goes on TOP.
|
||||
*
|
||||
* Burying a card puts it out of reach — only the top of a pile may ever be taken — so the choice is
|
||||
* which face-up card to cover. Cover the one you would not have taken anyway, and the cards you DO
|
||||
* want stay reachable. `pickFirst` always chose Department 1, which buried whatever happened to be
|
||||
* sitting there, including the Depot the bot was waiting on.
|
||||
*
|
||||
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||||
* attack — which is why the choice belongs to the discarding player and not to the rules.
|
||||
*/
|
||||
function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
if (i.type !== 'card.discard') continue;
|
||||
// Cover a card that is no use, never one worth taking. Ties break toward the SHALLOWEST pile:
|
||||
// three piles offer three face-up cards, and piling discards onto one of them leaves the other
|
||||
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
|
||||
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
|
||||
const top = topOfDepartment(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, i.toSlot);
|
||||
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
|
||||
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
const id = s.decks.departments[slot];
|
||||
const id = topOfDepartment(s, slot);
|
||||
if (!id) return false;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return false;
|
||||
@@ -453,9 +491,9 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
// Closer to the Office is fewer Moves to reach, same as track scoring.
|
||||
let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
|
||||
|
||||
if (row === area.runningRow) {
|
||||
score -= 20;
|
||||
} else {
|
||||
// The Running Track is not merely a bad square for an industry any more, it is an illegal one
|
||||
// (`ON_RUNNING_TRACK`), so `check` has already filtered those out before this sees them.
|
||||
{
|
||||
let meetsLoop = false;
|
||||
let meetsReachable = false;
|
||||
for (const p of ports) {
|
||||
@@ -546,8 +584,33 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
c.geometry.geometry === 'straight',
|
||||
).length;
|
||||
|
||||
/**
|
||||
* A track play, split into the two things the scoring cares about.
|
||||
*
|
||||
* Track is an ordinary deck card now, so a lay arrives as `card.play` with a placement and a
|
||||
* rotation, and the piece it would put down has to be read back off the card in hand.
|
||||
*/
|
||||
type Lay = {
|
||||
intent: Intent;
|
||||
geometry: TrackGeometry;
|
||||
hand: Hand;
|
||||
variant: number | undefined;
|
||||
placement: GridCoord;
|
||||
};
|
||||
|
||||
const laysIn = (opts: Intent[]): Lay[] => {
|
||||
const out: Lay[] = [];
|
||||
for (const i of opts) {
|
||||
if (i.type !== 'card.play' || i.placement === undefined) continue;
|
||||
const kind = s.cards.get(i.cardId)?.kind;
|
||||
if (kind?.kind !== 'track') continue;
|
||||
out.push({ intent: i, geometry: kind.geometry, hand: kind.hand, variant: i.variant, placement: i.placement });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** The card a lay would place, built exactly as the reducer builds it. */
|
||||
const probeOf = (i: Extract<Intent, { type: 'track.lay' }>): TrackCard => {
|
||||
const probeOf = (i: Lay): TrackCard => {
|
||||
const v = variantsFor(i.geometry, i.hand)[i.variant ?? 0];
|
||||
return {
|
||||
geometry: {
|
||||
@@ -565,8 +628,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
} as unknown as TrackCard;
|
||||
};
|
||||
|
||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||
variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
|
||||
const arcOf = (i: Lay): string | undefined => variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
|
||||
|
||||
/**
|
||||
* RAIL THAT CAN NEVER GO ANYWHERE.
|
||||
@@ -581,7 +643,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
* end that DOES connect, and the last two attempts to forbid something outright starved the
|
||||
* district instead of tidying it.
|
||||
*/
|
||||
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
|
||||
const deadEnds = (i: Lay): number => {
|
||||
const probe = probeOf(i);
|
||||
let dead = 0;
|
||||
for (const p of new Set(connectionsFor(probe).flat())) {
|
||||
@@ -595,15 +657,22 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
};
|
||||
|
||||
/**
|
||||
* Arcs left that could meet THIS turnout's leg.
|
||||
* A curve of the RIGHT HAND to hang under this turnout, somewhere the player can still reach.
|
||||
*
|
||||
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
|
||||
* cutting one with nothing left to hang there is pure loss. Asked per hand, because handedness is
|
||||
* the slope: a left-hand turnout's 45° leg can only be continued by a left-hand curve, and a
|
||||
* supply full of right-hand ones is no help to it at all.
|
||||
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
|
||||
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
|
||||
* no help to it.
|
||||
*
|
||||
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
|
||||
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
|
||||
* a draw away rather than a certainty.
|
||||
*/
|
||||
const arcsLeft = (hand: Hand): number =>
|
||||
(area.trackSupply.get(`curved:${hand}`) ?? 0) + (area.trackSupply.get(`sharpCurved:${hand}`) ?? 0);
|
||||
const arcInHand = (hand: Hand): boolean =>
|
||||
(s.decks.hands.get(player) ?? []).some((id) => {
|
||||
const k = s.cards.get(id)?.kind;
|
||||
return k?.kind === 'track' && (k.geometry === 'curved' || k.geometry === 'sharpCurved') && k.hand === hand;
|
||||
});
|
||||
|
||||
// Where the district already turns off the main, either side.
|
||||
const turnouts = [...area.grid.entries()]
|
||||
@@ -618,8 +687,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const i of options) {
|
||||
if (i.type !== 'track.lay') continue;
|
||||
for (const i of laysIn(options)) {
|
||||
const { row, col } = i.placement;
|
||||
const dRow = Math.abs(row - area.officeCoord.row);
|
||||
const dCol = Math.abs(col - area.officeCoord.col);
|
||||
@@ -643,7 +711,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
// without competing further with the ways down. Scored below a first turnout, which is still
|
||||
// the most valuable single piece on the board, and below closing a run-around.
|
||||
bonus += 13;
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft(i.hand) > 0) {
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout' && arcInHand(i.hand)) {
|
||||
// A first way down is the most valuable single piece on the board; a second closes the
|
||||
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
||||
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
||||
@@ -722,7 +790,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
|
||||
if (score + bonus > bestScore) {
|
||||
bestScore = score + bonus;
|
||||
best = i;
|
||||
best = i.intent;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
@@ -942,13 +1010,30 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
const facility = bestFacilityPlay(s, player, options);
|
||||
if (facility) return because('put the industry where a crew can actually work it', facility);
|
||||
|
||||
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
|
||||
/**
|
||||
* TRACK IS NOT A FALLBACK.
|
||||
*
|
||||
* Track became an ordinary `card.play` when it moved into the deck, so this generic "play
|
||||
* anything placeable" catch dumped it on whatever square was legal — bypassing `bestTrackLay`
|
||||
* entirely, which had already looked at the same piece and declined it. Measured: 26 of 60
|
||||
* districts ran the siding east past the last column with a way up, spending track on a loop
|
||||
* that could never close. A track card `bestTrackLay` will not use is a card to discard.
|
||||
*/
|
||||
const placed = options.find(
|
||||
(i) =>
|
||||
i.type === 'card.play' &&
|
||||
i.placement !== undefined &&
|
||||
s.cards.get(i.cardId)?.kind.kind !== 'track',
|
||||
);
|
||||
if (placed) return because('develop the district with a card that goes on the board', placed);
|
||||
const play = options.find((i) => i.type === 'card.play');
|
||||
if (play) return because('play what is in hand', play);
|
||||
const end = options.find((i) => i.type === 'draw.end');
|
||||
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||
return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!);
|
||||
return because(
|
||||
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
|
||||
bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
@@ -965,6 +1050,51 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
);
|
||||
if (flyingFirst) return because('a Flying Switch rolls the back car straight into an industry that wants it', flyingFirst);
|
||||
|
||||
/**
|
||||
* DIG THE ENGINE OUT BEFORE ANYTHING ELSE.
|
||||
*
|
||||
* §8.2 will not let a train leave the Office with its engine buried among its own cars, and
|
||||
* taking a cut onto the nose is exactly how that happens. Left alone it is terminal: the train
|
||||
* holds an A/D track for the rest of the game, every train behind it collides, and the freight
|
||||
* chain it was serving starves. Measured before this: 90 holds across 40 games, six of them
|
||||
* with a train stuck permanently.
|
||||
*
|
||||
* Setting the nose cars out is the move Appendix A uses for it — "back up and drop off
|
||||
* everything on the nose of your train" — and it is worth spending a Move on ahead of any
|
||||
* freight work, because nothing else the crew does matters until the train can run again.
|
||||
*/
|
||||
const buried = trayOf(s, player);
|
||||
if (buried && buried.engineAt > 0 && buried.engineAt < buried.consist.length) {
|
||||
const dig = options.find(
|
||||
(i) => i.type === 'switch.dropCars' && i.fromNose === true && i.count === buried.engineAt,
|
||||
);
|
||||
if (dig) return because('the engine is buried in its own train — set out the cars on the nose so it can leave the Office', dig);
|
||||
}
|
||||
|
||||
/**
|
||||
* A CABOOSE OUT OF PLACE STOPS THE TRAIN JUST AS DEAD.
|
||||
*
|
||||
* It has to ride at the rear (§8.2), and a run-around can easily leave it mid-train. If it is
|
||||
* sitting at an outer end that is the WRONG end, setting it out is the one Move that frees the
|
||||
* train — §8.2 lets a train depart with fewer cars, never with the wrong ones in the wrong
|
||||
* order, so running cabooseless beats not running at all.
|
||||
*/
|
||||
if (buried) {
|
||||
const n = buried.consist.length;
|
||||
const cab = buried.consist.findIndex((c) => c.type === 'caboose');
|
||||
const rear = buried.engineAt === 0 ? n - 1 : 0;
|
||||
if (cab !== -1 && cab !== rear) {
|
||||
const atNoseEnd = cab === 0 && buried.engineAt > 0;
|
||||
const atTailEnd = cab === n - 1 && buried.engineAt < n;
|
||||
const shed = options.find(
|
||||
(i) => i.type === 'switch.dropCars' && i.count === 1 && (i.fromNose === true) === atNoseEnd,
|
||||
);
|
||||
if ((atNoseEnd || atTailEnd) && shed) {
|
||||
return because('the caboose is not at the rear and the train cannot leave — set it out', shed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!usefulSwitching(s, player)) {
|
||||
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
|
||||
// anywhere else `moveTrain` returns 'held', permanently. The bot used to end its turn
|
||||
|
||||
+7
-8
@@ -186,21 +186,20 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
where: e.to,
|
||||
text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`,
|
||||
};
|
||||
case 'trackLaid':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Laid ${e.hand === 'none' ? '' : e.hand + '-hand '}${e.geometry} track at ${at(e.at)} — ${e.remaining} left in the supply`,
|
||||
};
|
||||
case 'officeUpgraded':
|
||||
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
|
||||
case 'cardDiscarded':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: `Discarded ${card(e.cardId)} face-up to Department slot ${e.toSlot + 1}`,
|
||||
text: `Discarded ${card(e.cardId)} face-up on top of Department ${e.toSlot + 1}`,
|
||||
};
|
||||
case 'deckReshuffled':
|
||||
return { tone: 'quiet', text: 'Home Office deck ran out — Salvage Yard reshuffled back in' };
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text:
|
||||
`Home Office deck ran out — the Salvage Yard and all three Department decks ` +
|
||||
`(${e.order.length} cards) were collected, reshuffled and dealt back out`,
|
||||
};
|
||||
case 'departmentRefilled':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
|
||||
+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