v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.

This commit is contained in:
Jesse
2026-08-13 15:28:53 -04:00
parent 49f8504b05
commit a7221dcf20
22 changed files with 736 additions and 237 deletions
+3 -3
View File
@@ -39,7 +39,7 @@ import type { GameEvent } from './events.ts';
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts';
export type AdvanceResult = {
events: GameEvent[];
@@ -147,7 +147,7 @@ function playerPhase(
}
function actorAt(s: GameState, offset: number): PlayerIndex {
return (s.clock.superintendent + offset) % s.players.length;
return playerLeftOf(s, s.clock.superintendent, offset);
}
function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] {
@@ -972,7 +972,7 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = (s.clock.superintendent + 1) % s.players.length;
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
events.push({ type: 'actorChanged', player: s.clock.superintendent });
}
+7 -3
View File
@@ -9,8 +9,8 @@
* - `execute` reads state and emits events; it never mutates either.
* - `reduce` is the only thing that mutates, folding events into state.
*
* That keeps `state = fold(events)` true by construction, which is what makes replay and restart
* recovery work. It also lets component 6 (legalActions) call these very same `check` functions,
* That keeps every INTENT-driven change reducible by construction. Replay and restart recovery run
* on the intents themselves (`protocol.md` §3), not on folding the log. It also lets component 6 (legalActions) call these very same `check` functions,
* so the two can never drift apart — see legal.ts.
*/
@@ -1380,7 +1380,11 @@ function findTimetableSlot(s: GameState, from: number): number | null {
}
// ---------------------------------------------------------------------------
// reduce — the ONLY mutator. state = fold(events).
// reduce — the only mutator on the INTENT path.
//
// `applyIntent` never touches state except through here, so everything a player does is reducible.
// The phase driver (`advance.ts`) does not: it mutates and then describes, so folding the whole log
// does NOT reconstruct a game. `protocol.md` §3 has the consequence — the intents are canonical.
// ---------------------------------------------------------------------------
export function reduce(s: GameState, e: GameEvent): void {
+12 -3
View File
@@ -1,9 +1,18 @@
/**
* Events — protocol.md §3.
*
* An event is a FACT. Events are ordered, append-only, and fully determine state:
* `state = fold(events)`. That one property gives reconnection, restart recovery and post-game
* replay together.
* An event is a FACT: ordered, append-only, and standalone. Events NARRATE the game — they drive the
* log, the sounds and the replay's captions.
*
* THEY DO NOT RECONSTRUCT IT. This header claimed `state = fold(events)` until v0.4.0 and it was
* never true. `applyIntent` does go through `reduce`, but the phase driver in `advance.ts` mutates
* state and THEN emits a descriptive event, so fourteen of the forty-six types below are never
* reduced — the clock, and the whole Mainline phase, which is every train movement in the game.
*
* The canonical record is `{ seed, history: Intent[] }`, replayed by `fromSave`. That is what save,
* restore, undo, restart recovery and post-game replay all run on. See
* `docs/architecture/protocol.md` §3, and `test/events.test.ts`, which pins the unreduced set so
* that closing the gap is a deliberate act rather than a surprise.
*
* DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to,
* not just an id the renderer has to resolve against live state — otherwise a replay viewer has to
+29 -4
View File
@@ -256,14 +256,35 @@ export function createGame(opts: SetupOptions): GameState {
// 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.
const divisionRolls = players.map(() => rng.d12());
const superRolls = players.map(() => rng.d12());
const superintendent = argmax(superRolls);
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
/**
* §4.4 — THE OPENING D12 DECIDES WHO SITS WHERE.
*
* `seating[seat] = player`, and seat 0 is the WESTERN end of the chain (`buildDivision` lays the
* Western Division Point, then office 0, and finishes at the Eastern one). So the highest roll
* takes the last seat — "highest is the Eastern Division Point" — and the lowest ends up beside
* the Western Division Point, which is the rule's other named position.
*
* The rule names only those two ends, because at a table the players are already sitting in a
* chain and the roll only says which way round it is. There is no physical table here, so the
* roll orders everybody: ascending by roll, west to east. It uses a number every player is
* already told to roll, and it makes the roll matter to more than the winner.
*
* Ties break toward the LOWER player index sitting further east, which is the same convention
* `argmax` uses for the Superintendent roll on the line above — first max wins.
*
* This was `void divisionRolls` until v0.4.1: the roll was drawn and discarded, and seating was
* the identity mapping. Turning it on is what makes seat and player genuinely different at
* runtime rather than only in the type names.
*/
const seating: PlayerIndex[] = players
.map((_, p) => p)
.sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a);
/**
* §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one.
@@ -297,8 +318,11 @@ export function createGame(opts: SetupOptions): GameState {
const hands = new Map<PlayerIndex, CardId[]>();
let trackCursor = 0;
let otherCursor = 0;
// §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table
// and is therefore seat order, not player order.
const superSeat = seating.indexOf(superintendent);
for (let i = 0; i < playerCount; i++) {
const p = (superintendent + i) % playerCount;
const p = seating[(superSeat + i) % playerCount]!;
hands.set(p, [
...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK),
...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER),
@@ -340,6 +364,7 @@ export function createGame(opts: SetupOptions): GameState {
rngState: rng.getState(),
players,
seating,
openingRolls: { division: divisionRolls, superintendent: superRolls },
division: { nodes: buildDivision(playerCount, rng) },
officeAreas,
trays: new Map(),
+26 -3
View File
@@ -543,12 +543,19 @@ export type GameState = {
rngState: number;
players: Player[];
/**
* Who is sitting where: `seating[seat] = player`.
* Who is sitting where: `seating[seat] = player`, seat 0 at the WESTERN end of the chain.
*
* 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.
* Decided at setup by §4.4's D12 (`openingRolls.division`) — a real permutation, not the identity,
* except in solitaire where one player means one seat. Employee Rotation would rotate this array
* and nothing else.
*/
seating: PlayerIndex[];
/**
* The two opening D12s, per player, kept so a client can show the rolls rather than only their
* outcome — it is the game's first moment of drama (`lobby-and-sessions.md` §4). Indexed by
* PLAYER, since that is who rolls.
*/
openingRolls: { division: number[]; superintendent: number[] };
division: Division;
officeAreas: Map<SeatIndex, OfficeArea>;
trays: Map<TrayId, CrewTray>;
@@ -623,6 +630,22 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
}
/** Where this player is sitting, and therefore which Office Area is theirs. */
/**
* The player `n` seats to the LEFT of this one, wrapping round the table.
*
* Acting order, the deal and the Fedora are all "starting here and proceeding left" (Gap 1, §4.7,
* §5), which is a statement about the physical chain of Offices — so it is seat arithmetic, not
* player arithmetic. All three used to do `(player + n) % players`, which was the same thing only
* while seating was the identity mapping. It stopped being that when §4.4's D12 started deciding
* who sits where.
*
* "Left" is increasing seat index, i.e. eastward along the chain, matching what the shift-change
* tests have always asserted.
*/
export function playerLeftOf(state: GameState, player: PlayerIndex, n = 1): PlayerIndex {
return playerAtSeat(state, (seatOf(state, player) + n) % state.seating.length);
}
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`);
+1 -1
View File
@@ -1691,7 +1691,7 @@ export type PlayOutcome = {
trainsScheduled: number;
cardsPlayed: number;
outcome: GameState['outcome'];
/** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */
/** The full ordered log — everything the game emitted, in order. Narration, not a reducible record. */
events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][];
+4 -3
View File
@@ -1,9 +1,10 @@
/**
* End-of-game statistics.
*
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Because
* `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the
* engine to produce any of this.
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Every event
* the game emits is in there, so nothing needs instrumenting in the engine to produce any of this —
* which is a claim about COVERAGE, not about reducibility: the log narrates the game completely and
* reconstructs it not at all (`protocol.md` §3).
*
* Three purposes, in ascending order of usefulness:
*
+9 -2
View File
@@ -289,8 +289,14 @@ export type Frame = {
option: 'switch' | 'draw' | 'freightAgent' | null;
status: GameState['status'];
outcome: GameState['outcome'];
/** Every seat's public standing — names and Revenue. "The race is the game" (protocol.md §4). */
players: { index: number; name: string; revenue: number; hand: number }[];
/**
* Every PLAYER's public standing — names and Revenue. "The race is the game" (protocol.md §4).
*
* In player order, not seat order, because the list is about people. `seat` is carried so a client
* that wants to draw the table west-to-east can sort by it — which stopped being the same thing as
* player order once §4.4's D12 decided who sits where.
*/
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number;
lines: { text: string; tone: string }[];
@@ -1042,6 +1048,7 @@ export function snapshot(
outcome: s.outcome,
players: s.players.map((p) => ({
index: p.index,
seat: seatOf(s, p.index),
name: p.name,
revenue: p.revenue,
hand: (s.decks.hands.get(p.index) ?? []).length,
+4 -3
View File
@@ -17,9 +17,10 @@
* legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal
* move or refuses a legal one.
*
* SAVING. The event log is the game (`state = fold(events)`), and the RNG is seeded, so a save is
* the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
* which is far smaller and far more robust than serialising the state graph.
* SAVING. The intents ARE the game — the RNG is seeded and `applyIntent` is deterministic — so a save
* is the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
* which is far smaller and far more robust than serialising the state graph. (Not the event log:
* folding events does not rebuild a game — `protocol.md` §3.)
*/
import { pump } from '../engine/advance.ts';