diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c9269..c1eb1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f32b4db..e3ce92c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/design/track-geometry.html b/docs/design/track-geometry.html new file mode 100644 index 0000000..f10c0f3 --- /dev/null +++ b/docs/design/track-geometry.html @@ -0,0 +1,254 @@ +Station Master — track geometry + + +
+
+

Station Master · board geometry

+

The track is 45°, and the rail runs down the middle

+

Every card below is drawn by the game's own renderer, from the engine's own connection table. Nothing here is a mock-up — if the picture is wrong, the board is wrong.

+
+ +
+

01Measured off the sheet

+

Taken from docs/tracks.png rather than inferred from the art. The east–west rail sits at the card's exact vertical middle on every row of the sheet.

+
+
Sheet2136 × 1397 px, 8 × 9 cards
Card259 × 150 px · aspect 1.73
Grid lines, y15 · 164 · 314 · 464 · 614 · 764 · 914 · 1064 · 1213
Rail centres, y89.5 · 236 · — · — · 689.5 · 839 · 989 · 1135.5 · 1288.5
Deviation from centreunder 1 px on all nine rows
Leg crosses edge atx = W/2, at 45°
+
+
+ +
+

02The shapes

+

Five, and that is all of them. A card turns 180° but never flips, so each of these also exists upside down — the turnout's leg reaching north instead of south, the curve reaching ne instead of sw. What it cannot do is change diagonal.

+
+
+
Straight
+
+

e–w—

+

The through track, dead centre. One orientation: turning it gives the same card back.

+
+
+
+
Turnout · left
+
+

w–e · w–sne_sw

+

Frog on the west end, leg out through the middle of the south edge. Turn it 180° and the leg goes north instead.

+
+
+
+
Turnout · right
+
+

e–w · e–snw_se

+

The mirror card. Same shape, opposite diagonal — which is the whole reason a siding needs one of each hand.

+
+
+
+
Curve · left
+
+

s–wne_sw

+

The leg without the through track: along the centre line to the frog, then out at 45°.

+
+
+
+
Curve · right
+
+

s–enw_se

+

Its right-hand twin. Both cards also turn 180°, reaching ne and nw respectively.

+
+
+
+
Refinery
+
+

e–w—

+

Every office and industry card on the printed sheet is a plain straight, with no diverging leg at all.

+
+
+
+
+ +
+

03The rule

+

Two cards stacked. Both pairs have the ports; in both, the two legs meet the shared edge at the same point. Only one of them is a railway.

+
+
+
turnout · leftcurve · left
+

Joins

+

Both legs lie on the ne_sw diagonal, so the rail crosses the card edge without bending. This is the left-hand turnout with its own left-hand curve.

+
+
+
turnout · leftcurve · right
+

Does not join

+

The right-hand curve is on nw_se. The rails touch and turn straight back — a V, not a junction. The engine refuses it, and the picture has to show why.

+
+
+
+ +
+

04A district

+

The Running Track between the Limits, with a leg going up at the west end and a siding hanging below at the east. Because no card joins north to south, dropping a row always costs two cards — the leg out, then a curve turning the run back east–west. Districts come out wide and shallow.

+
LimitsWhistle Postturnout · left 180°straightturnout · leftstraightturnout · rightLimitscurve · leftcurve · leftRefinerycurve · rightRUNNING TRACK
+
+ + +
diff --git a/docs/rules/implications.md b/docs/rules/implications.md index 2e9464d..5bfd1c0 100644 --- a/docs/rules/implications.md +++ b/docs/rules/implications.md @@ -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? diff --git a/docs/rules/open-questions.md b/docs/rules/open-questions.md index 0ef4a5c..158a1bb 100644 --- a/docs/rules/open-questions.md +++ b/docs/rules/open-questions.md @@ -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. --- diff --git a/src/engine/apply.ts b/src/engine/apply.ts index cc4da36..a8a0b77 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -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 } : {}), diff --git a/src/engine/legal.ts b/src/engine/legal.ts index fa0f719..e16cffc 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -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 diff --git a/src/engine/state.ts b/src/engine/state.ts index 12c1c7a..d0ede52 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -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). */ diff --git a/src/engine/track.ts b/src/engine/track.ts index b320196..52b7532 100644 --- a/src/engine/track.ts +++ b/src/engine/track.ts @@ -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([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 = { + left: ['sw', 'ne'], + right: ['se', 'nw'], + none: ['sw', 'ne'], +}; -export function variantsFor(geometry: TrackGeometry): TrackVariant[] { +const TURNOUT_VARIANTS: Record = { + 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; } diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 3b30bad..7b4b8d4 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -294,18 +294,24 @@ export function divisionSvg(nodes: DivisionView[]): string { /** * The Office Area as a map: one card per grid square, rails drawn edge to edge. * - * The through rail sits at a constant height on every card — the alignment the printed cards use — - * so abutting cards produce one unbroken line and a gap is visibly a gap. + * THE PRINTED CARD, and nothing else (docs/tracks.png). The through rail runs east-west across the + * DEAD CENTRE of every card, so abutting cards produce one unbroken line and a gap is visibly a + * gap. Everything that leaves through the north or south edge does so at 45°, through the middle of + * that edge — which is what lets two stacked cards read as one continuous diagonal. + * + * The card is 166 x 96, the printed sheet's 259:150 proportions. The aspect is not decoration: the + * frog where a 45° leg meets the through track sits H/2 from the centre, so a squarer card pushes + * it almost to the edge and a turnout's two routes stop being tellable apart. */ export function officeSvg( cells: CellView[], runningRow: number, ghosts: { row: number; col: number }[] = [], ): string { - const W = 132; + const W = 166; const H = 96; const PAD = 3; - const RAIL = 30; + const RAIL = H / 2; const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); @@ -356,8 +362,8 @@ export function officeSvg( return out; }; - // Where each port meets the card edge. East and west sit at the rail height so a straight run - // stays straight across the whole row; north and south are centred. + // Where each port meets the card edge. East and west sit at the rail height — the card's middle — + // so a straight run stays straight across the whole row; north and south are centred on the edge. const port = (p: string): { x: number; y: number } => { if (p === 'e') return { x: W, y: RAIL }; if (p === 'w') return { x: 0, y: RAIL }; @@ -389,15 +395,27 @@ export function officeSvg( // The rails. A Modifier is not track and deliberately gets none — that is why nothing can be // routed through it, and the picture should say so. for (const link of cell.links) { - const a = port(link[0] ?? 'e'); - const b = port(link[1] ?? 'w'); - const straight = (link === 'ew' || link === 'we') || (link === 'ns' || link === 'sn'); - if (straight) { - out += rail(a.x, a.y, b.x, b.y); + const from = link[0] ?? 'e'; + const to = link[1] ?? 'w'; + const vertical = from === 'n' || from === 's' ? from : to === 'n' || to === 's' ? to : ''; + if (vertical === '') { + // The through track: edge to edge along the middle of the card. + out += rail(port(from).x, port(from).y, port(to).x, port(to).y); } else { - // A diverging leg: run out from each edge and meet, which reads as a curve at this size. - const mid = { x: W / 2, y: RAIL + (H - RAIL) / 2 }; - out += rail(a.x, a.y, mid.x, mid.y) + rail(mid.x, mid.y, b.x, b.y); + /** + * A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge + * to the FROG, then out at exactly 45° through the middle of the north or south edge. + * + * The frog is `H/2` from the centre because a 45° run climbing half the card's height + * travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2) + * — below the rail on the assumption everything diverged downward — which drew a leg + * reaching north as a hook that dropped past the rail and came back up, and drew nothing at + * any angle the matching rule cares about. + */ + const side = vertical === from ? to : from; + const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL }; + const edge = port(vertical); + out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y); } } @@ -453,9 +471,10 @@ export function officeSvg( } if (cell.enhancements.length > 0) { - // Above the load pipeline, not on it: an `onCard` enhancement can land on a facility, and at - // the old baseline the label printed straight through the green/MEN|AT|WORK/red squares. - out += `${esc(cell.enhancements.join(' · '))}`; + // 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 += `${esc(cell.enhancements.join(' · '))}`; } if (cell.tray) { out += `` + diff --git a/src/sim/bot.ts b/src/sim/bot.ts index c9c39fe..cce95b4 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -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; seen: Set } { const main = area.runningRow; const rejoins = new Set(); const seen = new Set(); - 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(); while (stack.length > 0) { const cur = stack.pop()!; @@ -341,8 +359,9 @@ function descendFrom( for (const exit of exitsFrom(card, cur.entry)) { const n = neighbour({ row: cur.row, col: cur.col }, exit); const next = area.grid.get(`${n.row},${n.col}`); - if (!next || !hasPort(next, opposite(exit))) continue; - if (n.row >= main) { + if (!next || !joins(card, exit, next)) continue; + // Back onto the main, or past it onto the far side: either way the siding has rejoined. + if (side === -1 ? n.row >= main : n.row <= main) { if (n.col !== col) rejoins.add(n.col); continue; } @@ -352,38 +371,43 @@ function descendFrom( return { rejoins, seen }; } -/** Every way down off the main, by column. */ -function waysDown(area: OfficeArea): number[] { +/** Every way off the main on one side, by column. */ +function waysOff(area: OfficeArea, side: Side): number[] { const out: number[] = []; + const p = legPort(side); for (const [k, c] of area.grid.entries()) { const [r, col] = k.split(',').map(Number); - if (r !== area.runningRow || !hasPort(c, 's')) continue; - const under = area.grid.get(`${area.runningRow - 1},${col}`); - if (under && hasPort(under, 'n')) out.push(col!); + if (r !== area.runningRow) continue; + const beyond = area.grid.get(`${area.runningRow + side},${col}`); + if (beyond && joins(c, p, beyond)) out.push(col!); } return out; } -/** Below-main squares a crew can reach from the Running Track at all — siding or stub. */ -function reachableBelowMain(area: OfficeArea): Set { +/** Off-main squares a crew can reach from the Running Track at all — siding or stub, either side. */ +function reachableOffMain(area: OfficeArea): Set { const reached = new Set(); - 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 { const onLoop = new Set(); - for (const col of waysDown(area)) { - const { rejoins, seen } = descendFrom(area, col); - if (rejoins.size === 0) continue; - for (const k of seen) onLoop.add(k); + for (const side of SIDES) { + for (const col of waysOff(area, side)) { + const { rejoins, seen } = descendFrom(area, col, side); + if (rejoins.size === 0) continue; + for (const k of seen) onLoop.add(k); + } } return onLoop; } @@ -411,8 +435,7 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): if (!area) return null; const onLoop = runAroundCells(area); - const reachable = reachableBelowMain(area); - const sidingRow = area.runningRow - 1; + const reachable = reachableOffMain(area); let best: Intent | null = null; let bestScore = -Infinity; @@ -422,9 +445,10 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): if (s.cards.get(i.cardId)?.kind.kind !== 'freightFacility') continue; const { row, col } = i.placement; - const axis = facilityVariants()[i.variant ?? 0]?.axis; - if (!axis) continue; - const ports: Port[] = axis === 'ns' ? ['n', 's'] : ['e', 'w']; + if (!facilityVariants()[i.variant ?? 0]) continue; + // An industry card is a plain east-west straight, as every one on the printed sheet is. There + // is no north-south rotation to weigh any more; a facility joins its neighbours along the row. + const ports: Port[] = ['e', 'w']; // Closer to the Office is fewer Moves to reach, same as track scoring. let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col)); @@ -440,15 +464,14 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): if (!nc || !hasPort(nc, opposite(p))) continue; const k = `${n.row},${n.col}`; if (onLoop.has(k)) meetsLoop = true; - if (reachable.has(k) || n.row >= area.runningRow) meetsReachable = true; + if (reachable.has(k) || n.row === area.runningRow) meetsReachable = true; } // A facility cannot be inserted into a loop that is already closed — one card per square — so // the placement that lands ON a run-around is the one laid IN LINE with a siding still being - // built, east-west on the siding row. That is scored highest deliberately. + // built, along a row beside the main. That is scored highest deliberately. const inLineWithSiding = - row === sidingRow && - axis === 'ew' && + Math.abs(row - area.runningRow) === 1 && (reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`)); if (inLineWithSiding) score += 26; @@ -469,11 +492,6 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int if (!area) return null; const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`); - const below = area.runningRow - 1; - - /** Does this card send a leg DOWN off the Running Track? */ - const divergesSouth = (c: TrackCard | undefined): boolean => - !!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout'; /** * Can the siding continue THROUGH this neighbour, into the square being scored? @@ -488,27 +506,34 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int !!c && hasPort(c, toward); /** - * A main-line card a siding can actually climb back onto — asked of the ENGINE, not guessed from - * the card kind. Turnouts diverge south, and so does the Office (its `e-s`/`w-s` pairs), so - * testing for a turnout alone missed a way up that is on every board. + * A main-line card a siding on this side can climb back onto — asked of the ENGINE, not guessed + * from the card kind. It is not enough that the card has the edge: the arc climbing to it must lie + * on the same diagonal, so this takes the arc's own slope and compares. */ - const wayUp = (c: TrackCard | undefined): boolean => !!c && hasPort(c, 's'); + const wayBack = (c: TrackCard | undefined, side: Side, from: TrackCard): boolean => + !!c && joins(from, opposite(legPort(side)), c); - /** Main-line columns a siding could rejoin at. Without one of these beyond it, a run goes nowhere. */ - const waysUp = [...area.grid.entries()] - .filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && wayUp(c)) - .map(([k]) => Number(k.split(',')[1])); + /** Main-line columns with any leg on this side. Without one beyond it, a run goes nowhere. */ + const waysBack = (side: Side): number[] => + [...area.grid.entries()] + .filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && hasPort(c, legPort(side))) + .map(([k]) => Number(k.split(',')[1])); - const reachableBelow = reachableBelowMain(area); + const reachableOff = reachableOffMain(area); /** * Straights already on the Running Track — the only thing an Enhancement can attach to. * * Interlocking, Water Column and Telegraph all print "any Running Track Straight", and Telephone - * and Radio chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw` - * directly), so it never needed a straight and never laid one: 0.00 on the Running Track across - * 100 games, and with it 13 of the 18 enhancement cards that go on the board were structurally - * unplayable. Enhancements were drawn 3.78 a game and placed 0.64. + * and Radio chain off Telegraph. The bot used to build minimal two-arc run-arounds, so it never + * needed a straight and never laid one: 0.00 on the Running Track across 100 games, and with it 13 + * of the 18 enhancement cards that go on the board were structurally unplayable. Enhancements were + * drawn 3.78 a game and placed 0.64. + * + * The 45° geometry has since made straights unavoidable — a leg off the main only ever reaches the + * next row, so the run back along it is straights — and the bot now lays 3.44 a game against 1.01 + * under the old rules. This bonus still earns its place for the FIRST one, which the sequence + * would otherwise reach only by accident. * * Interlocking is the expensive one. It is the designed answer to the ONLY penalty in the game — * "no free A/D track", which cost 2.40 revenue a game, 27% of gross — and it had never once been @@ -521,8 +546,27 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int c.geometry.geometry === 'straight', ).length; + /** The card a lay would place, built exactly as the reducer builds it. */ + const probeOf = (i: Extract): 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): 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): number => { - const v = variantsFor(i.geometry)[i.variant ?? 0]; - const probe = { - geometry: { - kind: 'track', - geometry: i.geometry, - ...(v?.axis ? { axis: v.axis } : {}), - ...(v?.arc ? { arc: v.arc } : {}), - ...(v?.turnout ? { turnout: v.turnout } : {}), - }, - baseOperationalRail: true, - standing: [], - facility: null, - modifiers: [], - enhancements: [], - } as unknown as TrackCard; + const probe = probeOf(i); let dead = 0; for (const p of new Set(connectionsFor(probe).flat())) { const n = neighbour(i.placement, p); const nb = at(n.row, n.col); - if (!nb) continue; // empty — may still be built on - if (hasPort(nb, opposite(p))) continue; // joins + if (!nb) continue; // empty — may still be built on + if (joins(probe, p, nb)) continue; // a real rail crosses the edge dead++; } return dead; }; /** - * Arcs left to build BENEATH a turnout. + * Arcs left that could meet THIS turnout's leg. * - * A turnout is a hole in the Running Track and is worth cutting only for what hangs under it, so - * cutting one with nothing left to hang there is pure loss. Measured at 0% today — the bot builds - * the arc immediately after the turnout and never runs the supply dry first — so this is a guard - * against the supply changing rather than a fix for something happening now. + * A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so + * cutting one with nothing left to hang there is pure loss. Asked per hand, because handedness is + * the slope: a left-hand turnout's 45° leg can only be continued by a left-hand curve, and a + * supply full of right-hand ones is no help to it at all. */ - const arcsLeft = - (area.trackSupply.get('curved:left') ?? 0) + - (area.trackSupply.get('curved:right') ?? 0) + - (area.trackSupply.get('sharpCurved:left') ?? 0) + - (area.trackSupply.get('sharpCurved:right') ?? 0); + const arcsLeft = (hand: Hand): number => + (area.trackSupply.get(`curved:${hand}`) ?? 0) + (area.trackSupply.get(`sharpCurved:${hand}`) ?? 0); - // Where the district already turns down off the main. + // Where the district already turns off the main, either side. const turnouts = [...area.grid.entries()] - .filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c)) + .filter( + ([k, c]) => + Number(k.split(',')[0]) === area.runningRow && + c.geometry.kind === 'track' && + c.geometry.geometry === 'turnout', + ) .map(([k]) => Number(k.split(',')[1])); let best: Intent | null = null; @@ -606,17 +638,12 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int * change the order of the cars. Scored as a sequence, so each piece is laid because the previous * one asked for it. */ - if ( - row === area.runningRow && - i.geometry === 'straight' && - variantsFor('straight')[i.variant ?? 0]?.axis === 'ew' && - mainStraights < 1 - ) { + if (row === area.runningRow && i.geometry === 'straight' && mainStraights < 1) { // Two is enough: Interlocking needs one, and a second gives Telegraph somewhere to go // without competing further with the ways down. Scored below a first turnout, which is still // the most valuable single piece on the board, and below closing a run-around. bonus += 13; - } else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft > 0) { + } else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft(i.hand) > 0) { // A first way down is the most valuable single piece on the board; a second closes the // run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game // when unrestrained, which consumed the whole 26-piece supply on ways down and none on the @@ -625,44 +652,50 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int // (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the // crew can actually reach, which matters more than a tidy Running Track. bonus += turnouts.length === 0 ? 14 : 3; - } else if (row === below) { + } else if (Math.abs(row - area.runningRow) === 1) { + // Which side of the main this square is on. Both are ordinary now: a turnout turned 180° + // sends its leg north, so a siding above the Running Track is built exactly like one below. + const side: Side = row < area.runningRow ? -1 : 1; const arc = arcOf(i); // What this square is anchored to, and therefore which way a run through it would extend. The // anchor must be reachable from the main: extending a stranded fragment builds nothing. const anchoredAt = (c: number): boolean => - reachableBelow.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w'); + reachableOff.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w'); const anchorWest = anchoredAt(col - 1); const anchorEast = anchoredAt(col + 1); - if (arc === 'ne' || arc === 'nw') { + // Does this arc climb back toward the main, rather than one row further away from it? + if (arc !== undefined && arc.includes(opposite(legPort(side)))) { /** * AN ARC IS ONLY WORTH LAYING WHERE IT CAN REACH THE MAIN. * - * Both of these used to score on shape alone: "close the loop back up to the main" was - * awarded to any arc beside an east-west run, whether or not there was anything above it to - * join. Measured, that produced 0 run-arounds in 100 games — the siding terminated in an arc - * pointing north into empty space, past the east end of the Running Track, every game. + * This used to score on shape alone: "close the loop back up to the main" was awarded to any + * arc beside an east-west run, whether or not there was anything above it to join. Measured, + * that produced 0 run-arounds in 100 games — the siding terminated in an arc pointing north + * into empty space, past the east end of the Running Track, every game. * - * An arc also has exactly two ports, so which way it faces decides what it meets: `nw` joins - * north and WEST, `ne` joins north and EAST. Scoring them alike let the bot close a run from - * the wrong side, which connects nothing. + * Two things now decide whether it actually reaches. An arc has exactly two ports, so which + * way it faces decides what it meets — `nw` joins north and WEST, `ne` north and EAST — and + * on top of that the main-line card's own leg must lie on the SAME DIAGONAL, or the two 45° + * rails meet at a point on the card edge and form a V. `wayBack` asks the engine both. */ - if (wayUp(at(area.runningRow, col))) { - const meetsRun = (arc === 'nw' && anchorWest) || (arc === 'ne' && anchorEast); + if (wayBack(at(area.runningRow, col), side, probeOf(i))) { + const meetsRun = (arc.includes('w') && anchorWest) || (arc.includes('e') && anchorEast); // Closing an existing run beats opening a new one: that is the whole difference between a // run-around and one more stub. bonus += meetsRun ? 15 : 13; } - // An arc with nothing above it earns no bonus but is not forbidden — reaching a facility - // two rows down is still worth doing when there is no loop to close. - } else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') { + // An arc with nothing beyond it earns no bonus but is not forbidden — reaching a facility + // two rows out is still worth doing when there is no loop to close. + } else if (i.geometry === 'straight') { /** * Extend the siding beside the main, but only from something that already turned AND only - * while there is still somewhere to climb back up. Unbounded, this rule ran the siding east + * while there is still somewhere to climb back. Unbounded, this rule ran the siding east * past the last usable column in 96 of 100 games — the loop then missed by one card. */ + const back = waysBack(side); const canStillClose = - (anchorWest && waysUp.some((c) => c > col)) || (anchorEast && waysUp.some((c) => c < col)); + (anchorWest && back.some((c) => c > col)) || (anchorEast && back.some((c) => c < col)); if ((anchorWest || anchorEast) && canStillClose) { bonus += 11; } diff --git a/src/sim/view.ts b/src/sim/view.ts index e2ad847..e866ec1 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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'; } /** diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index 78cdc99..e9245d8 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -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', ); }); diff --git a/test/sim.test.ts b/test/sim.test.ts index 13ffd11..a94a541 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -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(); 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; seen: Set } => { const main = area.runningRow; const rejoins = new Set(); const seen = new Set(); + 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(); @@ -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; diff --git a/test/track.test.ts b/test/track.test.ts index 0a81141..98adb04 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -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,26 +183,33 @@ 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, standing: [], facility: null, modifiers: [], - enhancements: [], + enhancements: [], }; assert.deepEqual(exitsFrom(r, 's'), ['w']); }); @@ -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 = { - '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 }; diff --git a/test/web.test.ts b/test/web.test.ts index a1cfa78..0c9b4e7 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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(//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',