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:
+50
-8
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
ACTION_CARDS,
|
||||
ENHANCEMENT_CARDS,
|
||||
HAND_LIMIT,
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
houseRules,
|
||||
@@ -348,6 +348,18 @@ export type Frame = {
|
||||
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
|
||||
*/
|
||||
houseRules: HouseRules;
|
||||
/**
|
||||
* The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the
|
||||
* running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState`
|
||||
* and needs to show live progress ("2 of 3 collisions today") without guessing a default. `0` on
|
||||
* any `max*`/`minCombinedRevenue` field means that check is off.
|
||||
*/
|
||||
days: number;
|
||||
minCombinedRevenue: number;
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
/**
|
||||
@@ -360,6 +372,13 @@ export type Frame = {
|
||||
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;
|
||||
/**
|
||||
* True when the VIEWER's hand is over §6.2's limit and their turn cannot end until it is played
|
||||
* down. Duplicates `game.ts`'s `overHandLimit(game, seat)` at the engine-data level rather than
|
||||
* importing the web layer here — a `RemoteSession` (Phase 2) has no `GameState` to compute this
|
||||
* from, only a `Frame`, so it has to already be resolved on the wire.
|
||||
*/
|
||||
overHandLimit: boolean;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
@@ -1207,6 +1226,12 @@ export function snapshot(
|
||||
wasted,
|
||||
option: turnOf(s, viewer).option,
|
||||
houseRules: houseRules(s.config),
|
||||
days: s.config.days,
|
||||
minCombinedRevenue: s.config.minCombinedRevenue,
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
players: s.players.map((p) => ({
|
||||
@@ -1217,6 +1242,8 @@ export function snapshot(
|
||||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||||
})),
|
||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||
overHandLimit:
|
||||
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
||||
objective: objectiveOf(s, viewer),
|
||||
runningRow: area.runningRow,
|
||||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||||
@@ -1633,21 +1660,36 @@ const SIMPLE_CARDS = [
|
||||
...ACTION_CARDS,
|
||||
];
|
||||
|
||||
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
|
||||
/**
|
||||
* The goal, and whether the VIEWER's score is keeping up with the clock.
|
||||
*
|
||||
* `target` is `config.minCombinedRevenue` now (2026-08-20) — the floor below which everyone loses,
|
||||
* not a per-player win threshold; `days` and `daysLeft` come off `config.days`. Paced against the
|
||||
* VIEWER's own Revenue, same as before: exact for solitaire (the viewer IS the whole table), an
|
||||
* approximation for competitive/coop until Phase 2 gives the objective panel a combined-progress
|
||||
* view of its own. `0` means no floor is configured — nothing to pace against.
|
||||
*/
|
||||
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
|
||||
const profile = lengthProfile(s.config.length);
|
||||
const { days, minCombinedRevenue: target } = s.config;
|
||||
const revenue = s.players[viewer]?.revenue ?? 0;
|
||||
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
|
||||
const elapsed = profile.days - daysLeft + 1;
|
||||
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
||||
const elapsed = days - daysLeft + 1;
|
||||
if (target <= 0) {
|
||||
const note =
|
||||
daysLeft === 0
|
||||
? 'the last Day is over'
|
||||
: `${revenue} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · no minimum this game`;
|
||||
return { target: 0, days, daysLeft, onPace: true, note };
|
||||
}
|
||||
// Straight-line pace: by the end of Day N you want N/days of the target.
|
||||
const expected = (profile.target * elapsed) / profile.days;
|
||||
const expected = (target * elapsed) / days;
|
||||
const onPace = revenue >= expected;
|
||||
const note =
|
||||
daysLeft === 0
|
||||
? 'the last Day is over'
|
||||
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
|
||||
: `${revenue} of ${target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
|
||||
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
|
||||
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
|
||||
return { target, days, daysLeft, onPace, note };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user