v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
+26
-7
@@ -876,7 +876,13 @@ export function mainlineCardCount(players: number): number {
|
||||
return players + 1;
|
||||
}
|
||||
|
||||
/** Provisional; not in the recovered files. */
|
||||
/**
|
||||
* Sourced, not provisional: `rules-v0.2.md`:339, "There are only a limited number of Crew Trays
|
||||
* and engine pieces [Gap 4b: player count + 3]." The rules tie Crew Trays and engine pieces
|
||||
* together as ONE combined resource, not two independently-tracked supplies — an engine is never
|
||||
* conjured separately from the tray it rides in, and the game has no state for "an engine with no
|
||||
* tray" or "a tray with no engine". `state.ts`'s `freeTrays` pool already IS the engine supply.
|
||||
*/
|
||||
export function crewTrayCount(players: number): number {
|
||||
return players + 3;
|
||||
}
|
||||
@@ -1003,7 +1009,11 @@ export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||
export const COLLISION_PENALTY = 5;
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
/** Suggested defaults for `GameConfig`'s collision floors — see `state.ts`'s `GameConfig` doc. */
|
||||
export const DEFAULT_MAX_COLLISIONS_PER_DAY = 3;
|
||||
export const DEFAULT_MAX_COLLISIONS_TOTAL = 5;
|
||||
/** Suggested default for `GameConfig.days`, all modes. */
|
||||
export const DEFAULT_DAYS = 5;
|
||||
/**
|
||||
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
|
||||
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
|
||||
@@ -1011,18 +1021,27 @@ export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
*/
|
||||
export const EXPEDITE_FAULT_PENALTY = 1;
|
||||
|
||||
/**
|
||||
* The suggested default for `GameConfig.minCombinedRevenue` — a caller computes this before
|
||||
* submitting a config; the engine itself never calls it (`state.ts`'s `GameConfig` doc explains why).
|
||||
*/
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GameLength` IS NOT PART OF `GameConfig` ANY MORE (2026-08-20) — `days` is a free variable there
|
||||
* now, replacing the old `target`-bearing preset. This survives only as a convenience for sim/CLI
|
||||
* tooling (`harness.ts`, `compare.ts`, `replay.ts`) that still wants a `short`/`standard`/`campaign`
|
||||
* argument instead of a raw day count; `lengthProfile()` just resolves that argument to `days`.
|
||||
*/
|
||||
export type GameLength = 'short' | 'standard' | 'campaign';
|
||||
export type LengthProfile = { length: GameLength; target: number; days: number };
|
||||
export type LengthProfile = { length: GameLength; days: number };
|
||||
|
||||
/** Provisional and now unvalidated — the balance run they came from used a placeholder ruleset. */
|
||||
export const LENGTH_PROFILES: readonly LengthProfile[] = [
|
||||
{ length: 'short', target: 10, days: 3 },
|
||||
{ length: 'standard', target: 20, days: 5 },
|
||||
{ length: 'campaign', target: 45, days: 10 },
|
||||
{ length: 'short', days: 3 },
|
||||
{ length: 'standard', days: 5 },
|
||||
{ length: 'campaign', days: 10 },
|
||||
];
|
||||
|
||||
export function lengthProfile(length: GameLength): LengthProfile {
|
||||
|
||||
Reference in New Issue
Block a user