make turnout description more clear. fix curves from breaking running track.

This commit is contained in:
Jesse
2026-08-05 10:03:50 -04:00
parent 9c76b1a5e2
commit 1c321d4cea
9 changed files with 226 additions and 28 deletions
+41
View File
@@ -10,6 +10,47 @@ The target is 20 Revenue over 5 Days.
## Unreleased ## 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 ### 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 **§8.2 was not checked at all.** A train could highball onto the Mainline engine-last with its
+4
View File
@@ -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 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 those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only. 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 - [ ] **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 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 deep. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting
+6
View File
@@ -50,6 +50,7 @@ import type { Occupancy, Port } from './track.ts';
import { import {
canDropCarsAt, canDropCarsAt,
canPlaceAt, canPlaceAt,
carriesThroughTrack,
facilityVariants, facilityVariants,
opposite, opposite,
reachableDestinations, reachableDestinations,
@@ -590,6 +591,11 @@ function checkPlay(
{ {
const proto = protoCard(card.kind, variant); const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT'; 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'; return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
} }
+2
View File
@@ -115,6 +115,8 @@ export type RejectionCode =
| 'FACILITY_LOCKED' | 'FACILITY_LOCKED'
/** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */ /** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */
| 'ON_RUNNING_TRACK' | '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' | 'NOT_IMPLEMENTED'
| 'WRONG_INTENT' | 'WRONG_INTENT'
| 'NOT_A_GRADE' | 'NOT_A_GRADE'
+18
View File
@@ -406,6 +406,20 @@ export function allReachable(
// Placement // 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 * 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. * 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. // with a Limits card stranded mid-track. The district below the Running Track is unbounded.
if (coord.row === area.runningRow) { if (coord.row === area.runningRow) {
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false; 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']; const ports: Port[] = ['n', 's', 'e', 'w'];
+23 -18
View File
@@ -27,7 +27,7 @@ import {
trainProfile, trainProfile,
} 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, TurnoutOrientation } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts'; import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts'; import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } 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': { case 'sharpCurved': {
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw'); 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 `${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': { case 'turnout': {
const t = g.turnout; const t = g.turnout;
if (!t) return 'turnout'; if (!t) return 'turnout';
// §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 ${compass(t.stem)}, through ${compass(t.through)}, diverges ${compass(t.diverge)} at 45° · ` + `turnout — ${turnoutPhrase(t)} · a train coming the other way, from the ` +
`a train may run stem↔through or stem↔diverge, but NEVER between ${compass(t.through)} and ` + `${compass(t.through)} or the ${compass(t.diverge)}, may only leave by the ` +
`${compass(t.diverge)}${slopePhrase(t.stem as Port, t.diverge as Port)}` `${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; 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 * WHAT A TURNOUT DOES, in the words a player would use at the table.
* the MIDDLE of an edge. Naming both halves is what stops it reading as a quarter-circle corner. *
* "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 [a, b] = [arc[0] as Port, arc[1] as Port];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b]; 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 { ): string {
const v = variantsFor(geometry, hand)[variant ?? 0]; const v = variantsFor(geometry, hand)[variant ?? 0];
if (!v) return ''; if (!v) return '';
if (v.turnout) { if (v.turnout) return ` — ${turnoutPhrase(v.turnout)}`;
return ` — stem ${compass(v.turnout.stem)}, through ${compass(v.turnout.through)}, diverges ${compass(v.turnout.diverge)} at 45°`; if (v.arc) return ` — ${curvePhrase(v.arc)}`;
} return ' — straight through, east to west';
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';
} }
/** /**
+7 -1
View File
@@ -969,7 +969,13 @@ describe('the freight figures count both halves (regression)', () => {
let loads = 0; let loads = 0;
let unloads = 0; let unloads = 0;
let begun = 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({ const s = createGame({
id: `fu-${seed}`, seed, id: `fu-${seed}`, seed,
config: { ...config, length: 'standard' }, config: { ...config, length: 'standard' },
+98
View File
@@ -23,6 +23,7 @@ import {
allReachable, allReachable,
canDropCarsAt, canDropCarsAt,
canPlaceAt, canPlaceAt,
carriesThroughTrack,
exitsFrom, exitsFrom,
hasPort, hasPort,
neighbour, 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');
});
});
+27 -9
View File
@@ -557,9 +557,14 @@ describe('the page explains itself', () => {
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
}); });
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /stem east/); // Said the way a player would ask it: if my train comes in from over there, where can it go?
assert.match(cell.what, /diverges south/); // "stem east, through west, diverges south" is three pieces of jargon and a compass reading.
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out'); 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', () => { 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) => { variantsFor(geometry, hand).forEach((v, variant) => {
const label = variantLabel(geometry, variant, hand); const label = variantLabel(geometry, variant, hand);
if (v.turnout) { if (v.turnout) {
assert.match(label, new RegExp(`stem ${say[v.turnout.stem]}`), `${geometry}/${hand} v${variant}: ${label}`); assert.equal(
assert.match(label, new RegExp(`diverges ${say[v.turnout.diverge]}`), `${geometry}/${hand} v${variant}: ${label}`); 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) { } else if (v.arc) {
const [a, b] = [v.arc[0]!, v.arc[1]!]; const [a, b] = [v.arc[0]!, v.arc[1]!];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b]; const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
assert.match(label, new RegExp(say[side]!), `${geometry}/${hand} v${variant}: ${label}`); assert.equal(
assert.match(label, new RegExp(`${say[leg]} edge`), `${geometry}/${hand} v${variant}: ${label}`); 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); .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.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'); 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. // 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'); const { option: turnout } = dealTrack(game, 'turnout', 'left');
if (turnout) submit(game, turnout); 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 menu = actionMenu(game);
const f = view(game); const f = view(game);