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
+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(