Initial commit
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 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 {
|
||||
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) {
|
||||
case 'limits':
|
||||
return [['e', 'w']];
|
||||
case 'facility':
|
||||
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
|
||||
case 'office':
|
||||
return [
|
||||
['e', 'w'],
|
||||
['e', 'n'],
|
||||
['w', 'n'],
|
||||
['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],
|
||||
];
|
||||
}
|
||||
case 'runAround': {
|
||||
const b = card.geometry.bypass ?? 'n';
|
||||
return [
|
||||
['e', 'w'],
|
||||
['e', b],
|
||||
['w', b],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [
|
||||
{ stem: 'e', through: 'w', diverge: 'n' },
|
||||
{ stem: 'e', through: 'w', diverge: 's' },
|
||||
{ stem: 'w', through: 'e', diverge: 'n' },
|
||||
{ stem: 'w', through: 'e', diverge: 's' },
|
||||
{ stem: 'n', through: 's', diverge: 'e' },
|
||||
{ stem: 's', through: 'n', diverge: 'w' },
|
||||
];
|
||||
|
||||
export function variantsFor(geometry: 'straight' | 'turnout' | 'runAround'): TrackVariant[] {
|
||||
switch (geometry) {
|
||||
case 'straight':
|
||||
return [{ axis: 'ew' }, { axis: 'ns' }];
|
||||
case 'turnout':
|
||||
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
|
||||
case 'runAround':
|
||||
return [{ bypass: 'n' }, { bypass: 's' }];
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (cardAt(area, coord)) 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;
|
||||
}
|
||||
Reference in New Issue
Block a user