diff --git a/CHANGELOG.md b/CHANGELOG.md index 905fcf6..4854ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,47 @@ The target is 20 Revenue over 5 Days. ## Unreleased +### Turnouts say what they do, and a curve may not break the Running Track + +**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon +and a compass reading, and none of it answers the only question being asked: *if my train comes in +from over there, where can it go?* Turnouts now read + +> allows traffic from the east to travel west or turn to the south + +and curves + +> carries traffic from the west round to the south + +everywhere they appear — on the card in hand, on the placement it would make, and on the card once it +is down. §A.1's rule falls out of the same sentence rather than needing a shouty clause of its own: +a train coming the other way, from the through end or the diverging one, may only leave by the stem, +so the two roads never join. + +**A curve laid in the Running Track dead-ends the main.** A curve has ONE road — from an east or west +edge round to its 45° leg — so a card of it standing in the running row stops the through route at +that square and cuts the Office off from its own Limits. Nothing forbade it, and the bot did it: an +early trace shows it laying a `ne` curve at (0,−1), turning the west end of its own Running Track +into a stub. Every card that may stand in that row now has to carry the road across it — +`carriesThroughTrack` — which straights, turnouts, Limits signs, the Office and industries all do. +Reported as `BREAKS_RUNNING_TRACK` rather than `NOT_CONNECTED`, because it is a different mistake: +the card would join perfectly well and would still leave the main stopping dead at it. + +**The crossover is confirmed, both hands.** A turnout that *"allows traffic from the east to travel +west or turn to the south"* joins the one directly beneath it that *"allows traffic from the west to +travel east or turn to the north"* — the two 45° legs are one continuous rail across the card edge. +It already worked; it now has a test that says so in those words, along with its mirror built from +the other hand, the mismatched pair that must NOT join, and a crew actually running down through it +onto the parallel track. + +**Measured, and worth flagging:** barring curves from the running row cost the bot a good deal — +districts 28.0 → 19.7 cards, facilities 2.23 → 1.85, revenue about 2.0 → 0.8. The rule is right and +the bot was partly living off an illegal placement. Two tests needed widening rather than weakening +as a result: the unload regression sampled three deals and now samples twelve (unloads still happen, +34 across a 40-deal sweep, just not on those three), and the canvas bounds test now holds a straight +as well as a curve, since a curve alone is offered only one square once the running row is closed to +it. + ### The Crew Tray is a train, and a train must be made up to leave **§8.2 was not checked at all.** A train could highball onto the Mainline engine-last with its diff --git a/TODO.md b/TODO.md index 3805141..e9f605f 100644 --- a/TODO.md +++ b/TODO.md @@ -16,6 +16,10 @@ Ordered within each section by how much it is currently costing us. holds 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure those multipliers exist to relieve. Until then, read no balance conclusion from the revenue numbers; they are a functionality signal only. +- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track + (they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and + revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone. + Same root cause as the two items below; fix them together, after the rebalance. - [ ] **The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows deep. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting diff --git a/src/engine/apply.ts b/src/engine/apply.ts index a99db6f..62dedd1 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -50,6 +50,7 @@ import type { Occupancy, Port } from './track.ts'; import { canDropCarsAt, canPlaceAt, + carriesThroughTrack, facilityVariants, opposite, reachableDestinations, @@ -590,6 +591,11 @@ function checkPlay( { const proto = protoCard(card.kind, variant); if (!proto) return 'NO_PLACEMENT'; + // Said separately from NOT_CONNECTED because it is a different mistake: the card would join + // perfectly well, and would still leave the Running Track stopping dead at it. + if (placement.row === area.runningRow && !carriesThroughTrack(proto)) { + return 'BREAKS_RUNNING_TRACK'; + } return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED'; } diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 5908640..89e82d4 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -115,6 +115,8 @@ export type RejectionCode = | 'FACILITY_LOCKED' /** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */ | 'ON_RUNNING_TRACK' + /** A card with no east-west road would dead-end the Running Track it was laid in. */ + | 'BREAKS_RUNNING_TRACK' | 'NOT_IMPLEMENTED' | 'WRONG_INTENT' | 'NOT_A_GRADE' diff --git a/src/engine/track.ts b/src/engine/track.ts index 27b0122..bbee74b 100644 --- a/src/engine/track.ts +++ b/src/engine/track.ts @@ -406,6 +406,20 @@ export function allReachable( // Placement // --------------------------------------------------------------------------- +/** + * Does this card carry the through route — an east-west road straight across it? + * + * The Running Track is the road trains run on, Limits to Limits, and every card standing in it has + * to pass traffic along the row. A curve does not: it has one road, from an east or west edge round + * to a 45° leg, and dropping one into the running row DEAD-ENDS the main there. Straights, turnouts, + * Limits signs, the Office and industries all carry it. + */ +export function carriesThroughTrack(card: TrackCard): boolean { + return connectionsFor(card).some( + ([a, b]) => (a === 'e' && b === 'w') || (a === 'w' && b === 'e'), + ); +} + /** * Gap 4a — a placed card must connect to existing track, and "connect" means `joins`: ports meeting * AND, on a north or south edge, 45° legs on the same diagonal. @@ -433,6 +447,10 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): // with a Limits card stranded mid-track. The district below the Running Track is unbounded. if (coord.row === area.runningRow) { if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false; + // And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main + // stops dead at it — the bot did exactly this, turning the west end of its own Running Track + // into a stub and cutting the Office off from the Limits. + if (!carriesThroughTrack(card)) return false; } const ports: Port[] = ['n', 's', 'e', 'w']; diff --git a/src/sim/view.ts b/src/sim/view.ts index 05bbea0..1692736 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -27,7 +27,7 @@ import { trainProfile, } from '../engine/content.ts'; import type { Intent } from '../engine/intents.ts'; -import type { Facility, GameState, TrackCard } from '../engine/state.ts'; +import type { Facility, GameState, TrackCard, TurnoutOrientation } from '../engine/state.ts'; import type { Hand, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; @@ -859,16 +859,17 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean case 'sharpCurved': { const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw'); const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : ''; - return `${arcPhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`; + return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`; } case 'turnout': { const t = g.turnout; if (!t) return 'turnout'; // §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street. return ( - `turnout — stem ${compass(t.stem)}, through ${compass(t.through)}, diverges ${compass(t.diverge)} at 45° · ` + - `a train may run stem↔through or stem↔diverge, but NEVER between ${compass(t.through)} and ` + - `${compass(t.diverge)}${slopePhrase(t.stem as Port, t.diverge as Port)}` + `turnout — ${turnoutPhrase(t)} · a train coming the other way, from the ` + + `${compass(t.through)} or the ${compass(t.diverge)}, may only leave by the ` + + `${compass(t.stem)} — the two roads never join` + + `${slopePhrase(t.stem as Port, t.diverge as Port)}` ); } } @@ -878,13 +879,23 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean 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. + * WHAT A TURNOUT DOES, in the words a player would use at the table. + * + * "Right-hand turnout, stem east, through west, diverges north" is three pieces of jargon and a + * compass reading, and none of it answers the only question being asked: if my train comes in from + * over there, where can it go? §A.1's rule falls straight out of the same sentence — traffic from + * the stem may take either road, and traffic arriving on either road may only leave by the stem, so + * the two roads never join. */ -function arcPhrase(arc: string): string { +function turnoutPhrase(t: TurnoutOrientation): string { + return `allows traffic from the ${compass(t.stem)} to travel ${compass(t.through)} or turn to the ${compass(t.diverge)}`; +} + +/** The same, for a curve: it has one road and no choice to make. */ +function curvePhrase(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`; + return `carries traffic from the ${compass(side!)} round to the ${compass(leg!)}`; } /** @@ -944,15 +955,9 @@ export function variantLabel( ): string { const v = variantsFor(geometry, hand)[variant ?? 0]; if (!v) return ''; - if (v.turnout) { - return ` — stem ${compass(v.turnout.stem)}, through ${compass(v.turnout.through)}, diverges ${compass(v.turnout.diverge)} at 45°`; - } - 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'; + if (v.turnout) return ` — ${turnoutPhrase(v.turnout)}`; + if (v.arc) return ` — ${curvePhrase(v.arc)}`; + return ' — straight through, east to west'; } /** diff --git a/test/sim.test.ts b/test/sim.test.ts index 877169d..fc8e920 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -969,7 +969,13 @@ describe('the freight figures count both halves (regression)', () => { let loads = 0; let unloads = 0; let begun = 0; - for (const seed of [1000, 8919, 16838]) { + // A WIDER SAMPLE than the three seeds this used to take. The subject is the instrument — does + // `freightUnload` count Revenue EARNED rather than unloads STARTED — and `unloads > 0` is only + // the precondition that makes the comparison mean anything. Three deals is a thin thread to hang + // that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it, + // and whether the bot manages all three on a given deal is luck, not the thing under test. + // Measured across these twelve: 34 unloads earned, on 12 of 40 deals sampled more widely. + for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433, 64352, 72271, 80190, 88109]) { const s = createGame({ id: `fu-${seed}`, seed, config: { ...config, length: 'standard' }, diff --git a/test/track.test.ts b/test/track.test.ts index 50a9afd..1703566 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -23,6 +23,7 @@ import { allReachable, canDropCarsAt, canPlaceAt, + carriesThroughTrack, exitsFrom, hasPort, neighbour, @@ -803,3 +804,100 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A } }); }); + +// --------------------------------------------------------------------------- + +describe('the Running Track must stay a through route', () => { + const runningArea = (): OfficeArea => + areaFrom( + { + [coordKey(at(0, -1))]: straight(), + [coordKey(at(0, 0))]: officeCard(), + [coordKey(at(0, 1))]: straight(), + }, + at(0, 0), + ); + + it('refuses a curve in the running row, which would dead-end the main', () => { + // A curve has ONE road — from an east or west edge round to a 45° leg — so a card of it standing + // in the Running Track stops the main dead at that square, cutting the Office off from its own + // Limits. Every other card that may stand there carries an east-west road across it. + const area = runningArea(); + for (const arc of ['ne', 'nw', 'se', 'sw'] as TrackArc[]) { + assert.ok(!carriesThroughTrack(curve(arc)), `a ${arc} curve has no through road`); + assert.ok(!canPlaceAt(area, at(0, 2), curve(arc)), `a ${arc} curve must not go in the Running Track`); + } + }); + + it('still accepts everything that does carry the road through', () => { + const area = runningArea(); + assert.ok(carriesThroughTrack(straight())); + assert.ok(carriesThroughTrack(turnout({ stem: 'w', through: 'e', diverge: 's' }))); + assert.ok(carriesThroughTrack(officeCard())); + assert.ok(canPlaceAt(area, at(0, 2), straight()), 'a straight extends the main'); + assert.ok( + canPlaceAt(area, at(0, 2), turnout({ stem: 'w', through: 'e', diverge: 's' })), + 'a turnout keeps the road AND opens a leg — which is why a district hangs off one', + ); + }); + + it('leaves a curve free to go anywhere off the main', () => { + 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 siding is exactly where a curve belongs'); + }); +}); + +describe('a turnout may be laid to face another, making a crossover', () => { + /** A turnout stated the way the cards read: who may come in, and where they may go. */ + const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({ + geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand }, + baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [], + }); + + it('joins a south-diverging turnout to the north-diverging one beneath it', () => { + // "Traffic from the east to travel west or turn to the south", with directly beneath it "traffic + // from the west to travel east or turn to the north". The two 45° legs are one continuous rail + // across the card edge — a crossover, and the way a siding gets a track parallel to the main. + const above = facing('e', 'w', 's', 'right'); + const below = facing('w', 'e', 'n', 'right'); + assert.ok(joins(above, 's', below), 'the crossover does not join'); + + // And its mirror, which is the same move built from the other hand. + const aboveL = facing('w', 'e', 's', 'left'); + const belowL = facing('e', 'w', 'n', 'left'); + assert.ok(joins(aboveL, 's', belowL), 'the mirrored crossover does not join'); + }); + + it('refuses the pair whose legs lie on opposite diagonals', () => { + // Both cards have the port and both legs meet the same point on the shared edge. They still form + // a V: one descends to the right and the other to the left. + assert.ok(!joins(facing('e', 'w', 's', 'right'), 's', facing('e', 'w', 'n', 'left'))); + assert.ok(!joins(facing('w', 'e', 's', 'left'), 's', facing('w', 'e', 'n', 'right'))); + }); + + it('lets a crew run down the crossover onto the parallel track', () => { + // The point of building one: the lower turnout carries its own east-west road, so the siding + // runs alongside the main rather than dead-ending under it. + const area = areaFrom( + { + [coordKey(at(0, -1))]: straight(), + [coordKey(at(0, 0))]: facing('w', 'e', 's', 'left'), + [coordKey(at(0, 1))]: straight(), + // Coming down the leg, the lower turnout is entered through its diverging north end, so the + // only way on is its stem — east, along the parallel track. + [coordKey(at(-1, 0))]: facing('e', 'w', 'n', 'left'), + [coordKey(at(-1, 1))]: straight(), + }, + at(0, 5), + ); + const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e'); + assert.ok(has(dests, -1, 1), 'a crew cannot reach the parallel track through the crossover'); + assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself'); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index dfc9bb7..7601a83 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -557,9 +557,14 @@ describe('the page explains itself', () => { baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], }); const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; - assert.match(cell.what, /stem east/); - assert.match(cell.what, /diverges south/); - assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out'); + // Said the way a player would ask it: if my train comes in from over there, where can it go? + // "stem east, through west, diverges south" is three pieces of jargon and a compass reading. + assert.match( + cell.what, + /allows traffic from the east to travel west or turn to the south/, + `the turnout does not say what it does: ${cell.what}`, + ); + assert.match(cell.what, /the two roads never join/, 'the missing edge is not spelled out'); }); it('describes every card kind in the deck, not just the easy ones', () => { @@ -649,13 +654,20 @@ describe('the page explains itself', () => { variantsFor(geometry, hand).forEach((v, variant) => { const label = variantLabel(geometry, variant, hand); if (v.turnout) { - assert.match(label, new RegExp(`stem ${say[v.turnout.stem]}`), `${geometry}/${hand} v${variant}: ${label}`); - assert.match(label, new RegExp(`diverges ${say[v.turnout.diverge]}`), `${geometry}/${hand} v${variant}: ${label}`); + assert.equal( + label.trim(), + `— allows traffic from the ${say[v.turnout.stem]} to travel ${say[v.turnout.through]} ` + + `or turn to the ${say[v.turnout.diverge]}`, + `${geometry}/${hand} v${variant}`, + ); } else if (v.arc) { const [a, b] = [v.arc[0]!, v.arc[1]!]; const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b]; - assert.match(label, new RegExp(say[side]!), `${geometry}/${hand} v${variant}: ${label}`); - assert.match(label, new RegExp(`${say[leg]} edge`), `${geometry}/${hand} v${variant}: ${label}`); + assert.equal( + label.trim(), + `— carries traffic from the ${say[side!]} round to the ${say[leg!]}`, + `${geometry}/${hand} v${variant}`, + ); } }); } @@ -682,7 +694,7 @@ describe('the page explains itself', () => { .filter((sp) => sp.coord.row === under.row && sp.coord.col === under.col); assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down'); - assert.match(spots[0]!.label, /diverges north/, 'the crossover turnout must point back up'); + assert.match(spots[0]!.label, /turn to the north/, 'the crossover turnout must point back up'); assert.match(spots[0]!.label, /joins the track above/, 'the spot must say what it connects to'); }); @@ -1278,7 +1290,13 @@ describe('every square the menu offers can actually be clicked (regression)', () // is offered a square OFF the Running Track, which is the case that used to draw off-canvas. const { option: turnout } = dealTrack(game, 'turnout', 'left'); if (turnout) submit(game, turnout); - dealTrack(game, 'curved', 'left'); + // Hold BOTH the curve its leg calls for and a straight: the curve is offered the square off + // the Running Track, the straight the growth points on it. A curve may no longer be laid in + // the running row at all — it has no east-west road and would dead-end the main — so on its + // own it now offers a single square and the bounds check would barely exercise anything. + const curve = dealTrack(game, 'curved', 'left'); + const straight = dealTrack(game, 'straight', 'none'); + game.state.decks.hands.set(0, [curve.cardId, straight.cardId]); const menu = actionMenu(game); const f = view(game);