fix tracks to all be EW. no more NS tracks

This commit is contained in:
Jesse
2026-08-05 01:00:11 -04:00
parent 821d3cdffa
commit 2d348e1ecf
16 changed files with 1157 additions and 355 deletions
+21 -19
View File
@@ -1345,44 +1345,44 @@ export function reduce(s: GameState, e: GameEvent): void {
}
/**
* Builds the card a play would place, at the chosen orientation (Gap 11). Returns null when the
* variant index is out of range, which `check` reports rather than silently defaulting — a wrong
* Builds the card a play would place, at the chosen orientation. Returns null when the variant
* index is out of range, which `check` reports rather than silently defaulting — a wrong
* orientation is a different card, not a detail.
*/
function protoCard(
kind: { kind: string; geometry?: string; facility?: string },
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
const options = variantsFor(geometry);
// Track is a per-player supply rather than a deck card, so nothing reaches this branch today.
// It still needs a hand: handedness IS the slope, and defaulting silently would lay a card on
// the wrong diagonal.
const hand = (kind.hand as Hand | undefined) ?? 'left';
const options = variantsFor(geometry, hand);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(v.bypass ? { bypass: v.bypass } : {}),
...(hand !== 'none' ? { hand } : {}),
},
baseOperationalRail: geometry !== 'turnout',
...base,
};
}
const options = facilityVariants();
const v = options[variant ?? 0];
if (!v) return null;
// A Facility is plain east-west track, as every industry card on the printed sheet is, so there
// is exactly one orientation and any index past it is out of range.
if (!facilityVariants()[variant ?? 0]) return null;
return {
geometry: {
kind: 'facility',
facility: kind.facility as never,
...(v.axis ? { axis: v.axis } : {}),
},
geometry: { kind: 'facility', facility: kind.facility as never },
baseOperationalRail: true,
...base,
};
@@ -1440,14 +1440,14 @@ function adjacentFacilityCoord(
coord: GridCoord,
modifier?: ModifierKind,
): GridCoord | null {
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
if (coord.row > area.runningRow) return null;
// A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows
// on both sides of the Running Track and §9's "nine nearby spots" really is nine. The old Q7
// guard here rejected the three above outright.
const hosts = modifier ? modifierProfile(modifier).hosts : null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const c = { row: coord.row + dr, col: coord.col + dc };
if (c.row > area.runningRow) continue;
const f = area.grid.get(coordKey(c))?.facility;
if (!f) continue;
if (hosts && !hosts.includes(f.subtype)) continue;
@@ -1560,20 +1560,22 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
s.decks.salvageYard.push(cardId);
}
/** A track piece from the player's own supply, at the chosen rotation. */
/**
* A track piece from the player's own supply, at the chosen rotation — which for a printed card is
* 0° or 180°, and never a flip, so `hand` decides which diagonal the 45° leg can lie on.
*/
function protoTrackCard(
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
): TrackCard | null {
const options = variantsFor(geometry);
const options = variantsFor(geometry, hand);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}),
+9 -4
View File
@@ -16,6 +16,7 @@ import type { CarType, Hand, TrackGeometry } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
import { variantsFor } from './track.ts';
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
@@ -139,7 +140,10 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
for (const placement of targets) {
for (let variant = 0; variant < 6; variant++) {
// A printed card turns but does not flip, so the widest variant set is TWO. This offered six
// per placement per card back when a curve could be laid at all four rotations; four of every
// six were dead weight in an option list the bot scores in full.
for (let variant = 0; variant < 2; variant++) {
out.push({ type: 'card.play', cardId, placement, variant });
}
}
@@ -151,7 +155,8 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
if (count < 1) continue;
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
for (const placement of placements) {
for (let variant = 0; variant < 2; variant++) {
// 0° and 180°. A straight is the same card either way; `check` discards the duplicate.
for (let variant = 0; variant < variantsFor(geometry, hand).length; variant++) {
out.push({ type: 'track.lay', geometry, hand, placement, variant });
}
}
@@ -249,8 +254,8 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
{ row: row!, col: col! - 1 },
];
for (const c of around) {
// Q7 — a district hangs BELOW the Running Track; there is nothing above it.
if (c.row > area.runningRow) continue;
// A district grows on BOTH sides of the Running Track: a turnout turned 180° sends its 45°
// leg north instead of south. The rows above used to be filtered out here on Q7's authority.
const k = `${c.row},${c.col}`;
if (seen.has(k)) continue;
// The Limits signs are candidates even though they are occupied: laying track there is how
+22 -14
View File
@@ -48,39 +48,47 @@ export type RollingStock = { type: CarType; loaded: boolean };
* A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that
* matters is that `through` and `diverge` are NOT joined to each other.
*
* OPEN (Gap 11) — the rules specify "turnout ×6" without saying how many face which way. Until
* that is settled, orientation is carried per card and defaults to stem-east / diverge-north.
* The through track is ALWAYS east-west (`{stem, through}` is `{e,w}`); the diverging leg is the
* 45° one and always reaches north or south. Which of the two the card can be turned to is decided
* by its printed handedness — see `Slope`.
*/
export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' };
/**
* A straight card's axis. Also part of Gap 11 — the catalogue says "straight ×3" without saying
* how many run each way, yet a layout with no north-south straight can never use the Office's
* junction stubs at all.
*/
/**
* A curve joins two ADJACENT edges. There are exactly four such arcs, and a card can be turned to
* any of them — which is what makes a siding possible: an arc reaching NORTH is the only way back
* up to the Running Track from the district below.
* A curve joins two ADJACENT edges: it runs along the card's centre line from the east or west edge
* to a frog, then leaves at 45° through the MIDDLE of the north or south edge. Four such arcs
* exist, but a printed card reaches only two of them (`Slope`).
*/
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
export type TrackAxis = 'ew' | 'ns';
/**
* WHICH DIAGONAL A 45° LEG LIES ON.
*
* The printed cards (docs/tracks.png) put the through rail dead centre and send every diverging leg
* out at 45° through the middle of the north or south edge. A card can be turned 180° but not
* flipped over, so its leg never changes diagonal: the slope is printed, not chosen.
*
* Each name is the pair of arcs that MATE across a horizontal card edge — an `sw` card sitting
* above an `ne` card is one unbroken rail, whereas `sw` above `nw` is a V and joins nothing. That
* is the whole matching rule, and it is why the name is spelt this way.
*
* On screen `ne_sw` descends to the right and `nw_se` descends to the left.
*/
export type Slope = 'ne_sw' | 'nw_se';
export type CardGeometry =
| {
kind: 'track';
geometry: TrackGeometry;
turnout?: TurnoutOrientation;
axis?: TrackAxis;
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
arc?: TrackArc;
/** Curves are printed left- or right-handed (design supply); not chosen on placement. */
/** Curves and turnouts are printed left- or right-handed, which fixes their `Slope`. */
hand?: 'left' | 'right';
}
| { kind: 'office' }
| { kind: 'limits' }
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
| { kind: 'facility'; facility: FreightKind }
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
| { kind: 'modifier'; modifier: ModifierKind }
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
+134 -69
View File
@@ -13,14 +13,22 @@
*
* 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.
*
* THE THIRD CONSTRAINT IS GEOMETRY, and it is not topological at all. The printed cards
* (docs/tracks.png) run the through rail dead centre, east-west, on every card; there is no
* north-south track anywhere. Everything that leaves through the north or south edge does so at 45°
* through the MIDDLE of that edge, which means two vertically stacked cards line up only when their
* legs lie on the same diagonal. Two ports meeting is therefore no longer enough to make a
* connection: use `joins`, never a bare pair of `hasPort` calls.
*/
import { MAX_CONSIST } from './content.ts';
import type { TrackGeometry } from './content.ts';
import type { Hand, TrackGeometry } from './content.ts';
import type {
GridCoord,
OfficeArea,
RollingStock,
Slope,
TrackCard,
TrayId,
TrackArc,
@@ -67,16 +75,16 @@ 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.
* - **straight / limits** — a plain east-west through track. There is no north-south straight: the
* printed sheet has none, and a vertical rail cannot meet a 45° leg at an edge.
* - **turnout** — the east-west through track plus ONE 45° leg reaching north or south. `{stem,
* through}` and `{stem, diverge}` exist; `{through, diverge}` deliberately does not. That absence
* is §A.1's rule.
* - **office / facility** — a plain through track. Every office and industry card on the printed
* sheet (Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing, Town) is a
* straight east-west rail with no diverging leg at all; the industry spur is the card's own
* spotting capacity rather than a separate port. A district therefore grows off a TURNOUT laid on
* the Running Track, which is how a real railroad does it.
*/
/**
* Exported so the board can DRAW what the engine believes. Deriving the rails from anything else
@@ -90,22 +98,13 @@ export function connectionsFor(card: TrackCard): readonly PortPair[] {
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'],
];
return [['e', 'w']];
case 'track':
switch (card.geometry.geometry) {
case 'straight':
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
return [['e', 'w']];
case 'turnout': {
const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
// stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1).
@@ -116,79 +115,138 @@ export function connectionsFor(card: TrackCard): readonly PortPair[] {
}
/**
* A CURVE IS AN ARC between two adjacent edges — one connection, no through track. The
* printed cards show exactly this (docs/tracks.png, rows 3-4): a single sweep from edge to
* edge with nothing running past it.
*
* It was previously modelled as a through track PLUS a diverging leg, which is a turnout —
* and made curves topologically identical duplicates of them. Worse, every leg diverged
* south, so no piece anywhere reached NORTH except an n-s straight. A district could
* therefore only ever be a vertical column: no siding, no parallel track, no run-around.
* printed cards show exactly this (docs/tracks.png, rows 3-4): a run along the centre line
* from the east or west edge to a frog, then a 45° leg out through the middle of the north
* or south edge, with nothing running past it.
*
* A SHARP curve is the same shape and costs two Moves to cross.
*/
case 'curved':
case 'sharpCurved': {
const arc = card.geometry.arc ?? (card.geometry.hand === 'left' ? 'sw' : 'se');
const arc = card.geometry.arc ?? (card.geometry.hand === 'right' ? 'se' : 'sw');
return [[arc[0] as Port, arc[1] as Port]];
}
}
}
}
/** A turnout with no stated orientation takes this one. */
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' };
/**
* A turnout with no stated orientation takes this one — the left-hand card at 0°, i.e. the
* `ne_sw` slope. Only hand-less fixtures reach it; `variantsFor` always states an orientation.
*/
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'w', through: 'e', diverge: 's' };
// ---------------------------------------------------------------------------
// Slope — the 45° matching rule
// ---------------------------------------------------------------------------
/**
* Which diagonal a port pair's 45° leg lies on, or `null` for the east-west through track.
*
* The pair IS the answer: a leg joining north to east and one joining south to west lie on the same
* line, so both are `ne_sw`. Naming the slope after its two arcs makes the matching rule read
* itself — `sw` above `ne` is continuous rail, `sw` above `nw` is a V.
*/
export function slopeOfPair(a: Port, b: Port): Slope | null {
const pair = new Set<Port>([a, b]);
if (!pair.has('n') && !pair.has('s')) return null;
if (pair.has('n')) return pair.has('e') ? 'ne_sw' : 'nw_se';
return pair.has('w') ? 'ne_sw' : 'nw_se';
}
/**
* The slope of the leg leaving this card through `p`, or `null` if it has no such leg.
*
* A card has at most one leg per north/south edge — the printed cards give a turnout exactly one
* and a curve exactly one — so there is never a second slope to choose between. `assertOneSlope`
* in the tests holds that true for everything the supply can produce.
*/
export function slopeAt(card: TrackCard, p: Port): Slope | null {
if (p !== 'n' && p !== 's') return null;
for (const [a, b] of connectionsFor(card)) {
if (a === p || b === p) return slopeOfPair(a, b);
}
return null;
}
/**
* THE ADJACENCY TEST. Do these two cards actually join, across edge `p` of `a`?
*
* Two ports meeting is not enough. East and west ports sit at the same height on every card, so a
* straight run is always continuous; but north and south are met at 45°, and a leg descending to
* the right cannot be continued by one descending to the left. Every place that used to ask
* `hasPort(a, p) && hasPort(b, opposite(p))` must ask this instead, or the board will draw — and
* the engine will route trains through — a rail that bends back on itself at the card edge.
*/
export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
if (!hasPort(a, p) || !hasPort(b, opposite(p))) return false;
if (p === 'e' || p === 'w') return true;
return slopeAt(a, p) === slopeAt(b, opposite(p));
}
// ---------------------------------------------------------------------------
// 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.
* HOW A TRACK CARD MAY BE LAID: turned, but never flipped.
*
* 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 printed card has a back, so the only rotation that keeps the through rail east-west is 180°.
* That gives every track card exactly TWO orientations, and it means the card's printed handedness
* fixes which diagonal its 45° leg lies on for good — turning the card swaps the leg between north
* and south, but never between diagonals.
*
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
* rotation is free.
* So handedness is not decoration and not merely a supply label: it is the slope, and a siding
* needs one card of each hand (a left turnout to drop off the main, a right curve to climb back).
* Which printed row we call "left" is the one arbitrary bit, and it lives entirely in the two
* tables below.
*
* §A.1's constraint is preserved: a turnout's two legs still never join each other.
*/
const ARCS: readonly TrackArc[] = ['ne', 'nw', 'se', 'sw'];
export type TrackVariant = {
arc?: TrackArc;
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' },
];
/** Left-hand cards lie on `ne_sw`, right-hand on `nw_se`; 0° sends the leg south, 180° north. */
const CURVE_VARIANTS: Record<Hand, readonly TrackArc[]> = {
left: ['sw', 'ne'],
right: ['se', 'nw'],
none: ['sw', 'ne'],
};
export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
const TURNOUT_VARIANTS: Record<Hand, readonly TurnoutOrientation[]> = {
left: [
{ stem: 'w', through: 'e', diverge: 's' },
{ stem: 'e', through: 'w', diverge: 'n' },
],
right: [
{ stem: 'e', through: 'w', diverge: 's' },
{ stem: 'w', through: 'e', diverge: 'n' },
],
none: [
{ stem: 'w', through: 'e', diverge: 's' },
{ stem: 'e', through: 'w', diverge: 'n' },
],
};
export function variantsFor(geometry: TrackGeometry, hand: Hand = 'none'): TrackVariant[] {
switch (geometry) {
case 'straight':
return [{ axis: 'ew' }, { axis: 'ns' }];
// East-west, and turning it 180° gives the same card back. One orientation, not two.
return [{}];
case 'turnout':
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
return TURNOUT_VARIANTS[hand].map((t) => ({ turnout: t }));
case 'curved':
case 'sharpCurved':
// All four rotations. A two-port arc has no handedness that survives turning — its mirror IS
// one of its rotations — so the printed hand governs supply, not what can be built.
return ARCS.map((arc) => ({ arc }));
return CURVE_VARIANTS[hand].map((arc) => ({ arc }));
}
}
/** Facility and Office cards have fixed geometry; only plain track rotates. */
/** Office and Facility cards are plain east-west track, so there is nothing to choose. */
export function facilityVariants(): TrackVariant[] {
return [{ axis: 'ew' }, { axis: 'ns' }];
return [{}];
}
/** Ports reachable from `from` within this card, never including `from` itself. */
@@ -275,7 +333,11 @@ export function reachableDestinations(
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
// 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 [];
const queue: Frontier[] = [
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
];
@@ -309,13 +371,13 @@ export function reachableDestinations(
}
for (const exit of exitsFrom(card, node.entry)) {
// Slope is a property of the EDGE, not of either card, so it can only be tested with both in
// 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;
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,
});
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
}
}
@@ -339,8 +401,12 @@ export function allReachable(
// ---------------------------------------------------------------------------
/**
* 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.
* Gap 4a — a placed card must connect to existing track, and "connect" means `joins`: ports meeting
* AND, on a north or south edge, 45° legs on the same diagonal.
*
* The opening Office Area is three east-west cards, so the first piece of a district is necessarily
* a TURNOUT laid on the Running Track — the Office no longer carries a stub of its own, because no
* office or industry card on the printed sheet does.
*/
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
const existing = cardAt(area, coord);
@@ -365,9 +431,8 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
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;
if (neighbourCard && joins(card, p, neighbourCard)) return true;
}
return false;
}