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:
+61
-49
@@ -27,9 +27,7 @@ import {
|
||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||
STAGES_PER_DAY,
|
||||
STAGES_PER_SHIFT,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
mainlineProfile,
|
||||
@@ -273,10 +271,23 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
|
||||
/**
|
||||
* Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains,
|
||||
* cycling Superintendent-then-left one car at a time (§7).
|
||||
*
|
||||
* `tray.consist.length` IS the round position: a freshly made-up tray always starts with
|
||||
* `consist: []`, and each `newTrain.placeCar` appends exactly one car to it (`apply.ts`'s
|
||||
* `carPlacedOnTrain` reducer), so it counts placements toward THIS tray without any new state —
|
||||
* and resets to 0 naturally for the next train made up, which a phase-wide `actorOffset` cannot
|
||||
* do. Reproduces the rulebook's worked example exactly: 2 players, 4-coach Limited → seats
|
||||
* 0, 1, 0, 1.
|
||||
*/
|
||||
const filling = trainNeedingCars(s);
|
||||
if (filling) {
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
|
||||
const tray = s.trays.get(filling)!;
|
||||
const nextActor = actorAt(s, tray.consist.length % s.players.length);
|
||||
if (s.clock.currentActor !== nextActor) events.push({ type: 'actorChanged', player: nextActor });
|
||||
s.clock.currentActor = nextActor;
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
@@ -893,8 +904,13 @@ function arriveAtOffice(
|
||||
|
||||
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
//
|
||||
// A coach is the one exception (v0.5.0, §A.4's Office carve-out): it may be legally, deliberately
|
||||
// parked at the Office while its engine switches, so it must not become a hazard to the next
|
||||
// arrival. Anything else standing there is still illegal to have dropped in the first place —
|
||||
// `canDropCarsAt` already refuses it — so this filter only ever excludes a coach in practice.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
if (officeCard && officeCard.standing.length > 0) {
|
||||
if (officeCard && officeCard.standing.some((c) => c.type !== 'coach')) {
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||
return 'moved';
|
||||
}
|
||||
@@ -956,6 +972,7 @@ function collide(
|
||||
}
|
||||
|
||||
s.collisionsToday += 1;
|
||||
s.collisionsTotal += 1;
|
||||
const player = s.players[faultPlayer];
|
||||
if (player) {
|
||||
player.revenue -= COLLISION_PENALTY;
|
||||
@@ -1067,63 +1084,58 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
// §3.4 — Competitive only: three collisions in one Day and everyone loses.
|
||||
if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
|
||||
s.status = 'finished';
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
||||
return { events, needsInput: false };
|
||||
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
|
||||
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
|
||||
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
|
||||
// collide, not a bigger shared budget.
|
||||
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
|
||||
const perDayBreach =
|
||||
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
||||
const totalBreach =
|
||||
s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal;
|
||||
if (perDayBreach || totalBreach) {
|
||||
s.status = 'finished';
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
||||
return { events, needsInput: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
|
||||
}
|
||||
|
||||
/** §3.3 — evaluated at the end of a Day. */
|
||||
/**
|
||||
* §3.3 — evaluated at the end of a Day.
|
||||
*
|
||||
* Unified 2026-08-20 across all three modes: play `config.days`, then whoever has the most Revenue
|
||||
* wins — unless the table's combined Revenue missed `config.minCombinedRevenue`, in which case
|
||||
* everyone loses. Solitaire is "everyone" with one player, so this is the same win/lose shape it
|
||||
* always had, just against a configured floor instead of a `length`-preset `target`. Coop keeps its
|
||||
* "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is
|
||||
* shared — now against the same configurable floor.
|
||||
*/
|
||||
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||
const profile = lengthProfile(s.config.length);
|
||||
const daysElapsed = s.clock.day - 1;
|
||||
|
||||
if (s.config.victory === 'firstToTarget') {
|
||||
const target =
|
||||
s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
|
||||
const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue));
|
||||
if (score >= target) {
|
||||
const winner =
|
||||
s.config.mode === 'coop'
|
||||
? null
|
||||
: s.players.findIndex((p) => p.revenue === score);
|
||||
s.status = 'finished';
|
||||
s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (daysElapsed < profile.days) return false;
|
||||
if (daysElapsed < s.config.days) return false;
|
||||
|
||||
s.status = 'finished';
|
||||
|
||||
if (s.config.mode === 'competitive') {
|
||||
// §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
|
||||
if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
return true;
|
||||
}
|
||||
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||
s.outcome = {
|
||||
result: 'win',
|
||||
winner: s.players.findIndex((p) => p.revenue === best),
|
||||
reason: 'daysElapsed',
|
||||
};
|
||||
const combined = totalRevenue(s);
|
||||
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
return true;
|
||||
}
|
||||
|
||||
// Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
|
||||
const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
|
||||
const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
|
||||
s.outcome =
|
||||
score >= target
|
||||
? { result: 'win', winner: null, reason: 'daysElapsed' }
|
||||
: { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
if (s.config.mode === 'coop') {
|
||||
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||
return true;
|
||||
}
|
||||
|
||||
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||
s.outcome = {
|
||||
result: 'win',
|
||||
winner: s.players.findIndex((p) => p.revenue === best),
|
||||
reason: 'daysElapsed',
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user