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:
+43
-10
@@ -12,7 +12,6 @@ import type {
|
||||
Hand,
|
||||
MainlineKind,
|
||||
FreightKind,
|
||||
GameLength,
|
||||
HouseRuleOverrides,
|
||||
ModifierKind,
|
||||
OfficeTier,
|
||||
@@ -326,6 +325,12 @@ export type CrewTray = {
|
||||
*
|
||||
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
|
||||
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
||||
*
|
||||
* This only records POSITION, not a supply — and that is not a gap. `rules-v0.2.md`:339 (Gap 4b)
|
||||
* ties Crew Trays and engine pieces together as one combined resource, `player count + 3`
|
||||
* (`crewTrayCount` in `content.ts`), so engine scarcity IS tray scarcity: `freeTrays` running out
|
||||
* already blocks a new train exactly when the engine supply would. There is no state for "a tray
|
||||
* with no engine" because the rules never separate the two.
|
||||
*/
|
||||
engineAt: number;
|
||||
/**
|
||||
@@ -540,12 +545,42 @@ export type Player = {
|
||||
};
|
||||
|
||||
export type GameMode = 'solitaire' | 'competitive' | 'coop';
|
||||
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';
|
||||
|
||||
/**
|
||||
* VICTORY CONDITIONS — designed 2026-08-20, unified across all three modes.
|
||||
*
|
||||
* Replaces the old `length`-preset lookup (`LENGTH_PROFILES.target`) and the dead
|
||||
* `VictoryCondition: 'firstToTarget'` (grepped: never selected anywhere in the codebase). Winner is
|
||||
* whoever has the most Revenue when `days` run out — solitaire's own player counts as "everyone" —
|
||||
* unless `minCombinedRevenue` was missed, in which case everyone loses. Coop keeps summing every
|
||||
* player's Revenue into one table score, now against a configurable floor instead of
|
||||
* `profile.target * players.length`.
|
||||
*
|
||||
* `0` means "off" for every numeric field below. `minCombinedRevenue`'s natural default is
|
||||
* `collectiveRevenueFloor(players, days)` (`content.ts`), computed by whoever authors the config —
|
||||
* the engine only ever reads a concrete number here, never resolves one lazily, because player count
|
||||
* is not always known at config-authoring time (a lobby, a CLI flag, a dialog).
|
||||
*
|
||||
* `maxCollisionsPerDay`/`maxCollisionsTotal` are deliberately FLAT, not scaled by player count: more
|
||||
* players means more independent chances to collide, not a bigger shared budget, so a multiplayer
|
||||
* table is genuinely riskier than solitaire at the same default (Jesse's call).
|
||||
*/
|
||||
export type GameConfig = {
|
||||
mode: GameMode;
|
||||
victory: VictoryCondition;
|
||||
length: GameLength;
|
||||
/** How many Days the game runs. */
|
||||
days: number;
|
||||
/** Everyone loses if the table's total Revenue is below this when `days` run out. 0 = off. */
|
||||
minCombinedRevenue: number;
|
||||
/** Everyone loses immediately, mid-game, once collisions in one Day reach this. 0 = off. */
|
||||
maxCollisionsPerDay: number;
|
||||
/** Same, summed across the whole game, never reset. 0 = off. */
|
||||
maxCollisionsTotal: number;
|
||||
/**
|
||||
* Whether the 22 opponent-directed cards are in the deck (`setup.ts`'s `buildDeck`). Forced off in
|
||||
* `solitaire` and `coop` — neither has a valid target for them — on by default in `competitive`.
|
||||
* Has no effect until those cards are built (Phase 5); see `TODO.md`.
|
||||
*/
|
||||
pvpCardsAllowed: boolean;
|
||||
optionalRules: {
|
||||
reducedVisibility: boolean;
|
||||
sisterTrains: boolean;
|
||||
@@ -562,11 +597,7 @@ export type GameConfig = {
|
||||
houseRules?: HouseRuleOverrides;
|
||||
};
|
||||
|
||||
export type OutcomeReason =
|
||||
| 'targetReached'
|
||||
| 'daysElapsed'
|
||||
| 'collisionFloor'
|
||||
| 'revenueFloor';
|
||||
export type OutcomeReason = 'daysElapsed' | 'collisionFloor' | 'revenueFloor';
|
||||
|
||||
export type Outcome = {
|
||||
result: 'win' | 'loss';
|
||||
@@ -742,8 +773,10 @@ export type GameState = {
|
||||
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. */
|
||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||
collisionsToday: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
outcome: Outcome | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user