@jgengine/ws 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -11,6 +11,22 @@ Agents building on the published SDK can also read this programmatically:
11
11
  same data as typed values, so an updater can diff its installed version against
12
12
  the latest and surface the migration steps.
13
13
 
14
+ ## Unreleased
15
+
16
+ ## 0.9.0
17
+
18
+ ### Added
19
+
20
+ - Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
21
+ - Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
22
+ - HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
23
+ - Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
24
+ - Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
25
+
26
+ ### Changed
27
+
28
+ - Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
29
+
14
30
  ## 0.8.0
15
31
 
16
32
  Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
@@ -13,8 +13,12 @@ export type WsBackendOptions = {
13
13
  token?: string;
14
14
  webSocketFactory?: (url: string) => WebSocket;
15
15
  reconnectDelayMs?: number;
16
+ maxReconnectDelayMs?: number;
17
+ rpcTimeoutMs?: number;
16
18
  poseTuning?: PoseSyncTuning;
17
19
  now?: () => number;
20
+ setTimeoutFn?: typeof setTimeout;
21
+ clearTimeoutFn?: typeof clearTimeout;
18
22
  };
19
23
  export type WsPresenceSync = {
20
24
  subscribe: (serverId: string, onChange: (rows: WsPresenceRow[]) => void) => () => void;
@@ -1,5 +1,5 @@
1
1
  import { createPoseSyncGate } from "@jgengine/core/multiplayer/poseSyncGate";
2
- import { decodeWsServerMessage, encodeWsMessage, subscriptionKey, } from "./protocol.js";
2
+ import { decodeWsServerMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
3
3
  import { webSocketPipe } from "./pipe.js";
4
4
  const DEFAULT_POSE_TUNING = {
5
5
  minIntervalMs: 100,
@@ -8,9 +8,16 @@ const DEFAULT_POSE_TUNING = {
8
8
  verticalEpsilon: 0.01,
9
9
  rotationEpsilon: 0.01,
10
10
  };
11
+ const DEFAULT_RPC_TIMEOUT_MS = 15_000;
12
+ const DEFAULT_RECONNECT_DELAY_MS = 1_000;
13
+ const DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;
11
14
  export function createWsBackend(options) {
12
15
  const now = options.now ?? Date.now;
13
- const reconnectDelayMs = options.reconnectDelayMs ?? 1_000;
16
+ const baseReconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
17
+ const maxReconnectDelayMs = options.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
18
+ const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
19
+ const schedule = options.setTimeoutFn ?? setTimeout;
20
+ const cancel = options.clearTimeoutFn ?? clearTimeout;
14
21
  const pipeFactory = options.pipe ??
15
22
  (() => {
16
23
  const url = options.url;
@@ -22,29 +29,89 @@ export function createWsBackend(options) {
22
29
  let open = false;
23
30
  let ready = null;
24
31
  let closed = false;
32
+ let wantConnection = false;
25
33
  let nextId = 1;
34
+ let reconnectAttempt = 0;
35
+ let reconnectTimer = null;
26
36
  const pending = new Map();
27
37
  const subscriptions = new Map();
38
+ const clearRequestTimer = (request) => {
39
+ if (request.timer !== null) {
40
+ cancel(request.timer);
41
+ request.timer = null;
42
+ }
43
+ };
28
44
  const failPending = (reason) => {
29
45
  for (const request of pending.values()) {
46
+ clearRequestTimer(request);
30
47
  request.reject(new Error(reason));
31
48
  }
32
49
  pending.clear();
33
50
  };
51
+ const armRpcTimeout = (request) => {
52
+ if (rpcTimeoutMs <= 0 || request.kind === "hello")
53
+ return;
54
+ request.timer = schedule(() => {
55
+ const current = pending.get(request.id);
56
+ if (current !== request)
57
+ return;
58
+ pending.delete(request.id);
59
+ request.timer = null;
60
+ request.reject(new Error("RPC timeout"));
61
+ }, rpcTimeoutMs);
62
+ };
34
63
  const rawSend = (message) => {
35
64
  if (pipe === null || !open)
36
65
  return;
37
66
  pipe.send(encodeWsMessage(message));
38
67
  };
68
+ const resendPendingRpcs = () => {
69
+ const entries = [...pending.entries()];
70
+ pending.clear();
71
+ for (const [, request] of entries) {
72
+ if (request.kind === "hello") {
73
+ clearRequestTimer(request);
74
+ request.reject(new Error("Connection reset"));
75
+ continue;
76
+ }
77
+ request.id = nextId++;
78
+ request.sent = true;
79
+ pending.set(request.id, request);
80
+ rawSend(request.build(request.id));
81
+ }
82
+ };
83
+ const resubscribeAll = () => {
84
+ for (const subscription of subscriptions.values()) {
85
+ rawSend({
86
+ v: 1,
87
+ t: "subscribe",
88
+ id: nextId++,
89
+ channel: subscription.channel,
90
+ serverId: subscription.serverId,
91
+ action: subscription.action,
92
+ });
93
+ }
94
+ };
39
95
  const handleMessage = (raw) => {
40
96
  const message = decodeWsServerMessage(raw);
41
- if (message === null)
97
+ if (message === null) {
98
+ const failure = inspectWsDecodeFailure(raw);
99
+ if (failure.id !== undefined) {
100
+ const request = pending.get(failure.id);
101
+ if (request !== undefined) {
102
+ pending.delete(failure.id);
103
+ clearRequestTimer(request);
104
+ request.reject(new Error(failure.reason));
105
+ }
106
+ }
42
107
  return;
108
+ }
43
109
  if (message.t === "reply") {
44
110
  const request = pending.get(message.id);
45
111
  if (request === undefined)
46
112
  return;
47
113
  pending.delete(message.id);
114
+ clearRequestTimer(request);
48
115
  if (message.ok) {
49
116
  request.resolve(message.result);
50
117
  }
@@ -65,58 +132,129 @@ export function createWsBackend(options) {
65
132
  for (const callback of subscription.callbacks)
66
133
  callback(data);
67
134
  };
135
+ const clearReconnectTimer = () => {
136
+ if (reconnectTimer !== null) {
137
+ cancel(reconnectTimer);
138
+ reconnectTimer = null;
139
+ }
140
+ };
141
+ const scheduleReconnect = () => {
142
+ if (closed || !wantConnection || reconnectTimer !== null)
143
+ return;
144
+ const delay = Math.min(maxReconnectDelayMs, baseReconnectDelayMs * 2 ** reconnectAttempt);
145
+ reconnectAttempt += 1;
146
+ reconnectTimer = schedule(() => {
147
+ reconnectTimer = null;
148
+ if (closed || !wantConnection)
149
+ return;
150
+ void connect().catch(() => undefined);
151
+ }, delay);
152
+ };
68
153
  const connect = () => {
69
154
  if (closed)
70
155
  return Promise.reject(new Error("Backend closed"));
156
+ wantConnection = true;
71
157
  if (ready !== null)
72
158
  return ready;
159
+ clearReconnectTimer();
73
160
  ready = new Promise((resolve, reject) => {
161
+ let settled = false;
162
+ const settleReject = (error) => {
163
+ if (settled)
164
+ return;
165
+ settled = true;
166
+ reject(error);
167
+ };
168
+ const settleResolve = () => {
169
+ if (settled)
170
+ return;
171
+ settled = true;
172
+ resolve();
173
+ };
74
174
  pipe = pipeFactory({
75
175
  onOpen: () => {
76
176
  open = true;
77
177
  const id = nextId++;
178
+ const helloBuild = (requestId) => ({
179
+ v: 1,
180
+ t: "hello",
181
+ id: requestId,
182
+ userId: options.userId,
183
+ token: options.token,
184
+ });
78
185
  pending.set(id, {
186
+ id,
79
187
  resolve: () => {
80
- for (const subscription of subscriptions.values()) {
81
- rawSend({
82
- v: 1,
83
- t: "subscribe",
84
- id: nextId++,
85
- channel: subscription.channel,
86
- serverId: subscription.serverId,
87
- action: subscription.action,
88
- });
89
- }
90
- resolve();
188
+ reconnectAttempt = 0;
189
+ resubscribeAll();
190
+ resendPendingRpcs();
191
+ settleResolve();
91
192
  },
92
- reject,
193
+ reject: (error) => {
194
+ settleReject(error);
195
+ },
196
+ build: helloBuild,
197
+ timer: null,
198
+ kind: "hello",
199
+ sent: true,
93
200
  });
94
- rawSend({ v: 1, t: "hello", id, userId: options.userId, token: options.token });
201
+ rawSend(helloBuild(id));
95
202
  },
96
203
  onMessage: handleMessage,
97
204
  onClose: () => {
98
205
  open = false;
99
- failPending("Connection closed");
100
206
  pipe = null;
101
207
  ready = null;
102
- reject(new Error("Connection closed"));
103
- if (!closed && subscriptions.size > 0) {
104
- setTimeout(() => {
105
- if (!closed)
106
- void connect().catch(() => undefined);
107
- }, reconnectDelayMs);
208
+ for (const [id, request] of [...pending.entries()]) {
209
+ if (request.kind === "hello") {
210
+ pending.delete(id);
211
+ clearRequestTimer(request);
212
+ request.reject(new Error("Connection closed"));
213
+ }
214
+ else {
215
+ request.sent = false;
216
+ }
217
+ }
218
+ settleReject(new Error("Connection closed"));
219
+ if (!closed && wantConnection) {
220
+ scheduleReconnect();
108
221
  }
109
222
  },
110
223
  });
111
224
  });
112
225
  return ready;
113
226
  };
114
- const request = async (build) => {
115
- await connect();
227
+ const request = (build) => {
228
+ if (closed)
229
+ return Promise.reject(new Error("Backend closed"));
230
+ wantConnection = true;
116
231
  const id = nextId++;
117
232
  return new Promise((resolve, reject) => {
118
- pending.set(id, { resolve, reject });
119
- rawSend(build(id));
233
+ const entry = {
234
+ id,
235
+ resolve,
236
+ reject,
237
+ build,
238
+ timer: null,
239
+ kind: "rpc",
240
+ sent: false,
241
+ };
242
+ pending.set(id, entry);
243
+ armRpcTimeout(entry);
244
+ void connect()
245
+ .then(() => {
246
+ if (pending.get(entry.id) !== entry || entry.sent || !open)
247
+ return;
248
+ entry.sent = true;
249
+ rawSend(build(entry.id));
250
+ })
251
+ .catch((error) => {
252
+ if (pending.get(entry.id) !== entry)
253
+ return;
254
+ pending.delete(entry.id);
255
+ clearRequestTimer(entry);
256
+ reject(error instanceof Error ? error : new Error(String(error)));
257
+ });
120
258
  });
121
259
  };
122
260
  const transport = {
@@ -290,6 +428,8 @@ export function createWsBackend(options) {
290
428
  },
291
429
  close: () => {
292
430
  closed = true;
431
+ wantConnection = false;
432
+ clearReconnectTimer();
293
433
  failPending("Backend closed");
294
434
  pipe?.close();
295
435
  pipe = null;
package/dist/host.d.ts CHANGED
@@ -21,6 +21,7 @@ export type GameHostOptions = {
21
21
  slotsPerServer?: number;
22
22
  now?: () => number;
23
23
  createServerId?: () => string;
24
+ allowedFeedActions?: readonly string[];
24
25
  };
25
26
  export type GameHost = {
26
27
  joinServer: (args: {
@@ -49,6 +50,10 @@ export type GameHost = {
49
50
  command: string;
50
51
  input: unknown;
51
52
  }) => Promise<TransportRunCommandResult>;
53
+ isMember: (args: {
54
+ userId: string;
55
+ serverId: string;
56
+ }) => Promise<boolean>;
52
57
  getServerView: (args: {
53
58
  userId: string;
54
59
  serverId: string;
package/dist/host.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
3
3
  import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
4
+ import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
5
  import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
5
6
  const builtinCommands = {
6
7
  "engine.ping": {
@@ -14,6 +15,7 @@ export function createGameHost(options) {
14
15
  const defaultSlots = options.slotsPerServer ?? 16;
15
16
  const createServerId = options.createServerId ?? (() => `srv-${crypto.randomUUID()}`);
16
17
  const persistence = options.persistence;
18
+ const feedWriteGate = createFeedWriteGate(options.allowedFeedActions ?? []);
17
19
  const runtimes = new Map();
18
20
  for (const runtime of options.runtimes ?? []) {
19
21
  runtimes.set(runtime.gameId, runtime);
@@ -55,6 +57,7 @@ export function createGameHost(options) {
55
57
  serverRow: record.serverState,
56
58
  playersByUserId: buildHydratePlayers(record, profiles),
57
59
  chunksByKey,
60
+ revision: record.revision,
58
61
  });
59
62
  const existing = live.get(record.serverId);
60
63
  if (existing)
@@ -89,6 +92,9 @@ export function createGameHost(options) {
89
92
  if (cleared.chunks.length > 0) {
90
93
  await persistence.saveChunks(entry.record.serverId, cleared.chunks);
91
94
  }
95
+ if (cleared.deletedChunks.length > 0) {
96
+ await persistence.deleteChunks(entry.record.serverId, cleared.deletedChunks);
97
+ }
92
98
  await persistence.saveServer(cleared.server);
93
99
  }
94
100
  entry.record = cleared.server;
@@ -132,7 +138,7 @@ export function createGameHost(options) {
132
138
  }
133
139
  return { ticked, saved };
134
140
  });
135
- const collectListings = async (gameId) => {
141
+ const collectListings = async (gameId, options = {}) => {
136
142
  const byId = new Map();
137
143
  for (const record of await persistence.listServers(gameId)) {
138
144
  byId.set(record.serverId, record);
@@ -151,7 +157,9 @@ export function createGameHost(options) {
151
157
  slotsPerServer: record.slotsPerServer,
152
158
  label: record.label,
153
159
  mode: record.mode,
154
- joinCode: record.joinCode,
160
+ ...(options.includeJoinCode === true && record.joinCode !== undefined
161
+ ? { joinCode: record.joinCode }
162
+ : {}),
155
163
  tags: record.tags,
156
164
  updatedAt: record.updatedAt,
157
165
  }));
@@ -161,6 +169,9 @@ export function createGameHost(options) {
161
169
  const timestamp = now();
162
170
  const runtime = resolveRuntime(args.gameId);
163
171
  let entry = args.serverId === undefined ? null : await getLive(args.serverId);
172
+ if (args.serverId !== undefined && entry === null) {
173
+ throw new Error("Server not found");
174
+ }
164
175
  if (entry !== null && entry.record.gameId !== args.gameId) {
165
176
  throw new Error("Server belongs to a different game");
166
177
  }
@@ -211,7 +222,7 @@ export function createGameHost(options) {
211
222
  }),
212
223
  browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
213
224
  joinByCode: async (args) => {
214
- const match = findByJoinCode(await collectListings(args.gameId), args.code);
225
+ const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
215
226
  if (match === null)
216
227
  return null;
217
228
  return host.joinServer({ userId: args.userId, gameId: args.gameId, serverId: match.serverId });
@@ -262,6 +273,12 @@ export function createGameHost(options) {
262
273
  emit({ type: "player", serverId: args.serverId, userId: args.userId });
263
274
  return { ok: true };
264
275
  }),
276
+ isMember: async (args) => {
277
+ const entry = await getLive(args.serverId);
278
+ if (entry === null)
279
+ return false;
280
+ return entry.record.memberUserIds.includes(args.userId);
281
+ },
265
282
  getServerView: async (args) => {
266
283
  const entry = await getLive(args.serverId);
267
284
  if (entry === null)
@@ -311,6 +328,10 @@ export function createGameHost(options) {
311
328
  return persistence.loadFeed({ serverId: args.serverId, action: args.action });
312
329
  },
313
330
  pushFeedEntry: (args) => enqueue(async () => {
331
+ const allowed = validateFeedWrite(feedWriteGate, args.action);
332
+ if (!allowed.ok) {
333
+ throw new Error(allowed.reason);
334
+ }
314
335
  const entry = await getLive(args.serverId);
315
336
  if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
316
337
  throw new Error("Not a member of this server");
@@ -403,6 +424,14 @@ export function memoryPersistence(now = Date.now) {
403
424
  }
404
425
  chunks.set(serverId, byKey);
405
426
  },
427
+ async deleteChunks(serverId, chunkKeys) {
428
+ const byKey = chunks.get(serverId);
429
+ if (byKey === undefined)
430
+ return;
431
+ for (const chunkKey of chunkKeys) {
432
+ byKey.delete(chunkKey);
433
+ }
434
+ },
406
435
  async loadFeed({ serverId, action }) {
407
436
  return clone(feeds.get(`${serverId}|${action}`) ?? []);
408
437
  },
@@ -2,12 +2,15 @@ import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
2
2
  import { type ChatRateLimit } from "@jgengine/core/game/chat";
3
3
  import type { GameHost } from "./host.js";
4
4
  import type { TransportPipeFactory } from "./pipe.js";
5
+ export type HostRouterAuthenticate = (args: {
6
+ userId: string;
7
+ token?: string;
8
+ }) => Promise<string | null> | string | null;
5
9
  export type HostRouterOptions = {
6
10
  host: GameHost;
7
- authenticate?: (args: {
8
- userId: string;
9
- token?: string;
10
- }) => Promise<string | null> | string | null;
11
+ authenticate?: HostRouterAuthenticate;
12
+ allowAnonymous?: boolean;
13
+ singleSession?: boolean;
11
14
  poseRules?: PoseSyncRules;
12
15
  positionHistoryMs?: number;
13
16
  chatRateLimit?: ChatRateLimit;