From d0699cf3106d92b81c71da3540cf2945b2e2a5a5 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 8 Jul 2026 17:02:11 +0200 Subject: [PATCH] feat(partyplug): adopt HexStacker's room-teardown kit + wire close-room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync PartyConnection with the HexStacker-Party reference: - closeRoom(): host/slot-0 tears the room down on the relay (members get a 4001 close, GET /room/:code turns 404 so stale join/claim QRs die) - 4001 handling: terminal onClose {roomClosed} — no futile reconnects against a deleted room - create(maxClients, url): register a controller-URL template ({room}/ {instance}) the relay resolves for code-only clients (https-only) - failAttempt(): drive the normal backoff path for self-detected failures (open socket, unanswered create) Game wiring: - display pagehide: closeRoom() after the DISPLAY_CLOSED goodbye, so the room dies server-side even when the app-level broadcast is dropped - display create: pass the controller-URL template; a rejected create now retries via failAttempt() instead of dead-ending on a warn - controller: {roomClosed} surfaces as 'room_closed' and bails straight to the device chooser (?bail=game_ended), same exit as DISPLAY_CLOSED set_state (retained roster snapshot) was already in place. Verified the deployed relay honors the new contract (4001 both sockets, /room 404). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WrjJk5evnn2g63cD2eaqhC --- partyplug/PartyConnection.d.ts | 13 ++++-- partyplug/PartyConnection.js | 55 +++++++++++++++++++++--- partyplug/README.md | 6 ++- partyplug/tests/party-connection.test.js | 44 +++++++++++++++++++ public/controller/Net.js | 4 ++ public/controller/main.js | 5 +++ public/display/Net.js | 39 ++++++++++++++++- public/display/main.js | 14 ++++-- 8 files changed, 164 insertions(+), 16 deletions(-) diff --git a/partyplug/PartyConnection.d.ts b/partyplug/PartyConnection.d.ts index bdad9ec..a9a16ed 100644 --- a/partyplug/PartyConnection.d.ts +++ b/partyplug/PartyConnection.d.ts @@ -11,8 +11,13 @@ declare class PartyConnection { readonly connected: boolean; connect(): void; - /** Create a room (display, slot 0). */ - create(maxClients: number): void; + /** + * Create a room (display, slot 0). `url` is an optional controller-URL + * template ({room}/{instance} placeholders) the relay resolves for clients + * that hold only the room code. Must be absolute https or the relay rejects + * the create; omit it on non-https origins. + */ + create(maxClients: number, url?: string): void; /** Join a room by code (controller). */ join(room: string): void; /** Pin auto-reconnect to a relay shard by rebuilding the sharded URL. */ @@ -21,13 +26,15 @@ declare class PartyConnection { broadcast(data: any): void; /** Publish a retained state snapshot (host/slot-0 only). Replayed to clients on (re)join and pushed live to peers. <= 16 KiB serialized. */ setState(data: any): void; + /** Tear the room down for everyone (host/slot-0 only). The relay deletes the room and closes every member socket with 4001 (onClose meta.roomClosed). */ + closeRoom(): void; reconnectNow(): void; resetReconnectCount(): void; close(): void; // Callbacks (assigned as properties). onOpen: (() => void) | null; - onClose: ((attempt: number, maxAttempts: number, meta?: { replaced?: boolean }) => void) | null; + onClose: ((attempt: number, maxAttempts: number, meta?: { replaced?: boolean; roomClosed?: boolean }) => void) | null; onError: (() => void) | null; onMessage: ((from: number, data: any) => void) | null; onProtocol: ((type: string, msg: any) => void) | null; diff --git a/partyplug/PartyConnection.js b/partyplug/PartyConnection.js index 31c03c6..21b0aef 100644 --- a/partyplug/PartyConnection.js +++ b/partyplug/PartyConnection.js @@ -17,11 +17,11 @@ * (e.g. in localStorage) and pass it in. * * Party-Server protocol: - * Client → PS: create { clientId, maxClients } + * Client → PS: create { clientId, maxClients, url? } * Client → PS: join { clientId, room } * Client → PS: send { data, to? } // to is a peer index (number) - * PS → Client: created { room, index: 0, instance?, region? } - * PS → Client: joined { room, index, peers: number[] } + * PS → Client: created { room, index: 0, instance?, region?, url? } + * PS → Client: joined { room, index, peers: number[], url? } * PS → Client: peer_joined { index } * PS → Client: peer_left { index } * PS → Client: message { from, data } // from is a peer index (number) @@ -40,7 +40,7 @@ class PartyConnection { // Callbacks this.onOpen = null; // () => void - this.onClose = null; // (attempt: number, maxAttempts: number, meta?: {replaced: boolean}) => void + this.onClose = null; // (attempt: number, maxAttempts: number, meta?: {replaced?: boolean, roomClosed?: boolean}) => void this.onError = null; // () => void this.onMessage = null; // (from: number, data: object) => void this.onProtocol = null; // (type: string, msg: object) => void @@ -98,6 +98,14 @@ class PartyConnection { if (this.onClose) this.onClose(0, 0, { replaced: true }); return; } + if (event && event.code === 4001) { + // The room itself is gone (host sent close_room, or the relay's + // hostless grace expired). Terminal: a reconnect would only bounce + // off "Room not found". + this._shouldReconnect = false; + if (this.onClose) this.onClose(0, 0, { roomClosed: true }); + return; + } this.reconnectAttempt++; if (this.onClose) this.onClose(this.reconnectAttempt, this.maxReconnectAttempts); if (this._shouldReconnect && this.reconnectAttempt <= this.maxReconnectAttempts) { @@ -135,8 +143,17 @@ class PartyConnection { } } - create(maxClients) { - this._send({ type: 'create', clientId: this.clientId, maxClients: maxClients }); + // Create a room. `url` is an optional controller-URL template the relay + // retains on the room and hands (with {room}/{instance} filled in) to anyone + // who holds only the room code — in `created`/`joined` replies and via + // GET /room/:code — so a code-only client can resolve which page to load. + // The relay only accepts absolute https templates and rejects the whole + // create on an invalid one, so callers must omit it rather than send a + // non-https URL (e.g. a plain-http dev origin). + create(maxClients, url) { + var msg = { type: 'create', clientId: this.clientId, maxClients: maxClients }; + if (url) msg.url = url; + this._send(msg); } join(room) { @@ -170,11 +187,37 @@ class PartyConnection { this._send({ type: 'set_state', data: data }); } + // Tear the room down for everyone (host/slot-0 only; the relay rejects it + // from anyone else). The relay deletes the room, GET /room/:code turns 404 + // (killing stale rejoin links), and every member socket is closed with 4001, + // which surfaces to them as onClose(0, 0, {roomClosed: true}). There is no + // ack message: the sender's own 4001 close is the confirmation, unless the + // caller close()s first (fine on pagehide, where the page is going away). + closeRoom() { + this._send({ type: 'close_room' }); + } + reconnectNow() { clearTimeout(this._reconnectTimer); this.connect(); } + // Treat the current socket as a failed attempt and drive the normal + // backoff/give-up path — for callers that detect failure themselves (e.g. a + // socket that opened but the relay never answered create/join, which never + // triggers onclose). Mirrors an unexpected onclose: discards the dead socket, + // bumps the attempt counter, notifies onClose, and either schedules a backoff + // reconnect or gives up once maxReconnectAttempts is passed. + failAttempt() { + if (!this._shouldReconnect) return; + this._discardOldWs(); + this.reconnectAttempt++; + if (this.onClose) this.onClose(this.reconnectAttempt, this.maxReconnectAttempts); + if (this.reconnectAttempt <= this.maxReconnectAttempts) { + this._scheduleReconnect(); + } + } + resetReconnectCount() { this.reconnectAttempt = 0; } diff --git a/partyplug/README.md b/partyplug/README.md index 2063dcc..36d1305 100644 --- a/partyplug/README.md +++ b/partyplug/README.md @@ -91,12 +91,13 @@ new PartyConnection(relayUrl, { clientId?, maxReconnectAttempts = 5 }) | Method | Purpose | | --- | --- | | `connect()` | Open the socket (auto-reconnects up to max) | -| `create(maxClients)` | Create a room (display, slot 0) | +| `create(maxClients, url?)` | Create a room (display, slot 0). `url` is an optional controller-URL template (`{room}`/`{instance}` placeholders) the relay resolves for clients that hold only the room code (in `created`/`joined` and via `GET /room/:code`). Absolute https only, or the relay rejects the create; omit it on non-https origins | | `join(room)` | Join a room by code (controller) | | `pinInstance(baseUrl, room, instance)` | Pin auto-reconnect to a relay shard (rebuilds the sharded URL) | | `sendTo(to, data)` | Send to one slot | | `broadcast(data)` | Send to all peers | | `setState(data)` | Publish the retained room snapshot (host/slot-0 only; ≤ 16 KiB serialized) | +| `closeRoom()` | Tear the room down for everyone (host/slot-0 only): the relay deletes it (`GET /room/:code` turns 404) and closes every member socket with 4001, surfaced to them as `onClose` `{ roomClosed }` | | `reconnectNow()` / `resetReconnectCount()` | Manual reconnect control | | `close()` | Tear down, stop reconnecting | @@ -104,6 +105,9 @@ Callbacks (assigned as properties): - `onOpen()` - `onClose(attempt, maxAttempts, meta?)` where `meta` may carry `{ replaced }` + (evicted by a same-clientId join, close 4000) or `{ roomClosed }` (the room + itself is gone: host `closeRoom()` or the relay's hostless grace, close 4001); + both are terminal, no auto-reconnect follows - `onError()` - `onMessage(from, data)` for game messages - `onProtocol(type, msg)` for relay events (`created`, `joined`, `peer_joined`, `peer_left`) diff --git a/partyplug/tests/party-connection.test.js b/partyplug/tests/party-connection.test.js index a3b1de5..ed65976 100644 --- a/partyplug/tests/party-connection.test.js +++ b/partyplug/tests/party-connection.test.js @@ -171,6 +171,14 @@ describe('PartyConnection', () => { }); }); + test('closeRoom sends a close_room message', () => { + const pc = new PartyConnection('wss://test.example.com', { clientId: 'display' }); + pc.connect(); + MockWebSocket._instances[0]._simulateOpen(); + pc.closeRoom(); + assert.deepStrictEqual(MockWebSocket._instances[0]._sent[0], { type: 'close_room' }); + }); + test('onState fires for a state message and carries the snapshot data', () => { const pc = new PartyConnection('wss://test.example.com'); let received; @@ -256,6 +264,24 @@ describe('PartyConnection - reconnect with exponential backoff', () => { assert.strictEqual(MockWebSocket._instances.length, 1); }); + test('room-closed close (4001) stops reconnecting and reports roomClosed', () => { + const pc = new PartyConnection('wss://test.example.com', { maxReconnectAttempts: 3 }); + let closeArgs = null; + pc.onClose = (attempt, max, meta) => { closeArgs = { attempt, max, meta }; }; + pc.connect(); + + MockWebSocket._instances[0]._simulateClose({ code: 4001 }); + + assert.strictEqual(pc._shouldReconnect, false); + assert.strictEqual(pc.reconnectAttempt, 0); + assert.deepStrictEqual(closeArgs, { + attempt: 0, + max: 0, + meta: { roomClosed: true }, + }); + assert.strictEqual(MockWebSocket._instances.length, 1); + }); + test('reconnect stops after maxReconnectAttempts', () => { const pc = new PartyConnection('wss://test.example.com', { maxReconnectAttempts: 2 }); pc.connect(); @@ -354,6 +380,24 @@ describe('PartyConnection - reconnect with exponential backoff', () => { }); }); + test('create with a controller-URL template includes url; undefined omits the field', () => { + // The relay rejects the whole create on an invalid template, so the field + // must be absent (not null/undefined) when the caller has none to register. + const pc = new PartyConnection('wss://test.example.com', { clientId: 'display' }); + pc.connect(); + MockWebSocket._instances[0]._simulateOpen(); + pc.create(9, 'https://play.example.com/{room}#{instance}'); + pc.create(9, undefined); + const sent = MockWebSocket._instances[0]._sent; + assert.deepStrictEqual(sent[0], { + type: 'create', + clientId: 'display', + maxClients: 9, + url: 'https://play.example.com/{room}#{instance}' + }); + assert.deepStrictEqual(Object.keys(sent[1]), ['type', 'clientId', 'maxClients']); + }); + test('join sends correct relay message', () => { const pc = new PartyConnection('wss://test.example.com', { clientId: 'player1' }); pc.connect(); diff --git a/public/controller/Net.js b/public/controller/Net.js index b38563f..b195704 100644 --- a/public/controller/Net.js +++ b/public/controller/Net.js @@ -142,6 +142,10 @@ export class ControllerNet extends GameNet { this.party.onClose = (attempt, max, meta) => { this._stopPing(); if (meta && meta.replaced) { this.onStatus('replaced'); return; } + // The relay tore the room down (display sent close_room on its way out, + // or the hostless grace expired): terminal — PartyConnection has already + // stopped reconnecting, a rejoin would only bounce off "Room not found". + if (meta && meta.roomClosed) { this.onStatus('room_closed'); return; } // attempt > max: PartyConnection has stopped retrying — the link is down for // good until the player acts. Surface a distinct 'lost' so the UI can offer a // retry (and point at the big screen's reconnect QR). diff --git a/public/controller/main.js b/public/controller/main.js index 86d87e2..753eb2e 100644 --- a/public/controller/main.js +++ b/public/controller/main.js @@ -94,6 +94,11 @@ const net = new ControllerNet({ setStatus('Waiting for the big screen…'); if (inRoom) showConn('Waiting for the big screen…', 'The host’s screen dropped — hang tight, it’ll reconnect you.', false); armBail(); // not back within the grace window → the game has ended (also covers a fresh join into an abandoned room, still on the name screen) + } else if (state === 'room_closed') { + // The relay closed the whole room (4001) — the display tore it down on + // its way out, or its hostless grace expired. Same destination as the + // DISPLAY_CLOSED goodbye, minus the wait: the party is over for good. + bailTo('game_ended'); } else if (state === 'replaced') { setStatus('Opened on another tab.'); if (inRoom) showConn('Opened on another tab', 'This seat is now controlled from another tab or device.', false); diff --git a/public/display/Net.js b/public/display/Net.js index 7dea98c..d022bb7 100644 --- a/public/display/Net.js +++ b/public/display/Net.js @@ -116,7 +116,7 @@ export class DisplayNet extends GameNet { this.party.onOpen = () => { if (this.roomCode) this.party.join(this.roomCode); - else this.party.create(MAX_PLAYERS + 1); // +1 for the display itself + else this.party.create(MAX_PLAYERS + 1, this._controllerUrlTemplate()); // +1 for the display itself }; this.party.onProtocol = (type, msg) => this._onProtocol(type, msg); this.party.onMessage = (from, data) => this._onMessage(from, data); @@ -149,7 +149,17 @@ export class DisplayNet extends GameNet { this._removePeer(msg.index); break; case 'error': - console.warn('[relay]', msg.message); + // A rejected CREATE (bad url template, relay hiccup) would otherwise + // dead-end silently — the socket is open, so no onclose-driven retry + // ever fires. failAttempt() treats it as a failed attempt and drives + // the normal backoff/reconnect, which re-creates on the next onOpen. + // Join-phase errors (roomCode set) keep the old warn-only behavior. + if (!this.roomCode) { + console.warn('[relay] create rejected:', msg.message); + this.party.failAttempt(); + } else { + console.warn('[relay]', msg.message); + } break; } } @@ -381,11 +391,36 @@ export class DisplayNet extends GameNet { sendTo(id, data) { if (this.party) this.party.sendTo(id, data); } get roomState() { return this.flow.state; } + // Goodbye on intentional close/navigate-away: tear the room down on the + // relay. Every controller socket gets a 4001 "room closed" (surfaced as + // onClose {roomClosed} — their terminal party-over signal) and GET + // /room/:code turns 404 right away, so stale rejoin/claim QRs die with the + // room. Best-effort on pagehide (bfcache freeze may drop the send); the + // DISPLAY_CLOSED broadcast and the controllers' display-gone bail cover + // whatever doesn't land. close() last so no auto-reconnect resurrects us. + closeRoom() { + if (!this.party) return; + try { this.party.closeRoom(); } catch (_) {} + this.party.close(); + } + // ---- join URL / QR base ---- _joinUrl() { const base = this.baseUrlOverride || window.location.origin; return base + '/' + this.roomCode + (this.instance ? '#' + enc(this.instance) : ''); } + // Controller-URL template registered with the relay on room create. The relay + // fills {room}/{instance} and hands the result to anyone holding only the + // room code (native shells via GET /room/:code, controllers in `joined`), so + // a code-only join can still resolve which page to load. Mirrors _joinUrl's + // shape: instance in the fragment, kept out of request logs. The relay + // accepts only absolute https templates and rejects the whole create on an + // invalid one, so plain-http origins (local dev, e2e) register none. + _controllerUrlTemplate() { + const base = (this.baseUrlOverride || window.location.origin).replace(/\/+$/, ''); + if (!base.startsWith('https://')) return undefined; + return base + '/{room}#{instance}'; + } async _fetchBaseUrl() { const host = window.location.hostname; if (host !== 'localhost' && host !== '127.0.0.1') return; diff --git a/public/display/main.js b/public/display/main.js index 30941d2..8b8184a 100644 --- a/public/display/main.js +++ b/public/display/main.js @@ -1079,10 +1079,16 @@ if (params.get('test') === '1' || scenario) { // Goodbye on the way out: closing/navigating the big screen ends the game // (a reload creates a brand-new room), so tell the phones NOW — they bail // straight to the device chooser instead of sitting out the display-gone - // grace window. Best-effort: an unload-time WS send can be dropped (crash, - // dead battery, iOS killing the page), which is exactly what the - // controller's bail timer still covers. - window.addEventListener('pagehide', () => net.broadcast({ type: MSG.DISPLAY_CLOSED })); + // grace window — and tear the room down on the relay (closeRoom): every + // controller socket gets its terminal 4001 {roomClosed} and GET /room/:code + // turns 404, so stale join/claim QRs die with the room. DISPLAY_CLOSED goes + // first (same socket, in order) as the app-level goodbye. Best-effort: an + // unload-time WS send can be dropped (crash, dead battery, iOS killing the + // page), which is exactly what the controller's bail timer still covers. + window.addEventListener('pagehide', () => { + net.broadcast({ type: MSG.DISPLAY_CLOSED }); + net.closeRoom(); + }); } // debug hooks