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
+81
View File
@@ -10,6 +10,87 @@ The target is 20 Revenue over 5 Days.
## Unreleased
### The track is 45° geometry, drawn from the printed cards
The prototype designer's feedback: **there are no north–south tracks.** Straight runs are always
east–west, the east–west line sits dead centre on the card rather than biased to the top, and a
turnout is an east–west through track plus a curved leg meeting the north or south edge at 45°, which
must line up with the corresponding leg on the card it abuts.
Measured off `docs/tracks.png` rather than inferred. Card grid lines at y = 15/164/314/464/614/764/
914/1064/1213 on a 2136×1397 sheet; rail centres at 89.5/236/–/–/689.5/839/989/1135.5/1288.5 — the
card's exact vertical middle, within a pixel, on every one of the nine rows. Cards are 259×150
(aspect ≈ 1.73). Every leg crosses the middle of its edge. Four shapes exist: turnout with the leg
off the west end, turnout with the leg off the east end, curve west↔south, curve east↔south, plus the
plain straight. Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing and Town
are all plain east–west straights with **no diverging leg at all**.
**Slope is now part of adjacency.** Name the two diagonals after the pair of arcs that mate across a
horizontal card edge: `ne_sw` (port pairs {n,e} and {s,w}) and `nw_se` ({n,w} and {s,e}). An `sw` card
above an `ne` card is one unbroken rail; `sw` above `nw` is a V — both cards have the port, both legs
meet the same point on the edge, and they still do not connect. `joins(a, p, b)` in
`src/engine/track.ts` is the single adjacency test, and every `hasPort(nb, opposite(p))` in the
engine, the bot and the tests now goes through it. Leaving one behind reintroduces the V.
**Handedness is that diagonal.** A printed card has a back: it turns 180° but never flips. So a
straight has ONE orientation, and a curve or turnout has two — 0° sends the 45° leg south, 180° sends
it north, and neither changes diagonal. Left-hand reaches `sw`/`ne`, right-hand `se`/`nw`. That is
what makes the 4-left/4-right split of the curve and turnout supply mean something: a run-around needs
one card of each hand — a left turnout down, its matching left curve, straights along, a right curve
back up into a right turnout. `variantsFor` takes a `hand` argument for this reason.
**What went, and what opened.** `TrackAxis` and every `axis` field are deleted outright — there is
one axis now, and a one-valued field invites the old branching back; deleting it turned `tsc` into
the migration checklist. The Office's `e-s`/`w-s` stubs are gone, as are north–south facility
placements. Q7's "a district hangs below the Running Track" is lifted: a turnout turned 180° reaches
north, so `placementCandidates` and `adjacentFacilityCoord` no longer reject the rows above, and §9's
"nine nearby spots" really is nine. `legalIntents` offered six variants per placement per card; the
widest set is now two.
**The renderer draws the card rather than approximating it.** `RAIL` moves from 30 to `H/2`, and the
card widens from 132×96 to 166×96 to match the sheet's 1.73 — the aspect is not decoration, since the
frog where a 45° leg meets the through track sits `H/2` from the centre and a squarer card pushes it
almost to the edge. The fixed elbow at `(W/2, RAIL + (H-RAIL)/2)` is replaced by the real frog at
`(W/2 ± H/2, H/2)` and a segment at exactly 45° to the edge midpoint. That elbow sat *below* the rail
on the assumption everything diverged downward, so a leg reaching north was drawn as a hook that
dropped past the rail and came back up. Four tests in `test/web.test.ts` now measure the emitted SVG:
the through rail level and centred and spanning the full card, every leg at 45°, `ne`/`sw` on one
diagonal and `nw`/`se` on the other, and the leg crossing the edge at its midpoint. The enhancement
label moved above the rail, where it used to print along it.
**Measured, 100 solitaire Standard games, against the same run before the change:**
| | before | after |
| --- | ---: | ---: |
| mismatched 45° joints on finished boards | — | **0** |
| district size | 25.4 cards | 28.5 |
| mean max depth off the main | 2.38 rows | 1.45 |
| built above the Running Track | 0/100 games | 93/100 |
| straights laid | 1.01/game | 3.44 |
| districts with a run-around | 99/100 | 70/100 |
| facilities on a run-around | 0.82/game | 0.51 |
| revenue | 7.34 mean | 5.80 |
The shallower districts are intrinsic: no card joins north to south, so descending a row costs at
least two cards — the leg out, then a curve turning the run back east–west. Districts are now wide
and shallow, which is what the printed sheet and a real yard both look like, and the run along the
siding is straights, which is why the bot lays three times as many (13 of the 18 enhancement cards
need one).
**The revenue and run-around drops are honest, not a regression to chase.** A run-around costs more
pieces than it did, and the Office no longer offers a free way back up onto the main — it used to
carry `e-s`/`w-s` stubs, so every district had one guaranteed climbing point. Two bot heuristics were
tried against it and both rejected on measurement: preferring one side of the main changed nothing at
−4 and made things worse at −100 (62/100 run-arounds), and paying more for the second turnout that
closes a siding bought 86/100 run-arounds and 1.08 facilities on a loop but cost 9% of revenue
(5.80 → 5.25). Revenue is the game's own measure, and both structural floors in `test/sim.test.ts`
still pass, so neither shipped.
The bot is otherwise rewritten around the new geometry: `descendFrom`/`waysOff`/`reachableOffMain`/
`runAroundCells` take a side rather than assuming "below", the arc score asks `joins` instead of
reading the arc name, and `arcsLeft` is asked per hand — a left-hand turnout's leg can only be
continued by a left-hand curve, and a supply full of right-hand ones is no help to it.
### Q13 — a train that catches the one ahead runs into it
Answered, and implemented as option B: **collide on catching up**, which is the version that rewards
+7
View File
@@ -67,3 +67,10 @@ syntax**: no `enum`, no parameter properties, no namespaces. `tsconfig.json` enf
- **State is `fold(events)`.** The event log is the source of truth, which is what gives reconnection,
restart recovery and post-game replay from a single decision.
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
- **The track is 45° geometry, not a graph on a grid.** Measured off `docs/tracks.png`: the through
rail runs east–west across the *exact vertical middle* of every card, there is no north–south track
anywhere, and everything that leaves through the north or south edge does so at **45°, through the
middle of that edge**. So two ports meeting is not enough to make a rail — two 45° legs can meet at
the same point and still form a V. Adjacency is `joins()` in `src/engine/track.ts`, never a bare
pair of `hasPort()` calls. A printed card turns 180° but never flips, so its handedness fixes which
diagonal its leg lies on for good, and a run-around needs one card of each hand.
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -46,7 +46,7 @@ All nine answers are implemented, **170 tests passing**:
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. |
| Q6 Space-use | **Solitaire decks omit all 22 opponent-directed cards**: `buildDeck('solitaire')` returns **93**, competitive returns 115. |
| Q7 Downward-only | Office cards have a stub **below only**; turnout and curve legs always diverge south; placement and modifier candidates above the Running Track are rejected. §9's "nine nearby spots" is really **six**. |
| Q7 45° geometry | The through track runs east-west across the **middle** of every card; there is no north-south track. Every leg leaves at **45° through the middle of the north or south edge**, so two stacked cards join only when their legs share a diagonal (`joins`/`slopeAt` in `track.ts`). Handedness is that diagonal, and a card turns 180° but never flips, so each has **two** orientations. Office and industry cards carry a plain through track and no stub. A district grows on **both** sides of the Running Track, so §9's "nine nearby spots" really is nine. |
| Q8 Crew Tray cap | Already correct. |
| Q9 Second Section | `newTrain.secondSection` plays on a train due out; an identical train is made up behind it next New Train Phase. The **Sister trains optional rule is removed** from Appendix B. |
@@ -610,7 +610,7 @@ anything), **Space-use** (lowest mechanical weight).
| 5 Extra/Timetabled ties | **moot** | Extras are X13–X22; the tie cannot occur |
| 6 Victory conditions | **needs re-derivation** | new revenue sources and point losses |
| 7 Terminology | **stands** | |
| 8 Office junction stubs | **needs checking against the art** | `tracks.png` shows the through-line at the card's top edge, not the middle |
| 8 Office junction stubs | **superseded** | `tracks.png` gives every office and industry card a plain east-west track and no stub; a district hangs off a turnout |
| 9 Make-up round repeats | **stands** | |
| 10 Card faces | **superseded** | this is the real card list |
| 11 Track orientation | **superseded** | handedness is printed; both hands are supplied |
@@ -656,7 +656,7 @@ Recorded as they are given, so nothing is lost between sessions.
| 4 | What does a lockout prevent? | **Building both in your district.** If you hold a Mine Tipple, a Power Plant card is unplayable in your Office Area, and vice versa. Each locked pair is a producer and the consumer of the same commodity, so this forces traffic to flow *between* districts — a deliberate multiplayer-interdependence mechanic. In solitaire it simply narrows the build options. |
| 5 | Is a run-around a card? | **No — a pattern built from the track supply.** Opposite-hand turnouts joined by straights. Nothing to implement: reachability is computed from geometry, so a valid bypass already works. This explains the 8 turnouts per player, and makes §A.5's facing-point move a building achievement rather than a lucky draw. |
| 6 | How are Space-use cards played? | **At another player's district**, consuming a grid cell they wanted. This explains Water Column ("remove any Watertower in *your* district" — one an opponent gave you) and Vandalism (exploits a Hobo Jungle you were saddled with). A second, milder attack channel alongside the Action cards. **In solitaire they have no target** — see the note below. |
| 7 | Does a district grow both ways? | **Downward only** — the through-track sits at the card's top edge and everything diverges beneath it, matching `tracks.png`. A district is a Running Track with all Secondary Track hanging below. **Supersedes Gap 8's north-and-south stubs.** Consequences: buildable area halves; districts become long and shallow; §9's nine-spot Modifier neighbourhood needs rewording, since three of the nine spots are above the track and no longer exist. |
| 7 | Does a district grow both ways? | **Yes, both** — measured off `tracks.png` rather than inferred. The through track runs east-west across the exact vertical middle of every card, and every diverging leg leaves at 45° through the middle of the north or south edge. Turning a card 180° swaps that leg between north and south, so a turnout serves either side. **Supersedes Gap 8's stubs in the other direction**: no office or industry card carries a leg at all, so a district hangs off a turnout laid on the Running Track. Consequences: no card joins north to south, so descending a row costs at least two cards (leg out, then a curve turning the run back east-west) and districts are wide and shallow; and a printed card's handedness fixes which diagonal its leg lies on, so a run-around needs one card of each hand. |
| 8 | Any cap on trains in play? | **No — Crew Trays are the only limit.** All 12 timetabled trains may end up scheduled; Extras run on top ad hoc; a due train with no free crew is held (§7). Already implemented. This makes the Crew Tray count (`players + 3`) the single most important balance dial, and "held" a common visible state. |
| 9 | What is the Second Section card? | **A card played on a train that is due out.** A second identical train runs immediately behind it — same number, rules and consist — and needs its own Crew Tray. It deliberately creates the following-train situation §8.1 makes the Superintendent rule on, so a player can force the game's central decision onto the table. Explains the green flags on the artwork. **Supersedes the "Sister trains" optional rule**, which should be removed from Appendix B. |
@@ -700,8 +700,9 @@ once the speed model is implemented; the Crew Tray count is the obvious dial.
5. **Run-around** — we treat it as a card; it is not in the supply. Is it a pattern built from two
turnouts?
6. **Space-use cards** — played at yourself as a cost of drawing, or at opponents?
7. **Where the Office's junction stubs are.** `tracks.png` draws the through-line along the top edge
with curves diverging downward. Gap 8 assumed stubs above *and* below a centred track.
7. ~~**Where the Office's junction stubs are.**~~ **Settled by measuring `tracks.png`**: the through
line is dead centre on every card, not along the top edge, and no office or industry card has a
stub in either direction. See Q7 above.
8. **Are all 22 trains in play at once**, or is the timetable still 12 slots with Extras run on top?
9. **Second Section** — is it a card played on a scheduled train, or its own train?
+19 -5
View File
@@ -760,7 +760,14 @@ figures moved, and only because Gap 10 replaced an assumption with real numbers.
## Gap 11 — Track card orientation
**Status:** DECIDED — orientation is chosen when the card is placed.
**Status:** DECIDED — orientation is chosen when the card is placed, but the choice is **two
rotations, not free rotation**. See the amendment at the end of this section.
> **AMENDED by the 45° geometry.** Measuring `docs/tracks.png` settled what this gap was guessing at:
> the through track is east-west across the middle of every card, there is no north-south track at
> all, and a printed card turns 180° but never flips. So a straight has ONE orientation, and a curve
> or turnout has two — the pair its printed handedness allows. The rest of this section is the
> reasoning that got there, kept because it is how the decision was reached.
**The problem.** Gap 4a fixed the track mix at turnout ×6, straight ×3, run-around ×3. But a track
card has an **orientation**, and nothing says what any of them is.
@@ -824,11 +831,18 @@ to build — hard to guarantee, and easy to break whenever the deck is retuned.
*Why not (c), rotationally symmetric cards.* It discards §A.1 entirely, one of the game's most
characterful rules.
**Implemented.** `variantsFor()` in `src/engine/track.ts` enumerates the rotations — 2 for a straight,
6 for a turnout, 2 for a run-around — and the chosen index rides the `cardPlayed` event, so replay
reproduces the same layout.
**Implemented.** `variantsFor(geometry, hand)` in `src/engine/track.ts` enumerates the rotations —
**1 for a straight** (east-west, and turning it gives the same card back) and **2 for a curve or
turnout** (0° sends the 45° leg south, 180° sends it north). The chosen index rides the `cardPlayed`
event, so replay reproduces the same layout.
**Consequence for print-and-play.** Card art must read correctly at any rotation the card allows.
Handedness is not a supply label: it IS the diagonal the 45° leg lies on. A left-hand card reaches
`sw` and `ne`, a right-hand card `se` and `nw`, and two stacked cards join only when their legs share
a diagonal. That is what makes the 4-left/4-right split of the curve and turnout supply meaningful,
and why a run-around needs one card of each hand.
**Consequence for print-and-play.** Card art must read correctly upside down — every track card is
laid at 0° or 180°, and nothing is ever turned on its side.
---
+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;
}
+36 -17
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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"/>` +
+131 -98
View File
@@ -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,39 +371,44 @@ 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);
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))
/** 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 (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
View File
@@ -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';
}
/**
+49 -31
View File
@@ -282,24 +282,32 @@ describe('Red Flags protect a stopped train', () => {
});
describe('Flying Switch rolls a cut into an industry', () => {
/** A crew on a straight with a facility next door, mid-switch. */
/** A crew on a siding below the main, with a facility next door along the same track. */
function setup(s: GameState) {
const area = areaOf(s, 0);
// A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
// north-south. Built east-west, they are two unconnected cards that merely look adjacent.
const spur: TrackCard = {
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
// A flying switch rolls the cut ALONG THE TRACK, so the crew and the industry must be joined by
// rail. Every run is east-west now, and the siding gets down off the main the only way there is:
//
// row 0: [office] [turnout, leg south] <- Running Track
// row -1: [curve ne] [straight, crew] [industry] <- the siding
//
const plain = (): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-1, 0)), spur);
});
area.grid.set(coordKey(at(0, 1)), {
...plain(),
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' },
baseOperationalRail: false,
});
area.grid.set(coordKey(at(-1, 1)), { ...plain(), geometry: { kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left' } });
area.grid.set(coordKey(at(-1, 2)), plain());
const industry: TrackCard = {
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
baseOperationalRail: true,
standing: [],
...plain(),
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
@@ -310,19 +318,14 @@ describe('Flying Switch rolls a cut into an industry', () => {
laborers: 1, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-2, 0)), industry);
area.grid.set(coordKey(at(-1, 3)), industry);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
// track needs its actual port — without it the engine looks for an east exit the card has not
// got, and the crew has no legal moves at all.
direction: 'east', facing: 's',
position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(-1, 2) }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
@@ -337,7 +340,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
const cardId = hand(s, 'maneuver', 'flyingSwitch');
const r = applyIntent(s, 0, {
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 0),
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 3),
});
assert.ok(r.ok, 'the flying switch should be accepted');
@@ -346,7 +349,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
assert.deepEqual(
s.trays.get('crew')!.position,
{ at: 'grid', owner: 0, coord: at(-1, 0) },
{ at: 'grid', owner: 0, coord: at(-1, 2) },
'the engine never enters the industry',
);
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
@@ -366,7 +369,8 @@ describe('Flying Switch rolls a cut into an industry', () => {
const s = game();
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
// (0,0) is the Office — track-connected to the crew, but not a freight industry.
// (0,0) is the Office — track-connected to the crew back up through the turnout, but not a
// freight industry.
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
'CANNOT_DROP_HERE',
@@ -377,13 +381,13 @@ describe('Flying Switch rolls a cut into an industry', () => {
const s = game();
setup(s);
const area = areaOf(s, 0);
// An industry sitting beside the crew, but joined east-west while the spur runs north-south.
const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
area.grid.set(coordKey(at(-1, 1)), stranded);
// An industry sitting directly beneath the crew. Both cards are plain east-west track, so
// nothing crosses the edge between them: adjacency is not a rail.
const stranded = { ...area.grid.get(coordKey(at(-1, 3)))! };
area.grid.set(coordKey(at(-2, 2)), stranded);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 1) }),
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 2) }),
'NOT_CONNECTED',
);
});
@@ -393,7 +397,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-2, 0) }),
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-1, 3) }),
'CONSIST_EMPTY',
);
});
@@ -454,14 +458,28 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
'laying on the Limits sign must be how the track extends',
);
// The district hangs below the Running Track and is not bounded by the Limits at all.
// A district opens with a TURNOUT on the Running Track — no office or industry card carries a
// stub of its own — so extend onto the east sign with one.
s.turn.laidThisTurn = false;
const turnoutCol = area.limitsEast.col;
assert.ok(
applyIntent(s, 0, {
type: 'track.lay', geometry: 'turnout', hand: 'left',
placement: { row: area.runningRow, col: turnoutCol }, variant: 0,
}).ok,
'a turnout must be layable on the Limits sign',
);
// The district hangs off that turnout and is not bounded by the Limits at all. Variant 1 of a
// left-hand curve is the `ne` arc — the one on the same diagonal as the turnout's leg.
s.turn.laidThisTurn = false;
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'straight', hand: 'none',
placement: { row: area.runningRow - 1, col: 0 }, variant: 1,
type: 'track.lay', geometry: 'curved', hand: 'left',
placement: { row: area.runningRow - 1, col: turnoutCol }, variant: 1,
}),
null,
'Secondary Track below the Office must stay legal',
'Secondary Track below the turnout must stay legal',
);
});
+59 -15
View File
@@ -8,11 +8,11 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { applyIntent, check, refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
import type { Intent } from '../src/engine/intents.ts';
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import type { Port } from '../src/engine/track.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
@@ -192,17 +192,57 @@ describe('the revenue chain works end to end (regression)', () => {
assert.ok(freight > 0, 'no freight load completed across 40 games');
});
it('grows the Office Area downward from the Running Track (Q7)', () => {
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
// Nothing should ever be built above row 0.
it('grows the Office Area off the Running Track, on either side', () => {
// The through track runs across the middle of every card and a turnout's 45° leg reaches north
// as readily as south, so a district hangs off the Running Track rather than beneath it. Q7's
// "downward only" is gone, and with it the rule that rejected every square above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
assert.ok([...rows].some((r) => r !== 0), 'nothing was ever built off the Running Track');
});
it('offers squares above the Running Track, not only below', () => {
// Asserted against LEGALITY rather than against what the bot happened to build: whether its
// heuristics favour one side is a tuning question, but the rules must allow both.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['bot'] });
const area = s.officeAreas.get(0)!;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
// A north-diverging turnout on the Running Track, and the arc that climbs to meet it. The
// column is captured BEFORE the lay: laying on the sign moves the sign outward, so reading
// `limitsEast` again afterwards names the next square along, not the turnout.
const col = area.limitsEast.col;
assert.ok(
applyIntent(s, 0, {
type: 'track.lay', geometry: 'turnout', hand: 'left',
placement: { row: area.runningRow, col }, variant: 1,
}).ok,
'a turnout must be layable on the Limits sign',
);
s.turn.laidThisTurn = false;
// Left-hand variant 0 is the `sw` arc — the one on the same diagonal as that turnout's leg.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'curved', hand: 'left',
placement: { row: area.runningRow + 1, col }, variant: 0,
}),
null,
'a curve ABOVE the Running Track must be legal',
);
// And the opposite diagonal must not be, at the same square, for the same turnout.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'curved', hand: 'right',
placement: { row: area.runningRow + 1, col }, variant: 0,
}),
'NOT_CONNECTED',
'the wrong diagonal must not join, however well the ports line up',
);
});
});
@@ -314,16 +354,18 @@ describe('switching accomplishes something (regression)', () => {
describe('the bot builds sidings that are actually sidings (regression)', () => {
/**
* Walk south off one Running Track column and report which OTHER main-line columns the walk can
* climb back up to. Uses the engine's own `exitsFrom`, so the test cannot credit a connection the
* rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation
* of the traversal would quietly invent that join.
* climb back up to. Uses the engine's own `exitsFrom` and `joins`, so the test cannot credit a
* connection the rules do not have — a turnout's two legs never join each other (§A.1), and two
* 45° legs on opposite diagonals meet at a point on the card edge without being a rail. A
* re-implementation of the traversal would quietly invent both.
*/
const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set<number>; seen: Set<string> } => {
const main = area.runningRow;
const rejoins = new Set<number>();
const seen = new Set<string>();
const top = area.grid.get(`${main},${col}`);
const start = area.grid.get(`${main - 1},${col}`);
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
if (!top || !start || !joins(top, 's', start)) return { rejoins, seen };
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
const visited = new Set<string>();
@@ -338,7 +380,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
for (const exit of exitsFrom(card, at.entry)) {
const n = neighbour({ row: at.row, col: at.col }, exit);
const next = area.grid.get(`${n.row},${n.col}`);
if (!next || !hasPort(next, opposite(exit))) continue;
if (!next || !joins(card, exit, next)) continue;
if (n.row >= main) {
if (n.col !== col) rejoins.add(n.col);
continue;
@@ -745,20 +787,22 @@ describe('the bot does not lay track that cannot work (regression)', () => {
if (pick.type === 'track.lay') {
laid++;
const area = st.officeAreas.get(p)!;
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
const v = variantsFor(pick.geometry, pick.hand)[pick.variant ?? 0];
const probe = {
geometry: {
kind: 'track', geometry: pick.geometry,
...(v?.axis ? { axis: v.axis } : {}),
...(v?.arc ? { arc: v.arc } : {}),
...(v?.turnout ? { turnout: v.turnout } : {}),
...(pick.hand !== 'none' ? { hand: pick.hand } : {}),
},
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never;
for (const port of new Set(connectionsFor(probe).flat())) {
const n = neighbour(pick.placement, port);
const nb = area.grid.get(`${n.row},${n.col}`);
if (nb && !hasPort(nb, opposite(port))) dead++;
// `joins`, not `hasPort`: on a north or south edge the two 45° legs must also lie on
// the same diagonal, or the rails meet at a point and still form a V.
if (nb && !joins(probe, port, nb)) dead++;
}
}
return pick;
+183 -50
View File
@@ -11,6 +11,7 @@ import type {
GridCoord,
OfficeArea,
RollingStock,
TrackArc,
TrackCard,
TurnoutOrientation,
} from '../src/engine/state.ts';
@@ -28,8 +29,12 @@ import {
opposite,
reachableDestinations,
connectionsFor,
joins,
slopeAt,
slopeOfPair,
variantsFor,
} from '../src/engine/track.ts';
import { TRACK_SUPPLY } from '../src/engine/content.ts';
// ---------------------------------------------------------------------------
// Fixture helpers
@@ -44,9 +49,13 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
enhancements: [],
});
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
/**
* A curve: along the centre line from the east or west edge to a frog, then out at 45° through the
* middle of the north or south edge. `ne`/`sw` lie on one diagonal, `nw`/`se` on the other, and only
* cards on the SAME diagonal meet across a horizontal edge.
*/
const curve = (arc: TrackArc, standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc },
baseOperationalRail: true,
standing,
facility: null,
@@ -100,7 +109,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
function straightCard(): TrackCard {
return {
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
};
}
@@ -174,19 +183,26 @@ describe('ports and geometry', () => {
assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
});
it('gives the Office card a junction stub BELOW only', () => {
// Q7 — the card art draws the through-track along the top edge with everything diverging
// downward, so a district grows beneath the Running Track. Supersedes Gap 8.
it('gives the Office card a plain east-west track and no stub at all', () => {
// 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. A district hangs off a TURNOUT laid on the Running Track instead.
const o = officeCard();
for (const p of ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
assert.ok(exitsFrom(o, 's').includes('e'));
assert.ok(exitsFrom(o, 's').includes('w'));
for (const p of ['e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
for (const p of ['n', 's'] as Port[]) assert.ok(!hasPort(o, p), `office must not have a ${p} stub`);
assert.deepEqual(exitsFrom(o, 'e'), ['w']);
});
it('joins a curve from one end of the through track downward', () => {
// Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
// handedness decides which end of the through track it leaves from.
it('runs every straight east-west, with no north-south rotation to choose', () => {
// There is no north-south track anywhere on the printed sheet, and there cannot be: a vertical
// rail meets a card edge at 90°, and everything crossing a horizontal edge does so at 45°.
assert.deepEqual(connectionsFor(straight()), [['e', 'w']]);
assert.equal(variantsFor('straight', 'none').length, 1, 'a straight has ONE orientation');
});
it('joins a curve from one end of the through track to the middle of an edge', () => {
// A curve runs along the centre line to a frog and leaves at 45°. Handedness decides which
// diagonal; turning the card 180° decides whether the leg goes north or south.
const r: TrackCard = {
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
baseOperationalRail: true,
@@ -201,16 +217,107 @@ describe('ports and geometry', () => {
// ---------------------------------------------------------------------------
describe('the 45° matching rule', () => {
const arcCard = (arc: TrackArc): TrackCard => curve(arc);
const turnoutOf = (o: TurnoutOrientation): TrackCard => turnout(o);
it('puts a pair of arcs on each diagonal, named after the pair', () => {
// The name IS the rule: `ne` and `sw` lie on one line, `nw` and `se` on the other.
assert.equal(slopeOfPair('n', 'e'), 'ne_sw');
assert.equal(slopeOfPair('s', 'w'), 'ne_sw');
assert.equal(slopeOfPair('n', 'w'), 'nw_se');
assert.equal(slopeOfPair('s', 'e'), 'nw_se');
assert.equal(slopeOfPair('e', 'w'), null, 'the through track has no slope');
});
it('joins two cards stacked vertically only when their legs share a diagonal', () => {
// `sw` above `ne` is one unbroken 45° rail across the card edge. `sw` above `nw` is a V: both
// cards have the port, the rails meet at the same point, and they still do not connect.
assert.ok(joins(arcCard('sw'), 's', arcCard('ne')), 'sw over ne is continuous rail');
assert.ok(joins(arcCard('se'), 's', arcCard('nw')), 'se over nw is continuous rail');
assert.ok(!joins(arcCard('sw'), 's', arcCard('nw')), 'sw over nw is a V');
assert.ok(!joins(arcCard('se'), 's', arcCard('ne')), 'se over ne is a V');
});
it('matches a turnout to the curve of the same hand, and to no other', () => {
// A siding needs one card of each hand: a left turnout to drop off the main, a right curve to
// climb back. This is the pairing that makes that true.
const leftTurnout = turnoutOf({ stem: 'w', through: 'e', diverge: 's' });
const rightTurnout = turnoutOf({ stem: 'e', through: 'w', diverge: 's' });
assert.ok(joins(leftTurnout, 's', arcCard('ne')));
assert.ok(!joins(leftTurnout, 's', arcCard('nw')));
assert.ok(joins(rightTurnout, 's', arcCard('nw')));
assert.ok(!joins(rightTurnout, 's', arcCard('ne')));
});
it('ignores slope on an east-west edge, where every card meets at the same height', () => {
assert.ok(joins(straight(), 'e', straight()));
assert.ok(joins(arcCard('ne'), 'e', straight()), 'a curve reaching east meets the through line');
});
it('never connects north to south on any card the supply can produce', () => {
// Descending a row therefore costs at least two cards — leg down, then a curve turning the run
// back east-west. A district can never be a vertical column, which is what the sheet shows.
for (const { geometry, hand } of TRACK_SUPPLY) {
for (const v of variantsFor(geometry, hand)) {
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, ...(hand !== 'none' ? { hand } : {}) } };
for (const [a, b] of connectionsFor(card)) {
const pair = new Set([a, b]);
assert.ok(!(pair.has('n') && pair.has('s')), `${geometry}/${hand} joins north to south`);
}
}
}
});
it('gives every north or south port exactly one slope', () => {
// `slopeAt` returns the first matching pair, so a card carrying two differently-sloped legs at
// one edge would silently answer with whichever came first. Nothing may.
for (const { geometry, hand } of TRACK_SUPPLY) {
for (const v of variantsFor(geometry, hand)) {
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, ...(hand !== 'none' ? { hand } : {}) } };
for (const p of ['n', 's'] as Port[]) {
const slopes = new Set(
connectionsFor(card).filter(([a, b]) => a === p || b === p).map(([a, b]) => slopeOfPair(a, b)),
);
assert.ok(slopes.size <= 1, `${geometry}/${hand} has two slopes at its ${p} edge`);
}
}
}
});
it('ties the slope to the printed hand, so turning a card never changes diagonal', () => {
// A printed card has a back: it turns 180° but never flips. That is the whole reason handedness
// is not decoration.
for (const geometry of ['curved', 'sharpCurved', 'turnout'] as const) {
for (const [hand, expected] of [['left', 'ne_sw'], ['right', 'nw_se']] as const) {
const variants = variantsFor(geometry, hand);
assert.equal(variants.length, 2, `${geometry}/${hand} should offer 0° and 180°, and nothing else`);
const legs = variants.map((v) => {
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, hand } };
return (slopeAt(card, 'n') ?? slopeAt(card, 's'));
});
assert.deepEqual(legs, [expected, expected], `${geometry}/${hand} changes diagonal when turned`);
}
}
});
});
// ---------------------------------------------------------------------------
describe('Move legality', () => {
// row 0: [D] [E office ] [F] <- Running Track
// row -1: [B n-s straight] <- Secondary Track hangs BELOW (Q7)
// row 0: [straight] [office] [turnout, leg south] [straight] <- Running Track
// row -1: [curve ne ] <- Secondary Track
//
// The turnout's leg is on the `ne_sw` diagonal ({w,s}), so the card beneath it must be the arc on
// the SAME diagonal that reaches north — `ne`. `nw` would meet the same point and still not join.
const basicArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(-1, 0))]: nsStraight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(0, 2))]: straight(),
[coordKey(at(-1, 1))]: curve('ne'),
},
at(0, 0),
);
@@ -218,15 +325,24 @@ describe('Move legality', () => {
it('travels any distance in a straight line', () => {
// §2.4 — "regardless of distance".
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
const dests = reachableDestinations(ctxFor(area), at(0, 2), 'w');
assert.ok(has(dests, 0, 0), 'reaches the office');
assert.ok(has(dests, 0, -1), 'reaches past it');
});
it('branches onto Secondary Track through the Office stub', () => {
it('branches onto Secondary Track through a turnout on the Running Track', () => {
// The crew must enter the turnout through its STEM (west) to take the diverging leg (§A.1).
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
assert.ok(has(dests, -1, 0), 'reaches the card below the office');
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(has(dests, -1, 1), 'reaches the curve below the turnout');
});
it('will not take a diverging leg entered from the through end', () => {
// §A.1 — through and diverge are not joined to each other. Approaching from the east reaches
// the stem and nothing else, so the leg below is unreachable from that side.
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 2), 'w');
assert.ok(!has(dests, -1, 1), 'no route from the through end onto the diverging leg');
});
it('never reverses within a single Move', () => {
@@ -239,19 +355,20 @@ describe('Move legality', () => {
it('separates forward and reverse into different Moves', () => {
const area = basicArea();
const both = allReachable(ctxFor(area), at(0, 0), 'e');
assert.ok(has(both.forward, 0, 1));
// Col 1 is the turnout, which carries no wheel icon, so east means running through it to col 2.
assert.ok(has(both.forward, 0, 2));
assert.ok(has(both.reverse, 0, -1));
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
});
it('will not stop on a turnout', () => {
// row 0: [start] [turnout ]
// row -1: [C n-s straight]
// row 0: [start] [turnout, leg south]
// row -1: [curve ne ]
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(-1, 1))]: nsStraight(),
[coordKey(at(-1, 1))]: curve('ne'),
},
at(0, 0),
);
@@ -372,32 +489,46 @@ describe('occupancy', () => {
// ---------------------------------------------------------------------------
describe('placement and drop-off', () => {
it('lets the first Facility attach to the Office stub', () => {
// Gap 8 — the case that was impossible before Office cards carried junction stubs.
it('grows a district on BOTH sides of the Running Track', () => {
// A turnout turned 180° sends its 45° leg north instead of south, so there is nothing special
// about the rows below. Q7 used to reject everything above the Running Track outright.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(0, 2))]: turnout({ stem: 'e', through: 'w', diverge: 'n' }),
},
at(0, 0),
);
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'below the Office');
assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'below a south-diverging turnout');
assert.ok(canPlaceAt(area, at(1, 2), curve('sw')), 'above a north-diverging turnout');
});
it('refuses a card whose 45° leg lies on the wrong diagonal', () => {
// Both cards have the port and both legs meet the same point on the shared edge. They still do
// not join: one descends to the right and the other to the left, so the rails form a V.
const area = areaFrom(
{
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
},
at(0, 0),
);
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'the matching diagonal joins');
assert.ok(!canPlaceAt(area, at(-1, 1), curve('nw')), 'the opposite diagonal must not');
});
it('refuses a card whose ports do not meet the neighbour it touches', () => {
// Gap 11 in miniature: an east-west straight placed north of the Office has no south port,
// so it cannot attach to the stub. Orientation is not cosmetic.
// An east-west straight below a turnout has no north port at all, so nothing can reach it.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
},
at(0, 0),
);
assert.ok(!canPlaceAt(area, at(-1, 0), straight()), 'e-w straight cannot meet a n-s stub');
assert.ok(!canPlaceAt(area, at(-1, 1), straight()), 'an east-west straight cannot meet a 45° leg');
});
it('refuses a card that connects to nothing', () => {
@@ -464,21 +595,20 @@ describe('curves are arcs, and arcs make sidings possible', () => {
}
});
it('can reach NORTH, which nothing but an n-s straight could before', () => {
// This is the whole point. With every leg diverging south, a district could only ever be a
// vertical column — no siding, no parallel track, no way back up to the Running Track.
it('can reach NORTH, which is the only way back up to the Running Track', () => {
// Without it a district could only ever hang below the main — no siding, no run-around.
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
);
assert.ok(reachesNorth, 'a curve still cannot reach north');
});
it('offers all four rotations when placed', () => {
// 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.
it('offers two rotations, and which two depends on the printed hand', () => {
// A card turns 180° but never flips, so a left-hand curve reaches `sw` and `ne` and can never
// become `se` or `nw` — those are the right-hand card.
for (const g of ['curved', 'sharpCurved'] as const) {
const arcs = variantsFor(g).map((v) => v.arc).sort();
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
assert.deepEqual(variantsFor(g, 'left').map((v) => v.arc).sort(), ['ne', 'sw'], `${g} left`);
assert.deepEqual(variantsFor(g, 'right').map((v) => v.arc).sort(), ['nw', 'se'], `${g} right`);
}
});
@@ -493,16 +623,19 @@ describe('curves are arcs, and arcs make sidings possible', () => {
});
it('lets a crew run a siding and rejoin the main', () => {
// The shape the whole change exists for: leave the Running Track, run parallel, come back up.
// THE CANONICAL SIDING, and the reason handedness matters: leave the Running Track on a LEFT
// turnout, drop onto its matching left curve (`ne`), run parallel, and climb back on a RIGHT
// curve (`nw`) into a RIGHT turnout. One card of each hand, and the slopes agree at both ends.
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
const ew = (): TrackCard => straightCard();
const grid: Record<string, TrackCard> = {
'0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-2': ew(),
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,-1': arcCard('ne'),
'-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,0': ew(),
'-1,1': arcCard('nw'),
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,0': ew(),
};
const area = areaFrom(grid, { row: 0, col: 0 });
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
+91 -4
View File
@@ -209,7 +209,7 @@ describe('the board shows freight work happening', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
@@ -248,7 +248,7 @@ describe('the board shows freight work happening', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
@@ -270,7 +270,7 @@ describe('the board shows freight work happening', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
facility: {
kind: 'freight', subtype: 'mineTipple',
@@ -295,6 +295,77 @@ describe('the board shows freight work happening', () => {
});
});
describe('the board draws the printed card (docs/tracks.png)', () => {
/** One card, alone, so the measurements are in card coordinates. */
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
};
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
const angles = (links: string[]): number[] => [
...new Set(
draw(links).map((r) => {
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
}),
),
].sort((a, b) => a - b);
it('runs the through track dead centre, edge to edge', () => {
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
// at the card's exact vertical middle. It used to be drawn at 31% down the card, on the belief
// that the art put it along the top edge.
const rails = draw(['ew']);
assert.equal(rails.length, 2, 'a track is two rails');
const H = 96;
for (const r of rails) {
assert.equal(r.y1, r.y2, 'the through track must be level');
assert.ok(Math.abs(r.y1 - H / 2) <= 3, `through rail at y=${r.y1}, not the card's middle (${H / 2})`);
assert.deepEqual([Math.min(r.x1, r.x2), Math.max(r.x1, r.x2)], [0, 166], 'it must span the whole card');
}
});
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
const found = angles(links);
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
assert.ok(
found.includes(45) || found.includes(135),
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
);
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
}
});
it('draws the two diagonals as two different diagonals', () => {
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
});
it('meets the card edge at its midpoint, so abutting cards line up', () => {
// A leg that met the edge anywhere else would join to nothing, however right its angle.
const W = 166;
const H = 96;
// Only the sloping segments, and only the end of each that lands on the south edge.
const atEdge = draw(['sw'])
.filter((r) => r.y1 !== r.y2)
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
assert.equal(atEdge.length, 2, 'the leg is two rails');
const mid = (atEdge[0]! + atEdge[1]!) / 2;
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
});
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);
@@ -316,6 +387,22 @@ describe('board highlighting', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
/**
* Open the district first, with a TURNOUT on the Running Track.
*
* Every office and industry card is a plain east-west straight and the Office carries no stub,
* so on the opening board the only legal squares are the two Limits signs — on the Running Track
* itself, inside the existing bounds, and this test would prove nothing. A turnout is what puts
* a square OFF the main in reach. The lay flag is then cleared rather than playing out a second
* turn: the subject here is the drawn canvas, not the turn economy.
*/
const lay = actionGroups(game).options.find(
(o) => o.type === 'track.lay' && o.geometry === 'turnout' && o.placement !== undefined,
);
assert.ok(lay, 'no turnout could be laid — the district can never open');
assert.ok(submit(game, lay), 'the turnout was refused');
game.state.turn.laidThisTurn = false;
const cells = view(game).cells;
const minCol = Math.min(...cells.map((c) => c.col));
const maxCol = Math.max(...cells.map((c) => c.col));
@@ -489,7 +576,7 @@ describe('the page explains itself', () => {
const game = newGame(9);
const area = game.state.officeAreas.get(0)!;
const industry = {
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const, axis: 'ew' as const },
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight' as const, subtype: 'grocersWarehouse',