v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs
Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
+3
-21
@@ -20,7 +20,6 @@ import {
|
||||
COLLISION_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
@@ -35,7 +34,9 @@ import {
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import { acceptsCar, areaOf } from './apply.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
@@ -286,25 +287,6 @@ function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A made-up train still short of its consist spec, with a suitable car available. */
|
||||
function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.trainNumber === null) continue;
|
||||
if (tray.position.at !== 'divisionPoint') continue;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) continue;
|
||||
const want = consistSize(profile.consist);
|
||||
if (tray.consist.length >= want) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it.
|
||||
// Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
|
||||
// believed a car could be added while check rejected every option, so the Stage never ended.
|
||||
const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
|
||||
if (suitable) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+133
-22
@@ -20,6 +20,7 @@ import {
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
consistSize,
|
||||
enhancementRule,
|
||||
industryProfile,
|
||||
mainlineModifierRule,
|
||||
@@ -46,11 +47,12 @@ import type {
|
||||
} from './state.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||
import type { Occupancy, Port } from './track.ts';
|
||||
import type { MoveBlock, Occupancy, Port } from './track.ts';
|
||||
import {
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
carriesThroughTrack,
|
||||
exploreMoves,
|
||||
facilityVariants,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
@@ -351,7 +353,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
const hand = s.decks.hands.get(player) ?? [];
|
||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||
return checkPlay(s, player, i.cardId, i.placement, i.variant);
|
||||
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
|
||||
}
|
||||
|
||||
case 'card.discard': {
|
||||
@@ -482,6 +484,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
|
||||
// train that is running, and loading one from the yard is teleporting cars onto it.
|
||||
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
||||
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
|
||||
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
||||
return 'NO_SUITABLE_CAR';
|
||||
@@ -509,6 +514,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
|
||||
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
||||
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
|
||||
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
|
||||
}
|
||||
@@ -572,10 +579,14 @@ function checkPlay(
|
||||
cardId: string,
|
||||
placement: GridCoord | undefined,
|
||||
variant: number | undefined,
|
||||
node?: number,
|
||||
): RejectionCode | null {
|
||||
const card = s.cards.get(cardId);
|
||||
if (!card) return 'NO_SUCH_CARD';
|
||||
const area = areaOf(s, player);
|
||||
// A Division node and an Office Area square are different boards. Naming both is not a placement
|
||||
// with extra detail, it is two contradictory answers to "where?".
|
||||
if (node !== undefined && placement) return 'NO_PLACEMENT';
|
||||
|
||||
switch (card.kind.kind) {
|
||||
case 'office': {
|
||||
@@ -629,6 +640,12 @@ function checkPlay(
|
||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'enhancement': {
|
||||
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
|
||||
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
|
||||
if (node === undefined) return 'NO_PLACEMENT';
|
||||
const target = s.division.nodes[node];
|
||||
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||
}
|
||||
@@ -696,6 +713,46 @@ function destinationsFor(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
|
||||
*
|
||||
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
|
||||
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
|
||||
* a reason that applies in one direction should not be reported when the other direction works.
|
||||
*
|
||||
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
|
||||
* that produced them.
|
||||
*/
|
||||
export function movesFor(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: TrayId,
|
||||
): { to: GridCoord[]; blocked: MoveBlock[] } {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
|
||||
const from = tray.position.coord;
|
||||
const ctx = {
|
||||
area: areaOf(s, player),
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
};
|
||||
const facing = facingPort(s, trayId);
|
||||
const forward = exploreMoves(ctx, from, facing);
|
||||
const back = exploreMoves(ctx, from, opposite(facing));
|
||||
|
||||
const to = new Map<string, GridCoord>();
|
||||
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
|
||||
|
||||
const blocked = new Map<string, MoveBlock>();
|
||||
for (const b of [...forward.blocked, ...back.blocked]) {
|
||||
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
|
||||
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
|
||||
}
|
||||
|
||||
return { to: [...to.values()], blocked: [...blocked.values()] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// execute — reads state, emits events, never mutates
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -850,13 +907,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
to: card.kind.tier,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'enhancement' && i.placement) {
|
||||
events.push({
|
||||
type: 'enhancementPlaced',
|
||||
player,
|
||||
key: card.kind.key,
|
||||
at: i.placement,
|
||||
});
|
||||
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
|
||||
events.push(
|
||||
i.node !== undefined
|
||||
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
|
||||
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
|
||||
);
|
||||
}
|
||||
if (card.kind.kind === 'extraTrain') {
|
||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
||||
@@ -1205,13 +1261,32 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'officeUpgraded': {
|
||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||
const area = areaOf(s, e.player);
|
||||
const from = officeProfile(e.from);
|
||||
const to = officeProfile(e.to);
|
||||
area.tier = e.to;
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
if (officeCard?.facility) {
|
||||
const p = officeProfile(e.to);
|
||||
officeCard.facility.porters = p.porters;
|
||||
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
|
||||
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
|
||||
/**
|
||||
* THE TIER IS A DELTA, NOT AN OVERWRITE.
|
||||
*
|
||||
* This wrote the new tier's printed numbers straight over the facility, which silently
|
||||
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
|
||||
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
|
||||
* with no message, after the card had been spent. Reported from a playtest where a
|
||||
* Restaurant's porter never appeared — it had appeared and then been erased.
|
||||
*
|
||||
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
|
||||
* upgrade is worth and leaves anything standing beside it untouched.
|
||||
*/
|
||||
const f = officeCard.facility;
|
||||
f.porters += to.porters - from.porters;
|
||||
f.capacity = {
|
||||
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
|
||||
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
|
||||
};
|
||||
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
|
||||
// Post has no passenger boxes to add to.
|
||||
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1263,12 +1338,11 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
|
||||
case 'enhancementPlaced': {
|
||||
const rule = enhancementRule(e.key);
|
||||
if (rule?.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[e.at.col];
|
||||
if (e.node !== undefined) {
|
||||
const node = s.division.nodes[e.node];
|
||||
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
||||
if (node?.kind === 'mainline') node.absSignals = true;
|
||||
} else {
|
||||
} else if (e.at) {
|
||||
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
||||
if (card) card.enhancements.push(e.key);
|
||||
}
|
||||
@@ -1564,11 +1638,9 @@ export function checkEnhancementPlacement(
|
||||
const rule = enhancementRule(key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
|
||||
// ABS Signals goes on a Mainline card; `placement.col` names which one.
|
||||
if (rule.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[placement.col];
|
||||
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
|
||||
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
|
||||
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
|
||||
|
||||
const card = area.grid.get(coordKey(placement));
|
||||
if (!card) return 'NOT_CONNECTED';
|
||||
@@ -1741,6 +1813,45 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
|
||||
*
|
||||
* A train is made up where it is built, standing at a Division Point, and only until its consist
|
||||
* matches its card. Everything else with a Crew Tray — a train working your district, a train
|
||||
* halfway across the Division — is running, not being assembled.
|
||||
*
|
||||
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
|
||||
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
|
||||
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
|
||||
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
|
||||
* including Train 9 mid-crossing with three cars already aboard.
|
||||
*/
|
||||
export function isBeingMadeUp(tray: CrewTray): boolean {
|
||||
if (tray.trainNumber === null) return false;
|
||||
if (tray.position.at !== 'divisionPoint') return false;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) return false;
|
||||
return tray.consist.length < consistSize(profile.consist);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tray the New Train Phase is waiting on, or null.
|
||||
*
|
||||
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
|
||||
* to ask for a car that cannot be supplied.
|
||||
*/
|
||||
export function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (!isBeingMadeUp(tray)) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||
// be added while `check` rejected every option, so the Stage never ended.
|
||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
||||
*
|
||||
|
||||
@@ -78,7 +78,12 @@ export type GameEvent =
|
||||
* which made the replay say "New Train" and nothing else. A train being made up, departing,
|
||||
* arriving or finishing its run are four distinct facts and deserve four event types.
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
|
||||
/**
|
||||
* `at` is a square in the Office Area; `node` is a Division node, for an Enhancement that goes on
|
||||
* a Mainline card. Exactly one is set — see `card.play` in intents.ts for why they are not one
|
||||
* field wearing a sentinel.
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
|
||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||
|
||||
+12
-1
@@ -38,8 +38,17 @@ export type Intent =
|
||||
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
|
||||
* can be laid. Track is played through here like every other card; there is no separate lay
|
||||
* intent, because there is no separate supply to lay from.
|
||||
*
|
||||
* `placement` is a square in the player's Office Area; `node` indexes `division.nodes` and is for
|
||||
* the one card that goes out on the Mainline instead (ABS Signals). They are alternatives, and
|
||||
* they are SEPARATE FIELDS on purpose. A Mainline placement used to travel as the fake coordinate
|
||||
* `{ row: -1, col: node }`, and row −1 is an ordinary district row — the first one below the
|
||||
* Running Track, where most districts start. So a real placement on row −1 was labelled "out on
|
||||
* the Mainline, node −2", and an ABS Signals placement highlighted whichever district card
|
||||
* happened to sit at that column. A sentinel inside a coordinate's own value range is not a
|
||||
* sentinel.
|
||||
*/
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number; node?: number }
|
||||
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
||||
| { type: 'draw.end' }
|
||||
// -- freight agent (§6.3)
|
||||
@@ -108,6 +117,8 @@ export type RejectionCode =
|
||||
| 'BOX_EMPTY'
|
||||
| 'NO_SUITABLE_CAR'
|
||||
| 'SUITABLE_CAR_EXISTS'
|
||||
/** §7 — cars go onto the train being assembled at a Division Point, not onto one already running. */
|
||||
| 'NOT_BEING_MADE_UP'
|
||||
| 'WRONG_CAR_TYPE'
|
||||
| 'NOT_SUPERINTENDENT'
|
||||
| 'NO_PENDING_DECISION'
|
||||
|
||||
+12
-9
@@ -143,17 +143,20 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const kind = s.cards.get(cardId)?.kind;
|
||||
/**
|
||||
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
|
||||
* one exception. ABS Signals goes on a MAINLINE card, and `checkEnhancementPlacement` reads
|
||||
* `placement.col` as a Division node index for it. Offering it grid squares meant the engine
|
||||
* accepted "(0, 1)" because node 1 happened to be a Mainline card: the label said one thing and
|
||||
* the placement meant another, so the card appeared to be playable all over the Office Area.
|
||||
* one exception. ABS Signals goes on a MAINLINE card, which is not a grid square at all, so it
|
||||
* is offered as a Division NODE and never as a coordinate.
|
||||
*
|
||||
* It used to be offered as `{ row: -1, col: node }`. Row −1 is a real district row — the first
|
||||
* one below the Running Track, where most districts start — so the card appeared to be playable
|
||||
* all over the Office Area, and ordinary placements on that row were labelled as Mainline ones.
|
||||
*/
|
||||
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
|
||||
const targets = onMainline
|
||||
? s.division.nodes.map((_, node) => ({ row: -1, col: node }))
|
||||
: kind?.kind === 'enhancement'
|
||||
? attachments
|
||||
: placements;
|
||||
if (onMainline) {
|
||||
for (let node = 0; node < s.division.nodes.length; node++) {
|
||||
out.push({ type: 'card.play', cardId, node });
|
||||
}
|
||||
}
|
||||
const targets = onMainline ? [] : kind?.kind === 'enhancement' ? attachments : placements;
|
||||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||||
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
||||
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
||||
|
||||
+87
-10
@@ -323,12 +323,57 @@ export function reachableDestinations(
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
const { area, occupancy } = ctx;
|
||||
const startCard = cardAt(area, start);
|
||||
if (!startCard) return [];
|
||||
if (!hasPort(startCard, initialExit)) return [];
|
||||
return exploreMoves(ctx, start, initialExit).destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card the walk reached and refused, with the rule that refused it, in a player's words.
|
||||
*
|
||||
* `kind` separates the OBSTRUCTIONS — another train, a locked industry, a consist that would
|
||||
* overfill, rails that do not meet — from `noStopping`, which is not an obstruction at all: a train
|
||||
* runs through a turnout freely and simply may not finish a Move on one. They are drawn differently
|
||||
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
|
||||
* turnout in the district would otherwise appear.
|
||||
*/
|
||||
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
|
||||
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||
|
||||
/**
|
||||
* THE SAME WALK, KEEPING ITS REJECTIONS.
|
||||
*
|
||||
* `reachableDestinations` answers "where may I go", and the UI could show that much — but a player
|
||||
* looking at a siding two cards away and no button for it is asking the opposite question, and the
|
||||
* answer was nowhere on screen. Reported as "trains seem to be blocked from moving onto industry in
|
||||
* certain conditions; make it clear what those conditions are".
|
||||
*
|
||||
* Both answers come out of ONE traversal on purpose. A second function that worked out why a square
|
||||
* was missing would be a second implementation of the movement rules, and the failure mode is a
|
||||
* reason that does not match the refusal — worse than no reason at all.
|
||||
*
|
||||
* A block is recorded for every card the walk actually touched and turned down, plus every card
|
||||
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
|
||||
* "there is no track between here and there" is not news.
|
||||
*/
|
||||
export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||
const { area, occupancy } = ctx;
|
||||
const results: MoveDestination[] = [];
|
||||
const blocked: MoveBlock[] = [];
|
||||
const noted = new Set<string>();
|
||||
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||
const k = coordKey(coord);
|
||||
if (noted.has(k)) return;
|
||||
noted.add(k);
|
||||
blocked.push({ coord, kind, why });
|
||||
};
|
||||
|
||||
const startCard = cardAt(area, start);
|
||||
if (!startCard) return { destinations: results, blocked };
|
||||
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
||||
@@ -337,7 +382,10 @@ export function reachableDestinations(
|
||||
// hop is checked at the push site below, where both sides of the edge are in hand.
|
||||
const first = neighbour(start, initialExit);
|
||||
const firstCard = cardAt(area, first);
|
||||
if (!firstCard || !joins(startCard, initialExit, firstCard)) return [];
|
||||
if (!firstCard || !joins(startCard, initialExit, firstCard)) {
|
||||
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
return { destinations: results, blocked };
|
||||
}
|
||||
const queue: Frontier[] = [
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||
];
|
||||
@@ -352,7 +400,16 @@ export function reachableDestinations(
|
||||
const occupant = occupancy.trayAt(node.coord);
|
||||
if (occupant !== null && occupant !== ctx.self) {
|
||||
const isOffice = sameCoord(node.coord, area.officeCoord);
|
||||
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
|
||||
if (!isOffice || occupancy.freeAdTracks() <= 0) {
|
||||
block(
|
||||
node.coord,
|
||||
'occupied',
|
||||
isOffice
|
||||
? 'the Office is full — every A/D track is taken, so there is no room to enter or pass through (§A.4)'
|
||||
: 'another train is standing here — two trains may not share a card, or move through each other (§A.4)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPort(card, node.entry)) continue;
|
||||
@@ -361,12 +418,22 @@ export function reachableDestinations(
|
||||
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
|
||||
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
|
||||
// spotted on it on the way past — the two things the safety lockout exists to prevent.
|
||||
if (isLockedByWork(card)) continue;
|
||||
if (isLockedByWork(card)) {
|
||||
block(node.coord, 'locked', 'MEN AT WORK — a load is on the sign, so this industry track is locked for safety: no train may enter it or cross it, and no car may be picked up or set out (§9.3)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
// overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
const couples = [...node.couples, ...carsOn(card)];
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) {
|
||||
block(
|
||||
node.coord,
|
||||
'tooManyCars',
|
||||
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
@@ -374,6 +441,10 @@ export function reachableDestinations(
|
||||
|
||||
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||
} else if (!sameCoord(node.coord, start)) {
|
||||
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
|
||||
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
|
||||
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
|
||||
}
|
||||
|
||||
for (const exit of exitsFrom(card, node.entry)) {
|
||||
@@ -381,13 +452,19 @@ export function reachableDestinations(
|
||||
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
|
||||
const to = neighbour(node.coord, exit);
|
||||
const next = cardAt(area, to);
|
||||
if (!next || !joins(card, exit, next)) continue;
|
||||
if (!next || !joins(card, exit, next)) {
|
||||
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
continue;
|
||||
}
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||
// from a bad angle first and a good one later.
|
||||
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||
return { destinations: results, blocked: blocked.filter((b) => !reached.has(coordKey(b.coord))) };
|
||||
}
|
||||
|
||||
/** Both directions at once — what the UI highlights when a tray is selected. */
|
||||
|
||||
Reference in New Issue
Block a user