v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user