fix tracks to all be EW. no more NS tracks
This commit is contained in:
@@ -10,6 +10,87 @@ The target is 20 Revenue over 5 Days.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
### 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
|
Answered, and implemented as option B: **collide on catching up**, which is the version that rewards
|
||||||
|
|||||||
@@ -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,
|
- **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.
|
restart recovery and post-game replay from a single decision.
|
||||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||||||
|
- **The track is 45° geometry, not a graph on a grid.** Measured off `docs/tracks.png`: the through
|
||||||
|
rail runs east–west across the *exact vertical middle* of every card, there is no north–south track
|
||||||
|
anywhere, and everything that leaves through the north or south edge does so at **45°, through the
|
||||||
|
middle of that edge**. So two ports meeting is not enough to make a rail — two 45° legs can meet at
|
||||||
|
the same point and still form a V. Adjacency is `joins()` in `src/engine/track.ts`, never a bare
|
||||||
|
pair of `hasPort()` calls. A printed card turns 180° but never flips, so its handedness fixes which
|
||||||
|
diagonal its leg lies on for good, and a run-around needs one card of each hand.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -46,7 +46,7 @@ All nine answers are implemented, **170 tests passing**:
|
|||||||
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
|
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
|
||||||
| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 |
|
| 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 |
|
| 6 Victory conditions | **needs re-derivation** | new revenue sources and point losses |
|
||||||
| 7 Terminology | **stands** | |
|
| 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** | |
|
| 9 Make-up round repeats | **stands** | |
|
||||||
| 10 Card faces | **superseded** | this is the real card list |
|
| 10 Card faces | **superseded** | this is the real card list |
|
||||||
| 11 Track orientation | **superseded** | handedness is printed; both hands are supplied |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
5. **Run-around** — we treat it as a card; it is not in the supply. Is it a pattern built from two
|
||||||
turnouts?
|
turnouts?
|
||||||
6. **Space-use cards** — played at yourself as a cost of drawing, or at opponents?
|
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
|
7. ~~**Where the Office's junction stubs are.**~~ **Settled by measuring `tracks.png`**: the through
|
||||||
with curves diverging downward. Gap 8 assumed stubs above *and* below a centred track.
|
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?
|
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?
|
9. **Second Section** — is it a card played on a scheduled train, or its own train?
|
||||||
|
|
||||||
|
|||||||
@@ -760,7 +760,14 @@ figures moved, and only because Gap 10 replaced an assumption with real numbers.
|
|||||||
|
|
||||||
## Gap 11 — Track card orientation
|
## 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
|
**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.
|
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
|
*Why not (c), rotationally symmetric cards.* It discards §A.1 entirely, one of the game's most
|
||||||
characterful rules.
|
characterful rules.
|
||||||
|
|
||||||
**Implemented.** `variantsFor()` in `src/engine/track.ts` enumerates the rotations — 2 for a straight,
|
**Implemented.** `variantsFor(geometry, hand)` in `src/engine/track.ts` enumerates the rotations —
|
||||||
6 for a turnout, 2 for a run-around — and the chosen index rides the `cardPlayed` event, so replay
|
**1 for a straight** (east-west, and turning it gives the same card back) and **2 for a curve or
|
||||||
reproduces the same layout.
|
turnout** (0° sends the 45° leg south, 180° sends it north). The chosen index rides the `cardPlayed`
|
||||||
|
event, so replay reproduces the same layout.
|
||||||
|
|
||||||
**Consequence for print-and-play.** Card art must read correctly at any rotation the card allows.
|
Handedness is not a supply label: it IS the diagonal the 45° leg lies on. A left-hand card reaches
|
||||||
|
`sw` and `ne`, a right-hand card `se` and `nw`, and two stacked cards join only when their legs share
|
||||||
|
a diagonal. That is what makes the 4-left/4-right split of the curve and turnout supply meaningful,
|
||||||
|
and why a run-around needs one card of each hand.
|
||||||
|
|
||||||
|
**Consequence for print-and-play.** Card art must read correctly upside down — every track card is
|
||||||
|
laid at 0° or 180°, and nothing is ever turned on its side.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+21
-19
@@ -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
|
* Builds the card a play would place, at the chosen orientation. Returns null when the variant
|
||||||
* variant index is out of range, which `check` reports rather than silently defaulting — a wrong
|
* index is out of range, which `check` reports rather than silently defaulting — a wrong
|
||||||
* orientation is a different card, not a detail.
|
* orientation is a different card, not a detail.
|
||||||
*/
|
*/
|
||||||
function protoCard(
|
function protoCard(
|
||||||
kind: { kind: string; geometry?: string; facility?: string },
|
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||||
variant: number | undefined,
|
variant: number | undefined,
|
||||||
): TrackCard | null {
|
): TrackCard | null {
|
||||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||||
|
|
||||||
if (kind.kind === 'track') {
|
if (kind.kind === 'track') {
|
||||||
const geometry = kind.geometry as TrackGeometry;
|
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];
|
const v = options[variant ?? 0];
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
return {
|
return {
|
||||||
geometry: {
|
geometry: {
|
||||||
kind: 'track',
|
kind: 'track',
|
||||||
geometry,
|
geometry,
|
||||||
...(v.axis ? { axis: v.axis } : {}),
|
|
||||||
...(v.arc ? { arc: v.arc } : {}),
|
...(v.arc ? { arc: v.arc } : {}),
|
||||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||||
...(v.bypass ? { bypass: v.bypass } : {}),
|
...(v.bypass ? { bypass: v.bypass } : {}),
|
||||||
|
...(hand !== 'none' ? { hand } : {}),
|
||||||
},
|
},
|
||||||
baseOperationalRail: geometry !== 'turnout',
|
baseOperationalRail: geometry !== 'turnout',
|
||||||
...base,
|
...base,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = facilityVariants();
|
// A Facility is plain east-west track, as every industry card on the printed sheet is, so there
|
||||||
const v = options[variant ?? 0];
|
// is exactly one orientation and any index past it is out of range.
|
||||||
if (!v) return null;
|
if (!facilityVariants()[variant ?? 0]) return null;
|
||||||
return {
|
return {
|
||||||
geometry: {
|
geometry: { kind: 'facility', facility: kind.facility as never },
|
||||||
kind: 'facility',
|
|
||||||
facility: kind.facility as never,
|
|
||||||
...(v.axis ? { axis: v.axis } : {}),
|
|
||||||
},
|
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
...base,
|
...base,
|
||||||
};
|
};
|
||||||
@@ -1440,14 +1440,14 @@ function adjacentFacilityCoord(
|
|||||||
coord: GridCoord,
|
coord: GridCoord,
|
||||||
modifier?: ModifierKind,
|
modifier?: ModifierKind,
|
||||||
): GridCoord | null {
|
): GridCoord | null {
|
||||||
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
|
// A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows
|
||||||
if (coord.row > area.runningRow) return null;
|
// 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;
|
const hosts = modifier ? modifierProfile(modifier).hosts : null;
|
||||||
for (let dr = -1; dr <= 1; dr++) {
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
for (let dc = -1; dc <= 1; dc++) {
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
if (dr === 0 && dc === 0) continue;
|
if (dr === 0 && dc === 0) continue;
|
||||||
const c = { row: coord.row + dr, col: coord.col + dc };
|
const c = { row: coord.row + dr, col: coord.col + dc };
|
||||||
if (c.row > area.runningRow) continue;
|
|
||||||
const f = area.grid.get(coordKey(c))?.facility;
|
const f = area.grid.get(coordKey(c))?.facility;
|
||||||
if (!f) continue;
|
if (!f) continue;
|
||||||
if (hosts && !hosts.includes(f.subtype)) 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);
|
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(
|
function protoTrackCard(
|
||||||
geometry: TrackGeometry,
|
geometry: TrackGeometry,
|
||||||
hand: Hand,
|
hand: Hand,
|
||||||
variant: number | undefined,
|
variant: number | undefined,
|
||||||
): TrackCard | null {
|
): TrackCard | null {
|
||||||
const options = variantsFor(geometry);
|
const options = variantsFor(geometry, hand);
|
||||||
const v = options[variant ?? 0];
|
const v = options[variant ?? 0];
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
return {
|
return {
|
||||||
geometry: {
|
geometry: {
|
||||||
kind: 'track',
|
kind: 'track',
|
||||||
geometry,
|
geometry,
|
||||||
...(v.axis ? { axis: v.axis } : {}),
|
|
||||||
...(v.arc ? { arc: v.arc } : {}),
|
...(v.arc ? { arc: v.arc } : {}),
|
||||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||||
...(hand !== 'none' ? { hand } : {}),
|
...(hand !== 'none' ? { hand } : {}),
|
||||||
|
|||||||
+9
-4
@@ -16,6 +16,7 @@ import type { CarType, Hand, TrackGeometry } from './content.ts';
|
|||||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||||
import type { Intent } from './intents.ts';
|
import type { Intent } from './intents.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex } from './state.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'];
|
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.
|
// 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;
|
const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
|
||||||
for (const placement of targets) {
|
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 });
|
out.push({ type: 'card.play', cardId, placement, variant });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +155,8 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
if (count < 1) continue;
|
if (count < 1) continue;
|
||||||
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
|
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
|
||||||
for (const placement of placements) {
|
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 });
|
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 },
|
{ row: row!, col: col! - 1 },
|
||||||
];
|
];
|
||||||
for (const c of around) {
|
for (const c of around) {
|
||||||
// Q7 — a district hangs BELOW the Running Track; there is nothing above it.
|
// A district grows on BOTH sides of the Running Track: a turnout turned 180° sends its 45°
|
||||||
if (c.row > area.runningRow) continue;
|
// leg north instead of south. The rows above used to be filtered out here on Q7's authority.
|
||||||
const k = `${c.row},${c.col}`;
|
const k = `${c.row},${c.col}`;
|
||||||
if (seen.has(k)) continue;
|
if (seen.has(k)) continue;
|
||||||
// The Limits signs are candidates even though they are occupied: laying track there is how
|
// The Limits signs are candidates even though they are occupied: laying track there is how
|
||||||
|
|||||||
+22
-14
@@ -48,39 +48,47 @@ export type RollingStock = { type: CarType; loaded: boolean };
|
|||||||
* A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that
|
* 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.
|
* 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
|
* The through track is ALWAYS east-west (`{stem, through}` is `{e,w}`); the diverging leg is the
|
||||||
* that is settled, orientation is carried per card and defaults to stem-east / diverge-north.
|
* 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' };
|
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
|
* A curve joins two ADJACENT edges: it runs along the card's centre line from the east or west edge
|
||||||
* how many run each way, yet a layout with no north-south straight can never use the Office's
|
* to a frog, then leaves at 45° through the MIDDLE of the north or south edge. Four such arcs
|
||||||
* junction stubs at all.
|
* exist, but a printed card reaches only two of them (`Slope`).
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
|
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 =
|
export type CardGeometry =
|
||||||
| {
|
| {
|
||||||
kind: 'track';
|
kind: 'track';
|
||||||
geometry: TrackGeometry;
|
geometry: TrackGeometry;
|
||||||
turnout?: TurnoutOrientation;
|
turnout?: TurnoutOrientation;
|
||||||
axis?: TrackAxis;
|
|
||||||
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
|
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
|
||||||
arc?: TrackArc;
|
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';
|
hand?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
| { kind: 'office' }
|
| { kind: 'office' }
|
||||||
| { kind: 'limits' }
|
| { 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). */
|
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
|
||||||
| { kind: 'modifier'; modifier: ModifierKind }
|
| { kind: 'modifier'; modifier: ModifierKind }
|
||||||
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
|
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
|
||||||
|
|||||||
+134
-69
@@ -13,14 +13,22 @@
|
|||||||
*
|
*
|
||||||
* The second constraint is that a traversal may never leave a card through the port it entered by.
|
* 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.
|
* 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 { MAX_CONSIST } from './content.ts';
|
||||||
import type { TrackGeometry } from './content.ts';
|
import type { Hand, TrackGeometry } from './content.ts';
|
||||||
import type {
|
import type {
|
||||||
GridCoord,
|
GridCoord,
|
||||||
OfficeArea,
|
OfficeArea,
|
||||||
RollingStock,
|
RollingStock,
|
||||||
|
Slope,
|
||||||
TrackCard,
|
TrackCard,
|
||||||
TrayId,
|
TrayId,
|
||||||
TrackArc,
|
TrackArc,
|
||||||
@@ -67,16 +75,16 @@ type PortPair = readonly [Port, Port];
|
|||||||
/**
|
/**
|
||||||
* Which ports a card joins internally.
|
* Which ports a card joins internally.
|
||||||
*
|
*
|
||||||
* - **straight / limits** — a plain through track.
|
* - **straight / limits** — a plain east-west through track. There is no north-south straight: the
|
||||||
* - **turnout** — the through track plus ONE diverging leg off the east end. `e-w` and `e-n` exist;
|
* printed sheet has none, and a vertical rail cannot meet a 45° leg at an edge.
|
||||||
* `w-n` deliberately does not. That absence is §A.1's rule.
|
* - **turnout** — the east-west through track plus ONE 45° leg reaching north or south. `{stem,
|
||||||
* - **runAround** — a double-ended siding: both ends reach the loop, so a train can pass around
|
* through}` and `{stem, diverge}` exist; `{through, diverge}` deliberately does not. That absence
|
||||||
* standing cars. §A.5's facing-point move is impossible without one.
|
* is §A.1's rule.
|
||||||
* - **office** — Gap 8: junction stubs above and below, each reaching both ends of the through
|
* - **office / facility** — a plain through track. Every office and industry card on the printed
|
||||||
* track. These are plain junctions, so unlike a turnout there is no missing pair. North and south
|
* sheet (Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing, Town) is a
|
||||||
* are not joined to each other — that would be crossing the running track.
|
* straight east-west rail with no diverging leg at all; the industry spur is the card's own
|
||||||
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
|
* spotting capacity rather than a separate port. A district therefore grows off a TURNOUT laid on
|
||||||
* than a separate port.
|
* 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
|
* 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':
|
case 'spaceUse':
|
||||||
return [];
|
return [];
|
||||||
case 'limits':
|
case 'limits':
|
||||||
return [['e', 'w']];
|
|
||||||
case 'facility':
|
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':
|
case 'office':
|
||||||
return [
|
return [['e', 'w']];
|
||||||
['e', 'w'],
|
|
||||||
['e', 's'],
|
|
||||||
['w', 's'],
|
|
||||||
];
|
|
||||||
case 'track':
|
case 'track':
|
||||||
switch (card.geometry.geometry) {
|
switch (card.geometry.geometry) {
|
||||||
case 'straight':
|
case 'straight':
|
||||||
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
|
return [['e', 'w']];
|
||||||
case 'turnout': {
|
case 'turnout': {
|
||||||
const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
|
const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
|
||||||
// stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1).
|
// 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
|
* 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
|
* printed cards show exactly this (docs/tracks.png, rows 3-4): a run along the centre line
|
||||||
* edge with nothing running past it.
|
* 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.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* A SHARP curve is the same shape and costs two Moves to cross.
|
* A SHARP curve is the same shape and costs two Moves to cross.
|
||||||
*/
|
*/
|
||||||
case 'curved':
|
case 'curved':
|
||||||
case 'sharpCurved': {
|
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]];
|
return [[arc[0] as Port, arc[1] as Port]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A turnout with no stated orientation takes this one. */
|
/**
|
||||||
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' };
|
* A turnout with no stated orientation takes this one — the left-hand card at 0°, i.e. the
|
||||||
|
* `ne_sw` slope. Only hand-less fixtures reach it; `variantsFor` always states an orientation.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'w', through: 'e', diverge: 's' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slope — the 45° matching rule
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which diagonal a port pair's 45° leg lies on, or `null` for the east-west through track.
|
||||||
|
*
|
||||||
|
* The pair IS the answer: a leg joining north to east and one joining south to west lie on the same
|
||||||
|
* line, so both are `ne_sw`. Naming the slope after its two arcs makes the matching rule read
|
||||||
|
* itself — `sw` above `ne` is continuous rail, `sw` above `nw` is a V.
|
||||||
|
*/
|
||||||
|
export function slopeOfPair(a: Port, b: Port): Slope | null {
|
||||||
|
const pair = new Set<Port>([a, b]);
|
||||||
|
if (!pair.has('n') && !pair.has('s')) return null;
|
||||||
|
if (pair.has('n')) return pair.has('e') ? 'ne_sw' : 'nw_se';
|
||||||
|
return pair.has('w') ? 'ne_sw' : 'nw_se';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slope of the leg leaving this card through `p`, or `null` if it has no such leg.
|
||||||
|
*
|
||||||
|
* A card has at most one leg per north/south edge — the printed cards give a turnout exactly one
|
||||||
|
* and a curve exactly one — so there is never a second slope to choose between. `assertOneSlope`
|
||||||
|
* in the tests holds that true for everything the supply can produce.
|
||||||
|
*/
|
||||||
|
export function slopeAt(card: TrackCard, p: Port): Slope | null {
|
||||||
|
if (p !== 'n' && p !== 's') return null;
|
||||||
|
for (const [a, b] of connectionsFor(card)) {
|
||||||
|
if (a === p || b === p) return slopeOfPair(a, b);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ADJACENCY TEST. Do these two cards actually join, across edge `p` of `a`?
|
||||||
|
*
|
||||||
|
* Two ports meeting is not enough. East and west ports sit at the same height on every card, so a
|
||||||
|
* straight run is always continuous; but north and south are met at 45°, and a leg descending to
|
||||||
|
* the right cannot be continued by one descending to the left. Every place that used to ask
|
||||||
|
* `hasPort(a, p) && hasPort(b, opposite(p))` must ask this instead, or the board will draw — and
|
||||||
|
* the engine will route trains through — a rail that bends back on itself at the card edge.
|
||||||
|
*/
|
||||||
|
export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
|
||||||
|
if (!hasPort(a, p) || !hasPort(b, opposite(p))) return false;
|
||||||
|
if (p === 'e' || p === 'w') return true;
|
||||||
|
return slopeAt(a, p) === slopeAt(b, opposite(p));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Orientation (Gap 11)
|
// Orientation (Gap 11)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a track card may be laid. Gap 11 resolved in favour of **orientation chosen on placement**:
|
* HOW A TRACK CARD MAY BE LAID: turned, but never flipped.
|
||||||
* a track card is generic and the player decides how to lay it.
|
|
||||||
*
|
*
|
||||||
* The measurement that settled it: with orientation fixed, an east-west straight has no north or
|
* A printed card has a back, so the only rotation that keeps the through rail east-west is 180°.
|
||||||
* south port, so it could never attach to the Office's junction stubs. Simulated Office Areas grew
|
* That gives every track card exactly TWO orientations, and it means the card's printed handedness
|
||||||
* only sideways and downward — never upward — purely as an artifact of the default. That silently
|
* fixes which diagonal its 45° leg lies on for good — turning the card swaps the leg between north
|
||||||
* undid Gap 8 and put Appendix A's switching puzzle out of reach.
|
* 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
|
* So handedness is not decoration and not merely a supply label: it is the slope, and a siding
|
||||||
* rotation is free.
|
* 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 = {
|
export type TrackVariant = {
|
||||||
arc?: TrackArc;
|
arc?: TrackArc;
|
||||||
axis?: 'ew' | 'ns';
|
|
||||||
turnout?: TurnoutOrientation;
|
turnout?: TurnoutOrientation;
|
||||||
bypass?: Port;
|
bypass?: Port;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Left-hand cards lie on `ne_sw`, right-hand on `nw_se`; 0° sends the leg south, 180° north. */
|
||||||
* Q7 — everything diverges downward, so a turnout's leg is always south. Handedness (printed on
|
const CURVE_VARIANTS: Record<Hand, readonly TrackArc[]> = {
|
||||||
* the card) decides which end of the through track it leaves from.
|
left: ['sw', 'ne'],
|
||||||
*/
|
right: ['se', 'nw'],
|
||||||
const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [
|
none: ['sw', 'ne'],
|
||||||
{ stem: 'e', through: 'w', diverge: 's' },
|
};
|
||||||
{ stem: 'w', through: 'e', diverge: 's' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
|
const TURNOUT_VARIANTS: Record<Hand, readonly TurnoutOrientation[]> = {
|
||||||
|
left: [
|
||||||
|
{ stem: 'w', through: 'e', diverge: 's' },
|
||||||
|
{ stem: 'e', through: 'w', diverge: 'n' },
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{ stem: 'e', through: 'w', diverge: 's' },
|
||||||
|
{ stem: 'w', through: 'e', diverge: 'n' },
|
||||||
|
],
|
||||||
|
none: [
|
||||||
|
{ stem: 'w', through: 'e', diverge: 's' },
|
||||||
|
{ stem: 'e', through: 'w', diverge: 'n' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function variantsFor(geometry: TrackGeometry, hand: Hand = 'none'): TrackVariant[] {
|
||||||
switch (geometry) {
|
switch (geometry) {
|
||||||
case 'straight':
|
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':
|
case 'turnout':
|
||||||
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
|
return TURNOUT_VARIANTS[hand].map((t) => ({ turnout: t }));
|
||||||
case 'curved':
|
case 'curved':
|
||||||
case 'sharpCurved':
|
case 'sharpCurved':
|
||||||
// All four rotations. A two-port arc has no handedness that survives turning — its mirror IS
|
return CURVE_VARIANTS[hand].map((arc) => ({ arc }));
|
||||||
// one of its rotations — so the printed hand governs supply, not what can be built.
|
|
||||||
return ARCS.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[] {
|
export function facilityVariants(): TrackVariant[] {
|
||||||
return [{ axis: 'ew' }, { axis: 'ns' }];
|
return [{}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ports reachable from `from` within this card, never including `from` itself. */
|
/** 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[] };
|
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 first = neighbour(start, initialExit);
|
||||||
|
const firstCard = cardAt(area, first);
|
||||||
|
if (!firstCard || !joins(startCard, initialExit, firstCard)) return [];
|
||||||
const queue: Frontier[] = [
|
const queue: Frontier[] = [
|
||||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||||
];
|
];
|
||||||
@@ -309,13 +371,13 @@ export function reachableDestinations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const exit of exitsFrom(card, node.entry)) {
|
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 };
|
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||||
queue.push({
|
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
||||||
coord: neighbour(node.coord, exit),
|
|
||||||
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
|
* Gap 4a — a placed card must connect to existing track, and "connect" means `joins`: ports meeting
|
||||||
* stubs above and below so this is satisfiable from the opening Stage.
|
* 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 {
|
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||||
const existing = cardAt(area, coord);
|
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'];
|
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||||
for (const p of ports) {
|
for (const p of ports) {
|
||||||
if (!hasPort(card, p)) continue;
|
|
||||||
const neighbourCard = cardAt(area, neighbour(coord, p));
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-17
@@ -294,18 +294,24 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
/**
|
/**
|
||||||
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
* The 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 —
|
* THE PRINTED CARD, and nothing else (docs/tracks.png). The through rail runs east-west across the
|
||||||
* so abutting cards produce one unbroken line and a gap is visibly a gap.
|
* 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(
|
export function officeSvg(
|
||||||
cells: CellView[],
|
cells: CellView[],
|
||||||
runningRow: number,
|
runningRow: number,
|
||||||
ghosts: { row: number; col: number }[] = [],
|
ghosts: { row: number; col: number }[] = [],
|
||||||
): string {
|
): string {
|
||||||
const W = 132;
|
const W = 166;
|
||||||
const H = 96;
|
const H = 96;
|
||||||
const PAD = 3;
|
const PAD = 3;
|
||||||
const RAIL = 30;
|
const RAIL = H / 2;
|
||||||
|
|
||||||
const esc = (t: string): string =>
|
const esc = (t: string): string =>
|
||||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
@@ -356,8 +362,8 @@ export function officeSvg(
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Where each port meets the card edge. East and west sit at the rail height so a straight run
|
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
|
||||||
// stays straight across the whole row; north and south are centred.
|
// 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 } => {
|
const port = (p: string): { x: number; y: number } => {
|
||||||
if (p === 'e') return { x: W, y: RAIL };
|
if (p === 'e') return { x: W, y: RAIL };
|
||||||
if (p === 'w') return { x: 0, 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
|
// 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.
|
// routed through it, and the picture should say so.
|
||||||
for (const link of cell.links) {
|
for (const link of cell.links) {
|
||||||
const a = port(link[0] ?? 'e');
|
const from = link[0] ?? 'e';
|
||||||
const b = port(link[1] ?? 'w');
|
const to = link[1] ?? 'w';
|
||||||
const straight = (link === 'ew' || link === 'we') || (link === 'ns' || link === 'sn');
|
const vertical = from === 'n' || from === 's' ? from : to === 'n' || to === 's' ? to : '';
|
||||||
if (straight) {
|
if (vertical === '') {
|
||||||
out += rail(a.x, a.y, b.x, b.y);
|
// 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 {
|
} 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 };
|
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
|
||||||
out += rail(a.x, a.y, mid.x, mid.y) + rail(mid.x, mid.y, b.x, b.y);
|
* 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) {
|
if (cell.enhancements.length > 0) {
|
||||||
// Above the load pipeline, not on it: an `onCard` enhancement can land on a facility, and at
|
// ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the
|
||||||
// the old baseline the label printed straight through the green/MEN|AT|WORK/red squares.
|
// load pipeline below and the through track — and with the rail now down the middle of the
|
||||||
out += `<text class="bs-enh" x="6" y="${H - 46}">${esc(cell.enhancements.join(' · '))}</text>`;
|
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||||||
|
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||||
}
|
}
|
||||||
if (cell.tray) {
|
if (cell.tray) {
|
||||||
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
||||||
|
|||||||
+135
-102
@@ -21,10 +21,11 @@
|
|||||||
|
|
||||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
||||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.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 { GameEvent } from '../engine/events.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.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 type { Port } from '../engine/track.ts';
|
||||||
import { coordKey } from '../engine/state.ts';
|
import { coordKey } from '../engine/state.ts';
|
||||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } 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.
|
* 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
|
* 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
|
* 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
|
* 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.
|
* (§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(
|
function descendFrom(
|
||||||
area: OfficeArea,
|
area: OfficeArea,
|
||||||
col: number,
|
col: number,
|
||||||
|
side: Side,
|
||||||
): { rejoins: Set<number>; seen: Set<string> } {
|
): { rejoins: Set<number>; seen: Set<string> } {
|
||||||
const main = area.runningRow;
|
const main = area.runningRow;
|
||||||
const rejoins = new Set<number>();
|
const rejoins = new Set<number>();
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const start = area.grid.get(`${main - 1},${col}`);
|
const entry = opposite(legPort(side));
|
||||||
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
|
const start = area.grid.get(`${main + side},${col}`);
|
||||||
|
if (!start || !hasPort(start, entry)) return { rejoins, seen };
|
||||||
|
|
||||||
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
|
const stack: { row: number; col: number; entry: Port }[] = [{ row: main + side, col, entry }];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
const cur = stack.pop()!;
|
const cur = stack.pop()!;
|
||||||
@@ -341,8 +359,9 @@ function descendFrom(
|
|||||||
for (const exit of exitsFrom(card, cur.entry)) {
|
for (const exit of exitsFrom(card, cur.entry)) {
|
||||||
const n = neighbour({ row: cur.row, col: cur.col }, exit);
|
const n = neighbour({ row: cur.row, col: cur.col }, exit);
|
||||||
const next = area.grid.get(`${n.row},${n.col}`);
|
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) {
|
// 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);
|
if (n.col !== col) rejoins.add(n.col);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -352,38 +371,43 @@ function descendFrom(
|
|||||||
return { rejoins, seen };
|
return { rejoins, seen };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every way down off the main, by column. */
|
/** Every way off the main on one side, by column. */
|
||||||
function waysDown(area: OfficeArea): number[] {
|
function waysOff(area: OfficeArea, side: Side): number[] {
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
|
const p = legPort(side);
|
||||||
for (const [k, c] of area.grid.entries()) {
|
for (const [k, c] of area.grid.entries()) {
|
||||||
const [r, col] = k.split(',').map(Number);
|
const [r, col] = k.split(',').map(Number);
|
||||||
if (r !== area.runningRow || !hasPort(c, 's')) continue;
|
if (r !== area.runningRow) continue;
|
||||||
const under = area.grid.get(`${area.runningRow - 1},${col}`);
|
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
|
||||||
if (under && hasPort(under, 'n')) out.push(col!);
|
if (beyond && joins(c, p, beyond)) out.push(col!);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Below-main squares a crew can reach from the Running Track at all — siding or stub. */
|
/** Off-main squares a crew can reach from the Running Track at all — siding or stub, either side. */
|
||||||
function reachableBelowMain(area: OfficeArea): Set<string> {
|
function reachableOffMain(area: OfficeArea): Set<string> {
|
||||||
const reached = new Set<string>();
|
const reached = new Set<string>();
|
||||||
for (const col of waysDown(area)) {
|
for (const side of SIDES) {
|
||||||
for (const k of descendFrom(area, col).seen) reached.add(k);
|
for (const col of waysOff(area, side)) {
|
||||||
|
for (const k of descendFrom(area, col, side).seen) reached.add(k);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return reached;
|
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
|
* 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.
|
* on a stub can only be reached by shoving in and backing out.
|
||||||
*/
|
*/
|
||||||
function runAroundCells(area: OfficeArea): Set<string> {
|
function runAroundCells(area: OfficeArea): Set<string> {
|
||||||
const onLoop = new Set<string>();
|
const onLoop = new Set<string>();
|
||||||
for (const col of waysDown(area)) {
|
for (const side of SIDES) {
|
||||||
const { rejoins, seen } = descendFrom(area, col);
|
for (const col of waysOff(area, side)) {
|
||||||
if (rejoins.size === 0) continue;
|
const { rejoins, seen } = descendFrom(area, col, side);
|
||||||
for (const k of seen) onLoop.add(k);
|
if (rejoins.size === 0) continue;
|
||||||
|
for (const k of seen) onLoop.add(k);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return onLoop;
|
return onLoop;
|
||||||
}
|
}
|
||||||
@@ -411,8 +435,7 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
|||||||
if (!area) return null;
|
if (!area) return null;
|
||||||
|
|
||||||
const onLoop = runAroundCells(area);
|
const onLoop = runAroundCells(area);
|
||||||
const reachable = reachableBelowMain(area);
|
const reachable = reachableOffMain(area);
|
||||||
const sidingRow = area.runningRow - 1;
|
|
||||||
|
|
||||||
let best: Intent | null = null;
|
let best: Intent | null = null;
|
||||||
let bestScore = -Infinity;
|
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;
|
if (s.cards.get(i.cardId)?.kind.kind !== 'freightFacility') continue;
|
||||||
|
|
||||||
const { row, col } = i.placement;
|
const { row, col } = i.placement;
|
||||||
const axis = facilityVariants()[i.variant ?? 0]?.axis;
|
if (!facilityVariants()[i.variant ?? 0]) continue;
|
||||||
if (!axis) continue;
|
// An industry card is a plain east-west straight, as every one on the printed sheet is. There
|
||||||
const ports: Port[] = axis === 'ns' ? ['n', 's'] : ['e', 'w'];
|
// 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.
|
// 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));
|
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;
|
if (!nc || !hasPort(nc, opposite(p))) continue;
|
||||||
const k = `${n.row},${n.col}`;
|
const k = `${n.row},${n.col}`;
|
||||||
if (onLoop.has(k)) meetsLoop = true;
|
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
|
// 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
|
// 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 =
|
const inLineWithSiding =
|
||||||
row === sidingRow &&
|
Math.abs(row - area.runningRow) === 1 &&
|
||||||
axis === 'ew' &&
|
|
||||||
(reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`));
|
(reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`));
|
||||||
|
|
||||||
if (inLineWithSiding) score += 26;
|
if (inLineWithSiding) score += 26;
|
||||||
@@ -469,11 +492,6 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
|||||||
if (!area) return null;
|
if (!area) return null;
|
||||||
|
|
||||||
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
|
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?
|
* 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);
|
!!c && hasPort(c, toward);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A main-line card a siding can actually climb back onto — asked of the ENGINE, not guessed from
|
* A main-line card a siding on this side can climb back onto — asked of the ENGINE, not guessed
|
||||||
* the card kind. Turnouts diverge south, and so does the Office (its `e-s`/`w-s` pairs), so
|
* from the card kind. It is not enough that the card has the edge: the arc climbing to it must lie
|
||||||
* testing for a turnout alone missed a way up that is on every board.
|
* 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. */
|
/** Main-line columns with any leg on this side. Without one beyond it, a run goes nowhere. */
|
||||||
const waysUp = [...area.grid.entries()]
|
const waysBack = (side: Side): number[] =>
|
||||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && wayUp(c))
|
[...area.grid.entries()]
|
||||||
.map(([k]) => Number(k.split(',')[1]));
|
.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.
|
* 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
|
* 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`
|
* and Radio chain off Telegraph. The bot used to build minimal two-arc run-arounds, so it never
|
||||||
* directly), so it never needed a straight and never laid one: 0.00 on the Running Track across
|
* needed a straight and never laid one: 0.00 on the Running Track across 100 games, and with it 13
|
||||||
* 100 games, and with it 13 of the 18 enhancement cards that go on the board were structurally
|
* of the 18 enhancement cards that go on the board were structurally unplayable. Enhancements were
|
||||||
* unplayable. Enhancements were drawn 3.78 a game and placed 0.64.
|
* 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 —
|
* 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
|
* "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',
|
c.geometry.geometry === 'straight',
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
/** The card a lay would place, built exactly as the reducer builds it. */
|
||||||
|
const probeOf = (i: Extract<Intent, { type: 'track.lay' }>): TrackCard => {
|
||||||
|
const v = variantsFor(i.geometry, i.hand)[i.variant ?? 0];
|
||||||
|
return {
|
||||||
|
geometry: {
|
||||||
|
kind: 'track',
|
||||||
|
geometry: i.geometry,
|
||||||
|
...(v?.arc ? { arc: v.arc } : {}),
|
||||||
|
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||||
|
...(i.hand !== 'none' ? { hand: i.hand } : {}),
|
||||||
|
},
|
||||||
|
baseOperationalRail: true,
|
||||||
|
standing: [],
|
||||||
|
facility: null,
|
||||||
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
|
} as unknown as TrackCard;
|
||||||
|
};
|
||||||
|
|
||||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||||
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RAIL THAT CAN NEVER GO ANYWHERE.
|
* RAIL THAT CAN NEVER GO ANYWHERE.
|
||||||
@@ -538,49 +582,37 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
|||||||
* district instead of tidying it.
|
* district instead of tidying it.
|
||||||
*/
|
*/
|
||||||
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
|
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
|
||||||
const v = variantsFor(i.geometry)[i.variant ?? 0];
|
const probe = probeOf(i);
|
||||||
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;
|
|
||||||
let dead = 0;
|
let dead = 0;
|
||||||
for (const p of new Set(connectionsFor(probe).flat())) {
|
for (const p of new Set(connectionsFor(probe).flat())) {
|
||||||
const n = neighbour(i.placement, p);
|
const n = neighbour(i.placement, p);
|
||||||
const nb = at(n.row, n.col);
|
const nb = at(n.row, n.col);
|
||||||
if (!nb) continue; // empty — may still be built on
|
if (!nb) continue; // empty — may still be built on
|
||||||
if (hasPort(nb, opposite(p))) continue; // joins
|
if (joins(probe, p, nb)) continue; // a real rail crosses the edge
|
||||||
dead++;
|
dead++;
|
||||||
}
|
}
|
||||||
return 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
|
* 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. Measured at 0% today — the bot builds
|
* cutting one with nothing left to hang there is pure loss. Asked per hand, because handedness is
|
||||||
* the arc immediately after the turnout and never runs the supply dry first — so this is a guard
|
* the slope: a left-hand turnout's 45° leg can only be continued by a left-hand curve, and a
|
||||||
* against the supply changing rather than a fix for something happening now.
|
* supply full of right-hand ones is no help to it at all.
|
||||||
*/
|
*/
|
||||||
const arcsLeft =
|
const arcsLeft = (hand: Hand): number =>
|
||||||
(area.trackSupply.get('curved:left') ?? 0) +
|
(area.trackSupply.get(`curved:${hand}`) ?? 0) + (area.trackSupply.get(`sharpCurved:${hand}`) ?? 0);
|
||||||
(area.trackSupply.get('curved:right') ?? 0) +
|
|
||||||
(area.trackSupply.get('sharpCurved:left') ?? 0) +
|
|
||||||
(area.trackSupply.get('sharpCurved:right') ?? 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()]
|
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]));
|
.map(([k]) => Number(k.split(',')[1]));
|
||||||
|
|
||||||
let best: Intent | null = null;
|
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
|
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
|
||||||
* one asked for it.
|
* one asked for it.
|
||||||
*/
|
*/
|
||||||
if (
|
if (row === area.runningRow && i.geometry === 'straight' && mainStraights < 1) {
|
||||||
row === area.runningRow &&
|
|
||||||
i.geometry === 'straight' &&
|
|
||||||
variantsFor('straight')[i.variant ?? 0]?.axis === 'ew' &&
|
|
||||||
mainStraights < 1
|
|
||||||
) {
|
|
||||||
// Two is enough: Interlocking needs one, and a second gives Telegraph somewhere to go
|
// 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
|
// 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.
|
// the most valuable single piece on the board, and below closing a run-around.
|
||||||
bonus += 13;
|
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
|
// 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
|
// 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
|
// 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
|
// (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.
|
// crew can actually reach, which matters more than a tidy Running Track.
|
||||||
bonus += turnouts.length === 0 ? 14 : 3;
|
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);
|
const arc = arcOf(i);
|
||||||
// What this square is anchored to, and therefore which way a run through it would extend. The
|
// 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.
|
// anchor must be reachable from the main: extending a stranded fragment builds nothing.
|
||||||
const anchoredAt = (c: number): boolean =>
|
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 anchorWest = anchoredAt(col - 1);
|
||||||
const anchorEast = 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.
|
* 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
|
* This used to score on shape alone: "close the loop back up to the main" was awarded to any
|
||||||
* awarded to any arc beside an east-west run, whether or not there was anything above it to
|
* arc beside an east-west run, whether or not there was anything above it to join. Measured,
|
||||||
* join. Measured, that produced 0 run-arounds in 100 games — the siding terminated in an arc
|
* that produced 0 run-arounds in 100 games — the siding terminated in an arc pointing north
|
||||||
* pointing north into empty space, past the east end of the Running Track, every game.
|
* 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
|
* Two things now decide whether it actually reaches. An arc has exactly two ports, so which
|
||||||
* north and WEST, `ne` joins north and EAST. Scoring them alike let the bot close a run from
|
* way it faces decides what it meets — `nw` joins north and WEST, `ne` north and EAST — and
|
||||||
* the wrong side, which connects nothing.
|
* 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))) {
|
if (wayBack(at(area.runningRow, col), side, probeOf(i))) {
|
||||||
const meetsRun = (arc === 'nw' && anchorWest) || (arc === 'ne' && anchorEast);
|
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
|
// Closing an existing run beats opening a new one: that is the whole difference between a
|
||||||
// run-around and one more stub.
|
// run-around and one more stub.
|
||||||
bonus += meetsRun ? 15 : 13;
|
bonus += meetsRun ? 15 : 13;
|
||||||
}
|
}
|
||||||
// An arc with nothing above it earns no bonus but is not forbidden — reaching a facility
|
// An arc with nothing beyond 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.
|
// two rows out is still worth doing when there is no loop to close.
|
||||||
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
|
} else if (i.geometry === 'straight') {
|
||||||
/**
|
/**
|
||||||
* Extend the siding beside the main, but only from something that already turned AND only
|
* 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.
|
* past the last usable column in 96 of 100 games — the loop then missed by one card.
|
||||||
*/
|
*/
|
||||||
|
const back = waysBack(side);
|
||||||
const canStillClose =
|
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) {
|
if ((anchorWest || anchorEast) && canStillClose) {
|
||||||
bonus += 11;
|
bonus += 11;
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-19
@@ -28,8 +28,9 @@ import {
|
|||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
|
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
|
||||||
import type { TrackGeometry } from '../engine/content.ts';
|
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||||
import { connectionsFor, variantsFor } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
|
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||||
import type { Impediment } from './narrate.ts';
|
import type { Impediment } from './narrate.ts';
|
||||||
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
|
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ export type CellView = {
|
|||||||
cars: string[];
|
cars: string[];
|
||||||
facility: FacilityView | null;
|
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
|
* the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do
|
||||||
* not have.
|
* not have.
|
||||||
*/
|
*/
|
||||||
@@ -337,7 +339,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
case 'track.lay':
|
case 'track.lay':
|
||||||
return (
|
return (
|
||||||
`lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${geometryLabel(i.geometry)} ` +
|
`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':
|
case 'switch.move':
|
||||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
||||||
@@ -803,28 +805,50 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
|||||||
case 'track':
|
case 'track':
|
||||||
switch (g.geometry) {
|
switch (g.geometry) {
|
||||||
case 'straight':
|
case 'straight':
|
||||||
return g.axis === 'ns' ? 'through track, north–south' : 'through track, east–west';
|
return 'through track, east–west';
|
||||||
case 'curved':
|
case 'curved':
|
||||||
case 'sharpCurved': {
|
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' : '';
|
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': {
|
case 'turnout': {
|
||||||
const t = g.turnout;
|
const t = g.turnout;
|
||||||
if (!t) return '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.
|
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
|
||||||
return (
|
return (
|
||||||
`turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${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 ${dir(t.through)} and ${dir(t.diverge)}`
|
`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. */
|
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
|
||||||
function facilityLabel(key: string): string {
|
function facilityLabel(key: string): string {
|
||||||
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
|
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
|
* "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
|
* 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 {
|
export function variantLabel(
|
||||||
const v = variantsFor(geometry)[variant ?? 0];
|
geometry: TrackGeometry,
|
||||||
|
variant: number | undefined,
|
||||||
|
hand: Hand = 'none',
|
||||||
|
): string {
|
||||||
|
const v = variantsFor(geometry, hand)[variant ?? 0];
|
||||||
if (!v) return '';
|
if (!v) return '';
|
||||||
if (v.axis) return v.axis === 'ew' ? ' — east–west' : ' — north–south';
|
|
||||||
if (v.turnout) {
|
if (v.turnout) {
|
||||||
const dir = (p: string): string =>
|
return ` — stem ${compass(v.turnout.stem)}, through ${compass(v.turnout.through)}, diverges ${compass(v.turnout.diverge)} at 45°`;
|
||||||
({ 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 '';
|
if (v.arc) {
|
||||||
|
const [a, b] = [v.arc[0] as Port, v.arc[1] as Port];
|
||||||
|
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
|
||||||
|
return ` — ${compass(side!)} to the middle of the ${compass(leg!)} edge`;
|
||||||
|
}
|
||||||
|
return ' — east–west';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-31
@@ -282,24 +282,32 @@ describe('Red Flags protect a stopped train', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Flying Switch rolls a cut into an industry', () => {
|
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) {
|
function setup(s: GameState) {
|
||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
// A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
|
// A flying switch rolls the cut ALONG THE TRACK, so the crew and the industry must be joined by
|
||||||
// north-south. Built east-west, they are two unconnected cards that merely look adjacent.
|
// rail. Every run is east-west now, and the siding gets down off the main the only way there is:
|
||||||
const spur: TrackCard = {
|
//
|
||||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
// 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,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
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 = {
|
const industry: TrackCard = {
|
||||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
...plain(),
|
||||||
baseOperationalRail: true,
|
|
||||||
standing: [],
|
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight', subtype: 'mineTipple',
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
allows: { outbound: true, inbound: false },
|
allows: { outbound: true, inbound: false },
|
||||||
@@ -310,19 +318,14 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
laborers: 1, porters: 0,
|
laborers: 1, porters: 0,
|
||||||
usedThisStage: { laborers: 0, 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', {
|
s.trays.set('crew', {
|
||||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
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
|
direction: 'east', facing: 'e',
|
||||||
// track needs its actual port — without it the engine looks for an east exit the card has not
|
position: { at: 'grid', owner: 0, coord: at(-1, 2) }, movesUsed: 0,
|
||||||
// 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,
|
|
||||||
});
|
});
|
||||||
s.clock.phase = 'localOps';
|
s.clock.phase = 'localOps';
|
||||||
s.clock.currentActor = 0;
|
s.clock.currentActor = 0;
|
||||||
@@ -337,7 +340,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||||
|
|
||||||
const r = applyIntent(s, 0, {
|
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');
|
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.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
s.trays.get('crew')!.position,
|
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',
|
'the engine never enters the industry',
|
||||||
);
|
);
|
||||||
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
|
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();
|
const s = game();
|
||||||
setup(s);
|
setup(s);
|
||||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
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(
|
assert.equal(
|
||||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
||||||
'CANNOT_DROP_HERE',
|
'CANNOT_DROP_HERE',
|
||||||
@@ -377,13 +381,13 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
const s = game();
|
const s = game();
|
||||||
setup(s);
|
setup(s);
|
||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
// An industry sitting beside the crew, but joined east-west while the spur runs north-south.
|
// An industry sitting directly beneath the crew. Both cards are plain east-west track, so
|
||||||
const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
|
// nothing crosses the edge between them: adjacency is not a rail.
|
||||||
stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
|
const stranded = { ...area.grid.get(coordKey(at(-1, 3)))! };
|
||||||
area.grid.set(coordKey(at(-1, 1)), stranded);
|
area.grid.set(coordKey(at(-2, 2)), stranded);
|
||||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||||
assert.equal(
|
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',
|
'NOT_CONNECTED',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -393,7 +397,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
setup(s);
|
setup(s);
|
||||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||||
assert.equal(
|
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',
|
'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',
|
'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(
|
assert.equal(
|
||||||
check(s, 0, {
|
check(s, 0, {
|
||||||
type: 'track.lay', geometry: 'straight', hand: 'none',
|
type: 'track.lay', geometry: 'curved', hand: 'left',
|
||||||
placement: { row: area.runningRow - 1, col: 0 }, variant: 1,
|
placement: { row: area.runningRow - 1, col: turnoutCol }, variant: 1,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
'Secondary Track below the Office must stay legal',
|
'Secondary Track below the turnout must stay legal',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+59
-15
@@ -8,11 +8,11 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { pump } from '../src/engine/advance.ts';
|
import { pump } from '../src/engine/advance.ts';
|
||||||
import { createGame } from '../src/engine/setup.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 { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||||
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
||||||
import type { Intent } from '../src/engine/intents.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 type { Port } from '../src/engine/track.ts';
|
||||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||||
import { simulate } from '../src/sim/harness.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');
|
assert.ok(freight > 0, 'no freight load completed across 40 games');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('grows the Office Area downward from the Running Track (Q7)', () => {
|
it('grows the Office Area off the Running Track, on either side', () => {
|
||||||
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
|
// The through track runs across the middle of every card and a turnout's 45° leg reaches north
|
||||||
// Nothing should ever be built above row 0.
|
// as readily as south, so a district hangs off the Running Track rather than beneath it. Q7's
|
||||||
|
// "downward only" is gone, and with it the rule that rejected every square above row 0.
|
||||||
const rows = new Set<number>();
|
const rows = new Set<number>();
|
||||||
for (const seed of [3, 9, 27, 81]) {
|
for (const seed of [3, 9, 27, 81]) {
|
||||||
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
|
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
|
||||||
playGame(s, developerBot, pump);
|
playGame(s, developerBot, pump);
|
||||||
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
|
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), 'nothing was ever built off the Running Track');
|
||||||
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE 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)', () => {
|
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
|
* 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
|
* climb back up to. Uses the engine's own `exitsFrom` and `joins`, so the test cannot credit a
|
||||||
* rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation
|
* connection the rules do not have — a turnout's two legs never join each other (§A.1), and two
|
||||||
* of the traversal would quietly invent that join.
|
* 45° legs on opposite diagonals meet at a point on the card edge without being a rail. A
|
||||||
|
* re-implementation of the traversal would quietly invent both.
|
||||||
*/
|
*/
|
||||||
const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set<number>; seen: Set<string> } => {
|
const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set<number>; seen: Set<string> } => {
|
||||||
const main = area.runningRow;
|
const main = area.runningRow;
|
||||||
const rejoins = new Set<number>();
|
const rejoins = new Set<number>();
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const top = area.grid.get(`${main},${col}`);
|
||||||
const start = area.grid.get(`${main - 1},${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 stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
@@ -338,7 +380,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
|
|||||||
for (const exit of exitsFrom(card, at.entry)) {
|
for (const exit of exitsFrom(card, at.entry)) {
|
||||||
const n = neighbour({ row: at.row, col: at.col }, exit);
|
const n = neighbour({ row: at.row, col: at.col }, exit);
|
||||||
const next = area.grid.get(`${n.row},${n.col}`);
|
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.row >= main) {
|
||||||
if (n.col !== col) rejoins.add(n.col);
|
if (n.col !== col) rejoins.add(n.col);
|
||||||
continue;
|
continue;
|
||||||
@@ -745,20 +787,22 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
|||||||
if (pick.type === 'track.lay') {
|
if (pick.type === 'track.lay') {
|
||||||
laid++;
|
laid++;
|
||||||
const area = st.officeAreas.get(p)!;
|
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 = {
|
const probe = {
|
||||||
geometry: {
|
geometry: {
|
||||||
kind: 'track', geometry: pick.geometry,
|
kind: 'track', geometry: pick.geometry,
|
||||||
...(v?.axis ? { axis: v.axis } : {}),
|
|
||||||
...(v?.arc ? { arc: v.arc } : {}),
|
...(v?.arc ? { arc: v.arc } : {}),
|
||||||
...(v?.turnout ? { turnout: v.turnout } : {}),
|
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||||
|
...(pick.hand !== 'none' ? { hand: pick.hand } : {}),
|
||||||
},
|
},
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
} as never;
|
} as never;
|
||||||
for (const port of new Set(connectionsFor(probe).flat())) {
|
for (const port of new Set(connectionsFor(probe).flat())) {
|
||||||
const n = neighbour(pick.placement, port);
|
const n = neighbour(pick.placement, port);
|
||||||
const nb = area.grid.get(`${n.row},${n.col}`);
|
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;
|
return pick;
|
||||||
|
|||||||
+184
-51
@@ -11,6 +11,7 @@ import type {
|
|||||||
GridCoord,
|
GridCoord,
|
||||||
OfficeArea,
|
OfficeArea,
|
||||||
RollingStock,
|
RollingStock,
|
||||||
|
TrackArc,
|
||||||
TrackCard,
|
TrackCard,
|
||||||
TurnoutOrientation,
|
TurnoutOrientation,
|
||||||
} from '../src/engine/state.ts';
|
} from '../src/engine/state.ts';
|
||||||
@@ -28,8 +29,12 @@ import {
|
|||||||
opposite,
|
opposite,
|
||||||
reachableDestinations,
|
reachableDestinations,
|
||||||
connectionsFor,
|
connectionsFor,
|
||||||
|
joins,
|
||||||
|
slopeAt,
|
||||||
|
slopeOfPair,
|
||||||
variantsFor,
|
variantsFor,
|
||||||
} from '../src/engine/track.ts';
|
} from '../src/engine/track.ts';
|
||||||
|
import { TRACK_SUPPLY } from '../src/engine/content.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixture helpers
|
// Fixture helpers
|
||||||
@@ -44,9 +49,13 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|||||||
enhancements: [],
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
|
/**
|
||||||
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
|
* A curve: along the centre line from the east or west edge to a frog, then out at 45° through the
|
||||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
* 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,
|
baseOperationalRail: true,
|
||||||
standing,
|
standing,
|
||||||
facility: null,
|
facility: null,
|
||||||
@@ -100,7 +109,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
|||||||
|
|
||||||
function straightCard(): TrackCard {
|
function straightCard(): TrackCard {
|
||||||
return {
|
return {
|
||||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
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');
|
assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives the Office card a junction stub BELOW only', () => {
|
it('gives the Office card a plain east-west track and no stub at all', () => {
|
||||||
// Q7 — the card art draws the through-track along the top edge with everything diverging
|
// Every office and industry card on the printed sheet — Depot, Station, Terminal, Refinery,
|
||||||
// downward, so a district grows beneath the Running Track. Supersedes Gap 8.
|
// 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();
|
const o = officeCard();
|
||||||
for (const p of ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
|
for (const p of ['e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
|
||||||
assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
|
for (const p of ['n', 's'] as Port[]) assert.ok(!hasPort(o, p), `office must not have a ${p} stub`);
|
||||||
assert.ok(exitsFrom(o, 's').includes('e'));
|
assert.deepEqual(exitsFrom(o, 'e'), ['w']);
|
||||||
assert.ok(exitsFrom(o, 's').includes('w'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('joins a curve from one end of the through track downward', () => {
|
it('runs every straight east-west, with no north-south rotation to choose', () => {
|
||||||
// Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
|
// There is no north-south track anywhere on the printed sheet, and there cannot be: a vertical
|
||||||
// handedness decides which end of the through track it leaves from.
|
// 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 = {
|
const r: TrackCard = {
|
||||||
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
enhancements: [],
|
enhancements: [],
|
||||||
};
|
};
|
||||||
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
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', () => {
|
describe('Move legality', () => {
|
||||||
// row 0: [D] [E office ] [F] <- Running Track
|
// row 0: [straight] [office] [turnout, leg south] [straight] <- Running Track
|
||||||
// row -1: [B n-s straight] <- Secondary Track hangs BELOW (Q7)
|
// 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 =>
|
const basicArea = (): OfficeArea =>
|
||||||
areaFrom(
|
areaFrom(
|
||||||
{
|
{
|
||||||
[coordKey(at(0, -1))]: straight(),
|
[coordKey(at(0, -1))]: straight(),
|
||||||
[coordKey(at(0, 0))]: officeCard(),
|
[coordKey(at(0, 0))]: officeCard(),
|
||||||
[coordKey(at(0, 1))]: straight(),
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
||||||
[coordKey(at(-1, 0))]: nsStraight(),
|
[coordKey(at(0, 2))]: straight(),
|
||||||
|
[coordKey(at(-1, 1))]: curve('ne'),
|
||||||
},
|
},
|
||||||
at(0, 0),
|
at(0, 0),
|
||||||
);
|
);
|
||||||
@@ -218,15 +325,24 @@ describe('Move legality', () => {
|
|||||||
it('travels any distance in a straight line', () => {
|
it('travels any distance in a straight line', () => {
|
||||||
// §2.4 — "regardless of distance".
|
// §2.4 — "regardless of distance".
|
||||||
const area = basicArea();
|
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, 0), 'reaches the office');
|
||||||
assert.ok(has(dests, 0, -1), 'reaches past it');
|
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 area = basicArea();
|
||||||
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
||||||
assert.ok(has(dests, -1, 0), 'reaches the card below the office');
|
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', () => {
|
it('never reverses within a single Move', () => {
|
||||||
@@ -239,19 +355,20 @@ describe('Move legality', () => {
|
|||||||
it('separates forward and reverse into different Moves', () => {
|
it('separates forward and reverse into different Moves', () => {
|
||||||
const area = basicArea();
|
const area = basicArea();
|
||||||
const both = allReachable(ctxFor(area), at(0, 0), 'e');
|
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.reverse, 0, -1));
|
||||||
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
|
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will not stop on a turnout', () => {
|
it('will not stop on a turnout', () => {
|
||||||
// row 0: [start] [turnout ]
|
// row 0: [start] [turnout, leg south]
|
||||||
// row -1: [C n-s straight]
|
// row -1: [curve ne ]
|
||||||
const area = areaFrom(
|
const area = areaFrom(
|
||||||
{
|
{
|
||||||
[coordKey(at(0, 0))]: straight(),
|
[coordKey(at(0, 0))]: straight(),
|
||||||
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
[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),
|
at(0, 0),
|
||||||
);
|
);
|
||||||
@@ -372,32 +489,46 @@ describe('occupancy', () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('placement and drop-off', () => {
|
describe('placement and drop-off', () => {
|
||||||
it('lets the first Facility attach to the Office stub', () => {
|
it('grows a district on BOTH sides of the Running Track', () => {
|
||||||
// Gap 8 — the case that was impossible before Office cards carried junction stubs.
|
// 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(
|
const area = areaFrom(
|
||||||
{
|
{
|
||||||
[coordKey(at(0, -1))]: straight(),
|
[coordKey(at(0, -1))]: straight(),
|
||||||
[coordKey(at(0, 0))]: officeCard(),
|
[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),
|
at(0, 0),
|
||||||
);
|
);
|
||||||
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'below the Office');
|
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'below a south-diverging turnout');
|
||||||
assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
|
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', () => {
|
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,
|
// An east-west straight below a turnout has no north port at all, so nothing can reach it.
|
||||||
// so it cannot attach to the stub. Orientation is not cosmetic.
|
|
||||||
const area = areaFrom(
|
const area = areaFrom(
|
||||||
{
|
{
|
||||||
[coordKey(at(0, -1))]: straight(),
|
|
||||||
[coordKey(at(0, 0))]: officeCard(),
|
[coordKey(at(0, 0))]: officeCard(),
|
||||||
[coordKey(at(0, 1))]: straight(),
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
||||||
},
|
},
|
||||||
at(0, 0),
|
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', () => {
|
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', () => {
|
it('can reach NORTH, which is the only way back up to the Running Track', () => {
|
||||||
// This is the whole point. With every leg diverging south, a district could only ever be a
|
// Without it a district could only ever hang below the main — no siding, no run-around.
|
||||||
// vertical column — no siding, no parallel track, no way back up to the Running Track.
|
|
||||||
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
|
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
|
||||||
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
|
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
|
||||||
);
|
);
|
||||||
assert.ok(reachesNorth, 'a curve still cannot reach north');
|
assert.ok(reachesNorth, 'a curve still cannot reach north');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers all four rotations when placed', () => {
|
it('offers two rotations, and which two depends on the printed hand', () => {
|
||||||
// A two-port arc has no handedness that survives turning — its mirror IS one of its rotations —
|
// A card turns 180° but never flips, so a left-hand curve reaches `sw` and `ne` and can never
|
||||||
// so the printed hand governs supply, not what can be built.
|
// become `se` or `nw` — those are the right-hand card.
|
||||||
for (const g of ['curved', 'sharpCurved'] as const) {
|
for (const g of ['curved', 'sharpCurved'] as const) {
|
||||||
const arcs = variantsFor(g).map((v) => v.arc).sort();
|
assert.deepEqual(variantsFor(g, 'left').map((v) => v.arc).sort(), ['ne', 'sw'], `${g} left`);
|
||||||
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
|
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', () => {
|
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).
|
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
|
||||||
|
const ew = (): TrackCard => straightCard();
|
||||||
const grid: Record<string, TrackCard> = {
|
const grid: Record<string, TrackCard> = {
|
||||||
'0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
'0,-2': ew(),
|
||||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
'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,-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'),
|
'-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,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||||
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
'0,0': ew(),
|
||||||
};
|
};
|
||||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||||
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
|
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
|
||||||
|
|||||||
+91
-4
@@ -209,7 +209,7 @@ describe('the board shows freight work happening', () => {
|
|||||||
const game = newGame(5);
|
const game = newGame(5);
|
||||||
const area = game.state.officeAreas.get(0)!;
|
const area = game.state.officeAreas.get(0)!;
|
||||||
area.grid.set('-1,0', {
|
area.grid.set('-1,0', {
|
||||||
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight', subtype: 'mineTipple',
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
@@ -248,7 +248,7 @@ describe('the board shows freight work happening', () => {
|
|||||||
const game = newGame(5);
|
const game = newGame(5);
|
||||||
const area = game.state.officeAreas.get(0)!;
|
const area = game.state.officeAreas.get(0)!;
|
||||||
area.grid.set('-1,0', {
|
area.grid.set('-1,0', {
|
||||||
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight', subtype: 'mineTipple',
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
@@ -270,7 +270,7 @@ describe('the board shows freight work happening', () => {
|
|||||||
const game = newGame(5);
|
const game = newGame(5);
|
||||||
const area = game.state.officeAreas.get(0)!;
|
const area = game.state.officeAreas.get(0)!;
|
||||||
area.grid.set('-1,0', {
|
area.grid.set('-1,0', {
|
||||||
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight', subtype: 'mineTipple',
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
@@ -295,6 +295,77 @@ describe('the board shows freight work happening', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the board draws the printed card (docs/tracks.png)', () => {
|
||||||
|
/** One card, alone, so the measurements are in card coordinates. */
|
||||||
|
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
|
||||||
|
const cell = {
|
||||||
|
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
|
||||||
|
tray: null, cars: [], facility: null, links, what: '',
|
||||||
|
};
|
||||||
|
const svg = officeSvg([cell] as never, 0);
|
||||||
|
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
|
||||||
|
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
|
||||||
|
const angles = (links: string[]): number[] => [
|
||||||
|
...new Set(
|
||||||
|
draw(links).map((r) => {
|
||||||
|
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
|
||||||
|
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
it('runs the through track dead centre, edge to edge', () => {
|
||||||
|
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
|
||||||
|
// at the card's exact vertical middle. It used to be drawn at 31% down the card, on the belief
|
||||||
|
// that the art put it along the top edge.
|
||||||
|
const rails = draw(['ew']);
|
||||||
|
assert.equal(rails.length, 2, 'a track is two rails');
|
||||||
|
const H = 96;
|
||||||
|
for (const r of rails) {
|
||||||
|
assert.equal(r.y1, r.y2, 'the through track must be level');
|
||||||
|
assert.ok(Math.abs(r.y1 - H / 2) <= 3, `through rail at y=${r.y1}, not the card's middle (${H / 2})`);
|
||||||
|
assert.deepEqual([Math.min(r.x1, r.x2), Math.max(r.x1, r.x2)], [0, 166], 'it must span the whole card');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
|
||||||
|
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
|
||||||
|
const found = angles(links);
|
||||||
|
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
|
||||||
|
assert.ok(
|
||||||
|
found.includes(45) || found.includes(135),
|
||||||
|
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
|
||||||
|
);
|
||||||
|
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the two diagonals as two different diagonals', () => {
|
||||||
|
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
|
||||||
|
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
|
||||||
|
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
|
||||||
|
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
|
||||||
|
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
|
||||||
|
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meets the card edge at its midpoint, so abutting cards line up', () => {
|
||||||
|
// A leg that met the edge anywhere else would join to nothing, however right its angle.
|
||||||
|
const W = 166;
|
||||||
|
const H = 96;
|
||||||
|
// Only the sloping segments, and only the end of each that lands on the south edge.
|
||||||
|
const atEdge = draw(['sw'])
|
||||||
|
.filter((r) => r.y1 !== r.y2)
|
||||||
|
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
|
||||||
|
assert.equal(atEdge.length, 2, 'the leg is two rails');
|
||||||
|
const mid = (atEdge[0]! + atEdge[1]!) / 2;
|
||||||
|
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('board highlighting', () => {
|
describe('board highlighting', () => {
|
||||||
it('gives every spot a coordinate to highlight', () => {
|
it('gives every spot a coordinate to highlight', () => {
|
||||||
const game = newGame(555);
|
const game = newGame(555);
|
||||||
@@ -316,6 +387,22 @@ describe('board highlighting', () => {
|
|||||||
const game = newGame(555);
|
const game = newGame(555);
|
||||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
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 cells = view(game).cells;
|
||||||
const minCol = Math.min(...cells.map((c) => c.col));
|
const minCol = Math.min(...cells.map((c) => c.col));
|
||||||
const maxCol = Math.max(...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 game = newGame(9);
|
||||||
const area = game.state.officeAreas.get(0)!;
|
const area = game.state.officeAreas.get(0)!;
|
||||||
const industry = {
|
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: [],
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||||
facility: {
|
facility: {
|
||||||
kind: 'freight' as const, subtype: 'grocersWarehouse',
|
kind: 'freight' as const, subtype: 'grocersWarehouse',
|
||||||
|
|||||||
Reference in New Issue
Block a user