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
+13 -7
View File
@@ -34,6 +34,7 @@ import type {
Card,
CardId,
DivisionNode,
SeatIndex,
GameConfig,
GameState,
OfficeArea,
@@ -42,7 +43,7 @@ import type {
TrackCard,
TrayId,
} from './state.ts';
import { coordKey, freshTurn } from './state.ts';
import { coordKey, freshTurns } from './state.ts';
export type SetupOptions = {
id: string;
@@ -129,7 +130,7 @@ export function buildRollingStock(): RollingStock[] {
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
* drawn turnout cards only.
*/
function buildOfficeArea(owner: PlayerIndex): OfficeArea {
function buildOfficeArea(seat: SeatIndex): OfficeArea {
const row = 0;
const officeCoord = { row, col: 0 };
const limitsWest = { row, col: -1 };
@@ -159,7 +160,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
grid.set(coordKey(limitsEast), limitsCard());
return {
owner,
seat,
tier: 'whistlePost',
grid,
officeCoord,
@@ -230,7 +231,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) {
nodes.push(mainline());
nodes.push({ kind: 'office', owner: p });
nodes.push({ kind: 'office', seat: p });
}
nodes.push(mainline());
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
@@ -250,8 +251,12 @@ export function createGame(opts: SetupOptions): GameState {
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
const officeAreas = new Map<PlayerIndex, OfficeArea>();
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p));
// Offices are keyed by SEAT — a fixed position in the west-to-east chain. `seating` maps seats to
// the players occupying them, and starts as the identity mapping, which is what makes the
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
const officeAreas = new Map<SeatIndex, OfficeArea>();
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat));
const seating: PlayerIndex[] = Array.from({ length: playerCount }, (_, seat) => seat);
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
@@ -334,6 +339,7 @@ export function createGame(opts: SetupOptions): GameState {
seed,
rngState: rng.getState(),
players,
seating,
division: { nodes: buildDivision(playerCount, rng) },
officeAreas,
trays: new Map(),
@@ -354,7 +360,7 @@ export function createGame(opts: SetupOptions): GameState {
superintendent,
actorOffset: 0,
},
turn: freshTurn(MOVES_PER_LOCAL_OPS),
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(),
collisionsToday: 0,
status: 'active',