@jgengine/ws 0.10.0 → 0.11.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,6 +1,7 @@
1
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 { createCommandMiddleware, } from "./commandMiddleware.js";
4
5
  import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
5
6
  export const DEFAULT_POSE_RULES = {
6
7
  maxSpeed: 12,
@@ -9,6 +10,8 @@ export const DEFAULT_POSE_RULES = {
9
10
  maxElapsedSec: 0.5,
10
11
  keepAliveRefreshMs: 10_000,
11
12
  };
13
+ /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
14
+ export const MAX_QUEUED_MESSAGES = 64;
12
15
  function appearanceEqual(a, b) {
13
16
  if (a === b)
14
17
  return true;
@@ -30,6 +33,29 @@ export function createHostRouter(options) {
30
33
  (allowAnonymous ? ({ userId }) => userId : null);
31
34
  const connections = new Set();
32
35
  const sessionsByUserId = new Map();
36
+ const subscribers = new Map();
37
+ const subscribersOf = (key) => {
38
+ const set = subscribers.get(key);
39
+ return set === undefined ? [] : [...set];
40
+ };
41
+ const addSubscription = (connection, key) => {
42
+ connection.subscriptions.add(key);
43
+ let set = subscribers.get(key);
44
+ if (set === undefined) {
45
+ set = new Set();
46
+ subscribers.set(key, set);
47
+ }
48
+ set.add(connection);
49
+ };
50
+ const removeSubscription = (connection, key) => {
51
+ connection.subscriptions.delete(key);
52
+ const set = subscribers.get(key);
53
+ if (set === undefined)
54
+ return;
55
+ set.delete(connection);
56
+ if (set.size === 0)
57
+ subscribers.delete(key);
58
+ };
33
59
  const presence = new Map();
34
60
  const positionHistoryMs = options.positionHistoryMs ?? 1_000;
35
61
  const histories = new Map();
@@ -65,13 +91,35 @@ export function createHostRouter(options) {
65
91
  };
66
92
  const broadcastPresence = (serverId) => {
67
93
  const key = subscriptionKey("presence", serverId);
94
+ const targets = subscribersOf(key);
95
+ if (targets.length === 0)
96
+ return;
68
97
  const data = presenceRows(serverId);
69
- for (const connection of connections) {
70
- if (!connection.subscriptions.has(key))
71
- continue;
98
+ for (const connection of targets) {
72
99
  send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
73
100
  }
74
101
  };
102
+ const commandMiddleware = createCommandMiddleware({
103
+ limits: options.limits,
104
+ authorize: options.authorize,
105
+ validate: options.validate,
106
+ });
107
+ const gate = async (connection, id, op, args = {}) => {
108
+ if (connection.userId === null)
109
+ return true;
110
+ const decision = await commandMiddleware.check({
111
+ connection,
112
+ userId: connection.userId,
113
+ op,
114
+ atMs: now(),
115
+ ...args,
116
+ });
117
+ if (!decision.allow) {
118
+ replyError(connection, id, decision.reason);
119
+ return false;
120
+ }
121
+ return true;
122
+ };
75
123
  const chatRings = new Map();
76
124
  const chatLimiter = createChatRateLimiter(options.chatRateLimit ?? DEFAULT_CHAT_RATE_LIMIT);
77
125
  const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
@@ -88,10 +136,11 @@ export function createHostRouter(options) {
88
136
  };
89
137
  const broadcastChat = (serverId, channelId) => {
90
138
  const key = subscriptionKey("chat", serverId, channelId);
139
+ const targets = subscribersOf(key);
140
+ if (targets.length === 0)
141
+ return;
91
142
  const data = chatRing(serverId, channelId).slice();
92
- for (const connection of connections) {
93
- if (!connection.subscriptions.has(key))
94
- continue;
143
+ for (const connection of targets) {
95
144
  send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
96
145
  }
97
146
  };
@@ -108,10 +157,11 @@ export function createHostRouter(options) {
108
157
  const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
109
158
  const broadcastVoice = (serverId, channelId) => {
110
159
  const key = subscriptionKey("voice", serverId, channelId);
160
+ const targets = subscribersOf(key);
161
+ if (targets.length === 0)
162
+ return;
111
163
  const data = voiceParticipants(serverId, channelId);
112
- for (const connection of connections) {
113
- if (!connection.subscriptions.has(key))
114
- continue;
164
+ for (const connection of targets) {
115
165
  send(connection, { v: 1, t: "update", channel: "voice", serverId, action: channelId, data });
116
166
  }
117
167
  };
@@ -226,24 +276,25 @@ export function createHostRouter(options) {
226
276
  }
227
277
  };
228
278
  const onHostEvent = (event) => {
229
- for (const connection of connections) {
230
- if (connection.userId === null)
231
- continue;
232
- if (event.type === "server") {
233
- if (connection.subscriptions.has(subscriptionKey("server", event.serverId))) {
234
- void pushSubscription(connection, "server", event.serverId);
235
- }
279
+ if (event.type === "server") {
280
+ for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
281
+ if (connection.userId === null)
282
+ continue;
283
+ void pushSubscription(connection, "server", event.serverId);
236
284
  }
237
- else if (event.type === "player") {
238
- if (connection.userId === event.userId &&
239
- connection.subscriptions.has(subscriptionKey("player", event.serverId))) {
285
+ }
286
+ else if (event.type === "player") {
287
+ for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
288
+ if (connection.userId === event.userId) {
240
289
  void pushSubscription(connection, "player", event.serverId);
241
290
  }
242
291
  }
243
- else {
244
- if (connection.subscriptions.has(subscriptionKey("feed", event.serverId, event.action))) {
245
- void pushSubscription(connection, "feed", event.serverId, event.action);
246
- }
292
+ }
293
+ else {
294
+ for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
295
+ if (connection.userId === null)
296
+ continue;
297
+ void pushSubscription(connection, "feed", event.serverId, event.action);
247
298
  }
248
299
  }
249
300
  };
@@ -251,6 +302,15 @@ export function createHostRouter(options) {
251
302
  const handlePose = async (connection, serverId, pose) => {
252
303
  if (connection.userId === null)
253
304
  return;
305
+ const gateDecision = await commandMiddleware.check({
306
+ connection,
307
+ userId: connection.userId,
308
+ op: "pose",
309
+ atMs: now(),
310
+ serverId,
311
+ });
312
+ if (!gateDecision.allow)
313
+ return;
254
314
  if (!(await host.isMember({ userId: connection.userId, serverId })))
255
315
  return;
256
316
  const rows = presence.get(serverId) ?? new Map();
@@ -335,17 +395,22 @@ export function createHostRouter(options) {
335
395
  try {
336
396
  switch (message.t) {
337
397
  case "join": {
398
+ if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
399
+ return;
338
400
  const result = await host.joinServer({
339
401
  userId,
340
402
  gameId: message.gameId,
341
403
  serverId: message.serverId,
342
404
  attributes: message.attributes,
405
+ code: message.code,
343
406
  });
344
407
  connection.joinedServers.add(result.serverId);
345
408
  reply(connection, message.id, result);
346
409
  return;
347
410
  }
348
411
  case "joinByCode": {
412
+ if (!(await gate(connection, message.id, "join", {})))
413
+ return;
349
414
  const result = await host.joinByCode({
350
415
  userId,
351
416
  gameId: message.gameId,
@@ -357,6 +422,8 @@ export function createHostRouter(options) {
357
422
  return;
358
423
  }
359
424
  case "browse": {
425
+ if (!(await gate(connection, message.id, "browse", {})))
426
+ return;
360
427
  const result = await host.browseServers({
361
428
  gameId: message.gameId,
362
429
  filter: message.filter,
@@ -374,6 +441,12 @@ export function createHostRouter(options) {
374
441
  return;
375
442
  }
376
443
  case "runCommand": {
444
+ if (!(await gate(connection, message.id, "runCommand", {
445
+ serverId: message.serverId,
446
+ command: message.command,
447
+ input: message.input,
448
+ })))
449
+ return;
377
450
  const result = await host.runCommand({
378
451
  userId,
379
452
  serverId: message.serverId,
@@ -398,6 +471,8 @@ export function createHostRouter(options) {
398
471
  return;
399
472
  }
400
473
  case "voiceJoin": {
474
+ if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
475
+ return;
401
476
  if ((await requireMembership(connection, message.id, message.serverId)) === null)
402
477
  return;
403
478
  const participant = { userId };
@@ -409,6 +484,8 @@ export function createHostRouter(options) {
409
484
  return;
410
485
  }
411
486
  case "voiceLeave": {
487
+ if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
488
+ return;
412
489
  if ((await requireMembership(connection, message.id, message.serverId)) === null)
413
490
  return;
414
491
  const roster = voiceRoster(message.serverId, message.channelId);
@@ -419,6 +496,8 @@ export function createHostRouter(options) {
419
496
  return;
420
497
  }
421
498
  case "voicePublish": {
499
+ if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
500
+ return;
422
501
  if ((await requireMembership(connection, message.id, message.serverId)) === null)
423
502
  return;
424
503
  const participant = voiceRoster(message.serverId, message.channelId).get(userId);
@@ -439,13 +518,13 @@ export function createHostRouter(options) {
439
518
  replyError(connection, message.id, "Not a member of this server");
440
519
  return;
441
520
  }
442
- connection.subscriptions.add(subscriptionKey(message.channel, message.serverId, message.action));
521
+ addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
443
522
  reply(connection, message.id, null);
444
523
  await pushSubscription(connection, message.channel, message.serverId, message.action);
445
524
  return;
446
525
  }
447
526
  case "unsubscribe": {
448
- connection.subscriptions.delete(subscriptionKey(message.channel, message.serverId, message.action));
527
+ removeSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
449
528
  reply(connection, message.id, null);
450
529
  return;
451
530
  }
@@ -473,30 +552,40 @@ export function createHostRouter(options) {
473
552
  subscriptions: new Set(),
474
553
  joinedServers: new Set(),
475
554
  queue: Promise.resolve(),
555
+ queuedMessages: 0,
476
556
  };
477
557
  connections.add(connection);
478
558
  return {
479
559
  handleRaw: (raw) => {
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;
560
+ const text = typeof raw === "string" ? raw : String(raw);
561
+ const message = decodeWsClientMessage(text);
562
+ if (message === null) {
563
+ const failure = inspectWsDecodeFailure(text);
564
+ if (failure.id !== undefined) {
565
+ replyError(connection, failure.id, failure.reason);
490
566
  }
491
- await handleMessage(connection, message);
492
- })
493
- .catch(() => undefined);
567
+ return;
568
+ }
569
+ if (connection.queuedMessages >= MAX_QUEUED_MESSAGES) {
570
+ if ("id" in message)
571
+ replyError(connection, message.id, "Too many pending requests");
572
+ return;
573
+ }
574
+ connection.queuedMessages += 1;
575
+ connection.queue = connection.queue
576
+ .then(() => handleMessage(connection, message))
577
+ .catch(() => undefined)
578
+ .finally(() => {
579
+ connection.queuedMessages -= 1;
580
+ });
494
581
  },
495
582
  close: () => {
496
583
  if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
497
584
  sessionsByUserId.delete(connection.userId);
498
585
  }
499
586
  connections.delete(connection);
587
+ for (const key of [...connection.subscriptions])
588
+ removeSubscription(connection, key);
500
589
  leaveJoinedServers(connection);
501
590
  dropPresence(connection);
502
591
  dropVoice(connection);
@@ -519,6 +608,7 @@ export function createHostRouter(options) {
519
608
  close: () => {
520
609
  unsubscribeHost();
521
610
  connections.clear();
611
+ subscribers.clear();
522
612
  },
523
613
  };
524
614
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./commandMiddleware.js";
1
2
  export * from "./createWsBackend.js";
2
3
  export * from "./host.js";
3
4
  export * from "./hostRouter.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./commandMiddleware.js";
1
2
  export * from "./createWsBackend.js";
2
3
  export * from "./host.js";
3
4
  export * from "./hostRouter.js";
package/dist/pipe.js CHANGED
@@ -1,3 +1,4 @@
1
+ const OPEN = 1;
1
2
  export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(target)) {
2
3
  return (handlers) => {
3
4
  const socket = webSocketFactory(url);
@@ -6,7 +7,7 @@ export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(
6
7
  socket.onclose = () => handlers.onClose();
7
8
  return {
8
9
  send: (data) => {
9
- if (socket.readyState !== 1)
10
+ if (socket.readyState !== OPEN)
10
11
  return;
11
12
  socket.send(data);
12
13
  },
@@ -3,6 +3,16 @@ import type { PlayerPose } from "@jgengine/core/multiplayer/poseSyncGate";
3
3
  import type { SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
4
4
  import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
5
5
  export declare const WS_PROTOCOL_VERSION = 1;
6
+ /** Max length of a `runCommand` command name, in UTF-16 code units. */
7
+ export declare const MAX_COMMAND_LENGTH = 4096;
8
+ /** Max length of a `pushFeed` action name, in UTF-16 code units. */
9
+ export declare const MAX_FEED_ACTION_LENGTH = 256;
10
+ /** Max serialized size of a `pushFeed` entry payload, in bytes. */
11
+ export declare const MAX_FEED_ENTRY_BYTES = 65536;
12
+ /** Max number of keys in a pose `appearance` tag map. */
13
+ export declare const MAX_APPEARANCE_ENTRIES = 32;
14
+ /** Max length of a single `appearance` tag string value, in UTF-16 code units. */
15
+ export declare const MAX_APPEARANCE_VALUE_LENGTH = 256;
6
16
  export type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice";
7
17
  /** Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. */
8
18
  export type WsAppearance = Record<string, string | number | boolean>;
@@ -36,6 +46,7 @@ export type WsClientMessage = {
36
46
  gameId: string;
37
47
  serverId?: string;
38
48
  attributes?: SessionAttributes;
49
+ code?: string;
39
50
  } | {
40
51
  v: 1;
41
52
  t: "joinByCode";
@@ -172,12 +183,17 @@ export type WsJoinResult = JoinServerResult;
172
183
  export type WsRunCommandResult = TransportRunCommandResult;
173
184
  export type WsBrowseResult = SessionListing[];
174
185
  export type WsJoinByCodeResult = JoinServerResult | null;
186
+ /** @internal */
175
187
  export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
176
188
  export type WsDecodeFailure = {
177
189
  reason: string;
178
190
  id?: number;
179
191
  };
192
+ /** @internal */
180
193
  export declare function inspectWsDecodeFailure(raw: unknown): WsDecodeFailure;
194
+ /** @internal */
181
195
  export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
196
+ /** @internal */
182
197
  export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
198
+ /** @internal */
183
199
  export declare function subscriptionKey(channel: WsChannel, serverId: string, action?: string): string;
package/dist/protocol.js CHANGED
@@ -1,4 +1,15 @@
1
1
  export const WS_PROTOCOL_VERSION = 1;
2
+ /** Max length of a `runCommand` command name, in UTF-16 code units. */
3
+ export const MAX_COMMAND_LENGTH = 4_096;
4
+ /** Max length of a `pushFeed` action name, in UTF-16 code units. */
5
+ export const MAX_FEED_ACTION_LENGTH = 256;
6
+ /** Max serialized size of a `pushFeed` entry payload, in bytes. */
7
+ export const MAX_FEED_ENTRY_BYTES = 65_536;
8
+ /** Max number of keys in a pose `appearance` tag map. */
9
+ export const MAX_APPEARANCE_ENTRIES = 32;
10
+ /** Max length of a single `appearance` tag string value, in UTF-16 code units. */
11
+ export const MAX_APPEARANCE_VALUE_LENGTH = 256;
12
+ /** @internal */
2
13
  export function encodeWsMessage(message) {
3
14
  return JSON.stringify(message);
4
15
  }
@@ -32,8 +43,17 @@ function isWsChannel(value) {
32
43
  value === "voice");
33
44
  }
34
45
  function isAppearance(value) {
35
- return (isRecord(value) &&
36
- Object.values(value).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
46
+ if (!isRecord(value))
47
+ return false;
48
+ const entries = Object.values(value);
49
+ if (entries.length > MAX_APPEARANCE_ENTRIES)
50
+ return false;
51
+ return entries.every((v) => (typeof v === "string" && v.length <= MAX_APPEARANCE_VALUE_LENGTH) ||
52
+ typeof v === "number" ||
53
+ typeof v === "boolean");
54
+ }
55
+ function withinFeedEntryBudget(entry) {
56
+ return new TextEncoder().encode(JSON.stringify(entry)).length <= MAX_FEED_ENTRY_BYTES;
37
57
  }
38
58
  function isFiniteNumber(value) {
39
59
  return typeof value === "number" && Number.isFinite(value);
@@ -47,6 +67,7 @@ function isPose(value) {
47
67
  isFiniteNumber(value.rotationPitch) &&
48
68
  (value.appearance === undefined || isAppearance(value.appearance)));
49
69
  }
70
+ /** @internal */
50
71
  export function inspectWsDecodeFailure(raw) {
51
72
  if (typeof raw !== "string")
52
73
  return { reason: "Invalid message framing" };
@@ -66,8 +87,27 @@ export function inspectWsDecodeFailure(raw) {
66
87
  if (typeof parsed.t !== "string") {
67
88
  return { reason: "Invalid message type", id };
68
89
  }
90
+ if (parsed.t === "runCommand" && typeof parsed.command === "string" && parsed.command.length > MAX_COMMAND_LENGTH) {
91
+ return { reason: "Command exceeds max length", id };
92
+ }
93
+ if (parsed.t === "pushFeed" && typeof parsed.action === "string" && parsed.action.length > MAX_FEED_ACTION_LENGTH) {
94
+ return { reason: "Feed action exceeds max length", id };
95
+ }
96
+ if (parsed.t === "pushFeed" && "entry" in parsed && !withinFeedEntryBudget(parsed.entry)) {
97
+ return { reason: "Feed entry exceeds max size", id };
98
+ }
99
+ if (parsed.t === "pose" && isRecord(parsed.pose) && isRecord(parsed.pose.appearance)) {
100
+ const values = Object.values(parsed.pose.appearance);
101
+ if (values.length > MAX_APPEARANCE_ENTRIES) {
102
+ return { reason: "Appearance exceeds max entries", id };
103
+ }
104
+ if (values.some((v) => typeof v === "string" && v.length > MAX_APPEARANCE_VALUE_LENGTH)) {
105
+ return { reason: "Appearance value exceeds max length", id };
106
+ }
107
+ }
69
108
  return { reason: "Malformed message", id };
70
109
  }
110
+ /** @internal */
71
111
  export function decodeWsClientMessage(raw) {
72
112
  const message = parseVersioned(raw);
73
113
  if (message === null)
@@ -82,7 +122,8 @@ export function decodeWsClientMessage(raw) {
82
122
  case "join":
83
123
  return typeof message.id === "number" &&
84
124
  typeof message.gameId === "string" &&
85
- (message.serverId === undefined || typeof message.serverId === "string")
125
+ (message.serverId === undefined || typeof message.serverId === "string") &&
126
+ (message.code === undefined || typeof message.code === "string")
86
127
  ? message
87
128
  : null;
88
129
  case "joinByCode":
@@ -102,13 +143,16 @@ export function decodeWsClientMessage(raw) {
102
143
  case "runCommand":
103
144
  return typeof message.id === "number" &&
104
145
  typeof message.serverId === "string" &&
105
- typeof message.command === "string"
146
+ typeof message.command === "string" &&
147
+ message.command.length <= MAX_COMMAND_LENGTH
106
148
  ? message
107
149
  : null;
108
150
  case "pushFeed":
109
151
  return typeof message.id === "number" &&
110
152
  typeof message.serverId === "string" &&
111
- typeof message.action === "string"
153
+ typeof message.action === "string" &&
154
+ message.action.length <= MAX_FEED_ACTION_LENGTH &&
155
+ withinFeedEntryBudget(message.entry)
112
156
  ? message
113
157
  : null;
114
158
  case "subscribe":
@@ -154,6 +198,7 @@ export function decodeWsClientMessage(raw) {
154
198
  return null;
155
199
  }
156
200
  }
201
+ /** @internal */
157
202
  export function decodeWsServerMessage(raw) {
158
203
  const message = parseVersioned(raw);
159
204
  if (message === null)
@@ -176,6 +221,7 @@ export function decodeWsServerMessage(raw) {
176
221
  return null;
177
222
  }
178
223
  }
224
+ /** @internal */
179
225
  export function subscriptionKey(channel, serverId, action) {
180
226
  return `${channel}|${serverId}|${action ?? ""}`;
181
227
  }
@@ -41,7 +41,8 @@ export function createReadsHandler(options) {
41
41
  return json(await options.listOpenServers({ gameId, limit }));
42
42
  }
43
43
  const records = await (await getPersistence()).listServers(gameId);
44
- return json(toOpenServerListings(records.map((record) => toServerListing(record)), limit));
44
+ const publicRecords = records.filter((record) => (record.visibility ?? "public") !== "private");
45
+ return json(toOpenServerListings(publicRecords.map((record) => toServerListing(record)), limit));
45
46
  }
46
47
  if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
47
48
  const scope = url.searchParams.get("scope") ?? "global";
package/dist/worldHost.js CHANGED
@@ -72,7 +72,7 @@ export function createWorldGameHost(options) {
72
72
  async isMember({ userId, serverId }) {
73
73
  return live.get(serverId)?.session.members().includes(userId) ?? false;
74
74
  },
75
- async getServerView({ serverId }) {
75
+ async getServerView({ userId, serverId }) {
76
76
  const entry = live.get(serverId);
77
77
  if (entry === undefined)
78
78
  return null;
@@ -81,7 +81,7 @@ export function createWorldGameHost(options) {
81
81
  gameId: entry.gameId,
82
82
  revision: entry.session.revision(),
83
83
  memberUserIds: [...entry.session.members()],
84
- serverState: entry.session.runner().snapshot(),
84
+ serverState: entry.session.snapshotFor({ userId }),
85
85
  updatedAt: now(),
86
86
  };
87
87
  },
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@jgengine/ws",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.",
5
- "license": "AGPL-3.0-only",
5
+ "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "repository": {
@@ -13,7 +13,7 @@
13
13
  "files": [
14
14
  "dist",
15
15
  "CHANGELOG.md",
16
- "llms.txt"
16
+ "skills"
17
17
  ],
18
18
  "exports": {
19
19
  ".": {
@@ -31,7 +31,7 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.10.0"
34
+ "@jgengine/core": "^0.11.0"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: jgengine
3
+ description: Route game work to the smallest useful JGengine guidance set.
4
+ ---
5
+
6
+ # JGengine intake and router
7
+
8
+ Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
9
+
10
+ ## 1. Establish the target
11
+
12
+ Capture only decisions that change architecture:
13
+
14
+ - player point of view and controls
15
+ - world shape and authored content
16
+ - core loop, interaction, combat, and progression
17
+ - solo, local, hosted, or persistent multiplayer
18
+ - HUD/menu states and art direction
19
+ - one observable completion scenario
20
+
21
+ For a new in-repo game, scaffold with `bun run new:game <id> --name "Title"`. Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
+
23
+ ## 2. Select domains
24
+
25
+ Load only rows the target needs. Do not preload every domain.
26
+
27
+ | Need | Skill | Owner |
28
+ | --- | --- | --- |
29
+ | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
30
+ | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
31
+ | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
32
+ | targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
33
+ | HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
34
+ | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
35
+ | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
36
+
37
+ Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
38
+
39
+ ## 3. Discover before designing
40
+
41
+ Within each selected domain:
42
+
43
+ 1. Search `capabilities.md` by intent; it maps needs to imports.
44
+ 2. Open `api.md` only when exact signatures or export inventory are needed.
45
+ 3. Open the linked reference only for a deeper recipe or trap.
46
+
47
+ If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
48
+
49
+ ## 4. Build and finish
50
+
51
+ Keep scene coordinates in the editor document, state serializable, randomness injected, and hot-path work bounded. Use `jgengine-verify` for evidence appropriate to the change. Inside this monorepo, `workflow` owns issue/PR completion. Visual polish work also uses the `jgengine-ui` quality reference.