v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+1 -1
View File
@@ -586,7 +586,7 @@ describe('X18 Circus Train — a point for standing still', () => {
s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: [], direction: 'east',
position: { at: 'grid', owner: 0, coord: { row: -1, col: 0 } },
position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } },
movesUsed: 0,
} as never);
// A card under it, so the crew is somewhere real rather than off the grid.
+9 -9
View File
@@ -12,7 +12,7 @@ import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
@@ -42,7 +42,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist']
engineAt: 0,
consist,
direction: 'east',
position: { at: 'grid', owner: 0, coord },
position: { at: 'grid', seat: 0, coord },
movesUsed: 0,
});
return id;
@@ -90,7 +90,7 @@ describe('Local Operations: the three-way exclusive choice (§6)', () => {
const s = game();
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.ok(r.ok);
assert.equal(s.turn.option, 'draw');
assert.equal(turnOf(s, 0).option, 'draw');
});
it('forecloses the other two options for the Stage', () => {
@@ -366,7 +366,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => {
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
assert.ok(r.ok);
assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1);
assert.equal(turnOf(s, 0).movesRemaining, MOVES_PER_LOCAL_OPS - 1);
});
it('couples standing cars automatically and mandatorily', () => {
@@ -395,7 +395,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => {
const s = game();
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
s.turn.movesRemaining = 0;
turnOf(s, 0).movesRemaining = 0;
assert.equal(
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
'NO_MOVES_REMAINING',
@@ -993,7 +993,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
}));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
return at(area.runningRow - 1, col + 1);
}
@@ -1151,7 +1151,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
s.trays.get(id)!.engineAt = 1;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
turnOf(s, 0).option = 'switch';
// One car ahead of the engine and one behind: at most one may come off either end.
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), null);
@@ -1185,7 +1185,7 @@ describe('only the train being made up may take cars (§7)', () => {
// Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding
// in your own district: cars appeared on a train nobody was making up.
const s = game();
trayAt(s, { at: 'grid', owner: 0, coord: at(0, 1) });
trayAt(s, { at: 'grid', seat: 0, coord: at(0, 1) });
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
@@ -1225,7 +1225,7 @@ describe('an Office upgrade keeps what Modifiers added (§9)', () => {
for (const [id, card] of s.cards) {
if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue;
s.decks.hands.set(0, [id]);
s.turn.option = null;
turnOf(s, 0).option = null;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
assert.ok(r.ok, `${tier} upgrade should apply`);
+5 -5
View File
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey, subdivisions } from '../src/engine/state.ts';
import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
const config: GameConfig = {
mode: 'solitaire',
@@ -65,7 +65,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
consist, direction: 'east', position: { at: 'grid', seat: 0, coord }, movesUsed: 0,
});
return id;
}
@@ -197,9 +197,9 @@ describe('Small Yard — the card that makes switching solvable', () => {
it('costs one Move — "spends one move in the yard"', () => {
const { s, tray } = yardGame();
const before = s.turn.movesRemaining;
const before = turnOf(s, 0).movesRemaining;
applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] });
assert.equal(s.turn.movesRemaining, before - 1);
assert.equal(turnOf(s, 0).movesRemaining, before - 1);
});
it('is refused anywhere without a Small Yard', () => {
@@ -338,7 +338,7 @@ describe('one Revenue for every train that clears your section', () => {
const id = 'leaving';
s.trays.set(id, {
id, trainNumber: 2, trainIsExtra: false, engineAt: 0,
consist: [], direction, position: { at: 'grid', owner: 0, coord: areaOf(s, 0).officeCoord },
consist: [], direction, position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
movesUsed: 0,
});
areaOf(s, 0).adOccupancy.push(id);
+16 -16
View File
@@ -27,7 +27,7 @@ import {
import { createGame } from '../src/engine/setup.ts';
import type { GameEvent } from '../src/engine/events.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
@@ -81,8 +81,8 @@ function pinned(s: GameState, index: number, card: string) {
function drawTurn(s: GameState): void {
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
s.turn.drawnThisTurn = true;
turnOf(s, 0).option = 'draw';
turnOf(s, 0).drawnThisTurn = true;
}
// ---------------------------------------------------------------------------
@@ -343,12 +343,12 @@ describe('Flying Switch rolls a cut into an industry', () => {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(-1, 2) }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: at(-1, 2) }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
return industry;
}
@@ -367,10 +367,10 @@ describe('Flying Switch rolls a cut into an industry', () => {
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
assert.deepEqual(
s.trays.get('crew')!.position,
{ at: 'grid', owner: 0, coord: at(-1, 2) },
{ at: 'grid', seat: 0, coord: at(-1, 2) },
'the engine never enters the industry',
);
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
assert.equal(turnOf(s, 0).movesRemaining, 5, 'the manoeuvre costs a Move');
});
it('only reaches an adjacent card', () => {
@@ -454,7 +454,7 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
// — track on the far side of the sign, and a second sign planted beyond that.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
const straight = trackInHand(s, 'straight', 'none');
const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 };
@@ -508,7 +508,7 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
// player's Limits is what makes a collision his fault.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
for (let n = 0; n < 4; n++) {
const target = { row: area.runningRow, col: area.limitsWest.col };
@@ -549,7 +549,7 @@ describe('a turnout may be laid on top of a card already down', () => {
// straight along the main and then wanted to branch there had no move at all — the piece had to
// have been a turnout when it went down.
const s = game();
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
const at = layStraight(s);
for (const hand of ['left', 'right'] as const) {
@@ -575,7 +575,7 @@ describe('a turnout may be laid on top of a card already down', () => {
// opposite edges, so swapping one for the other would move the leg off whatever it joined.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
// A turnout on the main, then its matching curve on the row below — the start of every siding.
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
@@ -609,7 +609,7 @@ describe('a turnout may be laid on top of a card already down', () => {
it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
const at = layStraight(s);
const square = area.grid.get(`${at.row},${at.col}`)!;
@@ -633,7 +633,7 @@ describe('a turnout may be laid on top of a card already down', () => {
// The Office is east-west track like a straight, but it is not track to build over.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
assert.equal(
check(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'),
@@ -646,7 +646,7 @@ describe('a turnout may be laid on top of a card already down', () => {
it('does not let a straight or a curve upgrade anything — only a turnout may', () => {
const s = game();
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
const at = layStraight(s);
assert.equal(
@@ -665,7 +665,7 @@ describe('a turnout may be laid on top of a card already down', () => {
// The 18 Enhancement cards were permanently dead for weeks for exactly this reason: the engine
// allowed the play and `legalIntents` never once offered a square it could go on.
const s = game();
s.turn.option = 'draw';
turnOf(s, 0).option = 'draw';
const at = layStraight(s);
const cardId = trackInHand(s, 'turnout', 'right');
+215 -4
View File
@@ -15,9 +15,12 @@ import { areaOf } from '../src/engine/apply.ts';
import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { coordKey, subdivisions } from '../src/engine/state.ts';
import { coordKey, playerAtSeat, seatOf, subdivisions } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts';
import { impediments } from '../src/sim/narrate.ts';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const competitive: GameConfig = {
mode: 'competitive',
@@ -39,11 +42,34 @@ function readyToLeave(s: GameState, owner: PlayerIndex, id: string, direction: '
const area = areaOf(s, owner);
s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction, position: { at: 'grid', owner, coord: area.officeCoord }, movesUsed: 0,
direction, position: { at: 'grid', seat: owner, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push(id);
}
/** An industry with nothing in its green box — the simplest thing `impediments` reports. */
const idleIndustry = () =>
({
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
modifiers: [],
enhancements: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 1, cars: [] },
laborers: 1,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
}) as never;
const pinTerrain = (s: GameState): void => {
// Double Track and Uncontrolled Siding print "trains may pass", which would clear any departure.
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
@@ -68,7 +94,7 @@ describe('multi-player games run at all', () => {
const s = game(players);
assert.equal(s.officeAreas.size, players, `${players}p office areas`);
for (let p = 0; p < players; p++) {
assert.equal(areaOf(s, p).owner, p, 'an Office Area is owned by the wrong seat');
assert.equal(areaOf(s, p).seat, p, 'an Office Area is owned by the wrong seat');
}
// §7 — trays are scarce on purpose, and the count is per player count.
assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`);
@@ -163,7 +189,7 @@ describe('Subdivisions are split by whoever is a Control Point', () => {
// The upgraded Office is a BOUNDARY, so it appears in neither group; the others still sit inside.
const officeIndex = (owner: number): number =>
s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === owner);
const all = split.flat();
assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision');
for (const other of [0, 2, 3]) {
@@ -241,6 +267,125 @@ describe("one player's train blocks another's", () => {
});
});
describe('a seat is a place, a player is a person', () => {
it('starts with the identity mapping, so the split changes nothing today', () => {
// The whole seat/player split is behaviour-neutral until something rotates `seating`. This is
// what makes that claim checkable rather than asserted.
for (const players of [1, 2, 3, 4]) {
const s = players === 1
? createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] })
: game(players);
assert.deepEqual(s.seating, [...Array(players).keys()], `${players}p seating is not the identity`);
for (let p = 0; p < players; p++) {
assert.equal(seatOf(s, p), p);
assert.equal(playerAtSeat(s, p), p);
assert.equal(areaOf(s, p).seat, p);
}
}
});
it("follows a player to their new Office when the seating rotates", () => {
/**
* Employee Rotation (Appendix B) moves every player one seat left at the end of a Day while
* their Revenue and the Fedora travel with them. The rule is not implemented, but the MODEL now
* supports it: rotating `seating` is the whole operation, and every `areaOf(s, player)` caller
* follows without changing. This is the test that the split actually bought something.
*/
const s = game(3);
const officeOf = (p: PlayerIndex): number => areaOf(s, p).seat;
assert.deepEqual([0, 1, 2].map(officeOf), [0, 1, 2]);
// Mark each Office so we can see which one a player is looking at.
for (let seat = 0; seat < 3; seat++) areaOf(s, seat).tier = (['depot', 'station', 'terminal'] as const)[seat]!;
const tierOf = (p: PlayerIndex): string => areaOf(s, p).tier;
assert.deepEqual([0, 1, 2].map(tierOf), ['depot', 'station', 'terminal']);
// Everyone shuffles one chair along. The Offices do not move; the people do.
s.seating = [2, 0, 1];
assert.deepEqual([0, 1, 2].map(officeOf), [1, 2, 0], 'players did not move seats');
assert.deepEqual([0, 1, 2].map(tierOf), ['station', 'terminal', 'depot'], 'the Offices moved with them');
assert.equal(playerAtSeat(s, 0), 2, 'seat 0 should now be occupied by player 2');
// Revenue belongs to the person and must NOT have moved with the chair.
assert.equal(s.players[0]!.index, 0, 'a player index changed when the seating rotated');
});
it('plays a whole game with the seating rotated', () => {
/**
* The abstraction is only worth something if the game still RUNS through it. Rotating `seating`
* before the first move puts every seat/player confusion in the engine on the critical path at
* once — the terminal check, the dispatch devices, the bot's own district lookups — where under
* the identity mapping they were all silently correct.
*/
const s = game(3);
s.seating = [2, 0, 1];
const r = playGame(s, developerBot, pump);
assert.ok(r.finished, 'a rotated game did not finish');
assert.equal(new Set(s.players.map((p) => p.index)).size, 3, 'seats stopped being distinct');
// Each Office is still occupied by exactly one player, and by the one `seating` says.
for (let seat = 0; seat < 3; seat++) {
assert.equal(areaOf(s, playerAtSeat(s, seat)).seat, seat, `seat ${seat} lost its occupant`);
}
});
it('reports the impediments of the district a player is now sitting at', () => {
/**
* `impediments` took a PLAYER and used it as an `officeAreas` key, and `Frame.blocked` is built
* from it — so after a rotation a player would have been shown the jams of whoever inherited
* their old chair, naming squares that are not on the board in front of them.
*/
const s = game(3);
const area = areaOf(s, 1);
area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), idleIndustry());
const withImpediments = (): PlayerIndex[] =>
[0, 1, 2].filter((p) => impediments(s, p).length > 0);
assert.deepEqual(withImpediments(), [1], 'the industry is not reported to its own player');
// Everyone shuffles one chair along: player 0 now sits at seat 1, so the industry is theirs.
s.seating = [2, 0, 1];
assert.deepEqual(withImpediments(), [0], 'the impediment did not follow the chair');
assert.deepEqual(
[0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length > 0),
[true, false, false],
'the Frame disagrees with impediments after a rotation',
);
});
});
describe('nothing keys an Office Area by player', () => {
it('reaches officeAreas only through areaAtSeat', () => {
/**
* The class of bug, rather than the five instances of it that were found by hand. `officeAreas`
* is keyed by SEAT; every one of these lookups was passing a PLAYER, which is right only while
* seating is the identity mapping and wrong the moment Employee Rotation lands. Routing them all
* through `areaOf`/`areaAtSeat` fixes them; this keeps them fixed.
*/
// `state.ts` and `apply.ts` hold the seat-typed accessors — `areaAtSeat`, `isControlPoint`,
// `adTrackCount` — and are the only files allowed to touch the map directly. Everywhere else
// goes through them, which is what makes the argument's meaning checkable at the call site.
const allowed = ['engine/state.ts', 'engine/apply.ts'];
const root = join(import.meta.dirname, '..', 'src');
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (e.name.endsWith('.ts')) {
readFileSync(full, 'utf8').split('\n').forEach((line, i) => {
// `areaAtSeat` is the one legal reader — it is what everything else goes through.
if (line.includes('officeAreas.get(') && !allowed.some((a) => full.endsWith(a))) {
offenders.push(`${full}:${i + 1}`);
}
});
}
}
};
walk(root);
assert.deepEqual(offenders, [], 'officeAreas is being indexed outside areaAtSeat');
});
});
describe('the view shows one seat at a time', () => {
it('gives each seat its own board and its own hand', () => {
/**
@@ -276,6 +421,72 @@ describe('the view shows one seat at a time', () => {
assert.deepEqual(frames.map((f) => f.hand.length), [1, 2, 3], 'seats do not have distinct hands');
});
it('gives each seat its own Revenue, pace note, Moves and impediments', () => {
/**
* The board and the hand were seat-scoped; four scalar fields beside them were not, and each was
* still reporting player 0. A player looking at their own railroad would have been shown someone
* else's score, someone else's Moves left, and someone else's jammed facilities — the last of
* which is a list of squares that do not exist on the board they are looking at.
*/
const s = game(3);
s.players[0]!.revenue = 1;
s.players[1]!.revenue = 9;
s.players[2]!.revenue = 17;
const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p));
assert.deepEqual(frames.map((f) => f.revenue), [1, 9, 17], 'seats do not see their own Revenue');
// The pace note is computed from the viewer's score against the clock, so it must move with it.
const pace = frames.map((f) => f.objective.onPace);
assert.notDeepEqual(pace, [pace[0], pace[0], pace[0]], 'every seat got the same pace verdict');
assert.ok(frames[2]!.objective.note.startsWith('17 of '), `seat 2's note reads "${frames[2]!.objective.note}"`);
// Stand an unstocked industry in ONE district — "green box empty" is the impediment. Only that
// seat should be told about it.
const area = areaOf(s, 1);
const key = coordKey({ row: area.runningRow - 1, col: 0 });
area.grid.set(key, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
modifiers: [],
enhancements: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 1, cars: [] },
laborers: 1,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const blocked = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length);
assert.deepEqual([blocked[0], blocked[2]], [0, 0], 'an impediment in one district was reported to the others');
assert.ok(blocked[1]! > 0, 'the district with the idle industry was not told about it');
});
it('never draws one seat’s crew onto another seat’s board', () => {
/**
* REGRESSION. The tray lookup was keyed by `row,col` alone while every district uses the same
* origin, so a crew standing at (0, 1) in one Office Area appeared at (0, 1) in ALL of them. The
* cells were the viewer's own; the train drawn on them was whoever's happened to be there.
*/
const s = game(3);
// Crews are made up during play, so the opening state has none — stand one in seat 1's district.
readyToLeave(s, 1, 'crew-1', 'east');
const withTray = (p: PlayerIndex): number =>
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length;
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
});
it('defaults to seat 0, so solitaire and every replay are unaffected', () => {
const s = game(2);
assert.deepEqual(
+232
View File
@@ -0,0 +1,232 @@
/**
* The Session boundary.
*
* Phase 1 of `docs/architecture/multiplayer.md` moved the page off `game.ts` and onto a `Session`,
* so that a server can later be substituted for the local engine without the page noticing. The
* whole point of the change is that nothing about solitaire changed, which is a hard thing to prove
* by playing — hence this file: the same seed driven the same way through both routes must land in
* the same position, card for card.
*
* The rest of the suite covers what a remote session will have to reproduce exactly: which calls
* fire the redraw, which signals drain, and what `capabilities` admits a local session can do that a
* server cannot.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts';
import { createLocalSession } from '../src/web/session.ts';
/**
* Drive a session by always taking the first offered action.
*
* `menu().options` and `actionGroups(game).options` are the same `legalActions` list in the same
* order, so taking index 0 on either side is the same rule — which is what makes the two routes
* comparable below.
*/
async function playSession(seed: number, maxTurns = 20_000) {
const session = createLocalSession(seed);
let turns = 0;
for (; turns < maxTurns; turns++) {
if (session.actor() === null) break;
const options = session.menu().options;
if (options.length === 0) break;
if (!(await session.submit(options[0]!))) break;
}
return { session, turns };
}
/** One action through the session, first option, for tests that just need the game to move. */
async function step(session: ReturnType<typeof createLocalSession>): Promise<boolean> {
const options = session.menu().options;
if (options.length === 0) return false;
return session.submit(options[0]!);
}
describe('a local session plays the same game as the calls it replaced', () => {
it('reaches an identical position from the same seed', async () => {
// The equivalence proof. `game.ts` directly on the left, the Session on the right, same seed and
// same tie-breaking rule — if the boundary leaked anything the boards diverge.
const game = newGame(77);
for (let i = 0; i < 20_000; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const { session, turns } = await playSession(77);
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
const f = session.view();
assert.equal(f.status, 'finished');
assert.equal(f.status, view(game).status);
assert.equal(f.day, view(game).day);
assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary');
assert.deepEqual(session.save(), toSave(game), 'the histories differ across the boundary');
});
it('reports the same seat, actor and hand as the underlying game', () => {
const session = createLocalSession(31);
assert.equal(session.seat(), 0);
assert.equal(session.actor(), currentActor(session.game));
assert.deepEqual(session.handPlayable(), handPlayable(session.game));
assert.equal(session.overHandLimit(), overHandLimit(session.game));
});
});
describe('a session tells the page when to redraw', () => {
it('notifies on an accepted intent and not on a refused one', async () => {
const session = createLocalSession(404);
let redraws = 0;
session.subscribe(() => {
redraws++;
});
assert.ok(await step(session));
assert.equal(redraws, 1);
// A refused intent leaves the game where it was, so there is nothing to redraw. This matters
// more than it looks: a remote session will push on state change, and a page that redrew on
// every submit would flicker on every rejection the server sends back.
assert.equal(await session.submit({ type: 'turn.end', player: 0 } as never), false);
assert.equal(redraws, 1);
});
it('stops notifying after unsubscribe', async () => {
const session = createLocalSession(404);
let redraws = 0;
const off = session.subscribe(() => {
redraws++;
});
assert.ok(await step(session));
off();
await step(session);
assert.equal(redraws, 1);
});
});
describe('the transient signals drain', () => {
it('hands out cues once', async () => {
// Cues are a moment, not a state — the Frame can be rebuilt any number of times per render, and
// a sound that replayed on each rebuild would stutter. So they are taken, not read.
const session = createLocalSession(88);
for (let i = 0; i < 40; i++) {
if (session.actor() === null) break;
if (!(await step(session))) break;
if (session.takeCues().length > 0) {
assert.deepEqual(session.takeCues(), [], 'a cue was handed out twice');
return;
}
}
assert.fail('no cue was earned in 40 actions — the driver never reached a sounding event');
});
it('hands out a scheduled slot once', () => {
const session = createLocalSession(88);
session.game.scheduled = 3;
assert.equal(session.takeScheduled(), 3);
assert.equal(session.takeScheduled(), null, 'the timetable flash fired twice');
});
it('keeps the newest card badged until another draw replaces it', () => {
// Unlike the other two this one PERSISTS: it says which card is new, not that something just
// happened, so it survives redraws and is superseded rather than consumed.
const session = createLocalSession(88);
session.game.justDrawn = 'card-a';
assert.equal(session.justDrawn(), 'card-a');
assert.equal(session.justDrawn(), 'card-a');
});
});
describe('undo and restore rebuild the game without leaking the replay', () => {
it('steps back one action and refuses at the start', async () => {
const session = createLocalSession(909);
assert.equal(session.steps(), 0);
assert.equal(session.undo(), false, 'undo at the start must be a no-op');
// Cloned: `toSave` hands back the game's own history array, so holding the object would watch it
// grow rather than record where the game was.
const before = structuredClone(session.save());
await step(session);
assert.equal(session.steps(), 1);
assert.equal(session.undo(), true);
assert.equal(session.steps(), 0);
assert.deepEqual(session.save(), before, 'undo did not return to the previous position');
});
it('drops the rebuilt game’s cues and draws', async () => {
// Undo replays the history from the start, which re-earns every cue and re-records every draw
// along the way. None of that is news to a player who just stepped back, so a page that read it
// would replay a whole game of sounds and badge whichever card the replay ended on.
const session = createLocalSession(909);
for (let i = 0; i < 12; i++) {
if (session.actor() === null) break;
if (!(await step(session))) break;
}
session.takeCues();
assert.equal(session.undo(), true);
assert.deepEqual(session.takeCues(), [], 'undo replayed the game’s sounds');
assert.equal(session.takeScheduled(), null);
assert.equal(session.justDrawn(), null, 'undo badged a card from the replay');
});
it('restores to the same position with nothing badged', async () => {
const session = createLocalSession(909);
for (let i = 0; i < 30; i++) {
if (session.actor() === null) break;
if (!(await step(session))) break;
}
const save = session.save();
const cells = session.view().cells;
const fresh = createLocalSession(1);
fresh.restore(save);
assert.equal(fresh.seed(), save.seed, 'restore kept the session’s original seed');
assert.equal(fresh.steps(), save.history.length);
assert.deepEqual(fresh.view().cells, cells, 'the board differs after restore');
assert.equal(fresh.justDrawn(), null, 'restore badged a card from the replay');
});
});
describe('the page stays on the near side of the boundary', () => {
const main = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'main.ts'), 'utf8');
it('never reaches through to GameState', () => {
// There were eleven of these, and each one was a place the page knew something a server would
// never send it — the deck order, another player's hand, the RNG. They all had to go before a
// `RemoteSession` could be dropped in, and the cheapest way to keep them gone is to say so here:
// a reader that comes back is a failing test rather than a bug found in Phase 2.
const reaches = main.match(/\b(session\.game|game)\.state\b/g) ?? [];
assert.deepEqual(reaches, [], 'main.ts reached through the Session into GameState');
});
it('imports only types from game.ts', () => {
// Everything the page DOES now goes through `session`. Values from `game.ts` — `submit`, `view`,
// `undo` — are the local engine by another name, and importing one is how the boundary gets
// quietly reopened. Types are fine: `Frame` and `Menu` are what a server sends.
const lines = main.split('\n').filter((l) => l.includes("from './game.ts'"));
assert.ok(lines.length > 0, 'the check found no game.ts import at all — it has stopped checking');
for (const l of lines) {
assert.match(l, /^import type /, `main.ts imports values from game.ts: ${l.trim()}`);
}
});
});
describe('capabilities say what only a local session can do', () => {
it('offers undo, a local save and a new deal', () => {
// The page hides these rather than calling them and failing. A server can offer none of them: it
// cannot un-see what other players have already seen, it is itself the store, and dealing is the
// lobby's job. The page reads this object instead of assuming it is local.
assert.deepEqual(createLocalSession(1).capabilities, {
undo: true,
saveLocal: true,
newGame: true,
});
});
});
+4 -4
View File
@@ -13,7 +13,7 @@ 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, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import { isOperationalRail } from '../src/engine/state.ts';
import { isOperationalRail, turnOf } from '../src/engine/state.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';
@@ -97,7 +97,7 @@ describe('trains complete their runs (regression)', () => {
assert.ok(tray, `A/D track holds ${id}, which no longer exists`);
assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`);
if (tray.position.at === 'grid') {
assert.equal(tray.position.owner, owner);
assert.equal(tray.position.seat, owner);
}
}
}
@@ -213,7 +213,7 @@ describe('the revenue chain works end to end (regression)', () => {
const area = s.officeAreas.get(0)!;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
turnOf(s, 0).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.
@@ -727,7 +727,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'freightAgent';
turnOf(s, 0).option = 'freightAgent';
const jammed = area.grid.get('-1,0')!;
assert.ok(!isOperationalRail(jammed), 'a jammed industry is not Operational Rail');
+8 -8
View File
@@ -15,7 +15,7 @@ import type {
TrackCard,
TurnoutOrientation,
} from '../src/engine/state.ts';
import { coordKey, isOperationalRail } from '../src/engine/state.ts';
import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts';
import { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
@@ -134,7 +134,7 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
const grid = new Map<string, TrackCard>();
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
return {
owner: 0,
seat: 0,
tier: 'whistlePost',
grid,
officeCoord,
@@ -682,12 +682,12 @@ describe('coupling lifts only the cars the crew ran over', () => {
// A local CREW, which is what this test is about — `trainNumber: null`. It used to say train 1,
// the Crack Limited, which prints "no switching" (§7) and may not make this move at all.
id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
assert.ok(r.ok, 'the move should be legal');
@@ -927,12 +927,12 @@ describe('backing up does not turn the train around', () => {
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(0, 1) }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: at(0, 1) }, movesUsed: 0,
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
// Running forward, east: the engine leads, so it still points east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
+5 -5
View File
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { trainRules } from '../src/sim/view.ts';
const config: GameConfig = {
@@ -54,12 +54,12 @@ function switching(
area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight());
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist,
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: here }, movesUsed: 0,
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: here }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
return 't';
}
@@ -210,7 +210,7 @@ describe('§7 — trains Porters may not work', () => {
};
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [coach(false)],
direction: 'east', position: { at: 'grid', owner: 0, coord: area.officeCoord }, movesUsed: 0,
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('t');
s.clock.phase = 'loadUnload';
+26 -4
View File
@@ -7,6 +7,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { turnOf } from '../src/engine/state.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
@@ -1520,6 +1521,27 @@ describe('the static build', () => {
}
});
it('renders from the Frame and the Menu, never from GameState', () => {
/**
* THE PROPERTY THAT MAKES A REMOTE CLIENT POSSIBLE.
*
* `main.ts` used to reach into `game.state` in eleven places — the phase, the outcome, another
* player's name, the hand count. That is free with the engine in the same process and impossible
* with a server, where the client holds no state at all: it has neither the deck order nor
* anyone else's hand, and could not be given them without handing over the game.
*
* Everything it needs now lives on `Frame` and `Menu`. This is a cheap guard on a property that
* is very easy to lose — one `game.state.clock.day` would compile, run, and quietly make the
* page unable to run against a server. See `docs/architecture/multiplayer.md` §5.
*/
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const reads = [...src.matchAll(/game\.state[.[]/g)];
assert.equal(
reads.length, 0,
`main.ts reaches into game.state ${reads.length} time(s); it must render from Frame + Menu`,
);
});
it('asks only for elements the page actually has', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
@@ -1826,7 +1848,7 @@ describe('the static build', () => {
{ type: 'caboose', loaded: false },
],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
@@ -1862,7 +1884,7 @@ describe('the static build', () => {
{ type: 'caboose', loaded: false },
],
direction: facing === 'e' ? 'east' : 'west', facing,
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
const svg = officeSvg([cell], area.runningRow);
@@ -1899,7 +1921,7 @@ describe('the static build', () => {
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
} as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
@@ -2276,7 +2298,7 @@ describe('the hand limit is a limit, not a toll on drawing (regression)', () =>
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
if (game.state.clock.phase === 'localOps' && game.state.turn.option === 'draw') {
if (game.state.clock.phase === 'localOps' && turnOf(game.state, 0).option === 'draw') {
const engineAllows = options.some((o) => o.type === 'draw.end');
assert.equal(
engineAllows,