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:
+70
-7
@@ -474,6 +474,65 @@ export function officeSvg(
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* A 45° LEG, drawn as a smooth curve rather than two straight segments meeting at a hard corner.
|
||||
*
|
||||
* It is an EASEMENT, not an arbitrary curve: tangent to horizontal at `p0` (the east or west edge),
|
||||
* so an abutting straight card's through-rail still reads as one unbroken line, and tangent to
|
||||
* exactly 45° at `p3` (the north or south edge), so two stacked curves still read as one continuous
|
||||
* diagonal and the edge-crossing angle the matching rule depends on is unchanged. `p1`/`p2` are
|
||||
* cubic-Bezier control points chosen to hit those two tangents — see the call site.
|
||||
*
|
||||
* Sampled as a short polyline rather than left as one SVG path command, because the two parallel
|
||||
* rails and the tie marks all need points evenly spaced ALONG THE CURVE with the local tangent at
|
||||
* each one — `rail()`'s straight-line version does the same sampling, just on a line.
|
||||
*/
|
||||
const curvedRail = (
|
||||
p0: { x: number; y: number },
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number },
|
||||
p3: { x: number; y: number },
|
||||
): string => {
|
||||
const at = (t: number): { x: number; y: number } => {
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
|
||||
y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
|
||||
};
|
||||
};
|
||||
const tangentAt = (t: number): { x: number; y: number } => {
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x),
|
||||
y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y),
|
||||
};
|
||||
};
|
||||
const N = 24;
|
||||
const left: string[] = [];
|
||||
const right: string[] = [];
|
||||
let ties = '';
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = i / N;
|
||||
const pt = at(t);
|
||||
const tan = tangentAt(t);
|
||||
const tlen = Math.hypot(tan.x, tan.y) || 1;
|
||||
const nx = (-tan.y / tlen) * 2.5;
|
||||
const ny = (tan.x / tlen) * 2.5;
|
||||
left.push(`${pt.x + nx} ${pt.y + ny}`);
|
||||
right.push(`${pt.x - nx} ${pt.y - ny}`);
|
||||
// Every third sample — the same rough 9px spacing `rail()` uses for a straight run of similar
|
||||
// length, not tied to `N` itself.
|
||||
if (i % 3 === 0) {
|
||||
ties += `<line class="bs-tie" x1="${pt.x + nx * 1.8}" y1="${pt.y + ny * 1.8}" x2="${pt.x - nx * 1.8}" y2="${pt.y - ny * 1.8}"/>`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<path class="bs-rail" fill="none" d="M${left.join(' L')}"/>` +
|
||||
`<path class="bs-rail" fill="none" d="M${right.join(' L')}"/>` +
|
||||
ties
|
||||
);
|
||||
};
|
||||
|
||||
// Where each port meets the card edge. East and west sit at the rail height — the card's middle —
|
||||
// so a straight run stays straight across the whole row; north and south are centred on the edge.
|
||||
const port = (p: string): { x: number; y: number } => {
|
||||
@@ -534,19 +593,23 @@ export function officeSvg(
|
||||
out += rail(port(from).x, port(from).y, port(to).x, port(to).y);
|
||||
} else {
|
||||
/**
|
||||
* A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge
|
||||
* to the FROG, then out at exactly 45° through the middle of the north or south edge.
|
||||
* A 45° LEG — drawn as a smooth curve (v0.5.0) that still passes through the same FROG the
|
||||
* old two-segment version bent at, so the underlying geometry (and the `frog` position used
|
||||
* elsewhere for the actual joining/matching rules) is unchanged; only the picture is.
|
||||
*
|
||||
* The frog is `H/2` from the centre because a 45° run climbing half the card's height
|
||||
* travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2)
|
||||
* — below the rail on the assumption everything diverged downward — which drew a leg
|
||||
* reaching north as a hook that dropped past the rail and came back up, and drew nothing at
|
||||
* any angle the matching rule cares about.
|
||||
* travels half its height sideways. The control points sit halfway from each endpoint to the
|
||||
* frog, which is what gives the curve a horizontal tangent at the e/w edge (matching an
|
||||
* abutting straight card's through-rail) and an exact 45° tangent at the n/s edge (matching
|
||||
* the card above or below) — see `curvedRail`.
|
||||
*/
|
||||
const side = vertical === from ? to : from;
|
||||
const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL };
|
||||
const start = port(side);
|
||||
const edge = port(vertical);
|
||||
out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y);
|
||||
const c1 = { x: start.x + 0.5 * (frog.x - start.x), y: start.y };
|
||||
const c2 = { x: edge.x + 0.5 * (frog.x - edge.x), y: edge.y + 0.5 * (frog.y - edge.y) };
|
||||
out += curvedRail(start, c1, c2, edge);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user