fix tracks to all be EW. no more NS tracks

This commit is contained in:
Jesse
2026-08-05 01:00:11 -04:00
parent 821d3cdffa
commit 2d348e1ecf
16 changed files with 1157 additions and 355 deletions
+59 -15
View File
@@ -8,11 +8,11 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { applyIntent, check, refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
import type { Intent } from '../src/engine/intents.ts';
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import type { Port } from '../src/engine/track.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
@@ -192,17 +192,57 @@ describe('the revenue chain works end to end (regression)', () => {
assert.ok(freight > 0, 'no freight load completed across 40 games');
});
it('grows the Office Area downward from the Running Track (Q7)', () => {
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
// Nothing should ever be built above row 0.
it('grows the Office Area off the Running Track, on either side', () => {
// The through track runs across the middle of every card and a turnout's 45° leg reaches north
// as readily as south, so a district hangs off the Running Track rather than beneath it. Q7's
// "downward only" is gone, and with it the rule that rejected every square above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
assert.ok([...rows].some((r) => r !== 0), 'nothing was ever built off the Running Track');
});
it('offers squares above the Running Track, not only below', () => {
// Asserted against LEGALITY rather than against what the bot happened to build: whether its
// heuristics favour one side is a tuning question, but the rules must allow both.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['bot'] });
const area = s.officeAreas.get(0)!;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
// A north-diverging turnout on the Running Track, and the arc that climbs to meet it. The
// column is captured BEFORE the lay: laying on the sign moves the sign outward, so reading
// `limitsEast` again afterwards names the next square along, not the turnout.
const col = area.limitsEast.col;
assert.ok(
applyIntent(s, 0, {
type: 'track.lay', geometry: 'turnout', hand: 'left',
placement: { row: area.runningRow, col }, variant: 1,
}).ok,
'a turnout must be layable on the Limits sign',
);
s.turn.laidThisTurn = false;
// Left-hand variant 0 is the `sw` arc — the one on the same diagonal as that turnout's leg.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'curved', hand: 'left',
placement: { row: area.runningRow + 1, col }, variant: 0,
}),
null,
'a curve ABOVE the Running Track must be legal',
);
// And the opposite diagonal must not be, at the same square, for the same turnout.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'curved', hand: 'right',
placement: { row: area.runningRow + 1, col }, variant: 0,
}),
'NOT_CONNECTED',
'the wrong diagonal must not join, however well the ports line up',
);
});
});
@@ -314,16 +354,18 @@ describe('switching accomplishes something (regression)', () => {
describe('the bot builds sidings that are actually sidings (regression)', () => {
/**
* Walk south off one Running Track column and report which OTHER main-line columns the walk can
* climb back up to. Uses the engine's own `exitsFrom`, so the test cannot credit a connection the
* rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation
* of the traversal would quietly invent that join.
* climb back up to. Uses the engine's own `exitsFrom` and `joins`, so the test cannot credit a
* connection the rules do not have — a turnout's two legs never join each other (§A.1), and two
* 45° legs on opposite diagonals meet at a point on the card edge without being a rail. A
* re-implementation of the traversal would quietly invent both.
*/
const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set<number>; seen: Set<string> } => {
const main = area.runningRow;
const rejoins = new Set<number>();
const seen = new Set<string>();
const top = area.grid.get(`${main},${col}`);
const start = area.grid.get(`${main - 1},${col}`);
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
if (!top || !start || !joins(top, 's', start)) return { rejoins, seen };
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
const visited = new Set<string>();
@@ -338,7 +380,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
for (const exit of exitsFrom(card, at.entry)) {
const n = neighbour({ row: at.row, col: at.col }, exit);
const next = area.grid.get(`${n.row},${n.col}`);
if (!next || !hasPort(next, opposite(exit))) continue;
if (!next || !joins(card, exit, next)) continue;
if (n.row >= main) {
if (n.col !== col) rejoins.add(n.col);
continue;
@@ -745,20 +787,22 @@ describe('the bot does not lay track that cannot work (regression)', () => {
if (pick.type === 'track.lay') {
laid++;
const area = st.officeAreas.get(p)!;
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
const v = variantsFor(pick.geometry, pick.hand)[pick.variant ?? 0];
const probe = {
geometry: {
kind: 'track', geometry: pick.geometry,
...(v?.axis ? { axis: v.axis } : {}),
...(v?.arc ? { arc: v.arc } : {}),
...(v?.turnout ? { turnout: v.turnout } : {}),
...(pick.hand !== 'none' ? { hand: pick.hand } : {}),
},
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never;
for (const port of new Set(connectionsFor(probe).flat())) {
const n = neighbour(pick.placement, port);
const nb = area.grid.get(`${n.row},${n.col}`);
if (nb && !hasPort(nb, opposite(port))) dead++;
// `joins`, not `hasPort`: on a north or south edge the two 45° legs must also lie on
// the same diagonal, or the rails meet at a point and still form a V.
if (nb && !joins(probe, port, nb)) dead++;
}
}
return pick;