@jgengine/ws 0.18.0 → 0.19.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.
package/dist/host.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import type { HostPersistence, ServerListing, SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
3
3
  import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
4
- import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
4
+ import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult, WorldSyncFrame } from "@jgengine/core/runtime/transport";
5
+ import type { SnapshotViewer } from "@jgengine/core/runtime/worldSnapshot";
5
6
  /** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */
6
7
  export type HostChangeEvent = {
7
8
  type: "server";
@@ -33,6 +34,7 @@ export type GameHost = {
33
34
  serverId?: string;
34
35
  attributes?: SessionAttributes;
35
36
  code?: string;
37
+ role?: SnapshotViewer["role"];
36
38
  }) => Promise<JoinServerResult>;
37
39
  browseServers: (args: {
38
40
  gameId: string;
@@ -53,6 +55,8 @@ export type GameHost = {
53
55
  serverId: string;
54
56
  command: string;
55
57
  input: unknown;
58
+ /** Set only from host code: runs commands marked `access: "server"`, which client messages cannot. */
59
+ trusted?: boolean;
56
60
  }) => Promise<TransportRunCommandResult>;
57
61
  isMember: (args: {
58
62
  userId: string;
@@ -61,7 +65,14 @@ export type GameHost = {
61
65
  getServerView: (args: {
62
66
  userId: string;
63
67
  serverId: string;
68
+ role?: SnapshotViewer["role"];
64
69
  }) => Promise<GameRuntimeServerView | null>;
70
+ pullWorld?: (args: {
71
+ userId: string;
72
+ serverId: string;
73
+ sinceRevision: number | null;
74
+ role?: SnapshotViewer["role"];
75
+ }) => Promise<WorldSyncFrame | null>;
65
76
  getPlayerView: (args: {
66
77
  userId: string;
67
78
  serverId: string;
package/dist/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
+ import { SERVER_ONLY_COMMAND_REASON } from "@jgengine/core/runtime/commandRunner";
2
3
  import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
3
4
  import { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
4
5
  import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
@@ -96,6 +97,7 @@ export function createGameHost(options) {
96
97
  playersByUserId: buildHydratePlayers(record, profiles),
97
98
  chunksByKey,
98
99
  revision: record.revision,
100
+ nowMs: now(),
99
101
  });
100
102
  const existing = live.get(record.serverId);
101
103
  if (existing)
@@ -160,7 +162,7 @@ export function createGameHost(options) {
160
162
  return { ticked: 0, saved: 0 };
161
163
  const runtime = resolveRuntime(entry.record.gameId);
162
164
  const before = entry.snapshot.revision;
163
- entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
165
+ entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000, timestamp);
164
166
  entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
165
167
  if (entry.snapshot.revision !== before) {
166
168
  markMutated(entry);
@@ -265,7 +267,7 @@ export function createGameHost(options) {
265
267
  updatedAt: timestamp,
266
268
  dirtyAt: timestamp,
267
269
  };
268
- entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
270
+ entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew, timestamp);
269
271
  await flushServer(entry);
270
272
  emit({ type: "server", serverId: entry.record.serverId });
271
273
  emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
@@ -329,7 +331,10 @@ export function createGameHost(options) {
329
331
  }
330
332
  const commandInput = envelope !== null ? envelope.input : args.input;
331
333
  const runtime = resolveRuntime(entry.record.gameId);
332
- const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput);
334
+ if (args.trusted !== true && runtime.commandAccess(args.command) === "server") {
335
+ return { ok: false, reason: SERVER_ONLY_COMMAND_REASON };
336
+ }
337
+ const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput, now());
333
338
  const outcome = result.ok
334
339
  ? { ok: true }
335
340
  : { ok: false, reason: result.reason };
@@ -17,13 +17,15 @@ export type HostRouterOptions = {
17
17
  chatRateLimit?: ChatRateLimit;
18
18
  chatHistoryLimit?: number;
19
19
  chatMaxBodyLength?: number;
20
- /** Per-op rate limits for pose/runCommand/join/browse/voice. Omit (default) for no rate limiting; pass {@link DEFAULT_COMMAND_LIMITS} or a custom {@link CommandLimits} to opt in. */
20
+ /** Per-op rate limits for pose/runCommand/join/browse/voice. Defaults to {@link DEFAULT_COMMAND_LIMITS}. */
21
21
  limits?: CommandLimits;
22
22
  /** Per-command authorization hook; defaults to allow-all. */
23
23
  authorize?: CommandAuthorize;
24
24
  /** Declared `runCommand` names and input validators. Omit (default) to pass every command through unchanged; set to reject unknown command names and validate declared ones. */
25
25
  validate?: CommandCatalog;
26
26
  now?: () => number;
27
+ /** How long a disconnected membership remains reclaimable. Defaults to 15 seconds. */
28
+ graceMs?: number;
27
29
  };
28
30
  export type HostRouterTransport = {
29
31
  send: (data: string) => void;
@@ -51,5 +53,7 @@ export type HostRouter = {
51
53
  export declare const DEFAULT_POSE_RULES: PoseSyncRules;
52
54
  /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
53
55
  export declare const MAX_QUEUED_MESSAGES = 64;
56
+ /** Default reconnect grace period for hosted socket sessions. */
57
+ export declare const DEFAULT_GRACE_MS = 15000;
54
58
  export declare function createHostRouter(options: HostRouterOptions): HostRouter;
55
59
  export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;
@@ -2,10 +2,13 @@ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@j
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
4
  import { createCommandMiddleware, } from "./commandMiddleware.js";
5
+ import { DEFAULT_COMMAND_LIMITS } from "./commandMiddleware.js";
5
6
  import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
6
7
  export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
7
8
  /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
8
9
  export const MAX_QUEUED_MESSAGES = 64;
10
+ /** Default reconnect grace period for hosted socket sessions. */
11
+ export const DEFAULT_GRACE_MS = 15_000;
9
12
  function appearanceEqual(a, b) {
10
13
  if (a === b)
11
14
  return true;
@@ -43,6 +46,7 @@ function dropUserFrom(store, userId, shouldVisit, onChanged) {
43
46
  export function createHostRouter(options) {
44
47
  const host = options.host;
45
48
  const now = options.now ?? Date.now;
49
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
46
50
  const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
47
51
  const allowAnonymous = options.allowAnonymous === true;
48
52
  const singleSession = options.singleSession !== false;
@@ -50,6 +54,29 @@ export function createHostRouter(options) {
50
54
  (allowAnonymous ? ({ userId }) => userId : null);
51
55
  const connections = new Set();
52
56
  const sessionsByUserId = new Map();
57
+ const resumeTokens = new Map();
58
+ const pendingLeaves = new Map();
59
+ const ticketKey = (userId, serverId) => `${userId}|${serverId}`;
60
+ const ticketFor = (userId, serverId) => {
61
+ const token = resumeTokens.get(userId) ?? crypto.randomUUID();
62
+ resumeTokens.set(userId, token);
63
+ return { userId, serverId, token };
64
+ };
65
+ const clearPendingLeave = (userId, serverId) => {
66
+ const key = ticketKey(userId, serverId);
67
+ const timer = pendingLeaves.get(key);
68
+ if (timer === undefined)
69
+ return;
70
+ clearTimeout(timer);
71
+ pendingLeaves.delete(key);
72
+ };
73
+ const heldByOtherConnection = (self, userId, serverId) => {
74
+ for (const other of connections) {
75
+ if (other !== self && other.userId === userId && other.joinedServers.has(serverId))
76
+ return true;
77
+ }
78
+ return false;
79
+ };
53
80
  const subscribers = new Map();
54
81
  const subscribersOf = (key) => {
55
82
  const set = subscribers.get(key);
@@ -115,7 +142,7 @@ export function createHostRouter(options) {
115
142
  data: presenceRows(serverId),
116
143
  }));
117
144
  const commandMiddleware = createCommandMiddleware({
118
- limits: options.limits,
145
+ limits: options.limits ?? DEFAULT_COMMAND_LIMITS,
119
146
  authorize: options.authorize,
120
147
  validate: options.validate,
121
148
  });
@@ -221,7 +248,19 @@ export function createHostRouter(options) {
221
248
  if (connection.userId === null)
222
249
  return;
223
250
  if (channel === "server") {
224
- const data = await host.getServerView({ userId: connection.userId, serverId });
251
+ if (host.pullWorld !== undefined) {
252
+ const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
253
+ const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
254
+ if (world !== null) {
255
+ connection.worldRevisions.set(serverId, world.revision);
256
+ const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
257
+ if (data !== null) {
258
+ send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
259
+ }
260
+ return;
261
+ }
262
+ }
263
+ const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
225
264
  send(connection, { v: 1, t: "update", channel, serverId, data });
226
265
  }
227
266
  else if (channel === "player") {
@@ -336,7 +375,10 @@ export function createHostRouter(options) {
336
375
  connection.transport.close();
337
376
  return;
338
377
  }
339
- const userId = await authenticate({ userId: message.userId, token: message.token });
378
+ const resumeToken = resumeTokens.get(message.userId);
379
+ const userId = resumeToken !== undefined && message.token === resumeToken
380
+ ? message.userId
381
+ : await authenticate({ userId: message.userId, token: message.token });
340
382
  if (userId === null) {
341
383
  replyError(connection, message.id, "Not authenticated");
342
384
  connection.transport.close();
@@ -352,10 +394,20 @@ export function createHostRouter(options) {
352
394
  sessionsByUserId.set(userId, connection);
353
395
  }
354
396
  connection.userId = userId;
397
+ if (resumeToken !== undefined && message.token === resumeToken) {
398
+ for (const key of [...pendingLeaves.keys()]) {
399
+ if (key.startsWith(`${userId}|`)) {
400
+ const serverId = key.slice(userId.length + 1);
401
+ clearPendingLeave(userId, serverId);
402
+ }
403
+ }
404
+ }
355
405
  reply(connection, message.id, { userId });
356
406
  return;
357
407
  }
358
408
  if (message.t === "pose") {
409
+ if (connection.roles.get(message.serverId) === "spectator")
410
+ return;
359
411
  await handlePose(connection, message.serverId, message.pose);
360
412
  return;
361
413
  }
@@ -366,6 +418,9 @@ export function createHostRouter(options) {
366
418
  const userId = connection.userId;
367
419
  try {
368
420
  switch (message.t) {
421
+ case "ping":
422
+ send(connection, { v: 1, t: "pong", id: message.id, at: message.at, serverAt: now() });
423
+ break;
369
424
  case "join": {
370
425
  if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
371
426
  return;
@@ -375,9 +430,16 @@ export function createHostRouter(options) {
375
430
  serverId: message.serverId,
376
431
  attributes: message.attributes,
377
432
  code: message.code,
433
+ role: message.role,
378
434
  });
379
435
  connection.joinedServers.add(result.serverId);
380
- reply(connection, message.id, result);
436
+ connection.roles.set(result.serverId, message.role ?? "player");
437
+ if (message.sessionId !== undefined)
438
+ getOrCreate(connection.sessions, result.serverId, () => new Set()).add(message.sessionId);
439
+ reply(connection, message.id, {
440
+ ...result,
441
+ resumeTicket: ticketFor(userId, result.serverId),
442
+ });
381
443
  return;
382
444
  }
383
445
  case "joinByCode": {
@@ -388,9 +450,13 @@ export function createHostRouter(options) {
388
450
  gameId: message.gameId,
389
451
  code: message.code,
390
452
  });
391
- if (result !== null)
453
+ if (result !== null) {
392
454
  connection.joinedServers.add(result.serverId);
393
- reply(connection, message.id, result);
455
+ connection.roles.set(result.serverId, message.role ?? "player");
456
+ }
457
+ reply(connection, message.id, result === null
458
+ ? null
459
+ : { ...result, resumeTicket: ticketFor(userId, result.serverId) });
394
460
  return;
395
461
  }
396
462
  case "browse": {
@@ -405,14 +471,31 @@ export function createHostRouter(options) {
405
471
  return;
406
472
  }
407
473
  case "leave": {
408
- await host.leaveServer({ userId, serverId: message.serverId });
474
+ const sessions = connection.sessions.get(message.serverId);
475
+ // A leave for a session that is not live (a disposed join) must not end the sessions that are.
476
+ if (message.sessionId !== undefined && sessions !== undefined && sessions.size > 0) {
477
+ if (!sessions.delete(message.sessionId) || sessions.size > 0) {
478
+ reply(connection, message.id, null);
479
+ return;
480
+ }
481
+ }
482
+ connection.sessions.delete(message.serverId);
409
483
  connection.joinedServers.delete(message.serverId);
410
- dropPresenceForServer(userId, message.serverId);
411
- dropVoiceForServer(userId, message.serverId);
484
+ connection.roles.delete(message.serverId);
485
+ if (!heldByOtherConnection(connection, userId, message.serverId)) {
486
+ await host.leaveServer({ userId, serverId: message.serverId });
487
+ clearPendingLeave(userId, message.serverId);
488
+ dropPresenceForServer(userId, message.serverId);
489
+ dropVoiceForServer(userId, message.serverId);
490
+ }
412
491
  reply(connection, message.id, null);
413
492
  return;
414
493
  }
415
494
  case "runCommand": {
495
+ if (connection.roles.get(message.serverId) === "spectator") {
496
+ replyError(connection, message.id, "Spectators cannot run commands");
497
+ return;
498
+ }
416
499
  if (!(await gate(connection, message.id, "runCommand", {
417
500
  serverId: message.serverId,
418
501
  command: message.command,
@@ -490,6 +573,8 @@ export function createHostRouter(options) {
490
573
  replyError(connection, message.id, "Not a member of this server");
491
574
  return;
492
575
  }
576
+ if (message.channel === "server")
577
+ connection.worldRevisions.set(message.serverId, null);
493
578
  addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
494
579
  reply(connection, message.id, null);
495
580
  await pushSubscription(connection, message.channel, message.serverId, message.action);
@@ -512,8 +597,16 @@ export function createHostRouter(options) {
512
597
  const userId = connection.userId;
513
598
  const servers = [...connection.joinedServers];
514
599
  connection.joinedServers.clear();
600
+ connection.sessions.clear();
515
601
  for (const serverId of servers) {
516
- void host.leaveServer({ userId, serverId }).catch(() => undefined);
602
+ const key = ticketKey(userId, serverId);
603
+ clearPendingLeave(userId, serverId);
604
+ pendingLeaves.set(key, setTimeout(() => {
605
+ pendingLeaves.delete(key);
606
+ if (heldByOtherConnection(connection, userId, serverId))
607
+ return;
608
+ void host.leaveServer({ userId, serverId }).catch(() => undefined);
609
+ }, graceMs));
517
610
  }
518
611
  };
519
612
  return {
@@ -523,8 +616,11 @@ export function createHostRouter(options) {
523
616
  userId: null,
524
617
  subscriptions: new Set(),
525
618
  joinedServers: new Set(),
619
+ sessions: new Map(),
526
620
  queue: Promise.resolve(),
527
621
  queuedMessages: 0,
622
+ worldRevisions: new Map(),
623
+ roles: new Map(),
528
624
  };
529
625
  connections.add(connection);
530
626
  return {
@@ -579,6 +675,9 @@ export function createHostRouter(options) {
579
675
  },
580
676
  close: () => {
581
677
  unsubscribeHost();
678
+ for (const timer of pendingLeaves.values())
679
+ clearTimeout(timer);
680
+ pendingLeaves.clear();
582
681
  connections.clear();
583
682
  subscribers.clear();
584
683
  },
@@ -5,6 +5,8 @@ import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/mat
5
5
  export declare const WS_PROTOCOL_VERSION = 1;
6
6
  /** Max length of a `runCommand` command name, in UTF-16 code units. */
7
7
  export declare const MAX_COMMAND_LENGTH = 4096;
8
+ /** Max length of a join/leave `sessionId`, in UTF-16 code units. */
9
+ export declare const MAX_SESSION_ID_LENGTH = 128;
8
10
  /** Max length of a `pushFeed` action name, in UTF-16 code units. */
9
11
  export declare const MAX_FEED_ACTION_LENGTH = 256;
10
12
  /** Max serialized size of a `pushFeed` entry payload, in bytes. */
@@ -34,6 +36,11 @@ export type WsVoiceParticipant = {
34
36
  streamId?: string;
35
37
  };
36
38
  export type WsClientMessage = {
39
+ v: 1;
40
+ t: "ping";
41
+ id: number;
42
+ at: number;
43
+ } | {
37
44
  v: 1;
38
45
  t: "hello";
39
46
  id: number;
@@ -47,12 +54,16 @@ export type WsClientMessage = {
47
54
  serverId?: string;
48
55
  attributes?: SessionAttributes;
49
56
  code?: string;
57
+ role?: "player" | "spectator";
58
+ /** Client session holding this join; see `GameRuntimeTransport.joinServer`. */
59
+ sessionId?: string;
50
60
  } | {
51
61
  v: 1;
52
62
  t: "joinByCode";
53
63
  id: number;
54
64
  gameId: string;
55
65
  code: string;
66
+ role?: "player" | "spectator";
56
67
  } | {
57
68
  v: 1;
58
69
  t: "browse";
@@ -65,6 +76,7 @@ export type WsClientMessage = {
65
76
  t: "leave";
66
77
  id: number;
67
78
  serverId: string;
79
+ sessionId?: string;
68
80
  } | {
69
81
  v: 1;
70
82
  t: "runCommand";
@@ -167,6 +179,12 @@ export type WsUpdateMessage = {
167
179
  data: WsVoiceParticipant[];
168
180
  };
169
181
  export type WsServerMessage = {
182
+ v: 1;
183
+ t: "pong";
184
+ id: number;
185
+ at: number;
186
+ serverAt: number;
187
+ } | {
170
188
  v: 1;
171
189
  t: "reply";
172
190
  id: number;
package/dist/protocol.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export const WS_PROTOCOL_VERSION = 1;
2
2
  /** Max length of a `runCommand` command name, in UTF-16 code units. */
3
3
  export const MAX_COMMAND_LENGTH = 4_096;
4
+ /** Max length of a join/leave `sessionId`, in UTF-16 code units. */
5
+ export const MAX_SESSION_ID_LENGTH = 128;
4
6
  /** Max length of a `pushFeed` action name, in UTF-16 code units. */
5
7
  export const MAX_FEED_ACTION_LENGTH = 256;
6
8
  /** Max serialized size of a `pushFeed` entry payload, in bytes. */
@@ -13,6 +15,9 @@ export const MAX_APPEARANCE_VALUE_LENGTH = 256;
13
15
  export function encodeWsMessage(message) {
14
16
  return JSON.stringify(message);
15
17
  }
18
+ function isOptionalSessionId(value) {
19
+ return value === undefined || (typeof value === "string" && value.length > 0 && value.length <= MAX_SESSION_ID_LENGTH);
20
+ }
16
21
  function isRecord(value) {
17
22
  return typeof value === "object" && value !== null;
18
23
  }
@@ -113,6 +118,8 @@ export function decodeWsClientMessage(raw) {
113
118
  if (message === null)
114
119
  return null;
115
120
  switch (message.t) {
121
+ case "ping":
122
+ return typeof message.id === "number" && typeof message.at === "number" ? message : null;
116
123
  case "hello":
117
124
  return typeof message.id === "number" &&
118
125
  typeof message.userId === "string" &&
@@ -123,13 +130,16 @@ export function decodeWsClientMessage(raw) {
123
130
  return typeof message.id === "number" &&
124
131
  typeof message.gameId === "string" &&
125
132
  (message.serverId === undefined || typeof message.serverId === "string") &&
126
- (message.code === undefined || typeof message.code === "string")
133
+ (message.code === undefined || typeof message.code === "string") &&
134
+ (message.role === undefined || message.role === "player" || message.role === "spectator") &&
135
+ isOptionalSessionId(message.sessionId)
127
136
  ? message
128
137
  : null;
129
138
  case "joinByCode":
130
139
  return typeof message.id === "number" &&
131
140
  typeof message.gameId === "string" &&
132
- typeof message.code === "string"
141
+ typeof message.code === "string" &&
142
+ (message.role === undefined || message.role === "player" || message.role === "spectator")
133
143
  ? message
134
144
  : null;
135
145
  case "browse":
@@ -137,7 +147,7 @@ export function decodeWsClientMessage(raw) {
137
147
  ? message
138
148
  : null;
139
149
  case "leave":
140
- return typeof message.id === "number" && typeof message.serverId === "string"
150
+ return typeof message.id === "number" && typeof message.serverId === "string" && isOptionalSessionId(message.sessionId)
141
151
  ? message
142
152
  : null;
143
153
  case "runCommand":
@@ -204,6 +214,8 @@ export function decodeWsServerMessage(raw) {
204
214
  if (message === null)
205
215
  return null;
206
216
  switch (message.t) {
217
+ case "pong":
218
+ return typeof message.id === "number" && typeof message.at === "number" && typeof message.serverAt === "number" ? message : null;
207
219
  case "reply":
208
220
  if (typeof message.id !== "number")
209
221
  return null;
@@ -9,7 +9,7 @@ export interface WorldGameHostOptions {
9
9
  session(args: {
10
10
  gameId: string;
11
11
  serverId: string;
12
- }): HostedWorldSession | null;
12
+ }): HostedWorldSession | Promise<HostedWorldSession | null> | null;
13
13
  now?: () => number;
14
14
  }
15
15
  /** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
package/dist/worldHost.js CHANGED
@@ -7,6 +7,7 @@ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
7
7
  */
8
8
  export function createWorldGameHost(options) {
9
9
  const live = new Map();
10
+ const roles = new Map();
10
11
  const listeners = new Set();
11
12
  const now = options.now ?? (() => Date.now());
12
13
  function emit(event) {
@@ -17,10 +18,19 @@ export function createWorldGameHost(options) {
17
18
  const existing = live.get(serverId);
18
19
  if (existing !== undefined)
19
20
  return existing;
20
- const session = options.session({ gameId, serverId });
21
- if (session === null)
21
+ const resolved = options.session({ gameId, serverId });
22
+ if (resolved instanceof Promise) {
23
+ return resolved.then((session) => {
24
+ if (session === null)
25
+ return null;
26
+ const entry = { gameId, session };
27
+ live.set(serverId, entry);
28
+ return entry;
29
+ });
30
+ }
31
+ if (resolved === null)
22
32
  return null;
23
- const entry = { gameId, session };
33
+ const entry = { gameId, session: resolved };
24
34
  live.set(serverId, entry);
25
35
  return entry;
26
36
  }
@@ -33,13 +43,21 @@ export function createWorldGameHost(options) {
33
43
  }
34
44
  }
35
45
  return {
36
- async joinServer({ userId, gameId, serverId }) {
46
+ async joinServer({ userId, gameId, serverId, role }) {
37
47
  const id = serverId ?? gameId;
38
- const entry = ensure(gameId, id);
48
+ const pending = ensure(gameId, id);
49
+ const entry = pending instanceof Promise ? await pending : pending;
39
50
  if (entry === null)
40
51
  throw new Error(`no hosted world for game "${gameId}"`);
41
52
  const isNew = !entry.session.members().includes(userId);
42
- entry.session.join(userId, isNew);
53
+ let serverRoles = roles.get(id);
54
+ if (serverRoles === undefined) {
55
+ serverRoles = new Map();
56
+ roles.set(id, serverRoles);
57
+ }
58
+ serverRoles.set(userId, role ?? "player");
59
+ if (role !== "spectator")
60
+ entry.session.join(userId, isNew);
43
61
  emit({ type: "server", serverId: id });
44
62
  emit({ type: "player", serverId: id, userId });
45
63
  return { serverId: id, isNew };
@@ -48,7 +66,9 @@ export function createWorldGameHost(options) {
48
66
  const entry = live.get(serverId);
49
67
  if (entry === undefined)
50
68
  return;
51
- entry.session.leave(userId);
69
+ if (entry.session.members().includes(userId))
70
+ entry.session.leave(userId);
71
+ roles.get(serverId)?.delete(userId);
52
72
  emit({ type: "server", serverId });
53
73
  },
54
74
  async runCommand({ userId, serverId, command, input }) {
@@ -70,9 +90,9 @@ export function createWorldGameHost(options) {
70
90
  return { ok: true };
71
91
  },
72
92
  async isMember({ userId, serverId }) {
73
- return live.get(serverId)?.session.members().includes(userId) ?? false;
93
+ return live.get(serverId)?.session.members().includes(userId) || roles.get(serverId)?.has(userId) === true;
74
94
  },
75
- async getServerView({ userId, serverId }) {
95
+ async getServerView({ userId, serverId, role }) {
76
96
  const entry = live.get(serverId);
77
97
  if (entry === undefined)
78
98
  return null;
@@ -81,10 +101,26 @@ export function createWorldGameHost(options) {
81
101
  gameId: entry.gameId,
82
102
  revision: entry.session.revision(),
83
103
  memberUserIds: [...entry.session.members()],
84
- serverState: entry.session.snapshotFor({ userId }),
104
+ serverState: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
85
105
  updatedAt: now(),
86
106
  };
87
107
  },
108
+ async pullWorld({ userId, serverId, sinceRevision, role }) {
109
+ const entry = live.get(serverId);
110
+ if (entry === undefined || (!entry.session.members().includes(userId) && roles.get(serverId)?.get(userId) !== "spectator"))
111
+ return null;
112
+ if (entry.session.projectsViewers()) {
113
+ return {
114
+ kind: "baseline",
115
+ revision: entry.session.revision(),
116
+ snapshot: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
117
+ };
118
+ }
119
+ const sync = entry.session.pull(sinceRevision);
120
+ return sync.kind === "baseline"
121
+ ? sync
122
+ : { kind: "diff", revision: sync.diff.revision, diff: sync.diff };
123
+ },
88
124
  async getPlayerView() {
89
125
  return null;
90
126
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/ws",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.18.0"
34
+ "@jgengine/core": "^0.19.0"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
143
143
 
144
144
  ## JGengine notes
145
145
 
146
- - Multi-session engine work often spans `docs/plans/`, GitHub issues, and stacked PRs — point at those; do not paste full plan bodies.
146
+ - Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
147
147
  - Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
148
148
  - Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
149
- - Papercuts and durable process changes belong in `PAPERCUTS.md` / skills, not only the handoff.
149
+ - Durable process changes belong in skills, not only the handoff.