diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs index 514d9b7a10..634b1e2f77 100644 --- a/session-server/oskiewar-live-manager.mjs +++ b/session-server/oskiewar-live-manager.mjs @@ -203,6 +203,18 @@ export class OskiewarLiveManager { ws.close?.(4409, "Publisher already connected"); return; } + // A new publisher counts frames from its own zero, so the room lets go of + // the old one's snapshot — otherwise the stale-frame guard in publish() + // reads every frame of a claimed room as old until the successor + // out-counts the dead host's sequence (measured: ~14 silent seconds after + // each versus handoff, one for each second the old host had been live). + // Dropping the snapshot also stops a mid-handoff spectator being handed a + // freeze-frame of the previous fight, and stops the matchmaker offering a + // chair beside a corpse. + if (room.state) { + room.state = null; + room.publishedAt = 0; + } room.publisher = ws; room.publisherSurface = surface; room.updatedAt = this.now(); diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs index d36890f57c..ec1f18a7f7 100644 --- a/session-server/oskiewar-live-manager.test.mjs +++ b/session-server/oskiewar-live-manager.test.mjs @@ -111,6 +111,32 @@ test("a second live publisher cannot take over a match", () => { assert.equal(second.closed?.code, 4409); }); +test("a successor publisher counts from zero — no corpse seq gate, no stale frame", () => { + let now = 100; + const manager = new OskiewarLiveManager({ now: () => now }); + const first = new FakeSocket(), viewer = new FakeSocket(); + const url = "/oskiewar-live?match=bafegu-dorimi-kunapo"; + manager.handleConnection(first, { url: `${url}&role=publisher` }); + manager.handleConnection(viewer, { url }); + now += 30; + first.emit("message", Buffer.from(JSON.stringify( + { type: "oskiewar:state", content: state(500) }))); + assert.equal(viewer.sent.at(-1).content.seq, 500); + first.close(); + const second = new FakeSocket(); + manager.handleConnection(second, { url: `${url}&role=publisher` }); + const late = new FakeSocket(); + manager.handleConnection(late, { url }); + assert.ok(!late.sent.some((m) => m.type === "oskiewar:state"), + "a mid-handoff spectator must not be handed the dead host's frame"); + now += 30; + second.emit("message", Buffer.from(JSON.stringify( + { type: "oskiewar:state", content: state(1) }))); + assert.equal(viewer.sent.at(-1).content.seq, 1, + "the claimed room must fan the new host's first frame, not mute it"); + assert.equal(late.sent.at(-1).content.seq, 1); +}); + test("a challenger is seated, watches state, and their presses reach the publisher", () => { let now = 100; const manager = new OskiewarLiveManager({ now: () => now });