v0.8.4 — the multiplayer transport: server and browser
The second release from the audit. Every fault here was invisible in solitaire, and four of the five server faults were in the one file no test had ever stood up; `http.ts` now has an end-to-end suite on a real port. CHANGELOG has the reasoning. SERVER. Leaving a lobby freed the chair and kept the token, so a leaver could stream and move for whoever took the seat next — revoked now, in memory and on disk. The browser numbered intents from 1 per page load while the server remembered the seat's last number, so the first move after a reload was swallowed as a resend — the connect push carries the count and the client continues from it. Nothing serialised moves within a game and every write shared one `.tmp` name, so two moves at once tore `game.json` (measured: 6 of 200), and the boot's bare `JSON.parse` then took every game down — per-path write queues, a per-game move queue, and a boot that skips one bad file. An error after the SSE head was sent crashed the process. Bodies were unbounded before any secret check. BROWSER. A double-click did the thing twice: one submit in flight at a time. A failed submit is `false`, not an unhandled rejection. The documentation renderer flattened nested bullets into a literal "- " mid-sentence on the published home-deck page. The make-up panel promised cars the engine refuses; it asks `acceptsCar` now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4d222a7eba
commit
e47cd3d400
+49
-10
@@ -282,6 +282,8 @@ type Push = {
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/** Where the server's count of this seat's accepted intents stands — connect push only (v0.8.4). */
|
||||
lastSeq?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -322,7 +324,19 @@ export function createRemoteSession(
|
||||
let scheduled: number | null = null;
|
||||
let announcement: string | null = null;
|
||||
let justDrawnCard: string | null = null;
|
||||
/**
|
||||
* NUMBERED FROM WHERE THE SERVER SAYS, NOT FROM 1 (v0.8.4).
|
||||
*
|
||||
* This started at 1 on every page load, and the server remembers a seat's last accepted number
|
||||
* for the life of the game and answers a repeat with "already applied" (`protocol.md` §5). So a
|
||||
* seat that had made one move, reloaded, and clicked again sent `seq: 1` twice: the server said
|
||||
* ok, did nothing, pushed nothing, and the click looked dead. The connect push now carries the
|
||||
* server's count and this continues from it — never backwards, in case a submit is in flight
|
||||
* across a reconnect.
|
||||
*/
|
||||
let nextSeq = 1;
|
||||
/** One submit in flight at a time — see `submit`. */
|
||||
let inFlight = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
@@ -355,7 +369,15 @@ export function createRemoteSession(
|
||||
});
|
||||
};
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
const push = JSON.parse(ev.data) as Push;
|
||||
let push: Push;
|
||||
try {
|
||||
push = JSON.parse(ev.data) as Push;
|
||||
} catch {
|
||||
// A push that is not JSON is dropped rather than thrown out of an event handler nothing
|
||||
// catches; the next push carries a full delta chain from what this seat was last sent.
|
||||
return;
|
||||
}
|
||||
if (push.lastSeq !== undefined) nextSeq = Math.max(nextSeq, push.lastSeq + 1);
|
||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||
// seat's turn — only a push that actually came from the game (always carries a real `frame`,
|
||||
// per `session.ts`'s `Push`) updates the board or the menu.
|
||||
@@ -402,16 +424,33 @@ export function createRemoteSession(
|
||||
actor: () => need().actor,
|
||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||
async submit(intent: Intent): Promise<boolean> {
|
||||
/**
|
||||
* ONE AT A TIME (v0.8.4). The page redraws the SAME menu the instant a submit is sent — the
|
||||
* new one only arrives with the push — so a second click before the round trip posted a
|
||||
* second, fresh `seq` for the same option, and the server, which de-duplicates on `seq`
|
||||
* alone, applied it again when it was still legal: two cards drawn, two Moves spent, two cars
|
||||
* coupled. A click that lands while one is in flight is dropped; the push is milliseconds away.
|
||||
*/
|
||||
if (inFlight) return false;
|
||||
inFlight = true;
|
||||
const seq = nextSeq++;
|
||||
const res = await fetch(`/api/intent?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq, intent }),
|
||||
});
|
||||
const result = (await res.json()) as { ok: boolean; code?: string };
|
||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||
// not from this response — this only reports whether the rules accepted it.
|
||||
return result.ok;
|
||||
try {
|
||||
const res = await fetch(`/api/intent?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq, intent }),
|
||||
});
|
||||
const result = (await res.json()) as { ok?: boolean; code?: string };
|
||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||
// not from this response — this only reports whether the rules accepted it.
|
||||
return result.ok === true;
|
||||
} catch {
|
||||
// A network failure or a non-JSON answer used to reject out of a `void`ed promise — an
|
||||
// unhandled rejection and nothing on screen. False is honest: the move was not confirmed.
|
||||
return false;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
},
|
||||
subscribe(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
|
||||
Reference in New Issue
Block a user