v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)
CI / build + unit + e2e (pull_request) Successful in 14m29s
CI / build + unit + e2e (pull_request) Successful in 14m29s
This commit is contained in:
+35
-14
@@ -1,29 +1,50 @@
|
||||
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
||||
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
|
||||
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because
|
||||
// sim state alive) — no new dependencies, no WS upgrade needed. The Map lives on globalThis because
|
||||
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
||||
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
|
||||
// give each its own empty set and no push would ever be delivered.
|
||||
// give each its own empty map and no push would ever be delivered. v1.4.0 (ISS-112): subscribers are
|
||||
// keyed by PLAYER ID — the engine projects one snapshot per connected player and pushes each frame
|
||||
// only to that player's group, so every client sees its own account row in every pushed frame.
|
||||
|
||||
type Push = (text: string) => void;
|
||||
|
||||
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
||||
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
||||
const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
|
||||
const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
|
||||
|
||||
export function subscribe(fn: Push): () => void {
|
||||
subscribers.add(fn);
|
||||
export function subscribe(playerId: string, fn: Push): () => void {
|
||||
let group = subscribers.get(playerId);
|
||||
if (!group) {
|
||||
group = new Set();
|
||||
subscribers.set(playerId, group);
|
||||
}
|
||||
group.add(fn);
|
||||
return () => {
|
||||
subscribers.delete(fn);
|
||||
const s = subscribers.get(playerId);
|
||||
if (!s) return; // already unsubscribed — idempotent
|
||||
s.delete(fn);
|
||||
if (s.size === 0) subscribers.delete(playerId); // drop the empty group so projections stop for it
|
||||
};
|
||||
}
|
||||
|
||||
// Call each subscriber safely — one dead stream must not take the others down.
|
||||
export function broadcast(text: string): void {
|
||||
for (const fn of [...subscribers]) {
|
||||
try {
|
||||
fn(text);
|
||||
} catch {
|
||||
// subscriber's stream is gone; its own cleanup unsubscribes it
|
||||
// v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
|
||||
// projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
|
||||
export function subscribedPlayerIds(): string[] {
|
||||
return [...subscribers.keys()];
|
||||
}
|
||||
|
||||
// Call each subscriber safely — one dead stream must not take the others down. `frames` maps
|
||||
// player id → pre-serialized frame text (the engine projects per player, this only fans out).
|
||||
export function broadcast(frames: Map<string, string>): void {
|
||||
for (const [playerId, text] of frames) {
|
||||
const group = subscribers.get(playerId);
|
||||
if (!group) continue; // unsubscribed between projection and push — fine
|
||||
for (const fn of [...group]) {
|
||||
try {
|
||||
fn(text);
|
||||
} catch {
|
||||
// subscriber's stream is gone; its own cleanup unsubscribes it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user