diff --git a/packages/studio-server/src/router/index.ts b/packages/studio-server/src/router/index.ts index eb70d486f4..e6d00496e4 100644 --- a/packages/studio-server/src/router/index.ts +++ b/packages/studio-server/src/router/index.ts @@ -348,14 +348,17 @@ export function createStudioRouter( watch: oe.studio.events.watch.effect(function* () { const config = yield* StudioConfig; const eventHub = yield* StudioEventHub; + const observedAt = new Date().toISOString(); + const latestLiveGame = eventHub.latestLiveGameEvent(); return eventHub.subscribe({ initialEvents: [ { type: "hello", serverInstanceId: config.serverInstanceId, serverStartedAt: config.serverStartedAt, - observedAt: new Date().toISOString(), + observedAt, }, + ...(latestLiveGame ? [latestLiveGame] : []), ], }); }), diff --git a/packages/studio-server/src/services/StudioEventHub.ts b/packages/studio-server/src/services/StudioEventHub.ts index 09d9b8a555..f148b7e216 100644 --- a/packages/studio-server/src/services/StudioEventHub.ts +++ b/packages/studio-server/src/services/StudioEventHub.ts @@ -1,10 +1,11 @@ import { AsyncIteratorClass } from "@orpc/server"; import { Context, Effect, Exit, PubSub, Queue, Scope } from "effect"; -import type { StudioEvent } from "../contract/studio.js"; +import type { StudioEvent, StudioLiveGameEvent } from "../contract/studio.js"; export interface StudioEventHubApi { publish(event: StudioEvent): Promise; + latestLiveGameEvent(): StudioLiveGameEvent | null; subscribe(options?: { initialEvents?: readonly StudioEvent[]; }): AsyncIteratorObject; @@ -19,14 +20,20 @@ export class StudioEventHub extends Context.Tag("@civ7/studio-server/StudioEvent export function createStudioEventHub(): StudioEventHubApi { let activeSubscribers = 0; + let latestLiveGameEvent: StudioLiveGameEvent | null = null; const pubsubPromise = Effect.runPromise(PubSub.unbounded()); return { async publish(event) { + if (event.type === "live-game") latestLiveGameEvent = event; const pubsub = await pubsubPromise; await Effect.runPromise(PubSub.publish(pubsub, event)); }, + latestLiveGameEvent() { + return latestLiveGameEvent; + }, + subscribe(options = {}) { const initialEvents = options.initialEvents ?? []; let initialEventIndex = 0; diff --git a/packages/studio-server/test/handler.test.ts b/packages/studio-server/test/handler.test.ts index 93498c2933..e8e8f4a7cf 100644 --- a/packages/studio-server/test/handler.test.ts +++ b/packages/studio-server/test/handler.test.ts @@ -227,6 +227,56 @@ describe("studio-server RPC handler", () => { await iterator.return?.(); await expect.poll(() => eventHub.activeSubscriberCount(), { timeout: 1_000 }).toBe(0); }, 10_000); + + test("replays latest live-game event to late studio.events.watch subscribers", async () => { + const eventHub = trackEventHub(createStudioEventHub()); + await eventHub.publish({ + type: "live-game", + observedAt: "2026-06-13T01:00:00.000Z", + state: { + status: "ok", + readiness: "tuner-ready", + turn: 160, + seed: 298035825, + updatedAt: "2026-06-13T01:00:00.000Z", + snapshotStatus: "idle", + snapshotId: "status:160:live", + snapshotHash: "live", + bindingStatus: "unbound-runtime", + failureCount: 0, + }, + }); + const handler = trackHandle(createStudioRpcHandler(makeContext({ eventHub }))); + const client = directClient(handler); + + const iterator = await client.studio.events.watch({}); + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "hello" }, + }); + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + type: "live-game", + observedAt: "2026-06-13T01:00:00.000Z", + state: { + status: "ok", + readiness: "tuner-ready", + turn: 160, + seed: 298035825, + updatedAt: "2026-06-13T01:00:00.000Z", + snapshotStatus: "idle", + snapshotId: "status:160:live", + snapshotHash: "live", + bindingStatus: "unbound-runtime", + failureCount: 0, + }, + }, + }); + + await iterator.return?.(); + }, 10_000); }); function trackHandle(handle: StudioRpcHandle): StudioRpcHandle {