svelte-realtime 0.6.0-next.21 → 0.6.0-next.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2933,6 +2933,39 @@ How it behaves:
2933
2933
 
2934
2934
  `tolerance` (per call: `ctx.compensate(t, fn, { tolerance: 20 })`) skips the rewind when the stamp is within that many milliseconds of now - the low-latency common case.
2935
2935
 
2936
+ ### Room enumeration (lobby browser)
2937
+
2938
+ A lobby browser needs to list the *active* rooms of a type - the open games, and how many players are in each. Opt in with a `meta` function (or `enumerable: true` for a count-only list) and the export gains a `rooms()` view:
2939
+
2940
+ ```js
2941
+ export const game = live.room({
2942
+ topic: (ctx, id) => 'game:' + id,
2943
+ topicArgs: 1,
2944
+ init: async (ctx, id) => loadGame(id),
2945
+ meta: (id) => ({ name: nameFor(id), map: mapFor(id), cap: 32 }) // opt-in; resolved once when a room opens
2946
+ });
2947
+ ```
2948
+ ```svelte
2949
+ <script>
2950
+ import { game } from '$live/game';
2951
+ const lobby = game.rooms(); // a snapshot, then live
2952
+ $effect(() => () => lobby.destroy());
2953
+ </script>
2954
+
2955
+ {#each [...lobby.rooms] as [id, r] (id)}
2956
+ <a href={'/game/' + id}>{r.meta.name} - {r.count}/{r.meta.cap}</a>
2957
+ {/each}
2958
+ ```
2959
+
2960
+ How it behaves:
2961
+
2962
+ - **A room is "active" while it has a subscriber.** The first client to subscribe to a topic (`game:7`) opens that room in the enumeration; the last to leave closes it. `count` is the live subscriber count and moves as players join and leave. The view is a snapshot on subscribe, then live deltas - no polling.
2963
+ - **`lobby.rooms` is a reactive `Map` keyed by the room args** - the single arg (a `gameId`) when the room takes one, else the joined args. Each value is `{ args, count, meta }`. `lobby.list()` returns a one-shot snapshot array without opening a live subscription (for a `+page.server` load or a one-off fetch).
2964
+ - **`meta(args)` is resolved once, when the room opens** (its first subscriber), and frozen - it is the room's display card (name, map, cap). A throwing `meta` never blocks the room; the room appears with an empty meta. Mutable per-room state belongs in the room's own data stream, not in `meta`.
2965
+ - **Off by default.** A room without `meta` or `enumerable` installs no registry and no enumeration stream, so it is byte-identical to a plain room - you pay only when you browse.
2966
+
2967
+ Single-instance today: `rooms()` lists the rooms active on the instance the client is connected to. Cluster-wide aggregation (a Redis roster unioning every instance's active rooms) is a separate, additive step; until it lands, host a lobby browser on a single instance or one all its viewers share.
2968
+
2936
2969
  ---
2937
2970
 
2938
2971
  ## Multiplayer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.6.0-next.21",
3
+ "version": "0.6.0-next.23",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -44,6 +44,10 @@
44
44
  "types": "./src/client-doc.d.ts",
45
45
  "default": "./src/client-doc.svelte.js"
46
46
  },
47
+ "./rooms": {
48
+ "types": "./src/client-rooms.d.ts",
49
+ "default": "./src/client-rooms.svelte.js"
50
+ },
47
51
  "./vite": {
48
52
  "types": "./src/vite.d.ts",
49
53
  "default": "./src/vite.js"
@@ -0,0 +1,32 @@
1
+ /** One active room in a lobby view. */
2
+ export interface RoomEntry<Meta = any> {
3
+ /** The room-identifying args (e.g. `[gameId]`), as the topic function received them. */
4
+ args: any[];
5
+ /** Live subscriber count (connections subscribed to the room on this instance). */
6
+ count: number;
7
+ /** The value the export's `meta(args)` returned when the room opened, or `undefined`. */
8
+ meta: Meta;
9
+ }
10
+
11
+ /**
12
+ * Live view of a `live.room` export's ACTIVE rooms - the backing for a lobby
13
+ * browser. Subscribe through the generated `game.rooms()`; reads through Svelte 5
14
+ * runes (the view is a rune class, so it requires Svelte 5). The server keeps it
15
+ * current as rooms open (first subscriber), fill/empty (count), and close (last
16
+ * subscriber leaves).
17
+ */
18
+ export class RoomsList<Meta = any> {
19
+ constructor(deps: {
20
+ stream: { subscribe: (fn: (v: any) => void) => () => void };
21
+ status?: { subscribe: (fn: (v: string) => void) => () => void };
22
+ list?: (...args: any[]) => Promise<any>;
23
+ });
24
+ /** Reactive map of active rooms, keyed by room args (the single arg when there is one, else joined). */
25
+ readonly rooms: Map<string, RoomEntry<Meta>>;
26
+ /** The connection-status passthrough. */
27
+ readonly status: string;
28
+ /** One-shot snapshot of the active rooms without opening a live subscription. */
29
+ list(): Promise<Array<RoomEntry<Meta>>>;
30
+ /** Stop the live subscription. Call from the component's cleanup. */
31
+ destroy(): void;
32
+ }
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * A stable per-room key for the lobby Map: the single room arg (a gameId) when
5
+ * the room takes exactly one, the joined args when it takes several, else the
6
+ * wire topic. Each rendered value keeps the raw `args` so an app addresses a
7
+ * room however it declared it.
8
+ * @param {{ topic?: string, args?: any[] }} e
9
+ * @returns {string}
10
+ */
11
+ function _roomKey(e) {
12
+ const args = Array.isArray(e.args) ? e.args : [];
13
+ if (args.length === 1) return String(args[0]);
14
+ if (args.length > 1) return args.map(String).join(':');
15
+ return /** @type {string} */ (e.topic);
16
+ }
17
+
18
+ /**
19
+ * Normalize a wire entry to the public `{ args, count, meta }` shape.
20
+ * @param {{ args?: any[], count?: number, meta?: any }} e
21
+ */
22
+ function _roomEntry(e) {
23
+ return {
24
+ args: Array.isArray(e.args) ? e.args : [],
25
+ count: typeof e.count === 'number' ? e.count : 0,
26
+ meta: e.meta
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Project the enumeration stream's flat entry list into a Map keyed by room
32
+ * args. The server keys the stream by wire topic (unique), so a malformed entry
33
+ * with neither a topic nor args is dropped rather than collapsing onto a shared
34
+ * key.
35
+ * @param {Array<{ topic?: string, args?: any[], count?: number, meta?: any }>} entries
36
+ */
37
+ function _roomsMap(entries) {
38
+ const m = new Map();
39
+ for (const e of entries) {
40
+ if (e && (e.topic !== undefined || Array.isArray(e.args))) m.set(_roomKey(e), _roomEntry(e));
41
+ }
42
+ return m;
43
+ }
44
+
45
+ /**
46
+ * Live view of a `live.room` export's ACTIVE rooms - the backing for a lobby
47
+ * browser. Subscribes to the export's enumeration stream and projects its
48
+ * entries into a reactive Map keyed by the room's identifying args, each value
49
+ * `{ args, count, meta }`. The server feeds the stream as topics gain their
50
+ * first subscriber (a room opens), gain/lose subscribers (the live count moves),
51
+ * and lose their last (the room closes), so the view always reflects the open
52
+ * rooms and their current player counts.
53
+ *
54
+ * Mirrors `MultiplayerRoom`: subscribe in the constructor into `$state`, expose
55
+ * a `$derived` view, collect unsubscribes for `destroy()`. Requires Svelte 5
56
+ * (the view is a rune class).
57
+ */
58
+ export class RoomsList {
59
+ #entries = $state([]);
60
+ #status = $state('idle');
61
+ #list;
62
+ #unsubs = [];
63
+
64
+ /**
65
+ * @param {{
66
+ * stream: { subscribe: (fn: (v: any) => void) => () => void },
67
+ * status?: { subscribe: (fn: (v: string) => void) => () => void },
68
+ * list?: (...args: any[]) => Promise<any>
69
+ * }} deps - `stream` is the export's enumeration stream (a crud store keyed by
70
+ * topic), `status` the optional connection-status passthrough, `list` the
71
+ * one-shot snapshot RPC.
72
+ */
73
+ constructor(deps) {
74
+ this.#list = typeof deps.list === 'function' ? deps.list : null;
75
+ this.#unsubs.push(deps.stream.subscribe((v) => { this.#entries = Array.isArray(v) ? v : []; }));
76
+ if (deps.status) this.#unsubs.push(deps.status.subscribe((v) => { this.#status = v; }));
77
+ }
78
+
79
+ /** Reactive `Map<roomKey, { args, count, meta }>` of the active rooms. */
80
+ #roomsDerived = $derived(_roomsMap(this.#entries));
81
+ get rooms() { return this.#roomsDerived; }
82
+
83
+ /** The connection-status passthrough. */
84
+ get status() { return this.#status; }
85
+
86
+ /**
87
+ * One-shot snapshot of the active rooms WITHOUT opening a live subscription -
88
+ * the same `{ args, count, meta }` entries `rooms` holds. Resolves to an
89
+ * array (empty when enumeration is unavailable).
90
+ * @returns {Promise<Array<{ args: any[], count: number, meta: any }>>}
91
+ */
92
+ async list() {
93
+ if (this.#list === null) return [];
94
+ const snap = await this.#list();
95
+ return Array.isArray(snap) ? snap.map(_roomEntry) : [];
96
+ }
97
+
98
+ /** Stop the live subscription. Call from the component's cleanup. */
99
+ destroy() {
100
+ for (const off of this.#unsubs) off();
101
+ this.#unsubs = [];
102
+ }
103
+ }
@@ -465,7 +465,10 @@ async function _executeStreamRpc(ws, platform, fn, ctx, args, msg, subscribedRef
465
465
  subscribedRef.topic = topic;
466
466
 
467
467
  if (/** @type {any} */ (fn).__onSubscribe) {
468
- try { await /** @type {any} */ (fn).__onSubscribe(ctx, topic); } catch {}
468
+ // `streamArgs` (3rd arg) lets a room enumeration registry capture the
469
+ // room-identifying args of the topic that just gained a subscriber; the
470
+ // presence hook ignores it.
471
+ try { await /** @type {any} */ (fn).__onSubscribe(ctx, topic, streamArgs); } catch {}
469
472
  }
470
473
 
471
474
  if (/** @type {any} */ (fn).__isDerived && !state.activateDerivedCalled && !state.warnedActivateDerived) {
@@ -26,6 +26,12 @@ export function installRoom(seams) {
26
26
  * @returns {any}
27
27
  */
28
28
 
29
+ // Per-export enumeration topic counter. A process-local id is enough for the
30
+ // single-instance enumeration stream (the client reaches it through the
31
+ // generated path, never the raw string); a future cluster-wide variant will
32
+ // derive a stable topic from the module path so two instances of one export agree.
33
+ let _roomsEnumSeq = 0;
34
+
29
35
  export const _roomRegister = function room(config) {
30
36
  const {
31
37
  topic: topicFn,
@@ -37,11 +43,23 @@ export const _roomRegister = function room(config) {
37
43
  onJoin,
38
44
  onLeave,
39
45
  merge: mergeMode = 'crud',
40
- key: keyField = 'id'
46
+ key: keyField = 'id',
47
+ meta: metaFn,
48
+ enumerable: enumerableFlag
41
49
  } = config;
42
50
 
43
51
  /** @type {any} */ (topicFn).__topicUsesCtx = true;
44
52
 
53
+ // Room enumeration (opt-in: a `meta` function or `enumerable: true`). When on,
54
+ // a per-export registry tracks which of this room's topics currently have
55
+ // subscribers (and how many), so `game.rooms()` can render a live lobby
56
+ // browser. Off by default - a room without it installs no registry hooks and
57
+ // no enumeration stream, so it is byte-identical to before.
58
+ if (metaFn !== undefined && typeof metaFn !== 'function') {
59
+ throw new Error('[svelte-realtime] live.room() meta must be a function (args) => ({ ... })\n See: https://svti.me/rooms');
60
+ }
61
+ const isEnumerable = enumerableFlag === true || typeof metaFn === 'function';
62
+
45
63
  // Number of room-identifying args the topic function expects (excluding ctx).
46
64
  // Used by room actions to separate room args from action-specific payload.
47
65
  let _roomArgCount = Math.max(0, topicFn.length - 1);
@@ -133,6 +151,55 @@ export const _roomRegister = function room(config) {
133
151
 
134
152
  const roomExport = {};
135
153
 
154
+ // Enumeration registry (single-instance): which of this room's topics
155
+ // currently have subscribers, with a per-connection count and the `meta`
156
+ // captured at the moment the topic gained its first subscriber. Keyed by the
157
+ // data topic; the lobby-browser snapshot is `Array.from(roomsIndex.values())`.
158
+ const roomsIndex = new Map();
159
+ const enumTopic = 'rooms:' + ++_roomsEnumSeq;
160
+
161
+ // Resolve meta once, when a room opens. A throwing meta never blocks the room
162
+ // (the entry still appears, with empty meta); a frozen copy keeps a later
163
+ // reader from mutating the registry through the snapshot.
164
+ const _roomMeta = (args) => {
165
+ if (typeof metaFn !== 'function') return undefined;
166
+ try {
167
+ const m = metaFn(...args);
168
+ return m && typeof m === 'object' ? Object.freeze({ ...m }) : m;
169
+ } catch {
170
+ return {};
171
+ }
172
+ };
173
+ // First subscriber opens the room (created); later subscribers bump the count
174
+ // (updated). `args` is the room-identifying args of the topic, forwarded by
175
+ // the stream subscribe path as the hook's 3rd argument.
176
+ const _enumOnSub = (ctx, topic, args) => {
177
+ let entry = roomsIndex.get(topic);
178
+ if (entry === undefined) {
179
+ entry = { topic, args: Array.isArray(args) ? args.slice() : [], count: 1, meta: _roomMeta(args || []) };
180
+ roomsIndex.set(topic, entry);
181
+ // Publish a snapshot copy, not the live entry - the registry mutates
182
+ // `count` in place, so a delta must capture its value at this moment.
183
+ ctx.publish(enumTopic, 'created', { ...entry });
184
+ } else {
185
+ entry.count++;
186
+ ctx.publish(enumTopic, 'updated', { ...entry });
187
+ }
188
+ };
189
+ // The last subscriber to leave closes the room (deleted); otherwise the count
190
+ // drops to the authoritative remaining count the unsubscribe path supplies.
191
+ const _enumOnUnsub = (ctx, topic, remainingSubscribers) => {
192
+ const entry = roomsIndex.get(topic);
193
+ if (entry === undefined) return;
194
+ if (remainingSubscribers <= 0) {
195
+ roomsIndex.delete(topic);
196
+ ctx.publish(enumTopic, 'deleted', { topic });
197
+ } else {
198
+ entry.count = remainingSubscribers;
199
+ ctx.publish(enumTopic, 'updated', { ...entry });
200
+ }
201
+ };
202
+
136
203
  const dataStream = live.stream(topicFn, async function roomInit(ctx, ...args) {
137
204
  if (guardFn) await guardFn(ctx, ...args);
138
205
  const result = await initFn(ctx, ...args);
@@ -144,7 +211,9 @@ export const _roomRegister = function room(config) {
144
211
  }, {
145
212
  merge: mergeMode,
146
213
  key: keyField,
147
- onSubscribe: presenceFn ? async (ctx, topic) => {
214
+ onSubscribe: (presenceFn || isEnumerable) ? async (ctx, topic, args) => {
215
+ if (isEnumerable) _enumOnSub(ctx, topic, args);
216
+ if (!presenceFn) return;
148
217
  const userId = _getIdentityKey(ctx);
149
218
  const refKey = topic + '\0' + userId;
150
219
 
@@ -207,7 +276,9 @@ export const _roomRegister = function room(config) {
207
276
  }
208
277
  }
209
278
  } : undefined,
210
- onUnsubscribe: presenceFn ? (ctx, topic) => {
279
+ onUnsubscribe: (presenceFn || isEnumerable) ? (ctx, topic, remainingSubscribers) => {
280
+ if (isEnumerable) _enumOnUnsub(ctx, topic, remainingSubscribers);
281
+ if (!presenceFn) return;
211
282
  const userId = _getIdentityKey(ctx);
212
283
  const refKey = topic + '\0' + userId;
213
284
 
@@ -254,6 +325,21 @@ export const _roomRegister = function room(config) {
254
325
  /** @type {any} */ (roomExport).__hasPresence = !!presenceFn;
255
326
  /** @type {any} */ (roomExport).__hasCursors = !!cursorConfig;
256
327
  /** @type {any} */ (roomExport).__cursorThrottle = typeof cursorConfig === 'object' ? cursorConfig.throttle || 50 : 50;
328
+ /** @type {any} */ (roomExport).__hasRooms = isEnumerable;
329
+
330
+ // Enumeration stream (opt-in): one per-export stream whose snapshot is the
331
+ // active-rooms registry and whose live deltas (created/updated/deleted, fed by
332
+ // the data-stream subscribe hooks above) merge into the client's lobby view,
333
+ // keyed by topic. `__roomsSync` backs the one-shot `.list()` (snapshot, no
334
+ // subscription).
335
+ if (isEnumerable) {
336
+ /** @type {any} */ (roomExport).__roomsStream = live.stream(
337
+ enumTopic,
338
+ async () => Array.from(roomsIndex.values(), (e) => ({ ...e })),
339
+ { merge: 'crud', key: 'topic' }
340
+ );
341
+ /** @type {any} */ (roomExport).__roomsSync = live(async () => Array.from(roomsIndex.values(), (e) => ({ ...e })));
342
+ }
257
343
 
258
344
  // Presence stream (if enabled)
259
345
  if (presenceFn) {
@@ -269,6 +269,12 @@ function _smoothRecord(name, cfg, platform, rt) {
269
269
  lastSeenOwner: null,
270
270
  lastRenew: 0,
271
271
  owned: false,
272
+ // Owner wall-clock basis captured at a non-owner from inbound acks (which
273
+ // carry the owner's `t`): a forwarding edge reconstructs the owner's clock
274
+ // from this to measure a shot's latency on the owner's axis. Null until the
275
+ // first ack with a `t` lands; read only on the forwarded-shoot path.
276
+ lastOwnerT: null,
277
+ lastOwnerWall: 0,
272
278
  // Warm-handoff snapshot state (opt-in; null/0 on the default path).
273
279
  // `lastSnap` throttles the owner's debounced write; `pendingSnapshot`
274
280
  // holds states recovered on acquire until each entity's real client
@@ -886,6 +892,13 @@ function _ensureSmoothCluster(smooth) {
886
892
  onAck: (wireTopic, identity, payload) => {
887
893
  const rec = _smoothRecByWire(wireTopic);
888
894
  if (!rec) return;
895
+ // Capture the owner's wall stamp (carried on every ack) so a non-owner can
896
+ // reconstruct the owner's clock for an edge-measured forwarded shot. Gated
897
+ // on hitTest so a non-lag-comp topic pays nothing.
898
+ if (rec.cfg.hitTest !== undefined && payload && typeof payload.t === 'number' && Number.isFinite(payload.t)) {
899
+ rec.lastOwnerT = payload.t;
900
+ rec.lastOwnerWall = wallEpoch();
901
+ }
889
902
  const ws = rec.registry.get(identity);
890
903
  if (ws !== undefined) _smoothSendTo(rec, ws, 'ack', payload);
891
904
  },
@@ -902,6 +915,41 @@ function _ensureSmoothCluster(smooth) {
902
915
  _smoothRelayRemove(rec, removed[i]);
903
916
  }
904
917
  if (rec.authority.size === 0) _smoothForget(rec);
918
+ },
919
+ // Owner: a non-owner forwarded a client's shot. Resolve it against the ring
920
+ // using the EDGE-measured durations (reach width + rewind age) applied to the
921
+ // owner's OWN present - never re-measuring across the inter-instance hop, which
922
+ // would fold that hop into the window. The authoritative hit rides the owner's
923
+ // existing event broadcast back to the shooter's instance, so a forwarded shot
924
+ // needs no correlated reply.
925
+ onShoot: (wireTopic, identity, originInstance, payload) => {
926
+ const rec = _smoothRecByWire(wireTopic);
927
+ if (!rec || !rec.owned || rec.lagComp === null) return;
928
+ if (!payload || typeof payload !== 'object') return;
929
+ const shooterEntity = rec.authority.get(identity);
930
+ if (shooterEntity === undefined) return; // no entity here: this shooter cannot aim
931
+ const ht = rec.cfg.hitTest;
932
+ const reach = typeof payload.reach === 'number' && Number.isFinite(payload.reach)
933
+ ? Math.min(payload.reach, ht.maxRewindMs)
934
+ : ht.maxRewindMs;
935
+ const rewindAge =
936
+ typeof payload.rewindAge === 'number' && Number.isFinite(payload.rewindAge) && payload.rewindAge >= 0
937
+ ? payload.rewindAge
938
+ : null;
939
+ // The detection signal fires from the edge-measured picture the owner cannot
940
+ // recompute; a throwing hook never affects the shot.
941
+ if (ht.detectionHook !== undefined && payload.detect && typeof payload.detect === 'object') {
942
+ try {
943
+ // Spread the forwarded picture first, then the trusted identity, so a
944
+ // forged payload.detect.identity can never override the authoritative shooter.
945
+ ht.detectionHook({ ...payload.detect, identity });
946
+ } catch {
947
+ /* observability only */
948
+ }
949
+ }
950
+ const nowMono = rec.monoClock.mono(wallEpoch());
951
+ const rewindAt = _smoothRewindAt(nowMono, reach, rewindAge);
952
+ _smoothResolveShot(rec, rec.name, identity, shooterEntity, rec.platform, payload.cmd, rewindAt).catch(() => {});
905
953
  }
906
954
  });
907
955
  }
@@ -1082,6 +1130,240 @@ function _shotUnitDir(d) {
1082
1130
  return null;
1083
1131
  }
1084
1132
 
1133
+ /**
1134
+ * Reconstruct the topic owner's wall clock at a non-owner (the forwarding edge).
1135
+ * The owner stamps an absolute `t` on every ack; the edge captures it with its
1136
+ * own wall time (`onAck`), so the owner's clock "now" is that stamp plus the
1137
+ * wall time elapsed since. Returns null until an ack with a `t` has been seen
1138
+ * (cold start) - the edge then forwards no rewind age and the owner resolves the
1139
+ * shot at the present (favor the defender). Wall-elapsed (not the monotonic
1140
+ * seam) keeps it deterministic under a seeded/faked clock.
1141
+ * @param {any} rec
1142
+ * @returns {number | null}
1143
+ */
1144
+ function _edgeOwnerNow(rec) {
1145
+ if (rec.lastOwnerT === null) return null;
1146
+ return rec.lastOwnerT + (wallEpoch() - rec.lastOwnerWall);
1147
+ }
1148
+
1149
+ /**
1150
+ * Convert a reach window width + a rewind age (both DURATIONS, milliseconds)
1151
+ * into a rewindAt on a ring's own monotonic axis. A null age resolves at the
1152
+ * present (favor the defender); otherwise the aimed instant `now - age` is
1153
+ * floored by the reach window and capped at the present. This is the age-form of
1154
+ * the single-instance clamp `max(now - reach, min(rtMono, now))` and is
1155
+ * bit-identical to it for a local shot (where `age = now - rt`).
1156
+ * @param {number} nowMono @param {number} reach @param {number | null} rewindAge
1157
+ * @returns {number}
1158
+ */
1159
+ function _smoothRewindAt(nowMono, reach, rewindAge) {
1160
+ if (rewindAge === null || rewindAge === undefined) return nowMono;
1161
+ return Math.min(nowMono, Math.max(nowMono - reach, nowMono - rewindAge));
1162
+ }
1163
+
1164
+ /**
1165
+ * Edge measurement for a shot: from the shot payload compute the favor-shooter
1166
+ * reach WIDTH and the rewind AGE (both durations, axis-free), run the per-
1167
+ * connection replay defense + latch, and - when a `detectionHook` is configured
1168
+ * - the latency detection picture. `now` is the wall time on the OWNER's axis:
1169
+ * `wallEpoch()` on the owner / single instance, the reconstructed owner clock on
1170
+ * a forwarding edge (null on edge cold start -> resolve at present). Both the
1171
+ * uplink sample and the replay latch live on this connection's `ws` (the edge
1172
+ * always holds the real shooter socket, so the WeakMap already keys on the
1173
+ * origin client, never the inter-instance hop). Returns the forwarded payload
1174
+ * shape `{ cmd, reach, rewindAge, detect, nowMono }`, or null when the shot is a
1175
+ * replayed / older render-time the latch rejects.
1176
+ * @param {any} rec @param {any} ctx @param {any} payload @param {string} shooterKey @param {number | null} now
1177
+ * @returns {{ cmd: any, reach: number, rewindAge: number | null, detect: any, nowMono: number | null } | null}
1178
+ */
1179
+ function _smoothEdgeMeasure(rec, ctx, payload, shooterKey, now) {
1180
+ const ht = rec.cfg.hitTest;
1181
+ const cmd = payload.cmd;
1182
+ // No owner-clock basis yet (edge cold start): forward at the present.
1183
+ if (now === null) return { cmd, reach: ht.maxRewindMs, rewindAge: null, detect: null, nowMono: null };
1184
+ const nowMono = rec.monoClock.mono(now);
1185
+ const monoCorr = nowMono - now;
1186
+ const rtStamp = payload.rt;
1187
+ let reach = ht.maxRewindMs;
1188
+ let rewindAge = null;
1189
+ let detect = null;
1190
+ if (typeof rtStamp === 'number' && Number.isFinite(rtStamp)) {
1191
+ let st = ctx.ws ? _lcRtt.get(ctx.ws) : undefined;
1192
+ if (ctx.ws && st === undefined) {
1193
+ st = { tracker: createRttTracker(), lastRt: -Infinity };
1194
+ _lcRtt.set(ctx.ws, st);
1195
+ }
1196
+ // Map the wall-axis render-time onto the monotonic axis so the replay
1197
+ // defense, the latch, and the age all live on one axis (a wall backstep
1198
+ // then stays continuous). monoCorr is zero in normal operation.
1199
+ const rtMono = rtStamp + monoCorr;
1200
+ // Replay defense: a real rendered instant only advances, so a render-time
1201
+ // strictly OLDER than the last accepted one (a captured shot resent to
1202
+ // re-resolve an old lineup) is dropped before it can resolve or forward.
1203
+ if (st && rtMono < st.lastRt) return null;
1204
+ const ackT = payload.ackT;
1205
+ if (st && typeof ackT === 'number' && Number.isFinite(ackT) && ackT <= now && now - ackT <= ht.maxRewindMs) {
1206
+ st.tracker.sample((now - ackT) / 2, nowMono);
1207
+ }
1208
+ // Favor-the-shooter reach width = measured uplink (max-of-recent) + the
1209
+ // client's interpolation delay; both server-measured, clamped to the cap.
1210
+ const maxUp = st ? st.tracker.maxUplink() : null;
1211
+ const serverInterp = rec.interest.interpDelayMs(shooterKey, rec.tickMs, now);
1212
+ reach = maxUp === null ? ht.maxRewindMs : Math.min(ht.maxRewindMs, maxUp + serverInterp);
1213
+ // The rewind age is a pure duration the owner applies to its own present.
1214
+ rewindAge = Math.max(0, now - rtStamp);
1215
+ if (st) st.lastRt = Math.max(st.lastRt, Math.min(rtMono, nowMono));
1216
+ if (ht.detectionHook !== undefined && st) {
1217
+ const minUp = st.tracker.minUplink();
1218
+ detect = {
1219
+ minUplink: minUp,
1220
+ maxUplink: maxUp,
1221
+ reach,
1222
+ interpDelay: serverInterp,
1223
+ divergence: minUp !== null && maxUp !== null ? maxUp - minUp : 0
1224
+ };
1225
+ }
1226
+ }
1227
+ return { cmd, reach, rewindAge, detect, nowMono };
1228
+ }
1229
+
1230
+ /**
1231
+ * Resolve a shot against the rewound world: gate candidates at `rewindAt`, run
1232
+ * the shot geometry from the shooter's CURRENT state, the broadphase + per-
1233
+ * candidate narrowphase, the nearest-first `onHit` consequence, and the hit-
1234
+ * event broadcast (plus cluster relay). Shared by the local / owner-direct shot
1235
+ * path and the forwarded-shot owner handler; the caller computes `rewindAt`
1236
+ * (directly from a local measurement, or from forwarded durations on the owner)
1237
+ * and supplies the platform whose `smooth` coordinator relays the hit events.
1238
+ * @param {any} rec @param {string} name @param {string} shooterKey
1239
+ * @param {any} shooterEntity @param {any} ctxPlatform @param {any} cmd @param {number} rewindAt
1240
+ */
1241
+ async function _smoothResolveShot(rec, name, shooterKey, shooterEntity, ctxPlatform, cmd, rewindAt) {
1242
+ const ht = rec.cfg.hitTest;
1243
+ const cluster = ctxPlatform && ctxPlatform.smooth;
1244
+ // Candidate set, gated at the REWIND instant rather than at receipt: a target
1245
+ // the shooter had on screen when it fired is a valid hit even if it drifted
1246
+ // out of range in flight, and one that drifted in only after firing is not.
1247
+ const candKeys = new Set();
1248
+ const liveCand = rec.interest.getCandidates(shooterKey);
1249
+ if (liveCand !== undefined) for (const k of liveCand) if (k !== shooterKey) candKeys.add(k);
1250
+ // The geometric gate compares ring positions against the interest radius, so
1251
+ // it is only sound when the ring records the SAME position the interest set
1252
+ // uses (the default, where hitTest.position falls back to interest.position).
1253
+ // A custom hitTest.position in another space, or a null rewound center, skips
1254
+ // the gate and falls back to the receipt-time membership.
1255
+ const gateInRingSpace = rec.cfg.hitTest.position === rec.cfg.interest.position;
1256
+ const shooterAt = gateInRingSpace ? rec.lagComp.sample(shooterKey, rewindAt) : null;
1257
+ let world;
1258
+ if (shooterAt === null) {
1259
+ if (candKeys.size === 0) return;
1260
+ world = rec.lagComp.rewind(candKeys, rewindAt);
1261
+ } else {
1262
+ const radius = rec.interest.radius;
1263
+ // Also broadphase the departed shell - entities near the shooter's rewound
1264
+ // position the receipt-time set no longer lists (they left in flight). The
1265
+ // exact gate below trims it back, so over-pulling is safe.
1266
+ const near = rec.interest.candidatesAt(shooterAt.x, shooterAt.y, radius * 2);
1267
+ for (let i = 0; i < near.length; i++) if (near[i] !== shooterKey) candKeys.add(near[i]);
1268
+ if (candKeys.size === 0) return;
1269
+ world = rec.lagComp.rewindWithin(candKeys, rewindAt, shooterAt.x, shooterAt.y, radius * radius);
1270
+ }
1271
+ if (world.size === 0) return;
1272
+ // Shot geometry from the shooter's CURRENT state: only the targets rewind. A
1273
+ // throw on malformed state drops the shot (favor-defender miss).
1274
+ let origin, dir;
1275
+ try {
1276
+ origin = ht.shot.origin(cmd, shooterEntity.state);
1277
+ dir = _shotUnitDir(ht.shot.dir(cmd, shooterEntity.state));
1278
+ } catch {
1279
+ return;
1280
+ }
1281
+ if (origin === null || typeof origin !== 'object' || !Number.isFinite(origin.x) || !Number.isFinite(origin.y)) return;
1282
+ if (dir === null) return;
1283
+ const maxDist = ht.shot.maxDist;
1284
+ const useResolve = typeof ht.resolve === 'function';
1285
+ // Broadphase distance cull, defaulting to maxDist plus the hitbox's own reach
1286
+ // so a target centred just past maxDist can still be struck on its near edge.
1287
+ const hitboxReach = useResolve
1288
+ ? Infinity
1289
+ : ht.hitbox.shape === 'circle'
1290
+ ? ht.hitbox.radius
1291
+ : 0.5 * Math.sqrt(ht.hitbox.w * ht.hitbox.w + ht.hitbox.h * ht.hitbox.h);
1292
+ const bpMaxDist = ht.broadphase && ht.broadphase.maxDist ? ht.broadphase.maxDist : maxDist + hitboxReach;
1293
+ const bpMaxSq = bpMaxDist * bpMaxDist;
1294
+ const cone = ht.broadphase ? ht.broadphase.cone : undefined;
1295
+ const shot = { origin, dir, maxDist };
1296
+ // The shoot ctx is per-shot (not per-target): applyTo (authoritative cross-
1297
+ // entity mutation) and emitEvent (the hit signal) are plain locals.
1298
+ let armed = false;
1299
+ const pendingEvents = [];
1300
+ const shootCtx = {
1301
+ identity: shooterKey,
1302
+ platform: ctxPlatform,
1303
+ applyTo(victimKey, victimCmd) {
1304
+ if (typeof victimKey !== 'string') return false;
1305
+ if (rec.authority.inject(victimKey, victimCmd)) {
1306
+ armed = true;
1307
+ return true;
1308
+ }
1309
+ return false;
1310
+ },
1311
+ emitEvent(type, data, opts) {
1312
+ if (typeof type !== 'string') return;
1313
+ pendingEvents.push({ type, data, opts });
1314
+ }
1315
+ };
1316
+ // Broadphase cull + narrowphase per candidate, nearest-first. Penetration is
1317
+ // ON by default: every aligned candidate is hit unless onHit returns { stop: true }.
1318
+ const hits = [];
1319
+ for (const [key, s] of world) {
1320
+ const vx = s.x - origin.x;
1321
+ const vy = s.y - origin.y;
1322
+ const distSq = vx * vx + vy * vy;
1323
+ if (distSq > bpMaxSq) continue;
1324
+ if (cone !== undefined && cone !== null && distSq > 0) {
1325
+ if ((vx * dir.x + vy * dir.y) / Math.sqrt(distSq) < cone) continue;
1326
+ }
1327
+ let hit;
1328
+ if (useResolve) {
1329
+ hit = ht.resolve(shot, { key, pos: { x: s.x, y: s.y }, state: s.state }, shootCtx);
1330
+ } else if (ht.hitbox.shape === 'circle') {
1331
+ hit = rayCircleHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.radius);
1332
+ } else {
1333
+ hit = rayAabbHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.w, ht.hitbox.h);
1334
+ }
1335
+ if (hit !== null && hit !== undefined && Number.isFinite(hit.dist)) {
1336
+ hits.push({ key, pos: { x: s.x, y: s.y }, state: s.state, dist: hit.dist, point: hit.point, fallback: s.fallback });
1337
+ }
1338
+ }
1339
+ if (hits.length === 0) return;
1340
+ hits.sort((a, b) => a.dist - b.dist);
1341
+ for (let i = 0; i < hits.length; i++) {
1342
+ const h = hits[i];
1343
+ const target = { key: h.key, pos: h.pos, state: h.state };
1344
+ const info = { dist: h.dist, point: h.point, fraction: maxDist > 0 ? h.dist / maxDist : 0, rewindAt, fallback: h.fallback };
1345
+ const verdict = await ht.onHit(shootCtx, target, info);
1346
+ if (verdict && verdict.stop) break;
1347
+ }
1348
+ // onHit may have awaited; if the topic was forgotten or this instance lost
1349
+ // ownership meanwhile, do not publish the events or arm a dead/demoted record.
1350
+ if (_smoothTopics.get(name) !== rec || (cluster && !rec.owned)) return;
1351
+ for (let i = 0; i < pendingEvents.length; i++) {
1352
+ const pe = pendingEvents[i];
1353
+ const wire = {
1354
+ type: pe.type,
1355
+ key: pe.opts && typeof pe.opts.key === 'string' ? pe.opts.key : shooterKey,
1356
+ data: pe.data,
1357
+ id: ++rec.shootEventSeq
1358
+ };
1359
+ _smoothPublish(rec, 'event', wire, undefined);
1360
+ if (cluster && typeof cluster.relayBroadcast === 'function') {
1361
+ cluster.relayBroadcast(rec.wireTopic, 'event', wire, undefined, rec.eventSeq++);
1362
+ }
1363
+ }
1364
+ if (armed) _armSmoothTick(rec);
1365
+ }
1366
+
1085
1367
  /**
1086
1368
  * Declare a topic of smoothed (predicted / reconciled) entities.
1087
1369
  *
@@ -1404,263 +1686,46 @@ export const _smoothRegister = function smooth(config) {
1404
1686
  // No live record, or hit testing off: nothing to resolve. (Defense in depth -
1405
1687
  // the RPC is only registered when hitTest is configured.)
1406
1688
  if (rec === undefined || rec.lagComp === null) return;
1407
- // The ring and the authoritative catalog live only on the ticking owner. A
1408
- // non-owner shooter's shot is forwarded to the owner in a later step; until
1409
- // then it is inert on a non-owning instance (never resolved against an empty
1410
- // local ring, which would be a silent miss).
1411
- const cluster = ctx.platform && ctx.platform.smooth;
1412
- if (cluster && !rec.owned) return;
1413
1689
  const shooterKey = _getIdentityKey(ctx);
1690
+ const cluster = ctx.platform && ctx.platform.smooth;
1691
+ if (cluster && !rec.owned) {
1692
+ // EDGE: this instance does not own the ring (the authoritative catalog and
1693
+ // the history ring live on the owner). Measure latency against the
1694
+ // reconstructed owner clock, run the replay defense, and forward the
1695
+ // bounded DURATIONS (reach width + rewind age) to the owner, which resolves
1696
+ // the shot on its own ring axis - never re-measuring across the hop, which
1697
+ // would fold the inter-instance latency into the reach. Inert when the
1698
+ // coordinator predates relayShoot (an older extensions build): the shot
1699
+ // stays a no-op, exactly as it did before forwarded shots existed.
1700
+ if (typeof cluster.relayShoot !== 'function') return;
1701
+ const fwd = _smoothEdgeMeasure(rec, ctx, payload, shooterKey, _edgeOwnerNow(rec));
1702
+ if (fwd === null) return; // a replayed / older render-time, dropped at the edge
1703
+ cluster.relayShoot(rec.wireTopic, shooterKey, cluster.instanceId, {
1704
+ cmd: fwd.cmd,
1705
+ reach: fwd.reach,
1706
+ rewindAge: fwd.rewindAge,
1707
+ ...(fwd.detect && { detect: fwd.detect })
1708
+ });
1709
+ return;
1710
+ }
1711
+ // OWNER / single instance: this instance holds the ring. Measure against the
1712
+ // local wall clock and resolve the shot here.
1414
1713
  const shooterEntity = rec.authority.get(shooterKey);
1415
1714
  if (shooterEntity === undefined) return; // a shooter with no entity cannot aim
1416
1715
  const ht = rec.cfg.hitTest;
1417
- const cmd = payload.cmd;
1418
- const now = wallEpoch();
1419
- // The ring is keyed on a monotonic axis, so the rewind works on that axis too:
1420
- // `nowMono` is the present on it and `monoCorr` (the wall->monotonic offset, zero
1421
- // in normal operation) maps the client's wall-axis render-time onto it.
1422
- const nowMono = rec.monoClock.mono(now);
1423
- const monoCorr = nowMono - now;
1424
- // Rewind DIRECTLY to the client's absolute synced-clock renderTime (idTech3-
1425
- // faithful) - reconstructing `now - rtt` at receipt would re-add the uplink leg
1426
- // and under-compensate. The client proposes WHERE in time; the server bounds HOW
1427
- // WIDE the window may be from latency it measures itself. A missing / non-finite
1428
- // stamp resolves at the present (favor defender).
1429
- const rtStamp = payload.rt;
1430
- let rewindAt = nowMono;
1431
- if (typeof rtStamp === 'number' && Number.isFinite(rtStamp)) {
1432
- // Per-connection server-anchored latency: the client echoed the latest
1433
- // server stamp it saw (ackT); roundTrip = now - ackT, BOTH ends server wall
1434
- // times, so the client cannot fake a lower latency (only inflate it, which
1435
- // costs real responsiveness and is bounded below by the policy cap). Feed
1436
- // this shot's own sample first so even a first shot self-seeds its reach.
1437
- let st = ctx.ws ? _lcRtt.get(ctx.ws) : undefined;
1438
- if (ctx.ws && st === undefined) {
1439
- st = { tracker: createRttTracker(), lastRt: -Infinity };
1440
- _lcRtt.set(ctx.ws, st);
1441
- }
1442
- // Map the wall-axis render-time onto the ring's monotonic axis up front, so the
1443
- // replay defense, the latch, AND the rewind all live on that one axis. A server
1444
- // wall backstep steps the client's synced render-time DOWN by the same offset, so
1445
- // the raw stamp would look like it moved backward; on the monotonic axis it stays
1446
- // continuous (rtMono = the stepped-down stamp + the absorbed offset). monoCorr is
1447
- // zero in normal operation, so rtMono == rtStamp and nothing below changes.
1448
- const rtMono = rtStamp + monoCorr;
1449
- // Replay defense: a real rendered instant only advances, so a renderTime OLDER
1450
- // than the last accepted one (a captured shot resent to re-resolve an old enemy
1451
- // lineup) is dropped. Strict `<` admits an EQUAL stamp: a shotgun's pellets / a
1452
- // burst fired in one frame share the same render-time and must all resolve. A
1453
- // replay of a stale lineup is strictly older once the shooter has fired since, so
1454
- // it is still rejected. One number per connection, on the monotonic axis - so a
1455
- // wall backstep (which steps the raw stamp down) is not mistaken for a replay.
1456
- if (st && rtMono < st.lastRt) return;
1457
- const ackT = payload.ackT;
1458
- if (st && typeof ackT === 'number' && Number.isFinite(ackT) && ackT <= now && now - ackT <= ht.maxRewindMs) {
1459
- // Uplink is a wall-axis duration (ackT is a server wall stamp); rotate the
1460
- // bucket window on the monotonic axis so a wall backstep cannot disturb it.
1461
- st.tracker.sample((now - ackT) / 2, nowMono);
1462
- }
1463
- // Favor-the-shooter reach width = measured uplink (max-of-recent, so a latency
1464
- // spike never clamps an honest shot) + the client's interpolation delay. BOTH
1465
- // legs are now server-measured: the uplink from the ackT round trips, and the
1466
- // interp delay from how often the server sends THIS shooter frames (the same
1467
- // cadence the client measures to set its render delay). So a sparsely-served
1468
- // shooter, which legitimately renders further in the past, gets the wider reach
1469
- // it needs instead of clamping short - while a densely-served low-latency shooter
1470
- // stays tight (cadence == tick rate) and cannot borrow a laggy player's budget.
1471
- // All clamped to the policy cap.
1472
- const maxUp = st ? st.tracker.maxUplink() : null;
1473
- const serverInterp = rec.interest.interpDelayMs(shooterKey, rec.tickMs, now);
1474
- const reach = maxUp === null ? ht.maxRewindMs : Math.min(ht.maxRewindMs, maxUp + serverInterp);
1475
- // Clamp the mapped render-time into the rewind window. `min(rtMono, nowMono)`
1476
- // caps an over-mapped stamp (the brief post-backstep window before the client
1477
- // re-syncs to the stepped clock) to the present - favor defender, never a read
1478
- // outside the ring.
1479
- rewindAt = Math.max(nowMono - reach, Math.min(rtMono, nowMono));
1480
- // Latch the clamped monotonic value: a one-off future/overshooting renderTime is
1481
- // bounded by `min(rtMono, nowMono)` so it cannot strand subsequent honest shots
1482
- // behind an inflated floor, and because the floor lives on the monotonic axis a
1483
- // wall backstep (which steps the raw stamp down) does not read as a replay.
1484
- if (st) st.lastRt = Math.max(st.lastRt, Math.min(rtMono, nowMono));
1485
- // Detection signal (opt-in, off by default): surface the per-shot latency picture
1486
- // to an app/anti-cheat callback. The discriminating lag-switch tell is the
1487
- // divergence between the un-inflatable floor (minUplink) and the reach-driving max
1488
- // (maxUplink), plus an abrupt floor jump the app derives from the minUplink series -
1489
- // NOT the raw clamp rate (honest jittery/mobile players clamp routinely). This
1490
- // subsystem only emits; detection action lives in the app's module. A throwing hook
1491
- // never affects the shot (observability only).
1492
- if (ht.detectionHook !== undefined && st) {
1493
- const minUp = st.tracker.minUplink();
1494
- try {
1495
- ht.detectionHook({
1496
- identity: shooterKey,
1497
- minUplink: minUp,
1498
- maxUplink: maxUp,
1499
- reach,
1500
- interpDelay: serverInterp,
1501
- divergence: minUp !== null && maxUp !== null ? maxUp - minUp : 0
1502
- });
1503
- } catch {
1504
- /* observability only */
1505
- }
1506
- }
1507
- }
1508
- // Candidate set, gated at the REWIND instant rather than at receipt. The shooter
1509
- // aimed at the world it saw when it fired (rewindAt), so membership belongs there:
1510
- // a target that drifted out of the shooter's area of interest while the shot was in
1511
- // flight is still a valid hit (it was replicated when fired - the honest miss the
1512
- // receipt-time gate dropped), and one that drifted IN only after the shot was fired
1513
- // is not (it was never replicated at that instant). Both reduce to one geometric
1514
- // test on historical positions: in-gate iff dist(shooter, target) <= interest
1515
- // radius, both sampled at rewindAt. The transmit-bit guarantee is unchanged - you
1516
- // still cannot hit what the shooter never had - it is just evaluated at the right
1517
- // time. The set is server-computed; the client supplies no candidate list.
1518
- const candKeys = new Set();
1519
- const liveCand = rec.interest.getCandidates(shooterKey);
1520
- if (liveCand !== undefined) for (const k of liveCand) if (k !== shooterKey) candKeys.add(k);
1521
- // The geometric gate compares ring positions against the interest radius, so it is
1522
- // only sound when the ring records the SAME position the interest membership uses.
1523
- // That holds on the default path (hitTest.position falls back to interest.position,
1524
- // same reference); a custom hitTest.position in a different coordinate space would
1525
- // make the gate mix spaces, so there we skip it and fall back to the receipt-time
1526
- // membership (interest-space correct, just not rewindAt-precise). A null sample
1527
- // (shooter just spawned, pre-history, or a ring discontinuity at rewindAt) also
1528
- // has no usable rewound center, so it takes the same fallback.
1529
- const gateInRingSpace = rec.cfg.hitTest.position === rec.cfg.interest.position;
1530
- const shooterAt = gateInRingSpace ? rec.lagComp.sample(shooterKey, rewindAt) : null;
1531
- let world;
1532
- if (shooterAt === null) {
1533
- // No usable rewound gate: fall back to the receipt-time membership ungated -
1534
- // never worse than the pre-gate behavior.
1535
- if (candKeys.size === 0) return;
1536
- world = rec.lagComp.rewind(candKeys, rewindAt);
1537
- } else {
1538
- const radius = rec.interest.radius;
1539
- // Also broadphase the departed shell: entities near the shooter's rewound
1540
- // position that the receipt-time set no longer lists (they left during the
1541
- // flight window). A target must cross a full interest radius within the rewind
1542
- // window (<= maxRewindMs) to escape the doubled query - implausible for a radius
1543
- // sized to the arena - and the exact gate below trims the broadphase back to the
1544
- // true membership, so over-pulling is safe; under-pulling is the only real risk.
1545
- const near = rec.interest.candidatesAt(shooterAt.x, shooterAt.y, radius * 2);
1546
- for (let i = 0; i < near.length; i++) if (near[i] !== shooterKey) candKeys.add(near[i]);
1547
- if (candKeys.size === 0) return;
1548
- world = rec.lagComp.rewindWithin(candKeys, rewindAt, shooterAt.x, shooterAt.y, radius * radius);
1549
- }
1550
- if (world.size === 0) return;
1551
- // Shot geometry from the shooter's CURRENT state: only the targets rewind, the
1552
- // shooter fires from where the server says it is. The app's origin/dir are
1553
- // guarded like the ring's position() (lagcomp.record): a throw on malformed
1554
- // state drops the shot (favor-defender miss) rather than rejecting the handler.
1555
- let origin, dir;
1556
- try {
1557
- origin = ht.shot.origin(cmd, shooterEntity.state);
1558
- dir = _shotUnitDir(ht.shot.dir(cmd, shooterEntity.state));
1559
- } catch {
1560
- return;
1561
- }
1562
- if (origin === null || typeof origin !== 'object' || !Number.isFinite(origin.x) || !Number.isFinite(origin.y)) return;
1563
- if (dir === null) return;
1564
- const maxDist = ht.shot.maxDist;
1565
- const useResolve = typeof ht.resolve === 'function';
1566
- // Broadphase distance cull. The narrowphase accepts a hit whose ray ENTRY is
1567
- // within maxDist, but a hitbox reaches one radius (or half-diagonal) past its
1568
- // centre - so a target centred just beyond maxDist can still be struck on its
1569
- // near edge. Default the cull to maxDist + that reach so it never drops a valid
1570
- // hit; for a custom resolve (unknown reach) skip the distance cull entirely
1571
- // unless the app set an explicit broadphase.maxDist.
1572
- const hitboxReach = useResolve
1573
- ? Infinity
1574
- : ht.hitbox.shape === 'circle'
1575
- ? ht.hitbox.radius
1576
- : 0.5 * Math.sqrt(ht.hitbox.w * ht.hitbox.w + ht.hitbox.h * ht.hitbox.h);
1577
- const bpMaxDist = ht.broadphase && ht.broadphase.maxDist ? ht.broadphase.maxDist : maxDist + hitboxReach;
1578
- const bpMaxSq = bpMaxDist * bpMaxDist;
1579
- const cone = ht.broadphase ? ht.broadphase.cone : undefined;
1580
- const shot = { origin, dir, maxDist };
1581
- // Build the shoot ctx once (it is per-shot, not per-target): the seam where
1582
- // `applyTo` (authoritative cross-entity mutation) and `emitEvent` (the hit
1583
- // signal) are plain locals, never threaded through the authority's time-pure
1584
- // synchronous apply ctx.
1585
- let armed = false;
1586
- const pendingEvents = [];
1587
- const shootCtx = {
1588
- identity: shooterKey,
1589
- platform: ctx.platform,
1590
- // Apply a server-initiated command to any entity: a non-commanded update
1591
- // (broadcast to all incl. the victim, no ack), via the adapter authority's
1592
- // inject primitive. The victim's predictor is undisturbed (its ack
1593
- // watermark never moves); it sees the change through the normal broadcast.
1594
- applyTo(victimKey, victimCmd) {
1595
- if (typeof victimKey !== 'string') return false;
1596
- if (rec.authority.inject(victimKey, victimCmd)) {
1597
- armed = true;
1598
- return true;
1599
- }
1600
- return false;
1601
- },
1602
- // Queue a discrete one-shot event (a hit) for broadcast after resolution.
1603
- emitEvent(type, data, opts) {
1604
- if (typeof type !== 'string') return;
1605
- pendingEvents.push({ type, data, opts });
1606
- }
1607
- };
1608
- // Broadphase cull + narrowphase per candidate, collecting hits to order
1609
- // nearest-first. Penetration is ON by default: every aligned candidate is hit
1610
- // unless the app stops after the nearest by returning `{ stop: true }`.
1611
- const hits = [];
1612
- for (const [key, s] of world) {
1613
- const vx = s.x - origin.x;
1614
- const vy = s.y - origin.y;
1615
- const distSq = vx * vx + vy * vy;
1616
- if (distSq > bpMaxSq) continue;
1617
- if (cone !== undefined && cone !== null && distSq > 0) {
1618
- if ((vx * dir.x + vy * dir.y) / Math.sqrt(distSq) < cone) continue;
1619
- }
1620
- let hit;
1621
- if (useResolve) {
1622
- hit = ht.resolve(shot, { key, pos: { x: s.x, y: s.y }, state: s.state }, shootCtx);
1623
- } else if (ht.hitbox.shape === 'circle') {
1624
- hit = rayCircleHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.radius);
1625
- } else {
1626
- hit = rayAabbHit(origin.x, origin.y, dir.x, dir.y, maxDist, s.x, s.y, ht.hitbox.w, ht.hitbox.h);
1627
- }
1628
- if (hit !== null && hit !== undefined && Number.isFinite(hit.dist)) {
1629
- hits.push({ key, pos: { x: s.x, y: s.y }, state: s.state, dist: hit.dist, point: hit.point, fallback: s.fallback });
1630
- }
1631
- }
1632
- if (hits.length === 0) return;
1633
- hits.sort((a, b) => a.dist - b.dist);
1634
- for (let i = 0; i < hits.length; i++) {
1635
- const h = hits[i];
1636
- const target = { key: h.key, pos: h.pos, state: h.state };
1637
- const info = { dist: h.dist, point: h.point, fraction: maxDist > 0 ? h.dist / maxDist : 0, rewindAt, fallback: h.fallback };
1638
- const verdict = await ht.onHit(shootCtx, target, info);
1639
- if (verdict && verdict.stop) break;
1640
- }
1641
- // The app's onHit may have awaited. If the topic was forgotten (its last
1642
- // entity left) or this instance lost ownership in that window, do not publish
1643
- // the events or arm a tick on a dead or demoted record.
1644
- if (_smoothTopics.get(name) !== rec || (cluster && !rec.owned)) return;
1645
- // Broadcast the hit events. A shot bypasses the prediction ring, so there is
1646
- // no optimistic client copy to suppress: the event reaches everyone, the
1647
- // shooter (its hit marker) and the victim alike. The wire frame carries only
1648
- // {type,key,data,id}, exactly as the tick's event broadcast does.
1649
- for (let i = 0; i < pendingEvents.length; i++) {
1650
- const pe = pendingEvents[i];
1651
- const wire = {
1652
- type: pe.type,
1653
- key: pe.opts && typeof pe.opts.key === 'string' ? pe.opts.key : shooterKey,
1654
- data: pe.data,
1655
- id: ++rec.shootEventSeq
1656
- };
1657
- _smoothPublish(rec, 'event', wire, undefined);
1658
- if (cluster && typeof cluster.relayBroadcast === 'function') {
1659
- cluster.relayBroadcast(rec.wireTopic, 'event', wire, undefined, rec.eventSeq++);
1716
+ const m = _smoothEdgeMeasure(rec, ctx, payload, shooterKey, wallEpoch());
1717
+ if (m === null) return;
1718
+ // Detection signal (opt-in): fire from this shot's locally-measured picture.
1719
+ // A throwing hook never affects the shot.
1720
+ if (ht.detectionHook !== undefined && m.detect) {
1721
+ try {
1722
+ ht.detectionHook({ ...m.detect, identity: shooterKey });
1723
+ } catch {
1724
+ /* observability only */
1660
1725
  }
1661
1726
  }
1662
- // Arm the tick so the injected damage drains and broadcasts this frame.
1663
- if (armed) _armSmoothTick(rec);
1727
+ const rewindAt = _smoothRewindAt(m.nowMono, m.reach, m.rewindAge);
1728
+ await _smoothResolveShot(rec, name, shooterKey, shooterEntity, ctx.platform, m.cmd, rewindAt);
1664
1729
  });
1665
1730
 
1666
1731
  return smoothExport;
package/src/server.d.ts CHANGED
@@ -2395,6 +2395,19 @@ export interface RoomConfig {
2395
2395
  * actions. Requires `actions`.
2396
2396
  */
2397
2397
  history?: RoomHistoryConfig;
2398
+ /**
2399
+ * Per-room metadata for a lobby browser, resolved once when a room opens
2400
+ * (its first subscriber arrives). Providing it opts the room into
2401
+ * enumeration, so the generated `<export>.rooms()` lists the active rooms,
2402
+ * each `{ args, count, meta }`. Receives the room-identifying args. A throwing
2403
+ * `meta` never blocks the room (the room still appears, with empty meta).
2404
+ */
2405
+ meta?: (...args: any[]) => any;
2406
+ /**
2407
+ * Opt into room enumeration without per-room metadata (a count-only lobby).
2408
+ * Implied by `meta`. @default false
2409
+ */
2410
+ enumerable?: boolean;
2398
2411
  }
2399
2412
 
2400
2413
  /**
@@ -2406,8 +2419,13 @@ export interface RoomExport {
2406
2419
  __topicFn: Function;
2407
2420
  __hasPresence: boolean;
2408
2421
  __hasCursors: boolean;
2422
+ __hasRooms: boolean;
2409
2423
  __presenceStream?: any;
2410
2424
  __cursorStream?: any;
2425
+ /** The enumeration stream (active rooms) when the room opts into enumeration. */
2426
+ __roomsStream?: any;
2427
+ /** The one-shot enumeration snapshot RPC backing `rooms().list()`. */
2428
+ __roomsSync?: any;
2411
2429
  __actions?: Record<string, any>;
2412
2430
  }
2413
2431
 
package/src/testing.js CHANGED
@@ -323,6 +323,8 @@ export function createTestEnv(options) {
323
323
  if (fn.__dataStream) __register(path + '/__data', fn.__dataStream);
324
324
  if (fn.__presenceStream) __register(path + '/__presence', fn.__presenceStream);
325
325
  if (fn.__cursorStream) __register(path + '/__cursors', fn.__cursorStream);
326
+ if (fn.__roomsStream) __register(path + '/__rooms', fn.__roomsStream);
327
+ if (fn.__roomsSync) __register(path + '/__roomsSync', fn.__roomsSync);
326
328
  if (fn.__actions) {
327
329
  for (const [k, v] of Object.entries(fn.__actions)) {
328
330
  __register(path + '/__action/' + k, v);
@@ -345,6 +345,8 @@ export function _generateClientStubs(filePath, modulePath, dir) {
345
345
  }
346
346
 
347
347
  // Detect live.room() exports - generates data stream + presence stream + cursor stream + actions
348
+ // (and the lobby-browser `rooms()` view when the room opts into enumeration).
349
+ let roomsRuntimeImported = false;
348
350
  ROOM_EXPORT_RE.lastIndex = 0;
349
351
  while ((match = ROOM_EXPORT_RE.exec(source)) !== null) {
350
352
  const name = match[1];
@@ -356,6 +358,10 @@ export function _generateClientStubs(filePath, modulePath, dir) {
356
358
  // Room generates a namespace object with data, presence, cursors, and actions
357
359
  // Extract room config to determine which sub-streams exist
358
360
  const roomInfo = _extractRoomInfo(source, name);
361
+ if (roomInfo.isEnumerable && !roomsRuntimeImported) {
362
+ lines.push(`import { RoomsList } from 'svelte-realtime/rooms';`);
363
+ roomsRuntimeImported = true;
364
+ }
359
365
  const roomLines = [];
360
366
  roomLines.push(`export const ${name} = {`);
361
367
  roomLines.push(` data: __stream(${JSON.stringify(modulePath + '/' + name + '/__data')}, ${JSON.stringify(roomInfo.dataOpts)}, true),`);
@@ -365,6 +371,12 @@ export function _generateClientStubs(filePath, modulePath, dir) {
365
371
  if (roomInfo.hasCursors) {
366
372
  roomLines.push(` cursors: __stream(${JSON.stringify(modulePath + '/' + name + '/__cursors')}, ${JSON.stringify({ merge: 'cursor' })}, true),`);
367
373
  }
374
+ // Lobby-browser view (opt-in): a per-export enumeration stream of the
375
+ // active rooms, wrapped in the RoomsList rune. `game.rooms()` takes no
376
+ // args (it lists the whole export); `.list()` is the one-shot snapshot.
377
+ if (roomInfo.isEnumerable) {
378
+ roomLines.push(` rooms: () => new RoomsList({ stream: __stream(${JSON.stringify(modulePath + '/' + name + '/__rooms')}, ${JSON.stringify({ merge: 'crud', key: 'topic' })}, true)(), list: __rpc(${JSON.stringify(modulePath + '/' + name + '/__roomsSync')}) }),`);
379
+ }
368
380
  // Actions are RPCs
369
381
  for (const action of roomInfo.actions) {
370
382
  roomLines.push(` ${action}: __rpc(${JSON.stringify(modulePath + '/' + name + '/__action/' + action)}),`);
@@ -414,6 +414,10 @@ export function _generateRegistry(liveDir, dir, topicsRegistry) {
414
414
  lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__presence')}, __L(() => import(${importPath}).then(m => m.${name}.__presenceStream)), ${JSON.stringify(rel)});`);
415
415
  // Register cursor stream if present
416
416
  lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__cursors')}, __L(() => import(${importPath}).then(m => m.${name}.__cursorStream)), ${JSON.stringify(rel)});`);
417
+ // Register the enumeration stream + one-shot snapshot if the room opted
418
+ // in (resolves to undefined and is never subscribed for a plain room).
419
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__rooms')}, __L(() => import(${importPath}).then(m => m.${name}.__roomsStream)), ${JSON.stringify(rel)});`);
420
+ lines.push(`__register(${JSON.stringify(rel + '/' + name + '/__roomsSync')}, __L(() => import(${importPath}).then(m => m.${name}.__roomsSync)), ${JSON.stringify(rel)});`);
417
421
  // Register actions (deferred - resolved on first RPC or cron tick)
418
422
  lines.push(`__registerRoomActions(${JSON.stringify(rel + '/' + name)}, ${_lazy(name)});`);
419
423
  }
@@ -177,6 +177,11 @@ export function _generateSsrStubs(filePath, modulePath) {
177
177
  lines.push(`const _${name}_cursors = (...args) => readable(undefined);`);
178
178
  subFactories.push(`cursors: _${name}_cursors`);
179
179
  }
180
+ // The lobby-browser view renders empty during SSR (no live subscription);
181
+ // the client RoomsList takes over on hydration.
182
+ if (info.isEnumerable) {
183
+ subFactories.push(`rooms: () => ({ rooms: new Map(), status: 'idle', list: () => Promise.resolve([]), destroy: () => {} })`);
184
+ }
180
185
  for (const action of info.actions) {
181
186
  subFactories.push(`${action}: () => Promise.resolve(undefined)`);
182
187
  }
@@ -459,7 +459,7 @@ export function _extractChannelOptions(source, name) {
459
459
  * Extract room configuration info from source code for client stub generation.
460
460
  * @param {string} source
461
461
  * @param {string} name
462
- * @returns {{ dataOpts: any, hasPresence: boolean, hasCursors: boolean, actions: string[] }}
462
+ * @returns {{ dataOpts: any, hasPresence: boolean, hasCursors: boolean, actions: string[], isEnumerable: boolean }}
463
463
  */
464
464
  /**
465
465
  * Detect a windowed `live.aggregate(...)` export and return the window
@@ -495,8 +495,8 @@ export function _extractAggregateWindows(source, name) {
495
495
  }
496
496
 
497
497
  export function _extractRoomInfo(source, name) {
498
- /** @type {{ dataOpts: any, hasPresence: boolean, hasCursors: boolean, actions: string[] }} */
499
- const info = { dataOpts: { merge: 'crud' }, hasPresence: false, hasCursors: false, actions: [] };
498
+ /** @type {{ dataOpts: any, hasPresence: boolean, hasCursors: boolean, actions: string[], isEnumerable: boolean }} */
499
+ const info = { dataOpts: { merge: 'crud' }, hasPresence: false, hasCursors: false, actions: [], isEnumerable: false };
500
500
 
501
501
  // Find the start of live.room({ ... }) call
502
502
  const startPattern = new RegExp(
@@ -512,6 +512,8 @@ export function _extractRoomInfo(source, name) {
512
512
  const configKeys = new Set(_extractTopLevelKeys(body));
513
513
  info.hasPresence = configKeys.has('presence');
514
514
  info.hasCursors = configKeys.has('cursors');
515
+ // Room enumeration is opt-in via `enumerable: true` or a `meta` function.
516
+ info.isEnumerable = configKeys.has('enumerable') || configKeys.has('meta');
515
517
 
516
518
  const mergeVal = _extractTopLevelStringProp(body, 'merge');
517
519
  if (mergeVal) info.dataOpts.merge = mergeVal;
package/src/vite/hmr.js CHANGED
@@ -104,6 +104,8 @@ export async function _loadRegistryDirect(server, liveDir, dir) {
104
104
  if (fn.__dataStream) __register(rel + '/' + name + '/__data', fn.__dataStream, rel);
105
105
  if (fn.__presenceStream) __register(rel + '/' + name + '/__presence', fn.__presenceStream, rel);
106
106
  if (fn.__cursorStream) __register(rel + '/' + name + '/__cursors', fn.__cursorStream, rel);
107
+ if (fn.__roomsStream) __register(rel + '/' + name + '/__rooms', fn.__roomsStream, rel);
108
+ if (fn.__roomsSync) __register(rel + '/' + name + '/__roomsSync', fn.__roomsSync, rel);
107
109
  if (fn.__actions) {
108
110
  for (const [k, v] of Object.entries(fn.__actions)) {
109
111
  __register(rel + '/' + name + '/__action/' + k, v, rel);