fix tracks to all be EW. no more NS tracks
This commit is contained in:
+21
-19
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+36
-17
@@ -294,18 +294,24 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
/**
|
||||
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
||||
*
|
||||
* The through rail sits at a constant height on every card — the alignment the printed cards use —
|
||||
* so abutting cards produce one unbroken line and a gap is visibly a gap.
|
||||
* THE PRINTED CARD, and nothing else (docs/tracks.png). The through rail runs east-west across the
|
||||
* DEAD CENTRE of every card, so abutting cards produce one unbroken line and a gap is visibly a
|
||||
* gap. Everything that leaves through the north or south edge does so at 45°, through the middle of
|
||||
* that edge — which is what lets two stacked cards read as one continuous diagonal.
|
||||
*
|
||||
* The card is 166 x 96, the printed sheet's 259:150 proportions. The aspect is not decoration: the
|
||||
* frog where a 45° leg meets the through track sits H/2 from the centre, so a squarer card pushes
|
||||
* it almost to the edge and a turnout's two routes stop being tellable apart.
|
||||
*/
|
||||
export function officeSvg(
|
||||
cells: CellView[],
|
||||
runningRow: number,
|
||||
ghosts: { row: number; col: number }[] = [],
|
||||
): string {
|
||||
const W = 132;
|
||||
const W = 166;
|
||||
const H = 96;
|
||||
const PAD = 3;
|
||||
const RAIL = 30;
|
||||
const RAIL = H / 2;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
@@ -356,8 +362,8 @@ export function officeSvg(
|
||||
return out;
|
||||
};
|
||||
|
||||
// Where each port meets the card edge. East and west sit at the rail height so a straight run
|
||||
// stays straight across the whole row; north and south are centred.
|
||||
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
|
||||
// so a straight run stays straight across the whole row; north and south are centred on the edge.
|
||||
const port = (p: string): { x: number; y: number } => {
|
||||
if (p === 'e') return { x: W, y: RAIL };
|
||||
if (p === 'w') return { x: 0, y: RAIL };
|
||||
@@ -389,15 +395,27 @@ export function officeSvg(
|
||||
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
|
||||
// routed through it, and the picture should say so.
|
||||
for (const link of cell.links) {
|
||||
const a = port(link[0] ?? 'e');
|
||||
const b = port(link[1] ?? 'w');
|
||||
const straight = (link === 'ew' || link === 'we') || (link === 'ns' || link === 'sn');
|
||||
if (straight) {
|
||||
out += rail(a.x, a.y, b.x, b.y);
|
||||
const from = link[0] ?? 'e';
|
||||
const to = link[1] ?? 'w';
|
||||
const vertical = from === 'n' || from === 's' ? from : to === 'n' || to === 's' ? to : '';
|
||||
if (vertical === '') {
|
||||
// The through track: edge to edge along the middle of the card.
|
||||
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
|
||||
} else {
|
||||
// A diverging leg: run out from each edge and meet, which reads as a curve at this size.
|
||||
const mid = { x: W / 2, y: RAIL + (H - RAIL) / 2 };
|
||||
out += rail(a.x, a.y, mid.x, mid.y) + rail(mid.x, mid.y, b.x, b.y);
|
||||
/**
|
||||
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
|
||||
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
|
||||
*
|
||||
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
|
||||
* travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2)
|
||||
* — below the rail on the assumption everything diverged downward — which drew a leg
|
||||
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
|
||||
* any angle the matching rule cares about.
|
||||
*/
|
||||
const side = vertical === from ? to : from;
|
||||
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
||||
const edge = port(vertical);
|
||||
out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,9 +471,10 @@ export function officeSvg(
|
||||
}
|
||||
|
||||
if (cell.enhancements.length > 0) {
|
||||
// Above the load pipeline, not on it: an `onCard` enhancement can land on a facility, and at
|
||||
// the old baseline the label printed straight through the green/MEN|AT|WORK/red squares.
|
||||
out += `<text class="bs-enh" x="6" y="${H - 46}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
// ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the
|
||||
// load pipeline below and the through track — and with the rail now down the middle of the
|
||||
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||||
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.tray) {
|
||||
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
||||
|
||||
+135
-102
@@ -21,10 +21,11 @@
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { Hand } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
@@ -311,24 +312,41 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
|
||||
* district keeps Crew Moves cheap, and Moves are the real currency of Local Operations.
|
||||
*/
|
||||
/**
|
||||
* Walk the Secondary Track from the Running Track, using the ENGINE's own `exitsFrom`.
|
||||
* Which side of the Running Track a district is being walked on. `-1` is the rows below, `+1` the
|
||||
* rows above — both real now that a turnout turned 180° sends its 45° leg north. Every walk below
|
||||
* is written once and run for each side; the old code hard-coded "below" throughout and so could
|
||||
* not see half the board.
|
||||
*/
|
||||
type Side = -1 | 1;
|
||||
const SIDES: readonly Side[] = [-1, 1];
|
||||
|
||||
/** The edge of a main-line card that a district on this side hangs from. */
|
||||
const legPort = (side: Side): Port => (side === -1 ? 's' : 'n');
|
||||
|
||||
/**
|
||||
* Walk the Secondary Track away from the Running Track, using the ENGINE's own `exitsFrom`.
|
||||
*
|
||||
* Shared deliberately. Track laying and facility placement both need to know what a crew can
|
||||
* actually reach, and two copies of this walk would eventually disagree about whether a district
|
||||
* connects — which is the one thing both decisions rest on. A turnout's legs never join each other
|
||||
* (§A.1), so this traverses in (cell, entry port) space rather than treating a cell as one node.
|
||||
*
|
||||
* Every step tests `joins`, not `hasPort`: two 45° legs can meet at the same point on a card edge
|
||||
* and still not be a rail, if they lie on opposite diagonals.
|
||||
*/
|
||||
function descendFrom(
|
||||
area: OfficeArea,
|
||||
col: number,
|
||||
side: Side,
|
||||
): { rejoins: Set<number>; seen: Set<string> } {
|
||||
const main = area.runningRow;
|
||||
const rejoins = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
const start = area.grid.get(`${main - 1},${col}`);
|
||||
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
|
||||
const entry = opposite(legPort(side));
|
||||
const start = area.grid.get(`${main + side},${col}`);
|
||||
if (!start || !hasPort(start, entry)) return { rejoins, seen };
|
||||
|
||||
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
|
||||
const stack: { row: number; col: number; entry: Port }[] = [{ row: main + side, col, entry }];
|
||||
const visited = new Set<string>();
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
@@ -341,8 +359,9 @@ function descendFrom(
|
||||
for (const exit of exitsFrom(card, cur.entry)) {
|
||||
const n = neighbour({ row: cur.row, col: cur.col }, exit);
|
||||
const next = area.grid.get(`${n.row},${n.col}`);
|
||||
if (!next || !hasPort(next, opposite(exit))) continue;
|
||||
if (n.row >= main) {
|
||||
if (!next || !joins(card, exit, next)) continue;
|
||||
// Back onto the main, or past it onto the far side: either way the siding has rejoined.
|
||||
if (side === -1 ? n.row >= main : n.row <= main) {
|
||||
if (n.col !== col) rejoins.add(n.col);
|
||||
continue;
|
||||
}
|
||||
@@ -352,38 +371,43 @@ function descendFrom(
|
||||
return { rejoins, seen };
|
||||
}
|
||||
|
||||
/** Every way down off the main, by column. */
|
||||
function waysDown(area: OfficeArea): number[] {
|
||||
/** Every way off the main on one side, by column. */
|
||||
function waysOff(area: OfficeArea, side: Side): number[] {
|
||||
const out: number[] = [];
|
||||
const p = legPort(side);
|
||||
for (const [k, c] of area.grid.entries()) {
|
||||
const [r, col] = k.split(',').map(Number);
|
||||
if (r !== area.runningRow || !hasPort(c, 's')) continue;
|
||||
const under = area.grid.get(`${area.runningRow - 1},${col}`);
|
||||
if (under && hasPort(under, 'n')) out.push(col!);
|
||||
if (r !== area.runningRow) continue;
|
||||
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
|
||||
if (beyond && joins(c, p, beyond)) out.push(col!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Below-main squares a crew can reach from the Running Track at all — siding or stub. */
|
||||
function reachableBelowMain(area: OfficeArea): Set<string> {
|
||||
/** Off-main squares a crew can reach from the Running Track at all — siding or stub, either side. */
|
||||
function reachableOffMain(area: OfficeArea): Set<string> {
|
||||
const reached = new Set<string>();
|
||||
for (const col of waysDown(area)) {
|
||||
for (const k of descendFrom(area, col).seen) reached.add(k);
|
||||
for (const side of SIDES) {
|
||||
for (const col of waysOff(area, side)) {
|
||||
for (const k of descendFrom(area, col, side).seen) reached.add(k);
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below-main squares that lie on a RUN-AROUND — a siding reaching the main at both ends, so a crew
|
||||
* Off-main squares that lie on a RUN-AROUND — a siding reaching the main at both ends, so a crew
|
||||
* can pass its own standing cars (§A.5). A facility here can be worked from either direction; one
|
||||
* on a stub can only be reached by shoving in and backing out.
|
||||
*/
|
||||
function runAroundCells(area: OfficeArea): Set<string> {
|
||||
const onLoop = new Set<string>();
|
||||
for (const col of waysDown(area)) {
|
||||
const { rejoins, seen } = descendFrom(area, col);
|
||||
if (rejoins.size === 0) continue;
|
||||
for (const k of seen) onLoop.add(k);
|
||||
for (const side of SIDES) {
|
||||
for (const col of waysOff(area, side)) {
|
||||
const { rejoins, seen } = descendFrom(area, col, side);
|
||||
if (rejoins.size === 0) continue;
|
||||
for (const k of seen) onLoop.add(k);
|
||||
}
|
||||
}
|
||||
return onLoop;
|
||||
}
|
||||
@@ -411,8 +435,7 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
if (!area) return null;
|
||||
|
||||
const onLoop = runAroundCells(area);
|
||||
const reachable = reachableBelowMain(area);
|
||||
const sidingRow = area.runningRow - 1;
|
||||
const reachable = reachableOffMain(area);
|
||||
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
@@ -422,9 +445,10 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
if (s.cards.get(i.cardId)?.kind.kind !== 'freightFacility') continue;
|
||||
|
||||
const { row, col } = i.placement;
|
||||
const axis = facilityVariants()[i.variant ?? 0]?.axis;
|
||||
if (!axis) continue;
|
||||
const ports: Port[] = axis === 'ns' ? ['n', 's'] : ['e', 'w'];
|
||||
if (!facilityVariants()[i.variant ?? 0]) continue;
|
||||
// An industry card is a plain east-west straight, as every one on the printed sheet is. There
|
||||
// is no north-south rotation to weigh any more; a facility joins its neighbours along the row.
|
||||
const ports: Port[] = ['e', 'w'];
|
||||
|
||||
// Closer to the Office is fewer Moves to reach, same as track scoring.
|
||||
let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
|
||||
@@ -440,15 +464,14 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
if (!nc || !hasPort(nc, opposite(p))) continue;
|
||||
const k = `${n.row},${n.col}`;
|
||||
if (onLoop.has(k)) meetsLoop = true;
|
||||
if (reachable.has(k) || n.row >= area.runningRow) meetsReachable = true;
|
||||
if (reachable.has(k) || n.row === area.runningRow) meetsReachable = true;
|
||||
}
|
||||
|
||||
// A facility cannot be inserted into a loop that is already closed — one card per square — so
|
||||
// the placement that lands ON a run-around is the one laid IN LINE with a siding still being
|
||||
// built, east-west on the siding row. That is scored highest deliberately.
|
||||
// built, along a row beside the main. That is scored highest deliberately.
|
||||
const inLineWithSiding =
|
||||
row === sidingRow &&
|
||||
axis === 'ew' &&
|
||||
Math.abs(row - area.runningRow) === 1 &&
|
||||
(reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`));
|
||||
|
||||
if (inLineWithSiding) score += 26;
|
||||
@@ -469,11 +492,6 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
if (!area) return null;
|
||||
|
||||
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
|
||||
const below = area.runningRow - 1;
|
||||
|
||||
/** Does this card send a leg DOWN off the Running Track? */
|
||||
const divergesSouth = (c: TrackCard | undefined): boolean =>
|
||||
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
|
||||
|
||||
/**
|
||||
* Can the siding continue THROUGH this neighbour, into the square being scored?
|
||||
@@ -488,27 +506,34 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
!!c && hasPort(c, toward);
|
||||
|
||||
/**
|
||||
* A main-line card a siding can actually climb back onto — asked of the ENGINE, not guessed from
|
||||
* the card kind. Turnouts diverge south, and so does the Office (its `e-s`/`w-s` pairs), so
|
||||
* testing for a turnout alone missed a way up that is on every board.
|
||||
* A main-line card a siding on this side can climb back onto — asked of the ENGINE, not guessed
|
||||
* from the card kind. It is not enough that the card has the edge: the arc climbing to it must lie
|
||||
* on the same diagonal, so this takes the arc's own slope and compares.
|
||||
*/
|
||||
const wayUp = (c: TrackCard | undefined): boolean => !!c && hasPort(c, 's');
|
||||
const wayBack = (c: TrackCard | undefined, side: Side, from: TrackCard): boolean =>
|
||||
!!c && joins(from, opposite(legPort(side)), c);
|
||||
|
||||
/** Main-line columns a siding could rejoin at. Without one of these beyond it, a run goes nowhere. */
|
||||
const waysUp = [...area.grid.entries()]
|
||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && wayUp(c))
|
||||
.map(([k]) => Number(k.split(',')[1]));
|
||||
/** Main-line columns with any leg on this side. Without one beyond it, a run goes nowhere. */
|
||||
const waysBack = (side: Side): number[] =>
|
||||
[...area.grid.entries()]
|
||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && hasPort(c, legPort(side)))
|
||||
.map(([k]) => Number(k.split(',')[1]));
|
||||
|
||||
const reachableBelow = reachableBelowMain(area);
|
||||
const reachableOff = reachableOffMain(area);
|
||||
|
||||
/**
|
||||
* Straights already on the Running Track — the only thing an Enhancement can attach to.
|
||||
*
|
||||
* Interlocking, Water Column and Telegraph all print "any Running Track Straight", and Telephone
|
||||
* and Radio chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw`
|
||||
* directly), so it never needed a straight and never laid one: 0.00 on the Running Track across
|
||||
* 100 games, and with it 13 of the 18 enhancement cards that go on the board were structurally
|
||||
* unplayable. Enhancements were drawn 3.78 a game and placed 0.64.
|
||||
* and Radio chain off Telegraph. The bot used to build minimal two-arc run-arounds, so it never
|
||||
* needed a straight and never laid one: 0.00 on the Running Track across 100 games, and with it 13
|
||||
* of the 18 enhancement cards that go on the board were structurally unplayable. Enhancements were
|
||||
* drawn 3.78 a game and placed 0.64.
|
||||
*
|
||||
* The 45° geometry has since made straights unavoidable — a leg off the main only ever reaches the
|
||||
* next row, so the run back along it is straights — and the bot now lays 3.44 a game against 1.01
|
||||
* under the old rules. This bonus still earns its place for the FIRST one, which the sequence
|
||||
* would otherwise reach only by accident.
|
||||
*
|
||||
* Interlocking is the expensive one. It is the designed answer to the ONLY penalty in the game —
|
||||
* "no free A/D track", which cost 2.40 revenue a game, 27% of gross — and it had never once been
|
||||
@@ -521,8 +546,27 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
c.geometry.geometry === 'straight',
|
||||
).length;
|
||||
|
||||
/** The card a lay would place, built exactly as the reducer builds it. */
|
||||
const probeOf = (i: Extract<Intent, { type: 'track.lay' }>): TrackCard => {
|
||||
const v = variantsFor(i.geometry, i.hand)[i.variant ?? 0];
|
||||
return {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry: i.geometry,
|
||||
...(v?.arc ? { arc: v.arc } : {}),
|
||||
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||
...(i.hand !== 'none' ? { hand: i.hand } : {}),
|
||||
},
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
} as unknown as TrackCard;
|
||||
};
|
||||
|
||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
||||
variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
|
||||
|
||||
/**
|
||||
* RAIL THAT CAN NEVER GO ANYWHERE.
|
||||
@@ -538,49 +582,37 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
* district instead of tidying it.
|
||||
*/
|
||||
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
|
||||
const v = variantsFor(i.geometry)[i.variant ?? 0];
|
||||
const probe = {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry: i.geometry,
|
||||
...(v?.axis ? { axis: v.axis } : {}),
|
||||
...(v?.arc ? { arc: v.arc } : {}),
|
||||
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||
},
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
} as unknown as TrackCard;
|
||||
const probe = probeOf(i);
|
||||
let dead = 0;
|
||||
for (const p of new Set(connectionsFor(probe).flat())) {
|
||||
const n = neighbour(i.placement, p);
|
||||
const nb = at(n.row, n.col);
|
||||
if (!nb) continue; // empty — may still be built on
|
||||
if (hasPort(nb, opposite(p))) continue; // joins
|
||||
if (!nb) continue; // empty — may still be built on
|
||||
if (joins(probe, p, nb)) continue; // a real rail crosses the edge
|
||||
dead++;
|
||||
}
|
||||
return dead;
|
||||
};
|
||||
|
||||
/**
|
||||
* Arcs left to build BENEATH a turnout.
|
||||
* Arcs left that could meet THIS turnout's leg.
|
||||
*
|
||||
* A turnout is a hole in the Running Track and is worth cutting only for what hangs under it, so
|
||||
* cutting one with nothing left to hang there is pure loss. Measured at 0% today — the bot builds
|
||||
* the arc immediately after the turnout and never runs the supply dry first — so this is a guard
|
||||
* against the supply changing rather than a fix for something happening now.
|
||||
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
|
||||
* cutting one with nothing left to hang there is pure loss. Asked per hand, because handedness is
|
||||
* the slope: a left-hand turnout's 45° leg can only be continued by a left-hand curve, and a
|
||||
* supply full of right-hand ones is no help to it at all.
|
||||
*/
|
||||
const arcsLeft =
|
||||
(area.trackSupply.get('curved:left') ?? 0) +
|
||||
(area.trackSupply.get('curved:right') ?? 0) +
|
||||
(area.trackSupply.get('sharpCurved:left') ?? 0) +
|
||||
(area.trackSupply.get('sharpCurved:right') ?? 0);
|
||||
const arcsLeft = (hand: Hand): number =>
|
||||
(area.trackSupply.get(`curved:${hand}`) ?? 0) + (area.trackSupply.get(`sharpCurved:${hand}`) ?? 0);
|
||||
|
||||
// Where the district already turns down off the main.
|
||||
// Where the district already turns off the main, either side.
|
||||
const turnouts = [...area.grid.entries()]
|
||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
|
||||
.filter(
|
||||
([k, c]) =>
|
||||
Number(k.split(',')[0]) === area.runningRow &&
|
||||
c.geometry.kind === 'track' &&
|
||||
c.geometry.geometry === 'turnout',
|
||||
)
|
||||
.map(([k]) => Number(k.split(',')[1]));
|
||||
|
||||
let best: Intent | null = null;
|
||||
@@ -606,17 +638,12 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
|
||||
* one asked for it.
|
||||
*/
|
||||
if (
|
||||
row === area.runningRow &&
|
||||
i.geometry === 'straight' &&
|
||||
variantsFor('straight')[i.variant ?? 0]?.axis === 'ew' &&
|
||||
mainStraights < 1
|
||||
) {
|
||||
if (row === area.runningRow && i.geometry === 'straight' && mainStraights < 1) {
|
||||
// Two is enough: Interlocking needs one, and a second gives Telegraph somewhere to go
|
||||
// without competing further with the ways down. Scored below a first turnout, which is still
|
||||
// the most valuable single piece on the board, and below closing a run-around.
|
||||
bonus += 13;
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft > 0) {
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft(i.hand) > 0) {
|
||||
// A first way down is the most valuable single piece on the board; a second closes the
|
||||
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
||||
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
||||
@@ -625,44 +652,50 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
|
||||
// crew can actually reach, which matters more than a tidy Running Track.
|
||||
bonus += turnouts.length === 0 ? 14 : 3;
|
||||
} else if (row === below) {
|
||||
} else if (Math.abs(row - area.runningRow) === 1) {
|
||||
// Which side of the main this square is on. Both are ordinary now: a turnout turned 180°
|
||||
// sends its leg north, so a siding above the Running Track is built exactly like one below.
|
||||
const side: Side = row < area.runningRow ? -1 : 1;
|
||||
const arc = arcOf(i);
|
||||
// What this square is anchored to, and therefore which way a run through it would extend. The
|
||||
// anchor must be reachable from the main: extending a stranded fragment builds nothing.
|
||||
const anchoredAt = (c: number): boolean =>
|
||||
reachableBelow.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w');
|
||||
reachableOff.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w');
|
||||
const anchorWest = anchoredAt(col - 1);
|
||||
const anchorEast = anchoredAt(col + 1);
|
||||
|
||||
if (arc === 'ne' || arc === 'nw') {
|
||||
// Does this arc climb back toward the main, rather than one row further away from it?
|
||||
if (arc !== undefined && arc.includes(opposite(legPort(side)))) {
|
||||
/**
|
||||
* AN ARC IS ONLY WORTH LAYING WHERE IT CAN REACH THE MAIN.
|
||||
*
|
||||
* Both of these used to score on shape alone: "close the loop back up to the main" was
|
||||
* awarded to any arc beside an east-west run, whether or not there was anything above it to
|
||||
* join. Measured, that produced 0 run-arounds in 100 games — the siding terminated in an arc
|
||||
* pointing north into empty space, past the east end of the Running Track, every game.
|
||||
* This used to score on shape alone: "close the loop back up to the main" was awarded to any
|
||||
* arc beside an east-west run, whether or not there was anything above it to join. Measured,
|
||||
* that produced 0 run-arounds in 100 games — the siding terminated in an arc pointing north
|
||||
* into empty space, past the east end of the Running Track, every game.
|
||||
*
|
||||
* An arc also has exactly two ports, so which way it faces decides what it meets: `nw` joins
|
||||
* north and WEST, `ne` joins north and EAST. Scoring them alike let the bot close a run from
|
||||
* the wrong side, which connects nothing.
|
||||
* Two things now decide whether it actually reaches. An arc has exactly two ports, so which
|
||||
* way it faces decides what it meets — `nw` joins north and WEST, `ne` north and EAST — and
|
||||
* on top of that the main-line card's own leg must lie on the SAME DIAGONAL, or the two 45°
|
||||
* rails meet at a point on the card edge and form a V. `wayBack` asks the engine both.
|
||||
*/
|
||||
if (wayUp(at(area.runningRow, col))) {
|
||||
const meetsRun = (arc === 'nw' && anchorWest) || (arc === 'ne' && anchorEast);
|
||||
if (wayBack(at(area.runningRow, col), side, probeOf(i))) {
|
||||
const meetsRun = (arc.includes('w') && anchorWest) || (arc.includes('e') && anchorEast);
|
||||
// Closing an existing run beats opening a new one: that is the whole difference between a
|
||||
// run-around and one more stub.
|
||||
bonus += meetsRun ? 15 : 13;
|
||||
}
|
||||
// An arc with nothing above it earns no bonus but is not forbidden — reaching a facility
|
||||
// two rows down is still worth doing when there is no loop to close.
|
||||
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
|
||||
// An arc with nothing beyond it earns no bonus but is not forbidden — reaching a facility
|
||||
// two rows out is still worth doing when there is no loop to close.
|
||||
} else if (i.geometry === 'straight') {
|
||||
/**
|
||||
* Extend the siding beside the main, but only from something that already turned AND only
|
||||
* while there is still somewhere to climb back up. Unbounded, this rule ran the siding east
|
||||
* while there is still somewhere to climb back. Unbounded, this rule ran the siding east
|
||||
* past the last usable column in 96 of 100 games — the loop then missed by one card.
|
||||
*/
|
||||
const back = waysBack(side);
|
||||
const canStillClose =
|
||||
(anchorWest && waysUp.some((c) => c > col)) || (anchorEast && waysUp.some((c) => c < col));
|
||||
(anchorWest && back.some((c) => c > col)) || (anchorEast && back.some((c) => c < col));
|
||||
if ((anchorWest || anchorEast) && canStillClose) {
|
||||
bonus += 11;
|
||||
}
|
||||
|
||||
+50
-19
@@ -28,8 +28,9 @@ import {
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
|
||||
import type { TrackGeometry } from '../engine/content.ts';
|
||||
import { connectionsFor, variantsFor } from '../engine/track.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
import type { Impediment } from './narrate.ts';
|
||||
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
|
||||
|
||||
@@ -49,7 +50,8 @@ export type CellView = {
|
||||
cars: string[];
|
||||
facility: FacilityView | null;
|
||||
/**
|
||||
* The port pairs this card joins, as two-letter codes — 'ew', 'ns', 'es' and so on. Taken from
|
||||
* The port pairs this card joins, as two-letter codes — 'ew' for the through track, and 'ne',
|
||||
* 'nw', 'se' or 'sw' for a 45° leg. There is no 'ns': no card joins north to south. Taken from
|
||||
* the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do
|
||||
* not have.
|
||||
*/
|
||||
@@ -337,7 +339,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
case 'track.lay':
|
||||
return (
|
||||
`lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${geometryLabel(i.geometry)} ` +
|
||||
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant)}`
|
||||
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant, i.hand)}`
|
||||
);
|
||||
case 'switch.move':
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
||||
@@ -803,28 +805,50 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
case 'track':
|
||||
switch (g.geometry) {
|
||||
case 'straight':
|
||||
return g.axis === 'ns' ? 'through track, north–south' : 'through track, east–west';
|
||||
return 'through track, east–west';
|
||||
case 'curved':
|
||||
case 'sharpCurved': {
|
||||
const stem = g.hand === 'left' ? 'west' : 'east';
|
||||
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw');
|
||||
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
|
||||
return `curve — joins the ${stem} end to a leg running south${cost}`;
|
||||
return `${arcPhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
|
||||
}
|
||||
case 'turnout': {
|
||||
const t = g.turnout;
|
||||
if (!t) return 'turnout';
|
||||
const dir = (p: string): string =>
|
||||
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
|
||||
return (
|
||||
`turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${dir(t.diverge)} · ` +
|
||||
`a train may run stem↔through or stem↔diverge, but NEVER between ${dir(t.through)} and ${dir(t.diverge)}`
|
||||
`turnout — stem ${compass(t.stem)}, through ${compass(t.through)}, diverges ${compass(t.diverge)} at 45° · ` +
|
||||
`a train may run stem↔through or stem↔diverge, but NEVER between ${compass(t.through)} and ` +
|
||||
`${compass(t.diverge)}${slopePhrase(t.stem as Port, t.diverge as Port)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||
|
||||
/**
|
||||
* A curve, as the printed card draws it: along the centre line to a frog, then out at 45° through
|
||||
* the MIDDLE of an edge. Naming both halves is what stops it reading as a quarter-circle corner.
|
||||
*/
|
||||
function arcPhrase(arc: string): string {
|
||||
const [a, b] = [arc[0] as Port, arc[1] as Port];
|
||||
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
|
||||
return `curve — runs ${compass(side!)} along the centre line, then leaves at 45° through the middle of the ${compass(leg!)} edge`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The matching rule, said out loud. Which diagonal a leg sits on decides what may sit above or
|
||||
* below it, and that is not something a player can read off the card's shape at a glance.
|
||||
*/
|
||||
function slopePhrase(a: Port, b: Port): string {
|
||||
const slope = slopeOfPair(a, b);
|
||||
if (!slope) return '';
|
||||
const mate = slope === 'ne_sw' ? 'north–east / south–west' : 'north–west / south–east';
|
||||
return ` · its 45° leg lies on the ${mate} diagonal, and only meets a card whose leg lies on the same one`;
|
||||
}
|
||||
|
||||
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
|
||||
function facilityLabel(key: string): string {
|
||||
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
|
||||
@@ -861,18 +885,25 @@ function objectiveOf(s: GameState): Frame['objective'] {
|
||||
*
|
||||
* "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the
|
||||
* decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe
|
||||
* one rotation while the engine lays another.
|
||||
* one rotation while the engine lays another. There are only two: a printed card turns 180° but
|
||||
* never flips, so `hand` and not the rotation decides which diagonal the 45° leg lies on.
|
||||
*/
|
||||
export function variantLabel(geometry: TrackGeometry, variant: number | undefined): string {
|
||||
const v = variantsFor(geometry)[variant ?? 0];
|
||||
export function variantLabel(
|
||||
geometry: TrackGeometry,
|
||||
variant: number | undefined,
|
||||
hand: Hand = 'none',
|
||||
): string {
|
||||
const v = variantsFor(geometry, hand)[variant ?? 0];
|
||||
if (!v) return '';
|
||||
if (v.axis) return v.axis === 'ew' ? ' — east–west' : ' — north–south';
|
||||
if (v.turnout) {
|
||||
const dir = (p: string): string =>
|
||||
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||
return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
|
||||
return ` — stem ${compass(v.turnout.stem)}, through ${compass(v.turnout.through)}, diverges ${compass(v.turnout.diverge)} at 45°`;
|
||||
}
|
||||
return '';
|
||||
if (v.arc) {
|
||||
const [a, b] = [v.arc[0] as Port, v.arc[1] as Port];
|
||||
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
|
||||
return ` — ${compass(side!)} to the middle of the ${compass(leg!)} edge`;
|
||||
}
|
||||
return ' — east–west';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user