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 +33 -0
- package/package.json +5 -1
- package/src/client-rooms.d.ts +32 -0
- package/src/client-rooms.svelte.js +103 -0
- package/src/server/dispatch.js +4 -1
- package/src/server/room.js +89 -3
- package/src/server/smooth.js +316 -251
- package/src/server.d.ts +18 -0
- package/src/testing.js +2 -0
- package/src/vite/codegen-client.js +12 -0
- package/src/vite/codegen-registry.js +4 -0
- package/src/vite/codegen-ssr.js +5 -0
- package/src/vite/extract-options.js +5 -3
- package/src/vite/hmr.js +2 -0
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.
|
|
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
|
+
}
|
package/src/server/dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/server/room.js
CHANGED
|
@@ -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) {
|
package/src/server/smooth.js
CHANGED
|
@@ -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
|
|
1418
|
-
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
1663
|
-
|
|
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
|
}
|
package/src/vite/codegen-ssr.js
CHANGED
|
@@ -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);
|