Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+339
View File
@@ -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;
}