372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
/**
|
|
* Component 3 — Track graph and movement.
|
|
*
|
|
* The Office Area grid as a traversable graph, and the rules governing a Move.
|
|
* See architecture/components.md §2 A.3 and docs/rules/rules-v0.2.md Appendix A.
|
|
*
|
|
* THE CENTRAL SUBTLETY. §A.1 says a train entering a turnout from "A" may proceed to B or C, but
|
|
* one entering through B or C may only proceed to A. That is NOT a one-way restriction — A→B and
|
|
* B→A are both legal. What it means is that **B and C are not connected to each other**: the frog
|
|
* offers no route between the two diverging legs. Modelling this as a directed graph would forbid
|
|
* legal moves. It is an undirected graph over *port pairs*, and the constraint is which pairs
|
|
* exist on each card.
|
|
*
|
|
* The second constraint is that a traversal may never leave a card through the port it entered by.
|
|
* That is what "without changing direction" (§2.4) means in practice.
|
|
*/
|
|
|
|
import { MAX_CONSIST } from './content.ts';
|
|
import type { TrackGeometry } from './content.ts';
|
|
import type {
|
|
GridCoord,
|
|
OfficeArea,
|
|
RollingStock,
|
|
TrackCard,
|
|
TrayId,
|
|
TurnoutOrientation,
|
|
} from './state.ts';
|
|
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ports and geometry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** The four edges of a card. East is the player's right, west their left (§2.4). */
|
|
export type Port = 'n' | 's' | 'e' | 'w';
|
|
|
|
export function opposite(p: Port): Port {
|
|
switch (p) {
|
|
case 'n':
|
|
return 's';
|
|
case 's':
|
|
return 'n';
|
|
case 'e':
|
|
return 'w';
|
|
case 'w':
|
|
return 'e';
|
|
}
|
|
}
|
|
|
|
/** The grid neighbour across a given edge. Row increases northward. */
|
|
export function neighbour(c: GridCoord, p: Port): GridCoord {
|
|
switch (p) {
|
|
case 'n':
|
|
return { row: c.row + 1, col: c.col };
|
|
case 's':
|
|
return { row: c.row - 1, col: c.col };
|
|
case 'e':
|
|
return { row: c.row, col: c.col + 1 };
|
|
case 'w':
|
|
return { row: c.row, col: c.col - 1 };
|
|
}
|
|
}
|
|
|
|
type PortPair = readonly [Port, Port];
|
|
|
|
/**
|
|
* Which ports a card joins internally.
|
|
*
|
|
* - **straight / limits** — a plain through track.
|
|
* - **turnout** — the through track plus ONE diverging leg off the east end. `e-w` and `e-n` exist;
|
|
* `w-n` deliberately does not. That absence is §A.1's rule.
|
|
* - **runAround** — a double-ended siding: both ends reach the loop, so a train can pass around
|
|
* standing cars. §A.5's facing-point move is impossible without one.
|
|
* - **office** — Gap 8: junction stubs above and below, each reaching both ends of the through
|
|
* track. These are plain junctions, so unlike a turnout there is no missing pair. North and south
|
|
* are not joined to each other — that would be crossing the running track.
|
|
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
|
|
* than a separate port.
|
|
*/
|
|
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
|
switch (card.geometry.kind) {
|
|
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
|
|
case 'modifier':
|
|
case 'spaceUse':
|
|
return [];
|
|
case 'limits':
|
|
return [['e', 'w']];
|
|
case 'facility':
|
|
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
|
|
// Q7 — the card art draws the through-track along the TOP edge with everything diverging
|
|
// downward, so a district is a Running Track with all Secondary Track hanging beneath it.
|
|
// This supersedes Gap 8's north-and-south stubs.
|
|
case 'office':
|
|
return [
|
|
['e', 'w'],
|
|
['e', 's'],
|
|
['w', 's'],
|
|
];
|
|
case 'track':
|
|
switch (card.geometry.geometry) {
|
|
case 'straight':
|
|
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
|
|
case 'turnout': {
|
|
const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
|
|
// stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1).
|
|
return [
|
|
[o.stem, o.through],
|
|
[o.stem, o.diverge],
|
|
];
|
|
}
|
|
// A CURVE joins the through track to one diverging side — handedness is printed on the
|
|
// card, not chosen (the design supplies both hands). A SHARP curve is geometrically the
|
|
// same but costs two Moves to cross.
|
|
// Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides
|
|
// which end of the through track the leg leaves from, not whether it goes up or down.
|
|
case 'curved':
|
|
case 'sharpCurved': {
|
|
const stem = card.geometry.hand === 'left' ? 'w' : 'e';
|
|
return [
|
|
['e', 'w'],
|
|
[stem, 's'],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A turnout with no stated orientation takes this one. */
|
|
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orientation (Gap 11)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* How a track card may be laid. Gap 11 resolved in favour of **orientation chosen on placement**:
|
|
* a track card is generic and the player decides how to lay it.
|
|
*
|
|
* The measurement that settled it: with orientation fixed, an east-west straight has no north or
|
|
* south port, so it could never attach to the Office's junction stubs. Simulated Office Areas grew
|
|
* only sideways and downward — never upward — purely as an artifact of the default. That silently
|
|
* undid Gap 8 and put Appendix A's switching puzzle out of reach.
|
|
*
|
|
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
|
|
* rotation is free.
|
|
*/
|
|
export type TrackVariant = {
|
|
axis?: 'ew' | 'ns';
|
|
turnout?: TurnoutOrientation;
|
|
bypass?: Port;
|
|
};
|
|
|
|
/**
|
|
* Q7 — everything diverges downward, so a turnout's leg is always south. Handedness (printed on
|
|
* the card) decides which end of the through track it leaves from.
|
|
*/
|
|
const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [
|
|
{ stem: 'e', through: 'w', diverge: 's' },
|
|
{ stem: 'w', through: 'e', diverge: 's' },
|
|
];
|
|
|
|
export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
|
|
switch (geometry) {
|
|
case 'straight':
|
|
return [{ axis: 'ew' }, { axis: 'ns' }];
|
|
case 'turnout':
|
|
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
|
|
case 'curved':
|
|
case 'sharpCurved':
|
|
// Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left
|
|
// to choose — one variant.
|
|
return [{ axis: 'ew' }];
|
|
}
|
|
}
|
|
|
|
/** Facility and Office cards have fixed geometry; only plain track rotates. */
|
|
export function facilityVariants(): TrackVariant[] {
|
|
return [{ axis: 'ew' }, { axis: 'ns' }];
|
|
}
|
|
|
|
/** Ports reachable from `from` within this card, never including `from` itself. */
|
|
export function exitsFrom(card: TrackCard, from: Port): Port[] {
|
|
const out: Port[] = [];
|
|
for (const [a, b] of connectionsFor(card)) {
|
|
if (a === from && b !== from) out.push(b);
|
|
else if (b === from && a !== from) out.push(a);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function hasPort(card: TrackCard, p: Port): boolean {
|
|
return connectionsFor(card).some(([a, b]) => a === p || b === p);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Occupancy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Occupancy = {
|
|
/** Which tray sits on a given card, if any. */
|
|
trayAt(coord: GridCoord): TrayId | null;
|
|
/** Free A/D tracks at the Office card, for the pass-through allowance (§A.4). */
|
|
freeAdTracks(): number;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Move reachability
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type MoveStep = { coord: GridCoord; entry: Port; exit: Port };
|
|
|
|
export type MoveDestination = {
|
|
coord: GridCoord;
|
|
/** The port the train arrived through; its new facing is the opposite. */
|
|
entry: Port;
|
|
path: MoveStep[];
|
|
/** Standing cars coupled along the way, in the order encountered (§A.4). */
|
|
couples: RollingStock[];
|
|
};
|
|
|
|
export type MoveContext = {
|
|
area: OfficeArea;
|
|
occupancy: Occupancy;
|
|
/** Cars already in the tray; coupling may not push the consist past four (§A.4). */
|
|
consistSize: number;
|
|
/** The tray making the move, so it does not block itself. */
|
|
self: TrayId;
|
|
};
|
|
|
|
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
|
|
return area.grid.get(coordKey(c));
|
|
}
|
|
|
|
function sameCoord(a: GridCoord, b: GridCoord): boolean {
|
|
return a.row === b.row && a.col === b.col;
|
|
}
|
|
|
|
/**
|
|
* Every card a tray may finish a single Move on, starting from `start` and leaving through
|
|
* `initialExit`.
|
|
*
|
|
* A Move travels any distance without changing direction (§2.4) and must finish on Operational
|
|
* Rail (§A.1 — a turnout carries no wheel icon, so a train may pass through but not stop). Cars
|
|
* met along the way are coupled automatically and mandatorily; you may not go around them (§A.4).
|
|
*
|
|
* Direction is expressed by which port the train first leaves through. Reversing is a separate
|
|
* Move with the opposite initial exit, which is why §A.5's worked examples spend a Move on each
|
|
* change of direction.
|
|
*/
|
|
export function reachableDestinations(
|
|
ctx: MoveContext,
|
|
start: GridCoord,
|
|
initialExit: Port,
|
|
): MoveDestination[] {
|
|
const { area, occupancy } = ctx;
|
|
const startCard = cardAt(area, start);
|
|
if (!startCard) return [];
|
|
if (!hasPort(startCard, initialExit)) return [];
|
|
|
|
const results: MoveDestination[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
|
|
|
const first = neighbour(start, initialExit);
|
|
const queue: Frontier[] = [
|
|
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
|
];
|
|
|
|
while (queue.length > 0) {
|
|
const node = queue.shift()!;
|
|
const card = cardAt(area, node.coord);
|
|
if (!card) continue;
|
|
|
|
// Two trains may not share a card or move through each other (§A.4). The Office track is the
|
|
// exception: while it has free A/D tracks you may enter and pass through.
|
|
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 (!hasPort(card, node.entry)) 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;
|
|
|
|
const key = `${coordKey(node.coord)}|${node.entry}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
|
|
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
|
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
|
}
|
|
|
|
for (const exit of exitsFrom(card, node.entry)) {
|
|
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
|
queue.push({
|
|
coord: neighbour(node.coord, exit),
|
|
entry: opposite(exit),
|
|
path: [...node.path, step],
|
|
couples,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/** Both directions at once — what the UI highlights when a tray is selected. */
|
|
export function allReachable(
|
|
ctx: MoveContext,
|
|
start: GridCoord,
|
|
facing: Port,
|
|
): { forward: MoveDestination[]; reverse: MoveDestination[] } {
|
|
return {
|
|
forward: reachableDestinations(ctx, start, facing),
|
|
reverse: reachableDestinations(ctx, start, opposite(facing)),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Placement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Gap 4a — a placed card must connect to existing track. Gap 8 gives the Office card junction
|
|
* stubs above and below so this is satisfiable from the opening Stage.
|
|
*/
|
|
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
|
const existing = cardAt(area, coord);
|
|
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
|
|
// the card where the sign stands and moving the sign outward (§2.1, Gap 4a) — the physical act at
|
|
// the table. Treating the sign as occupied forced players to build PAST it, stranding the sign
|
|
// mid-track with everything beyond it nominally outside their own Limits.
|
|
const isMovableSign =
|
|
existing?.geometry.kind === 'limits' &&
|
|
coord.row === area.runningRow &&
|
|
existing.standing.length === 0;
|
|
if (existing && !isMovableSign) return false;
|
|
|
|
// §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so
|
|
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
|
|
// side of the sign and then planted a SECOND sign further out, leaving the board reading
|
|
// limits · Whistle Post · limits · straight · limits
|
|
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
|
|
if (coord.row === area.runningRow) {
|
|
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
|
|
}
|
|
|
|
const ports: Port[] = ['n', 's', 'e', 'w'];
|
|
for (const p of ports) {
|
|
if (!hasPort(card, p)) continue;
|
|
const neighbourCard = cardAt(area, neighbour(coord, p));
|
|
if (neighbourCard && hasPort(neighbourCard, opposite(p))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
|
|
* An industry track also has a finite length (§9.3), so it can be full.
|
|
*/
|
|
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
|
|
if (sameCoord(coord, area.officeCoord)) return false;
|
|
const card = cardAt(area, coord);
|
|
if (!card || !isOperationalRail(card)) return false;
|
|
return spaceOn(card) >= count;
|
|
}
|