@jgengine/ws 0.8.0 → 0.10.0

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.
@@ -1,7 +1,7 @@
1
- import { decidePoseSync } from "@jgengine/core/multiplayer/presenceModel";
1
+ import { decidePoseSync, spawnPresenceState } from "@jgengine/core/multiplayer/presenceModel";
2
2
  import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
3
3
  import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
4
- import { decodeWsClientMessage, encodeWsMessage, subscriptionKey, } from "./protocol.js";
4
+ import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
5
5
  export const DEFAULT_POSE_RULES = {
6
6
  maxSpeed: 12,
7
7
  maxVerticalOffset: 3,
@@ -24,8 +24,12 @@ export function createHostRouter(options) {
24
24
  const host = options.host;
25
25
  const now = options.now ?? Date.now;
26
26
  const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
27
- const authenticate = options.authenticate ?? (({ userId }) => userId);
27
+ const allowAnonymous = options.allowAnonymous === true;
28
+ const singleSession = options.singleSession !== false;
29
+ const authenticate = options.authenticate ??
30
+ (allowAnonymous ? ({ userId }) => userId : null);
28
31
  const connections = new Set();
32
+ const sessionsByUserId = new Map();
29
33
  const presence = new Map();
30
34
  const positionHistoryMs = options.positionHistoryMs ?? 1_000;
31
35
  const histories = new Map();
@@ -135,12 +139,22 @@ export function createHostRouter(options) {
135
139
  broadcastVoice(serverId, key.slice(prefix.length));
136
140
  }
137
141
  };
138
- const handleChatSend = (connection, message) => {
142
+ const requireMembership = async (connection, id, serverId) => {
139
143
  const userId = connection.userId;
140
144
  if (userId === null) {
141
- replyError(connection, message.id, "Not authenticated");
142
- return;
145
+ replyError(connection, id, "Not authenticated");
146
+ return null;
147
+ }
148
+ if (!(await host.isMember({ userId, serverId }))) {
149
+ replyError(connection, id, "Not a member of this server");
150
+ return null;
143
151
  }
152
+ return userId;
153
+ };
154
+ const handleChatSend = async (connection, message) => {
155
+ const userId = await requireMembership(connection, message.id, message.serverId);
156
+ if (userId === null)
157
+ return;
144
158
  const body = message.body.trim();
145
159
  if (body.length === 0) {
146
160
  replyError(connection, message.id, "empty message");
@@ -234,28 +248,19 @@ export function createHostRouter(options) {
234
248
  }
235
249
  };
236
250
  const unsubscribeHost = host.subscribe(onHostEvent);
237
- const handlePose = (connection, serverId, pose) => {
251
+ const handlePose = async (connection, serverId, pose) => {
238
252
  if (connection.userId === null)
239
253
  return;
254
+ if (!(await host.isMember({ userId: connection.userId, serverId })))
255
+ return;
240
256
  const rows = presence.get(serverId) ?? new Map();
241
257
  presence.set(serverId, rows);
242
258
  const timestamp = now();
243
- const current = rows.get(connection.userId);
244
- if (current === undefined) {
245
- rows.set(connection.userId, {
246
- position: { x: pose.x, y: pose.y, z: pose.z },
247
- rotationY: pose.rotationY,
248
- rotationPitch: pose.rotationPitch,
249
- lastSeenAtMs: timestamp,
250
- appearance: pose.appearance,
251
- });
252
- historyFor(serverId).record(connection.userId, timestamp, { x: pose.x, y: pose.y, z: pose.z });
253
- broadcastPresence(serverId);
254
- return;
255
- }
259
+ const hadRow = rows.has(connection.userId);
260
+ const current = rows.get(connection.userId) ?? spawnPresenceState(undefined, timestamp, poseRules);
256
261
  const decision = decidePoseSync(current, { position: { x: pose.x, y: pose.y, z: pose.z }, rotationY: pose.rotationY, rotationPitch: pose.rotationPitch }, poseRules, timestamp);
257
262
  const appearanceChanged = pose.appearance !== undefined && !appearanceEqual(pose.appearance, current.appearance);
258
- if (decision.changed || decision.refreshKeepAlive || appearanceChanged) {
263
+ if (decision.changed || decision.refreshKeepAlive || appearanceChanged || !hadRow) {
259
264
  rows.set(connection.userId, {
260
265
  position: decision.position,
261
266
  rotationY: decision.rotationY,
@@ -265,8 +270,9 @@ export function createHostRouter(options) {
265
270
  });
266
271
  historyFor(serverId).record(connection.userId, timestamp, decision.position);
267
272
  }
268
- if (decision.changed || appearanceChanged)
273
+ if (decision.changed || appearanceChanged || !hadRow) {
269
274
  broadcastPresence(serverId);
275
+ }
270
276
  };
271
277
  const dropPresence = (connection) => {
272
278
  if (connection.userId === null)
@@ -289,18 +295,36 @@ export function createHostRouter(options) {
289
295
  };
290
296
  const handleMessage = async (connection, message) => {
291
297
  if (message.t === "hello") {
298
+ if (connection.userId !== null) {
299
+ replyError(connection, message.id, "Already authenticated");
300
+ return;
301
+ }
302
+ if (authenticate === null) {
303
+ replyError(connection, message.id, "Not authenticated");
304
+ connection.transport.close();
305
+ return;
306
+ }
292
307
  const userId = await authenticate({ userId: message.userId, token: message.token });
293
308
  if (userId === null) {
294
309
  replyError(connection, message.id, "Not authenticated");
295
310
  connection.transport.close();
296
311
  return;
297
312
  }
313
+ if (singleSession) {
314
+ const previous = sessionsByUserId.get(userId);
315
+ if (previous !== undefined && previous !== connection) {
316
+ sessionsByUserId.delete(userId);
317
+ previous.userId = null;
318
+ previous.transport.close();
319
+ }
320
+ sessionsByUserId.set(userId, connection);
321
+ }
298
322
  connection.userId = userId;
299
323
  reply(connection, message.id, { userId });
300
324
  return;
301
325
  }
302
326
  if (message.t === "pose") {
303
- handlePose(connection, message.serverId, message.pose);
327
+ await handlePose(connection, message.serverId, message.pose);
304
328
  return;
305
329
  }
306
330
  if (connection.userId === null) {
@@ -317,6 +341,7 @@ export function createHostRouter(options) {
317
341
  serverId: message.serverId,
318
342
  attributes: message.attributes,
319
343
  });
344
+ connection.joinedServers.add(result.serverId);
320
345
  reply(connection, message.id, result);
321
346
  return;
322
347
  }
@@ -326,6 +351,8 @@ export function createHostRouter(options) {
326
351
  gameId: message.gameId,
327
352
  code: message.code,
328
353
  });
354
+ if (result !== null)
355
+ connection.joinedServers.add(result.serverId);
329
356
  reply(connection, message.id, result);
330
357
  return;
331
358
  }
@@ -340,6 +367,7 @@ export function createHostRouter(options) {
340
367
  }
341
368
  case "leave": {
342
369
  await host.leaveServer({ userId, serverId: message.serverId });
370
+ connection.joinedServers.delete(message.serverId);
343
371
  dropPresenceForServer(userId, message.serverId);
344
372
  dropVoiceForServer(userId, message.serverId);
345
373
  reply(connection, message.id, null);
@@ -366,10 +394,12 @@ export function createHostRouter(options) {
366
394
  return;
367
395
  }
368
396
  case "chatSend": {
369
- handleChatSend(connection, message);
397
+ await handleChatSend(connection, message);
370
398
  return;
371
399
  }
372
400
  case "voiceJoin": {
401
+ if ((await requireMembership(connection, message.id, message.serverId)) === null)
402
+ return;
373
403
  const participant = { userId };
374
404
  if (message.streamId !== undefined)
375
405
  participant.streamId = message.streamId;
@@ -379,6 +409,8 @@ export function createHostRouter(options) {
379
409
  return;
380
410
  }
381
411
  case "voiceLeave": {
412
+ if ((await requireMembership(connection, message.id, message.serverId)) === null)
413
+ return;
382
414
  const roster = voiceRoster(message.serverId, message.channelId);
383
415
  const removed = roster.delete(userId);
384
416
  reply(connection, message.id, null);
@@ -387,6 +419,8 @@ export function createHostRouter(options) {
387
419
  return;
388
420
  }
389
421
  case "voicePublish": {
422
+ if ((await requireMembership(connection, message.id, message.serverId)) === null)
423
+ return;
390
424
  const participant = voiceRoster(message.serverId, message.channelId).get(userId);
391
425
  if (participant === undefined) {
392
426
  replyError(connection, message.id, "not in this voice channel");
@@ -398,6 +432,13 @@ export function createHostRouter(options) {
398
432
  return;
399
433
  }
400
434
  case "subscribe": {
435
+ if ((message.channel === "chat" ||
436
+ message.channel === "voice" ||
437
+ message.channel === "presence") &&
438
+ !(await host.isMember({ userId, serverId: message.serverId }))) {
439
+ replyError(connection, message.id, "Not a member of this server");
440
+ return;
441
+ }
401
442
  connection.subscriptions.add(subscriptionKey(message.channel, message.serverId, message.action));
402
443
  reply(connection, message.id, null);
403
444
  await pushSubscription(connection, message.channel, message.serverId, message.action);
@@ -414,19 +455,49 @@ export function createHostRouter(options) {
414
455
  replyError(connection, message.id, error instanceof Error ? error.message : "Internal error");
415
456
  }
416
457
  };
458
+ const leaveJoinedServers = (connection) => {
459
+ if (connection.userId === null)
460
+ return;
461
+ const userId = connection.userId;
462
+ const servers = [...connection.joinedServers];
463
+ connection.joinedServers.clear();
464
+ for (const serverId of servers) {
465
+ void host.leaveServer({ userId, serverId }).catch(() => undefined);
466
+ }
467
+ };
417
468
  return {
418
469
  connect: (transport) => {
419
- const connection = { transport, userId: null, subscriptions: new Set() };
470
+ const connection = {
471
+ transport,
472
+ userId: null,
473
+ subscriptions: new Set(),
474
+ joinedServers: new Set(),
475
+ queue: Promise.resolve(),
476
+ };
420
477
  connections.add(connection);
421
478
  return {
422
479
  handleRaw: (raw) => {
423
- const message = decodeWsClientMessage(typeof raw === "string" ? raw : String(raw));
424
- if (message === null)
425
- return;
426
- void handleMessage(connection, message);
480
+ connection.queue = connection.queue
481
+ .then(async () => {
482
+ const text = typeof raw === "string" ? raw : String(raw);
483
+ const message = decodeWsClientMessage(text);
484
+ if (message === null) {
485
+ const failure = inspectWsDecodeFailure(text);
486
+ if (failure.id !== undefined) {
487
+ replyError(connection, failure.id, failure.reason);
488
+ }
489
+ return;
490
+ }
491
+ await handleMessage(connection, message);
492
+ })
493
+ .catch(() => undefined);
427
494
  },
428
495
  close: () => {
496
+ if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
497
+ sessionsByUserId.delete(connection.userId);
498
+ }
429
499
  connections.delete(connection);
500
+ leaveJoinedServers(connection);
430
501
  dropPresence(connection);
431
502
  dropVoice(connection);
432
503
  },
@@ -0,0 +1,11 @@
1
+ export * from "./createWsBackend.js";
2
+ export * from "./host.js";
3
+ export * from "./hostRouter.js";
4
+ export * from "./httpReads.js";
5
+ export * from "./peer.js";
6
+ export * from "./pipe.js";
7
+ export * from "./protocol.js";
8
+ export * from "./readsHandler.js";
9
+ export * from "./socketIoPipe.js";
10
+ export * from "./voiceChannel.js";
11
+ export * from "./worldHost.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./createWsBackend.js";
2
+ export * from "./host.js";
3
+ export * from "./hostRouter.js";
4
+ export * from "./httpReads.js";
5
+ export * from "./peer.js";
6
+ export * from "./pipe.js";
7
+ export * from "./protocol.js";
8
+ export * from "./readsHandler.js";
9
+ export * from "./socketIoPipe.js";
10
+ export * from "./voiceChannel.js";
11
+ export * from "./worldHost.js";
package/dist/peer.js CHANGED
@@ -93,7 +93,11 @@ export function createPeerHost(options) {
93
93
  });
94
94
  if (ownsHost)
95
95
  host.start();
96
- const router = createHostRouter({ ...options.router, host });
96
+ const router = createHostRouter({
97
+ allowAnonymous: true,
98
+ ...options.router,
99
+ host,
100
+ });
97
101
  const backend = createWsBackend({
98
102
  userId: options.userId,
99
103
  token: options.token,
@@ -173,6 +173,11 @@ export type WsRunCommandResult = TransportRunCommandResult;
173
173
  export type WsBrowseResult = SessionListing[];
174
174
  export type WsJoinByCodeResult = JoinServerResult | null;
175
175
  export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
176
+ export type WsDecodeFailure = {
177
+ reason: string;
178
+ id?: number;
179
+ };
180
+ export declare function inspectWsDecodeFailure(raw: unknown): WsDecodeFailure;
176
181
  export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
177
182
  export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
178
183
  export declare function subscriptionKey(channel: WsChannel, serverId: string, action?: string): string;
package/dist/protocol.js CHANGED
@@ -35,15 +35,39 @@ function isAppearance(value) {
35
35
  return (isRecord(value) &&
36
36
  Object.values(value).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
37
37
  }
38
+ function isFiniteNumber(value) {
39
+ return typeof value === "number" && Number.isFinite(value);
40
+ }
38
41
  function isPose(value) {
39
42
  return (isRecord(value) &&
40
- typeof value.x === "number" &&
41
- typeof value.y === "number" &&
42
- typeof value.z === "number" &&
43
- typeof value.rotationY === "number" &&
44
- typeof value.rotationPitch === "number" &&
43
+ isFiniteNumber(value.x) &&
44
+ isFiniteNumber(value.y) &&
45
+ isFiniteNumber(value.z) &&
46
+ isFiniteNumber(value.rotationY) &&
47
+ isFiniteNumber(value.rotationPitch) &&
45
48
  (value.appearance === undefined || isAppearance(value.appearance)));
46
49
  }
50
+ export function inspectWsDecodeFailure(raw) {
51
+ if (typeof raw !== "string")
52
+ return { reason: "Invalid message framing" };
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ }
57
+ catch {
58
+ return { reason: "Invalid JSON" };
59
+ }
60
+ if (!isRecord(parsed))
61
+ return { reason: "Invalid message" };
62
+ const id = typeof parsed.id === "number" ? parsed.id : undefined;
63
+ if (parsed.v !== WS_PROTOCOL_VERSION) {
64
+ return { reason: "Protocol version mismatch", id };
65
+ }
66
+ if (typeof parsed.t !== "string") {
67
+ return { reason: "Invalid message type", id };
68
+ }
69
+ return { reason: "Malformed message", id };
70
+ }
47
71
  export function decodeWsClientMessage(raw) {
48
72
  const message = parseVersioned(raw);
49
73
  if (message === null)
@@ -1,6 +1,7 @@
1
1
  import type { HostPersistence, ServerListing } from "@jgengine/core/runtime/hostPersistence";
2
2
  export type ReadsPersistence = Pick<HostPersistence, "listServers" | "loadProfile" | "getLeaderboardTop" | "getLeaderboardProfile">;
3
3
  export type ReadsHandler = (request: Request) => Promise<Response>;
4
+ export type ReadsAuthenticate = (request: Request) => Promise<string | null> | string | null;
4
5
  export type ReadsHandlerOptions = {
5
6
  persistence: ReadsPersistence | (() => Promise<ReadsPersistence>);
6
7
  basePath?: string;
@@ -8,5 +9,7 @@ export type ReadsHandlerOptions = {
8
9
  gameId: string;
9
10
  limit?: number;
10
11
  }) => Promise<ServerListing[]>;
12
+ authenticate?: ReadsAuthenticate;
13
+ allowPublicProfiles?: boolean;
11
14
  };
12
15
  export declare function createReadsHandler(options: ReadsHandlerOptions): ReadsHandler;
@@ -7,6 +7,7 @@ function json(body, status = 200) {
7
7
  }
8
8
  export function createReadsHandler(options) {
9
9
  const basePath = (options.basePath ?? "/api").replace(/\/$/, "");
10
+ const allowPublicProfiles = options.allowPublicProfiles === true;
10
11
  let persistence = null;
11
12
  const getPersistence = () => {
12
13
  persistence ??=
@@ -21,9 +22,18 @@ export function createReadsHandler(options) {
21
22
  if (request.method !== "GET") {
22
23
  return json({ error: "method not allowed" }, 405);
23
24
  }
24
- const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
25
+ let segments;
26
+ try {
27
+ segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
28
+ }
29
+ catch {
30
+ return json({ error: "malformed path" }, 400);
31
+ }
25
32
  const gameId = url.searchParams.get("gameId") ?? "";
26
33
  const limitParam = url.searchParams.get("limit");
34
+ if (limitParam !== null && !Number.isFinite(Number(limitParam))) {
35
+ return json({ error: "limit must be a number" }, 400);
36
+ }
27
37
  const limit = limitParam === null ? undefined : Number(limitParam);
28
38
  const [head, tail] = segments;
29
39
  if (head === "servers" && tail === undefined) {
@@ -31,7 +41,7 @@ export function createReadsHandler(options) {
31
41
  return json(await options.listOpenServers({ gameId, limit }));
32
42
  }
33
43
  const records = await (await getPersistence()).listServers(gameId);
34
- return json(toOpenServerListings(records.map(toServerListing), limit));
44
+ return json(toOpenServerListings(records.map((record) => toServerListing(record)), limit));
35
45
  }
36
46
  if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
37
47
  const scope = url.searchParams.get("scope") ?? "global";
@@ -50,6 +60,15 @@ export function createReadsHandler(options) {
50
60
  return json(await (await getPersistence()).getLeaderboardProfile({ gameId, userId: tail }));
51
61
  }
52
62
  if (head === "profile" && tail !== undefined && segments.length === 2) {
63
+ if (!allowPublicProfiles) {
64
+ if (options.authenticate === undefined) {
65
+ return json({ error: "unauthorized" }, 401);
66
+ }
67
+ const actorUserId = await options.authenticate(request);
68
+ if (actorUserId === null || actorUserId !== tail) {
69
+ return json({ error: "unauthorized" }, 401);
70
+ }
71
+ }
53
72
  const profile = await (await getPersistence()).loadProfile({ userId: tail, gameId });
54
73
  return json(profile === null
55
74
  ? null
@@ -0,0 +1,26 @@
1
+ import type { HostedWorldSession } from "@jgengine/core/runtime/hostedWorldSession";
2
+ import type { GameHost } from "./host.js";
3
+ /** Config for {@link createWorldGameHost}: how to resolve a hosted world's authoritative session per server. */
4
+ export interface WorldGameHostOptions {
5
+ /**
6
+ * Resolve (or lazily build) the authoritative {@link HostedWorldSession} for a server — called once per new
7
+ * `serverId`. Return `null` for an unknown game. Bind the game's definition + content here.
8
+ */
9
+ session(args: {
10
+ gameId: string;
11
+ serverId: string;
12
+ }): HostedWorldSession | null;
13
+ now?: () => number;
14
+ }
15
+ /** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
16
+ export interface WorldGameHost extends GameHost {
17
+ /** Advance every live world by `dtSeconds` and emit a `server` change for each whose revision moved. */
18
+ tick(dtSeconds: number): void;
19
+ }
20
+ /**
21
+ * The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each
22
+ * world's full `WorldSnapshot` as `serverState`, so the existing ws router, `createWsBackend`, and the shell's
23
+ * `attachWorldSync` carry host-authoritative GameContext worlds with zero changes to any of them. The harness owns
24
+ * the tick cadence (call {@link WorldGameHost.tick} on an interval); commands and joins broadcast immediately.
25
+ */
26
+ export declare function createWorldGameHost(options: WorldGameHostOptions): WorldGameHost;
@@ -0,0 +1,119 @@
1
+ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
2
+ /**
3
+ * The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each
4
+ * world's full `WorldSnapshot` as `serverState`, so the existing ws router, `createWsBackend`, and the shell's
5
+ * `attachWorldSync` carry host-authoritative GameContext worlds with zero changes to any of them. The harness owns
6
+ * the tick cadence (call {@link WorldGameHost.tick} on an interval); commands and joins broadcast immediately.
7
+ */
8
+ export function createWorldGameHost(options) {
9
+ const live = new Map();
10
+ const listeners = new Set();
11
+ const now = options.now ?? (() => Date.now());
12
+ function emit(event) {
13
+ for (const listener of listeners)
14
+ listener(event);
15
+ }
16
+ function ensure(gameId, serverId) {
17
+ const existing = live.get(serverId);
18
+ if (existing !== undefined)
19
+ return existing;
20
+ const session = options.session({ gameId, serverId });
21
+ if (session === null)
22
+ return null;
23
+ const entry = { gameId, session };
24
+ live.set(serverId, entry);
25
+ return entry;
26
+ }
27
+ function tickAll(dtSeconds) {
28
+ for (const [serverId, entry] of live) {
29
+ const before = entry.session.revision();
30
+ entry.session.tick(dtSeconds);
31
+ if (entry.session.revision() !== before)
32
+ emit({ type: "server", serverId });
33
+ }
34
+ }
35
+ return {
36
+ async joinServer({ userId, gameId, serverId }) {
37
+ const id = serverId ?? gameId;
38
+ const entry = ensure(gameId, id);
39
+ if (entry === null)
40
+ throw new Error(`no hosted world for game "${gameId}"`);
41
+ const isNew = !entry.session.members().includes(userId);
42
+ entry.session.join(userId, isNew);
43
+ emit({ type: "server", serverId: id });
44
+ emit({ type: "player", serverId: id, userId });
45
+ return { serverId: id, isNew };
46
+ },
47
+ async leaveServer({ userId, serverId }) {
48
+ const entry = live.get(serverId);
49
+ if (entry === undefined)
50
+ return;
51
+ entry.session.leave(userId);
52
+ emit({ type: "server", serverId });
53
+ },
54
+ async runCommand({ userId, serverId, command, input }) {
55
+ const entry = live.get(serverId);
56
+ if (entry === undefined)
57
+ return { ok: false, reason: "no-server" };
58
+ if (command === INPUT_COMMAND) {
59
+ entry.session.input(userId, input);
60
+ return { ok: true };
61
+ }
62
+ const before = entry.session.revision();
63
+ const result = entry.session.command(userId, command, input);
64
+ if (result.status === "rejected")
65
+ return { ok: false, reason: result.reason };
66
+ if (result.status === "unknown-command")
67
+ return { ok: false, reason: "unknown-command" };
68
+ if (entry.session.revision() !== before)
69
+ emit({ type: "server", serverId });
70
+ return { ok: true };
71
+ },
72
+ async isMember({ userId, serverId }) {
73
+ return live.get(serverId)?.session.members().includes(userId) ?? false;
74
+ },
75
+ async getServerView({ serverId }) {
76
+ const entry = live.get(serverId);
77
+ if (entry === undefined)
78
+ return null;
79
+ return {
80
+ serverId,
81
+ gameId: entry.gameId,
82
+ revision: entry.session.revision(),
83
+ memberUserIds: [...entry.session.members()],
84
+ serverState: entry.session.runner().snapshot(),
85
+ updatedAt: now(),
86
+ };
87
+ },
88
+ async getPlayerView() {
89
+ return null;
90
+ },
91
+ async getFeed() {
92
+ return [];
93
+ },
94
+ async pushFeedEntry() { },
95
+ async browseServers() {
96
+ return [];
97
+ },
98
+ async joinByCode() {
99
+ return null;
100
+ },
101
+ async listOpenServers() {
102
+ return [];
103
+ },
104
+ tick: tickAll,
105
+ async tickOnce() {
106
+ tickAll(0);
107
+ return { ticked: live.size, saved: 0 };
108
+ },
109
+ async flushAll() {
110
+ return 0;
111
+ },
112
+ start() { },
113
+ async stop() { },
114
+ subscribe(listener) {
115
+ listeners.add(listener);
116
+ return () => listeners.delete(listener);
117
+ },
118
+ };
119
+ }