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
+76 -12
View File
@@ -24,6 +24,22 @@ import { officeProfile } from './content.ts';
// ---------------------------------------------------------------------------
export type PlayerIndex = number;
/**
* A SEAT at the table — a fixed position in the west-to-east chain of Offices (§4.3).
*
* NOT the same thing as a `PlayerIndex`, even though the two are equal in every game today. An
* Office is a place: it sits between two Mainline cards and never moves. A player OCCUPIES a seat,
* and Employee Rotation (Appendix B) moves every player one seat left at the end of each Day while
* their Revenue and the Fedora travel with them.
*
* So: offices, districts and grid positions are keyed by SEAT; hands, Revenue, the Superintendent
* and whose turn it is are keyed by PLAYER. `s.seating` maps one to the other, and both are plain
* numbers, so the distinction is carried by naming and by the accessors rather than by the type
* system — `areaOf(s, player)` and `areaAtSeat(s, seat)` are the two doors, and code should use them
* rather than reaching into `officeAreas` directly.
*/
export type SeatIndex = number;
export type CardId = string;
export type TrayId = string;
@@ -111,7 +127,8 @@ export type TrackCard = {
};
export type OfficeArea = {
owner: PlayerIndex;
/** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */
seat: SeatIndex;
tier: OfficeTier;
grid: Map<string, TrackCard>;
officeCoord: GridCoord;
@@ -223,7 +240,8 @@ export function isLockedByWork(card: TrackCard): boolean {
export type NodeRef =
| { at: 'divisionPoint'; side: Direction }
| { at: 'mainline'; index: number }
| { at: 'grid'; owner: PlayerIndex; coord: GridCoord };
/** A tray standing in someone's district. `seat` is WHICH district, not whose turn it is. */
| { at: 'grid'; seat: SeatIndex; coord: GridCoord };
export type CrewTray = {
id: TrayId;
@@ -328,7 +346,7 @@ export type DivisionNode =
/** Red Flags protecting a stopped train here, by tray. */
redFlagged?: TrayId[];
}
| { kind: 'office'; owner: PlayerIndex };
| { kind: 'office'; seat: SeatIndex };
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
export type Division = { nodes: DivisionNode[] };
@@ -482,6 +500,30 @@ export type TurnState = {
done: boolean;
};
/**
* One turn per player, created together at phase entry.
*
* This was a single `TurnState` on the game, replaced one player at a time as the cursor walked the
* table. That is indistinguishable from this while only the player at the cursor may act — which is
* exactly the case today, and every existing test still describes the same game.
*
* It is per-player now because making it so later would mean the same change PLUS reworking a client
* built around "wait your turn". What it enables is Local Operations work that no other player can
* observe — switching inside your own district — happening off-cursor, which is a change to
* `isActor` and nothing else. See `docs/architecture/multiplayer.md` D19.
*/
export function freshTurns(players: number, moves: number): Map<PlayerIndex, TurnState> {
const turns = new Map<PlayerIndex, TurnState>();
for (let p = 0; p < players; p++) turns.set(p, freshTurn(moves));
return turns;
}
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
const t = s.turns.get(player);
if (!t) throw new Error(`no turn state for player ${player}`);
return t;
}
export function freshTurn(moves: number): TurnState {
return {
option: null,
@@ -500,8 +542,15 @@ export type GameState = {
seed: number;
rngState: number;
players: Player[];
/**
* Who is sitting where: `seating[seat] = player`.
*
* The identity mapping in every game today, which is what makes the seat/player split
* behaviour-neutral. Employee Rotation would rotate this array and nothing else.
*/
seating: PlayerIndex[];
division: Division;
officeAreas: Map<PlayerIndex, OfficeArea>;
officeAreas: Map<SeatIndex, OfficeArea>;
trays: Map<TrayId, CrewTray>;
/** Trays not yet in play; §7 scarcity is an explicit mechanic. */
freeTrays: TrayId[];
@@ -521,7 +570,8 @@ export type GameState = {
*/
pendingSecondSections: number[];
clock: Clock;
turn: TurnState;
/** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */
turns: Map<PlayerIndex, TurnState>;
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day. */
@@ -551,7 +601,7 @@ export function subdivisions(state: GameState): number[][] {
state.division.nodes.forEach((node, i) => {
const isBoundary =
node.kind === 'divisionPoint' ||
(node.kind === 'office' && isControlPoint(state, node.owner));
(node.kind === 'office' && isControlPoint(state, node.seat));
if (isBoundary) {
if (current.length > 0) out.push(current);
@@ -565,15 +615,29 @@ export function subdivisions(state: GameState): number[][] {
return out;
}
export function isControlPoint(state: GameState, owner: PlayerIndex): boolean {
const area = state.officeAreas.get(owner);
if (!area) throw new Error(`no Office Area for player ${owner}`);
/** Who is sitting at this seat. */
export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
const p = state.seating[seat];
if (p === undefined) throw new Error(`no player at seat ${seat}`);
return p;
}
/** Where this player is sitting, and therefore which Office Area is theirs. */
export function seatOf(state: GameState, player: PlayerIndex): SeatIndex {
const seat = state.seating.indexOf(player);
if (seat < 0) throw new Error(`player ${player} is not seated`);
return seat;
}
export function isControlPoint(state: GameState, seat: SeatIndex): boolean {
const area = state.officeAreas.get(seat);
if (!area) throw new Error(`no Office Area at seat ${seat}`);
return officeProfile(area.tier).isControlPoint;
}
export function adTrackCount(state: GameState, owner: PlayerIndex): number {
const area = state.officeAreas.get(owner);
if (!area) throw new Error(`no Office Area for player ${owner}`);
export function adTrackCount(state: GameState, seat: SeatIndex): number {
const area = state.officeAreas.get(seat);
if (!area) throw new Error(`no Office Area at seat ${seat}`);
return officeProfile(area.tier).adTracks;
}