@jgengine/convex 0.17.0 → 0.18.1

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/server.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
+ import { validateChatMessage } from "@jgengine/core/game/chat";
3
4
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
- import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
- import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
5
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
6
+ import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
7
+ import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
6
8
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
7
9
  import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
8
- import { createEmptyServerRow, markAllPlayersDirty } from "@jgengine/core/runtime/snapshot";
10
+ import { createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
11
+ import { chunkKeyAt, parseChunkKey, chunkKeysAround, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks";
9
12
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
10
13
  /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
11
14
  export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
@@ -23,6 +26,8 @@ export function jgengineTables() {
23
26
  modeConfig: v.optional(v.any()),
24
27
  visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
25
28
  joinCode: v.optional(v.string()),
29
+ topology: v.optional(v.union(v.literal("shared"), v.literal("rooms"))),
30
+ memberCount: v.optional(v.number()),
26
31
  memberUserIds: v.array(v.string()),
27
32
  slotsPerServer: v.number(),
28
33
  save: saveConfigValidator,
@@ -39,10 +44,31 @@ export function jgengineTables() {
39
44
  .index("by_status", ["status"])
40
45
  .index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
41
46
  .index("by_dirty", ["dirtyAt"]),
47
+ jgServerCapacity: defineTable({
48
+ serverId: v.id("jgGameServers"),
49
+ gameId: v.string(),
50
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
51
+ mode: v.optional(v.string()),
52
+ memberCount: v.number(),
53
+ slotsPerServer: v.number(),
54
+ }).index("by_server", ["serverId"]),
55
+ jgServerMembers: defineTable({
56
+ serverId: v.id("jgGameServers"),
57
+ userId: v.string(),
58
+ joinedAt: v.number(),
59
+ }).index("by_server", ["serverId"]).index("by_server_and_user", ["serverId", "userId"]),
60
+ jgRateLimits: defineTable({
61
+ key: v.string(), startedAt: v.number(), count: v.number(), expiresAt: v.number(),
62
+ }).index("by_key", ["key"]).index("by_expires", ["expiresAt"]),
63
+ jgClientErrors: defineTable({
64
+ userId: v.string(), message: v.string(), details: v.optional(v.any()), createdAt: v.number(),
65
+ }).index("by_created", ["createdAt"]),
42
66
  jgPlayerProfiles: defineTable({
43
67
  userId: v.string(),
44
68
  gameId: v.string(),
45
69
  playerState: v.any(),
70
+ sessionState: v.optional(v.any()),
71
+ sessionServerId: v.optional(v.id("jgGameServers")),
46
72
  revision: v.number(),
47
73
  dirtyAt: v.optional(v.number()),
48
74
  createdAt: v.number(),
@@ -76,23 +102,44 @@ export function jgengineTables() {
76
102
  }).index("by_server_and_action", ["serverId", "action"]),
77
103
  jgPoses: defineTable({
78
104
  serverId: v.string(),
105
+ homeGameId: v.optional(v.string()),
106
+ ownerActorId: v.optional(v.string()),
107
+ lastWorldSnapshotAt: v.optional(v.number()),
108
+ chunkKey: v.optional(v.string()),
79
109
  userId: v.string(),
110
+ /** One row per live session, so a second tab is a second row rather than a fight over one. */
111
+ sessionId: v.optional(v.string()),
112
+ /** Actor class, e.g. `"player"` / `"agent"`, which selects the pose rules applied to it. */
113
+ kind: v.optional(v.string()),
114
+ /** Display name carried on the row, so nameplates need no per-frame join against users. */
115
+ label: v.optional(v.string()),
80
116
  x: v.number(),
81
117
  y: v.number(),
82
118
  z: v.number(),
83
119
  rotationY: v.number(),
84
120
  rotationPitch: v.number(),
85
121
  updatedAt: v.number(),
122
+ /** Set when the session was displaced or left; the reaper deletes the row once it goes idle. */
123
+ revokedAt: v.optional(v.number()),
86
124
  })
87
125
  .index("by_server", ["serverId"])
88
- .index("by_server_and_user", ["serverId", "userId"]),
126
+ .index("by_server_and_user", ["serverId", "userId"])
127
+ .index("by_server_and_chunk", ["serverId", "chunkKey"])
128
+ .index("by_server_user_revoked_updated", ["serverId", "userId", "revokedAt", "updatedAt"])
129
+ .index("by_updated", ["updatedAt"])
130
+ .index("by_user", ["userId"])
131
+ .index("by_home_game", ["homeGameId"])
132
+ .index("by_home_game_revoked_updated", ["homeGameId", "revokedAt", "updatedAt"])
133
+ .index("by_revoked", ["revokedAt"])
134
+ .index("by_revoked_updated", ["revokedAt", "updatedAt"]),
89
135
  jgChatMessages: defineTable({
90
136
  serverId: v.string(),
91
137
  channelId: v.string(),
92
138
  userId: v.string(),
93
139
  body: v.string(),
140
+ authorName: v.optional(v.string()),
94
141
  at: v.number(),
95
- }).index("by_server_channel", ["serverId", "channelId"]),
142
+ }).index("by_server_channel", ["serverId", "channelId", "at"]).index("by_at", ["at"]),
96
143
  };
97
144
  }
98
145
  /**
@@ -141,11 +188,19 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
141
188
  }
142
189
  return null;
143
190
  }
191
+ async function hasMember(ctx, server, userId) {
192
+ if (server.memberUserIds.includes(userId))
193
+ return true;
194
+ if (server.topology !== "shared")
195
+ return false;
196
+ return (await ctx.db.query("jgServerMembers")
197
+ .withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique()) !== null;
198
+ }
144
199
  async function requireServerMember(ctx, serverId, actorUserId) {
145
200
  const server = await ctx.db.get("jgGameServers", serverId);
146
201
  if (!server)
147
202
  return null;
148
- if (!isServerMember(server.memberUserIds, actorUserId))
203
+ if (!(await hasMember(ctx, server, actorUserId)))
149
204
  return null;
150
205
  return server;
151
206
  }
@@ -219,6 +274,21 @@ function serverDocToRecord(server) {
219
274
  updatedAt: server.updatedAt,
220
275
  };
221
276
  }
277
+ async function serverCapacity(ctx, server) {
278
+ return server.topology === "shared"
279
+ ? ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", server._id)).unique()
280
+ : null;
281
+ }
282
+ async function ensureServerCapacity(ctx, server) {
283
+ const existing = await serverCapacity(ctx, server);
284
+ if (existing)
285
+ return existing;
286
+ const id = await ctx.db.insert("jgServerCapacity", {
287
+ serverId: server._id, gameId: server.gameId, status: server.status, mode: server.mode,
288
+ memberCount: server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer,
289
+ });
290
+ return (await ctx.db.get("jgServerCapacity", id));
291
+ }
222
292
  /**
223
293
  * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
224
294
  * Reach for it when writing your own mutation that must read runtime state before touching host tables.
@@ -228,14 +298,27 @@ function serverDocToRecord(server) {
228
298
  export async function loadServerSnapshot(ctx, server, runtime, scope) {
229
299
  const profiles = {};
230
300
  const memberIds = new Set(server.memberUserIds);
231
- const hydrateUserIds = scope?.players === undefined
232
- ? server.memberUserIds
233
- : scope.players.filter((userId) => memberIds.has(userId));
301
+ let hydrateUserIds;
302
+ if (server.topology === "shared") {
303
+ const requested = scope?.players ?? (await ctx.db.query("jgServerMembers")
304
+ .withIndex("by_server", q => q.eq("serverId", server._id)).collect()).map(row => row.userId);
305
+ hydrateUserIds = [];
306
+ for (const userId of new Set(requested))
307
+ if (await hasMember(ctx, server, userId))
308
+ hydrateUserIds.push(userId);
309
+ }
310
+ else {
311
+ hydrateUserIds = scope?.players === undefined ? server.memberUserIds : scope.players.filter(userId => memberIds.has(userId));
312
+ }
313
+ const sessionPlayers = {};
234
314
  for (const userId of hydrateUserIds) {
235
315
  const profile = await ctx.db
236
316
  .query("jgPlayerProfiles")
237
317
  .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
238
318
  .unique();
319
+ if (server.topology === "shared" && profile) {
320
+ sessionPlayers[userId] = { ...profile.playerState, ...(profile.sessionServerId === server._id ? { session: profile.sessionState ?? {} } : { session: {} }) };
321
+ }
239
322
  profiles[userId] = profile
240
323
  ? {
241
324
  userId,
@@ -246,7 +329,7 @@ export async function loadServerSnapshot(ctx, server, runtime, scope) {
246
329
  }
247
330
  : null;
248
331
  }
249
- const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
332
+ const playersByUserId = buildHydratePlayers(server.topology === "shared" ? { ...serverDocToRecord(server), memberUserIds: hydrateUserIds, sessionPlayers } : serverDocToRecord(server), profiles, hydrateUserIds);
250
333
  const chunksByKey = {};
251
334
  if (scope?.chunkKeys === undefined) {
252
335
  const chunks = await ctx.db
@@ -313,7 +396,9 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
313
396
  */
314
397
  export async function persistServerSnapshot(ctx, server, snapshot, save) {
315
398
  const now = Date.now();
316
- const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
399
+ const shared = server.topology === "shared";
400
+ const record = serverDocToRecord(server);
401
+ const plan = planServerPersist(shared ? { ...record, memberUserIds: Object.keys(snapshot.players), sessionPlayers: {} } : record, snapshot, save, now);
317
402
  if (!plan.changed.any)
318
403
  return false;
319
404
  if (plan.leaderboard.length > 0) {
@@ -326,6 +411,7 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
326
411
  .unique();
327
412
  const profilePatch = {
328
413
  playerState: profile.playerState,
414
+ ...(shared ? { sessionState: snapshot.players[profile.userId]?.session ?? {}, sessionServerId: server._id } : {}),
329
415
  revision: profile.revision,
330
416
  updatedAt: profile.updatedAt,
331
417
  };
@@ -341,6 +427,22 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
341
427
  });
342
428
  }
343
429
  }
430
+ if (shared) {
431
+ const persisted = new Set(plan.profiles.map(profile => profile.userId));
432
+ for (const userId of snapshot.dirty.players) {
433
+ if (persisted.has(userId))
434
+ continue;
435
+ const player = snapshot.players[userId];
436
+ if (!player)
437
+ continue;
438
+ const existing = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
439
+ const patch = { playerState: player, sessionState: player.session, sessionServerId: server._id, revision: snapshot.revision, updatedAt: now };
440
+ if (existing)
441
+ await ctx.db.patch(existing._id, patch);
442
+ else
443
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
444
+ }
445
+ }
344
446
  for (const chunk of plan.chunks) {
345
447
  const existing = await ctx.db
346
448
  .query("jgWorldChunks")
@@ -370,23 +472,25 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
370
472
  if (existing)
371
473
  await ctx.db.delete(existing._id);
372
474
  }
373
- await ctx.db.patch(server._id, {
374
- ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
375
- ...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
376
- revision: plan.server.revision,
377
- dirtyAt: plan.server.dirtyAt,
378
- updatedAt: plan.server.updatedAt,
379
- lastSavedAt: plan.server.lastSavedAt,
380
- });
475
+ if (!shared || plan.changed.serverState)
476
+ await ctx.db.patch(server._id, {
477
+ ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
478
+ ...(!shared && plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
479
+ revision: plan.server.revision,
480
+ dirtyAt: plan.server.dirtyAt,
481
+ updatedAt: plan.server.updatedAt,
482
+ lastSavedAt: plan.server.lastSavedAt,
483
+ });
381
484
  return true;
382
485
  }
383
486
  /** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
384
- async function flushServerIfDue(ctx, server, now, runtime, loaded) {
487
+ async function flushServerIfDue(ctx, server, now, _runtime, loaded) {
385
488
  if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
386
489
  return false;
387
490
  }
388
- const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
389
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
491
+ if (loaded)
492
+ await persistServerSnapshot(ctx, server, loaded, server.save);
493
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: now });
390
494
  return true;
391
495
  }
392
496
  export const JG_RUNTIME_TICK_MS = 1_000;
@@ -397,10 +501,9 @@ export const MAX_CHUNKS_PER_QUERY = 64;
397
501
  /**
398
502
  * Build the hosted-server Convex functions for a set of game runtimes.
399
503
  *
400
- * **Supported world size.** One server is one Convex document holding the world row, the member
401
- * roster, and every member's session state, and the host rewrites that document whenever the world
402
- * changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
403
- * larger value throws here, at wiring time, rather than at the write that overflows the document.
504
+ * Room topology keeps its roster and session map in the server document and caps capacity at
505
+ * {@link JG_MAX_MEMBERS_PER_SERVER}. Shared topology stores membership, capacity, and per-player
506
+ * session state separately; player-only commands and roster changes do not rewrite the world row.
404
507
  * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
405
508
  * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
406
509
  *
@@ -410,11 +513,12 @@ export const MAX_CHUNKS_PER_QUERY = 64;
410
513
  */
411
514
  export function createGameServerFunctions(options) {
412
515
  const mode = options?.auth ?? "required";
413
- const hostSlotsPerServer = options?.slotsPerServer ?? 16;
414
- const slotsCheck = validateSlotsPerServer(hostSlotsPerServer);
516
+ const shared = options?.topology === "shared" || (options?.topology === undefined && (options?.runtimes?.some(runtime => runtime.topology === "shared") ?? false));
517
+ const hostSlotsPerServer = shared ? Number.MAX_SAFE_INTEGER : options?.slotsPerServer ?? 16;
518
+ const slotsCheck = validateSlotsPerServer(hostSlotsPerServer, shared ? "shared" : "rooms");
415
519
  if (!slotsCheck.ok)
416
520
  throw new Error(slotsCheck.reason);
417
- const matchmaking = options?.matchmaking ?? "auto";
521
+ const matchmaking = options?.matchmaking ?? (shared ? "singleton" : "auto");
418
522
  const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
419
523
  const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
420
524
  const registry = buildRuntimeRegistry(options?.runtimes);
@@ -431,23 +535,24 @@ export function createGameServerFunctions(options) {
431
535
  joinCode: v.optional(v.string()),
432
536
  externalId: v.optional(v.string()),
433
537
  },
434
- returns: v.object({
435
- serverId: v.id("jgGameServers"),
436
- isNew: v.boolean(),
437
- }),
538
+ returns: v.union(v.object({ ok: v.literal(true), serverId: v.id("jgGameServers"), isNew: v.boolean() }), v.object({ ok: v.literal(false), reason: v.union(v.literal("full"), v.literal("closed"), v.literal("unauthorized")) })),
438
539
  handler: async (ctx, args) => {
439
- const actorUserId = await requireActor(ctx, args.externalId, mode);
540
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
541
+ if (!actorUserId)
542
+ return { ok: false, reason: "unauthorized" };
440
543
  const now = Date.now();
441
544
  const runtime = resolveRuntime(registry, args.gameId);
442
545
  const save = runtime.save;
443
546
  const slotsPerServer = hostSlotsPerServer;
444
547
  let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
445
548
  if (args.serverId !== undefined && !server) {
446
- throw new ConvexError("Server not found");
549
+ return { ok: false, reason: "closed" };
447
550
  }
448
551
  if (server && server.gameId !== args.gameId) {
449
- throw new ConvexError("Server belongs to a different game");
552
+ return { ok: false, reason: "unauthorized" };
450
553
  }
554
+ if (server?.status === "closed")
555
+ return { ok: false, reason: "closed" };
451
556
  let mayCreate = true;
452
557
  if (!server) {
453
558
  const joinable = [];
@@ -455,7 +560,7 @@ export function createGameServerFunctions(options) {
455
560
  const rows = await ctx.db
456
561
  .query("jgGameServers")
457
562
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
458
- .collect();
563
+ .take(100);
459
564
  joinable.push(...rows);
460
565
  }
461
566
  // Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
@@ -468,7 +573,7 @@ export function createGameServerFunctions(options) {
468
573
  slotsPerServer,
469
574
  });
470
575
  if (target.kind === "refuse")
471
- throw new ConvexError(target.reason);
576
+ return { ok: false, reason: "full" };
472
577
  server = target.kind === "join" ? target.row : null;
473
578
  mayCreate = target.kind === "create";
474
579
  }
@@ -480,6 +585,8 @@ export function createGameServerFunctions(options) {
480
585
  modeConfig: args.modeConfig,
481
586
  visibility: args.visibility ?? "public",
482
587
  joinCode: args.joinCode,
588
+ topology: shared ? "shared" : "rooms",
589
+ memberCount: 0,
483
590
  memberUserIds: [],
484
591
  slotsPerServer,
485
592
  save,
@@ -505,37 +612,64 @@ export function createGameServerFunctions(options) {
505
612
  throw new ConvexError("Server missing after reconcile");
506
613
  server = reconciled;
507
614
  }
508
- if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
509
- throw new ConvexError("Server is full");
615
+ if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
616
+ return { ok: false, reason: "full" };
510
617
  }
511
618
  if (isPrivateJoinBlocked({
512
619
  visibility: server.visibility,
513
- memberUserIds: server.memberUserIds,
620
+ memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
514
621
  userId: actorUserId,
515
622
  joinCode: server.joinCode,
516
623
  suppliedCode: args.joinCode,
517
624
  })) {
518
- throw new ConvexError("Server is private");
625
+ return { ok: false, reason: "unauthorized" };
519
626
  }
520
- const profile = await ctx.db
627
+ let profile = await ctx.db
521
628
  .query("jgPlayerProfiles")
522
629
  .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
523
630
  .unique();
631
+ if (shared && server.topology !== "shared") {
632
+ for (const userId of server.memberUserIds) {
633
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique();
634
+ if (!member)
635
+ await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now });
636
+ const state = server.sessionPlayers[userId];
637
+ if (!state)
638
+ continue;
639
+ const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
640
+ const patch = { sessionState: state.session ?? {}, sessionServerId: server._id };
641
+ if (stored)
642
+ await ctx.db.patch(stored._id, patch);
643
+ else
644
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch });
645
+ }
646
+ await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} });
647
+ server = (await ctx.db.get("jgGameServers", server._id));
648
+ profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique();
649
+ }
524
650
  const isNew = profile === null;
525
651
  const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
526
- await ctx.db.patch(server._id, {
527
- memberUserIds,
528
- status: "running",
529
- updatedAt: now,
530
- dirtyAt: now,
531
- });
652
+ if (shared) {
653
+ const capacity = await ensureServerCapacity(ctx, server);
654
+ const isMember = await hasMember(ctx, server, actorUserId);
655
+ await ctx.db.patch(capacity._id, {
656
+ memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer,
657
+ });
658
+ if (!isMember)
659
+ await ctx.db.insert("jgServerMembers", { serverId: server._id, userId: actorUserId, joinedAt: now });
660
+ if (server.status !== "running")
661
+ await ctx.db.patch(server._id, { status: "running" });
662
+ }
663
+ else {
664
+ await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now });
665
+ }
532
666
  const refreshed = await ctx.db.get("jgGameServers", server._id);
533
667
  if (!refreshed)
534
668
  throw new ConvexError("Server missing after join");
535
- let snapshot = await loadServerSnapshot(ctx, refreshed, runtime);
536
- snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
669
+ let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
670
+ snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
537
671
  await persistServerSnapshot(ctx, refreshed, snapshot, save);
538
- return { serverId: refreshed._id, isNew };
672
+ return { ok: true, serverId: refreshed._id, isNew };
539
673
  },
540
674
  });
541
675
  const leaveServer = mutation({
@@ -551,28 +685,74 @@ export function createGameServerFunctions(options) {
551
685
  return null;
552
686
  const now = Date.now();
553
687
  const runtime = resolveRuntime(registry, server.gameId);
554
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
688
+ // Flushes the leaver's own state before the roster drops them; nobody else's rows are touched.
689
+ const snapshot = await loadServerSnapshot(ctx, server, runtime, {
690
+ players: [actorUserId],
691
+ chunkKeys: [],
692
+ });
555
693
  await persistServerSnapshot(ctx, server, snapshot, server.save);
556
694
  const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
557
695
  const sessionPlayers = { ...server.sessionPlayers };
558
696
  delete sessionPlayers[actorUserId];
559
- await ctx.db.patch(server._id, {
560
- memberUserIds,
561
- sessionPlayers,
562
- updatedAt: now,
563
- dirtyAt: now,
564
- status: statusAfterLeave(memberUserIds.length, server.status),
565
- });
566
- const pose = await ctx.db
697
+ if (server.topology === "shared") {
698
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", actorUserId)).unique();
699
+ if (member) {
700
+ const capacity = await ensureServerCapacity(ctx, server);
701
+ await ctx.db.delete(member._id);
702
+ await ctx.db.patch(capacity._id, { memberCount: Math.max(0, capacity.memberCount - 1) });
703
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", server.gameId)).unique();
704
+ if (profile?.sessionServerId === server._id)
705
+ await ctx.db.patch(profile._id, { sessionState: {}, sessionServerId: undefined });
706
+ }
707
+ }
708
+ else {
709
+ await ctx.db.patch(server._id, { memberUserIds, sessionPlayers, updatedAt: now, status: statusAfterLeave(memberUserIds.length, server.status) });
710
+ }
711
+ const poses = await ctx.db
567
712
  .query("jgPoses")
568
713
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
569
- .unique();
570
- if (pose)
714
+ .collect();
715
+ for (const pose of poses)
571
716
  await ctx.db.delete(pose._id);
572
717
  return null;
573
718
  },
574
719
  });
575
720
  const helpers = {
721
+ async ensureServer(ctx, gameId) {
722
+ if (!shared || matchmaking !== "singleton")
723
+ throw new Error("ensureServer requires a shared singleton host");
724
+ for (const status of ["running", "open"]) {
725
+ const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first();
726
+ if (existing)
727
+ return existing;
728
+ }
729
+ const runtime = resolveRuntime(registry, gameId);
730
+ const now = Date.now();
731
+ const id = await ctx.db.insert("jgGameServers", {
732
+ gameId, topology: "shared", status: "running", memberUserIds: [], slotsPerServer: hostSlotsPerServer,
733
+ save: runtime.save, serverState: defaultServerStateForGame(gameId), sessionPlayers: {}, revision: 0,
734
+ tickAnchorMs: now, createdAt: now, updatedAt: now,
735
+ });
736
+ const server = (await ctx.db.get("jgGameServers", id));
737
+ await ensureServerCapacity(ctx, server);
738
+ return server;
739
+ },
740
+ async resetPlayerProfile(ctx, serverId, userId) {
741
+ const server = await requireServerMember(ctx, serverId, userId);
742
+ if (!server)
743
+ throw new ConvexError("Not a member of this server");
744
+ const state = initialPlayerState(resolveRuntime(registry, server.gameId), userId);
745
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
746
+ const now = Date.now();
747
+ const patch = { playerState: state, sessionState: state.session, sessionServerId: server._id, revision: (profile?.revision ?? 0) + 1, updatedAt: now };
748
+ if (profile)
749
+ await ctx.db.patch(profile._id, patch);
750
+ else
751
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
752
+ if (server.topology !== "shared")
753
+ await ctx.db.patch(server._id, { sessionPlayers: { ...server.sessionPlayers, [userId]: state } });
754
+ return state;
755
+ },
576
756
  async loadSnapshot(ctx, serverId, scope) {
577
757
  const server = await ctx.db.get("jgGameServers", serverId);
578
758
  if (!server)
@@ -587,18 +767,19 @@ export function createGameServerFunctions(options) {
587
767
  return persistServerSnapshot(ctx, server, snapshot, save ?? server.save);
588
768
  },
589
769
  async runCommand(ctx, args) {
590
- const actorUserId = await requireActor(ctx, args.externalId, mode);
770
+ const actorUserId = args.actorUserId ?? await requireActor(ctx, args.externalId, mode);
591
771
  const serverId = args.serverId;
592
772
  const server = await ctx.db.get("jgGameServers", serverId);
593
773
  if (!server) {
594
774
  return { ok: false, reason: "Server not found" };
595
775
  }
596
- if (!isServerMember(server.memberUserIds, actorUserId)) {
776
+ if (!(await hasMember(ctx, server, actorUserId))) {
597
777
  return { ok: false, reason: "Not a member of this server" };
598
778
  }
599
779
  const loadedRevision = server.revision;
600
780
  const runtime = resolveRuntime(registry, server.gameId);
601
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
781
+ const scope = scopeWithActor(args.scope ?? runtime.commandScope(args.command, args.input, actorUserId), actorUserId);
782
+ const snapshot = await loadServerSnapshot(ctx, server, runtime, scope);
602
783
  const result = applyCommandWithOcc({
603
784
  loadedRevision,
604
785
  currentRevision: server.revision,
@@ -611,6 +792,10 @@ export function createGameServerFunctions(options) {
611
792
  if (!result.ok) {
612
793
  return result;
613
794
  }
795
+ const escape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
796
+ if (escape !== null) {
797
+ return { ok: false, reason: escape };
798
+ }
614
799
  const latest = await ctx.db.get("jgGameServers", serverId);
615
800
  if (!latest) {
616
801
  return { ok: false, reason: "Server not found" };
@@ -619,6 +804,10 @@ export function createGameServerFunctions(options) {
619
804
  if (!commit.ok) {
620
805
  return commit;
621
806
  }
807
+ await args.beforePersist?.(ctx, { server: latest, runtime, snapshot: result.snapshot });
808
+ const callbackEscape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
809
+ if (callbackEscape !== null)
810
+ throw new ConvexError(callbackEscape);
622
811
  await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
623
812
  return { ok: true };
624
813
  },
@@ -629,9 +818,13 @@ export function createGameServerFunctions(options) {
629
818
  command: v.string(),
630
819
  input: v.any(),
631
820
  externalId: v.optional(v.string()),
821
+ scope: v.optional(v.object({
822
+ players: v.optional(v.array(v.string())),
823
+ chunkKeys: v.optional(v.array(v.string())),
824
+ })),
632
825
  },
633
826
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
634
- handler: (ctx, args) => helpers.runCommand(ctx, args),
827
+ handler: (ctx, args) => helpers.runCommand(ctx, { serverId: args.serverId, command: args.command, input: args.input, externalId: args.externalId }),
635
828
  });
636
829
  const flushSave = mutation({
637
830
  args: {
@@ -644,9 +837,7 @@ export function createGameServerFunctions(options) {
644
837
  const server = await requireServerMember(ctx, args.serverId, actorUserId);
645
838
  if (server === null)
646
839
  return false;
647
- const runtime = resolveRuntime(registry, server.gameId);
648
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
649
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
840
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
650
841
  return true;
651
842
  },
652
843
  });
@@ -702,20 +893,38 @@ export function createGameServerFunctions(options) {
702
893
  mode: v.optional(v.string()),
703
894
  memberCount: v.number(),
704
895
  slotsPerServer: v.number(),
705
- revision: v.number(),
706
- updatedAt: v.number(),
707
896
  }), v.null()),
708
- handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
897
+ handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => ({
709
898
  _id: server._id,
710
899
  gameId: server.gameId,
711
900
  status: server.status,
712
901
  mode: server.mode,
713
- memberCount: server.memberUserIds.length,
902
+ memberCount: (await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length,
714
903
  slotsPerServer: server.slotsPerServer,
715
- revision: server.revision,
716
- updatedAt: server.updatedAt,
717
904
  })),
718
905
  });
906
+ const getServerCapacity = query({
907
+ args: { serverId: v.id("jgGameServers"), externalId: v.optional(v.string()) },
908
+ returns: v.union(v.object({
909
+ _id: v.id("jgGameServers"), gameId: v.string(),
910
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
911
+ mode: v.optional(v.string()), memberCount: v.number(), slotsPerServer: v.number(),
912
+ }), v.null()),
913
+ handler: async (ctx, args) => {
914
+ const actor = await resolveActor(ctx, args.externalId, mode);
915
+ if (!actor)
916
+ return null;
917
+ const capacity = await ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", args.serverId)).unique();
918
+ if (capacity) {
919
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actor)).unique();
920
+ if (!member)
921
+ return null;
922
+ return { _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer };
923
+ }
924
+ const server = await requireServerMember(ctx, args.serverId, actor);
925
+ return server ? { _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer } : null;
926
+ },
927
+ });
719
928
  /**
720
929
  * Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
721
930
  * geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
@@ -849,17 +1058,17 @@ export function createGameServerFunctions(options) {
849
1058
  .query("jgGameServers")
850
1059
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
851
1060
  .take(Math.min(limit * 4, 200));
852
- return rows
1061
+ return Promise.all(rows
853
1062
  .filter((row) => isListablePublicly(row.visibility))
854
1063
  .slice(0, limit)
855
- .map((row) => ({
1064
+ .map(async (row) => ({
856
1065
  _id: row._id,
857
1066
  status: row.status,
858
- memberCount: row.memberUserIds.length,
1067
+ memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length,
859
1068
  slotsPerServer: row.slotsPerServer,
860
1069
  mode: row.mode,
861
1070
  updatedAt: row.updatedAt,
862
- }));
1071
+ })));
863
1072
  },
864
1073
  });
865
1074
  // Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
@@ -881,15 +1090,16 @@ export function createGameServerFunctions(options) {
881
1090
  .withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
882
1091
  .take(budget);
883
1092
  for (const server of servers) {
884
- if (server.memberUserIds.length === 0)
1093
+ if (((await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length) === 0)
885
1094
  continue;
886
1095
  const elapsedMs = now - server.tickAnchorMs;
887
1096
  if (elapsedMs < JG_RUNTIME_TICK_MS)
888
1097
  continue;
889
1098
  budget -= 1;
890
1099
  const runtime = resolveRuntime(registry, server.gameId);
1100
+ // Unscoped on purpose: onTick is handed the whole world and may touch any of it.
891
1101
  const loaded = await loadServerSnapshot(ctx, server, runtime);
892
- const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
1102
+ const snapshot = runtime.tick(loaded, elapsedMs / 1_000, now);
893
1103
  await persistServerSnapshot(ctx, server, snapshot, server.save);
894
1104
  await ctx.db.patch(server._id, { tickAnchorMs: now });
895
1105
  ticked += 1;
@@ -911,9 +1121,7 @@ export function createGameServerFunctions(options) {
911
1121
  .take(maxServersPerTick);
912
1122
  let saved = 0;
913
1123
  for (const server of servers) {
914
- const runtime = resolveRuntime(registry, server.gameId);
915
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
916
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
1124
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
917
1125
  saved += 1;
918
1126
  }
919
1127
  return { saved };
@@ -926,6 +1134,7 @@ export function createGameServerFunctions(options) {
926
1134
  flushSave,
927
1135
  getServer,
928
1136
  getServerMeta,
1137
+ getServerCapacity,
929
1138
  getChunks,
930
1139
  getPlayerProfile,
931
1140
  getFeed,
@@ -1002,34 +1211,63 @@ export function createLeaderboardFunctions(options) {
1002
1211
  });
1003
1212
  return { getTop, getProfile, incrementMany };
1004
1213
  }
1005
- /** @internal */
1214
+ /**
1215
+ * The pose lane of a hosted server: the backend behind `PresenceSync`, which replicates where each
1216
+ * member of one `jgGameServers` server is standing. It is not the backend for `PresenceTransport` —
1217
+ * that contract (`ConvexPresenceFunctions` in `./convexPresenceTransport`) is a game-owned world's
1218
+ * own presence, keyed by the game's own ids, with its own notion of who counts as a resident. A game
1219
+ * on `createConvexPresenceTransport` supplies those functions itself; these do not substitute for them.
1220
+ * @internal
1221
+ */
1006
1222
  export function createPresenceFunctions(options) {
1007
1223
  const mode = options?.auth ?? "required";
1008
1224
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
1009
- const poseRules = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
1225
+ const idleCutoffMs = options?.idleCutoffMs ?? 60_000;
1226
+ const maxReapPerRun = options?.maxReapPerRun ?? 256;
1227
+ const rulesOption = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
1228
+ const rulesFor = (kind) => typeof rulesOption === "function" ? rulesOption(kind) : rulesOption;
1229
+ const poseValidator = v.object({
1230
+ x: v.number(),
1231
+ y: v.number(),
1232
+ z: v.number(),
1233
+ rotationY: v.number(),
1234
+ rotationPitch: v.number(),
1235
+ });
1010
1236
  const list = query({
1011
1237
  args: {
1012
1238
  serverId: v.string(),
1239
+ viewerChunkKey: v.optional(v.string()),
1013
1240
  externalId: v.optional(v.string()),
1014
1241
  },
1015
1242
  returns: v.array(v.object({
1016
1243
  userId: v.string(),
1244
+ sessionId: v.optional(v.string()),
1245
+ kind: v.optional(v.string()),
1246
+ label: v.optional(v.string()),
1017
1247
  position: v.object({ x: v.number(), y: v.number(), z: v.number() }),
1018
1248
  rotationY: v.number(),
1019
1249
  rotationPitch: v.number(),
1020
1250
  lastSeenAt: v.number(),
1021
1251
  })),
1022
1252
  handler: async (ctx, args) => {
1023
- return withMember(ctx, mode, args, [], async () => {
1253
+ return withMember(ctx, mode, args, [], async (_server, actorUserId) => {
1024
1254
  const threshold = Date.now() - freshWindowMs;
1025
- const rows = await ctx.db
1026
- .query("jgPoses")
1027
- .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
1028
- .collect();
1255
+ const own = args.viewerChunkKey === undefined ? await ctx.db.query("jgPoses")
1256
+ .withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actorUserId)).first() : null;
1257
+ const center = parseChunkKey(args.viewerChunkKey ?? (own ? chunkKeyAt([own.x, own.y, own.z]) : "0,0"));
1258
+ if (!center)
1259
+ return [];
1260
+ const rows = [];
1261
+ for (const chunkKey of chunkKeysAround([center.cx * DEFAULT_CHUNK_SIZE, 0, center.cz * DEFAULT_CHUNK_SIZE], 1)) {
1262
+ rows.push(...await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId).eq("chunkKey", chunkKey)).take(256));
1263
+ }
1029
1264
  return rows
1030
- .filter((row) => row.updatedAt >= threshold)
1265
+ .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
1031
1266
  .map((row) => ({
1032
1267
  userId: row.userId,
1268
+ sessionId: row.sessionId,
1269
+ kind: row.kind,
1270
+ label: row.label,
1033
1271
  position: { x: row.x, y: row.y, z: row.z },
1034
1272
  rotationY: row.rotationY,
1035
1273
  rotationPitch: row.rotationPitch,
@@ -1038,60 +1276,105 @@ export function createPresenceFunctions(options) {
1038
1276
  });
1039
1277
  },
1040
1278
  });
1279
+ /**
1280
+ * Upload a pose, or keep the session alive without one. Returns the pose the server actually holds
1281
+ * after clamping, so a corrected client can reconcile, plus whether this session had been revoked
1282
+ * by another of the same user's sessions since its last sync.
1283
+ */
1041
1284
  const sync = mutation({
1042
1285
  args: {
1043
1286
  serverId: v.string(),
1044
- pose: v.object({
1045
- x: v.number(),
1046
- y: v.number(),
1047
- z: v.number(),
1048
- rotationY: v.number(),
1049
- rotationPitch: v.number(),
1050
- }),
1287
+ pose: v.optional(poseValidator),
1288
+ sessionId: v.optional(v.string()),
1289
+ kind: v.optional(v.string()),
1290
+ label: v.optional(v.string()),
1051
1291
  externalId: v.optional(v.string()),
1052
1292
  },
1053
- returns: v.null(),
1293
+ returns: v.union(v.null(), v.object({
1294
+ pose: poseValidator,
1295
+ lastSeenAt: v.number(),
1296
+ displaced: v.boolean(),
1297
+ })),
1054
1298
  handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
1055
1299
  const now = Date.now();
1056
- const existing = await ctx.db
1300
+ const rows = await ctx.db
1057
1301
  .query("jgPoses")
1058
1302
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
1059
- .unique();
1060
- const current = existing === null
1061
- ? spawnPresenceState(undefined, now, poseRules)
1303
+ .collect();
1304
+ const resolvable = rows.map((row) => ({
1305
+ row,
1306
+ revokedAt: row.revokedAt,
1307
+ lastSeenAt: row.updatedAt,
1308
+ }));
1309
+ const existing = args.sessionId === undefined
1310
+ ? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
1311
+ : rows.find((row) => row.sessionId === args.sessionId);
1312
+ const rules = rulesFor(args.kind ?? existing?.kind);
1313
+ if (existing && now - existing.updatedAt < (rules.minIntervalMs ?? 100)) {
1314
+ return { pose: { x: existing.x, y: existing.y, z: existing.z, rotationY: existing.rotationY, rotationPitch: existing.rotationPitch }, lastSeenAt: existing.updatedAt, displaced: existing.revokedAt !== undefined };
1315
+ }
1316
+ const current = existing === undefined
1317
+ ? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
1318
+ serverId: args.serverId,
1319
+ userId: actorUserId,
1320
+ kind: args.kind,
1321
+ })) ?? undefined, now, rules)
1062
1322
  : {
1063
1323
  position: { x: existing.x, y: existing.y, z: existing.z },
1064
1324
  rotationY: existing.rotationY,
1065
1325
  rotationPitch: existing.rotationPitch,
1066
1326
  lastSeenAtMs: existing.updatedAt,
1067
1327
  };
1068
- const decision = decidePoseSync(current, {
1069
- position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
1070
- rotationY: args.pose.rotationY,
1071
- rotationPitch: args.pose.rotationPitch,
1072
- }, poseRules, now);
1073
- const patch = {
1328
+ // A pose-less call is a keep-alive: hold the stored pose and only move the stamp forward.
1329
+ const decision = decidePoseSync(current, args.pose === undefined
1330
+ ? { position: current.position, rotationY: current.rotationY, rotationPitch: current.rotationPitch }
1331
+ : {
1332
+ position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
1333
+ rotationY: args.pose.rotationY,
1334
+ rotationPitch: args.pose.rotationPitch,
1335
+ }, rules, now);
1336
+ const pose = {
1074
1337
  x: decision.position.x,
1075
1338
  y: decision.position.y,
1076
1339
  z: decision.position.z,
1077
1340
  rotationY: decision.rotationY,
1078
1341
  rotationPitch: decision.rotationPitch,
1079
- updatedAt: now,
1080
1342
  };
1343
+ const identity = {
1344
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
1345
+ ...(args.kind === undefined ? {} : { kind: args.kind }),
1346
+ ...(args.label === undefined ? {} : { label: args.label }),
1347
+ };
1348
+ const displaced = existing?.revokedAt !== undefined;
1081
1349
  if (existing) {
1082
- if (decision.changed || decision.refreshKeepAlive) {
1083
- await ctx.db.patch(existing._id, patch);
1350
+ if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1351
+ await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
1084
1352
  }
1085
1353
  }
1086
1354
  else {
1087
- await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
1355
+ await ctx.db.insert("jgPoses", {
1356
+ serverId: args.serverId,
1357
+ userId: actorUserId,
1358
+ ...pose,
1359
+ chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
1360
+ ...identity,
1361
+ updatedAt: now,
1362
+ });
1088
1363
  }
1089
- return null;
1364
+ // This session is the live one; any other session of the same user is revoked, not deleted,
1365
+ // so it learns it was displaced on its next sync instead of silently reappearing.
1366
+ for (const row of rows) {
1367
+ if (row._id === existing?._id || row.revokedAt !== undefined)
1368
+ continue;
1369
+ await ctx.db.patch(row._id, { revokedAt: now });
1370
+ }
1371
+ return { pose, lastSeenAt: now, displaced };
1090
1372
  }),
1091
1373
  });
1092
1374
  const leave = mutation({
1093
1375
  args: {
1094
1376
  serverId: v.string(),
1377
+ sessionId: v.optional(v.string()),
1095
1378
  externalId: v.optional(v.string()),
1096
1379
  },
1097
1380
  returns: v.null(),
@@ -1099,23 +1382,170 @@ export function createPresenceFunctions(options) {
1099
1382
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
1100
1383
  if (!actorUserId)
1101
1384
  return null;
1102
- const existing = await ctx.db
1385
+ const now = Date.now();
1386
+ const rows = await ctx.db
1103
1387
  .query("jgPoses")
1104
1388
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
1105
- .unique();
1106
- if (existing)
1107
- await ctx.db.delete(existing._id);
1389
+ .collect();
1390
+ for (const row of rows) {
1391
+ if (args.sessionId !== undefined && row.sessionId !== args.sessionId)
1392
+ continue;
1393
+ if (row.revokedAt === undefined)
1394
+ await ctx.db.patch(row._id, { revokedAt: now });
1395
+ }
1108
1396
  return null;
1109
1397
  },
1110
1398
  });
1111
- return { list, sync, leave };
1399
+ /**
1400
+ * Delete rows nobody has written for `idleCutoffMs`. `list` filters stale rows at read time but
1401
+ * nothing removed them, so a player who closed the tab left a row behind forever. Register it on a
1402
+ * cron — {@link jgengineCronSpecs} carries the interval.
1403
+ */
1404
+ const reapIdlePresence = internalMutation({
1405
+ args: {},
1406
+ returns: v.object({ reaped: v.number() }),
1407
+ handler: async (ctx) => {
1408
+ const now = Date.now();
1409
+ const stale = await ctx.db
1410
+ .query("jgPoses")
1411
+ .withIndex("by_updated", (q) => q.lt("updatedAt", now - idleCutoffMs))
1412
+ .take(maxReapPerRun);
1413
+ let reaped = 0;
1414
+ for (const row of stale) {
1415
+ if (!isPresenceExpired(row.updatedAt, now, idleCutoffMs))
1416
+ continue;
1417
+ await ctx.db.delete(row._id);
1418
+ reaped += 1;
1419
+ }
1420
+ return { reaped };
1421
+ },
1422
+ });
1423
+ return { list, sync, leave, reapIdlePresence };
1424
+ }
1425
+ /**
1426
+ * Scan one bounded presence page and schedule its handler and continuation in separate transactions.
1427
+ * @capability online-player-batches Schedule bounded active-player or home-game work in separate transactions.
1428
+ */
1429
+ export async function forEachOnlinePlayer(ctx, options) {
1430
+ const batchSize = options.batchSize ?? 25;
1431
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 256)
1432
+ throw new Error("batchSize must be between 1 and 256");
1433
+ const nowMs = options.nowMs ?? Date.now();
1434
+ const freshWindowMs = options.freshWindowMs ?? 60_000;
1435
+ if (!Number.isFinite(nowMs) || !Number.isFinite(freshWindowMs) || freshWindowMs <= 0 || !Number.isFinite(nowMs - freshWindowMs))
1436
+ throw new Error("Invalid online-player time window");
1437
+ const page = await ctx.db.query("jgPoses").withIndex("by_updated", q => q.gte("updatedAt", nowMs - freshWindowMs))
1438
+ .paginate({ numItems: batchSize, cursor: options.cursor ?? null });
1439
+ const players = [];
1440
+ for (const row of page.page) {
1441
+ if (row.revokedAt !== undefined)
1442
+ continue;
1443
+ const canonical = options.groupBy === "homeGameId" && row.homeGameId
1444
+ ? await ctx.db.query("jgPoses").withIndex("by_home_game_revoked_updated", q => q.eq("homeGameId", row.homeGameId).eq("revokedAt", undefined)).order("desc").first()
1445
+ : await ctx.db.query("jgPoses").withIndex("by_server_user_revoked_updated", q => q.eq("serverId", row.serverId).eq("userId", row.userId).eq("revokedAt", undefined)).order("desc").first();
1446
+ if (canonical?._id === row._id)
1447
+ players.push({ serverId: row.serverId, userId: row.userId, ...(row.homeGameId ? { homeGameId: row.homeGameId } : {}) });
1448
+ }
1449
+ if (players.length)
1450
+ await ctx.scheduler.runAfter(0, options.handler, { players, nowMs });
1451
+ if (!page.isDone)
1452
+ await ctx.scheduler.runAfter(0, options.continuation, { cursor: page.continueCursor, nowMs });
1453
+ return { scheduled: players.length, remaining: !page.isDone };
1454
+ }
1455
+ /**
1456
+ * Consume one request in a fixed rate window; concurrent callers serialize on the indexed key.
1457
+ * @capability host-rate-limit Enforce indexed per-key request windows inside authoritative mutations.
1458
+ */
1459
+ export async function rateLimit(ctx, args) {
1460
+ const now = args.nowMs ?? Date.now();
1461
+ if (!args.key || args.key.length > 512 || !Number.isFinite(now) || !Number.isFinite(args.windowMs) || args.windowMs <= 0 || !Number.isFinite(now + args.windowMs) || !Number.isSafeInteger(args.max) || args.max < 1)
1462
+ throw new Error("Invalid rate limit policy");
1463
+ const row = await ctx.db.query("jgRateLimits").withIndex("by_key", q => q.eq("key", args.key)).unique();
1464
+ if (row && now < row.expiresAt && row.count >= args.max)
1465
+ return { ok: false, retryAfterMs: row.expiresAt - now };
1466
+ const patch = !row || now >= row.expiresAt ? { startedAt: now, expiresAt: now + args.windowMs, count: 1 } : { startedAt: row.startedAt, expiresAt: row.expiresAt, count: row.count + 1 };
1467
+ if (row)
1468
+ await ctx.db.patch(row._id, patch);
1469
+ else
1470
+ await ctx.db.insert("jgRateLimits", { key: args.key, ...patch });
1471
+ return { ok: true, retryAfterMs: 0 };
1472
+ }
1473
+ /**
1474
+ * Write validated chat in a host transaction after the caller resolves its actor and world.
1475
+ * @capability host-chat-send Validate, rate-limit and persist chat inside an authorized host mutation.
1476
+ */
1477
+ export async function sendChatMessage(ctx, args) {
1478
+ const maxBodyLength = args.maxBodyLength ?? 240;
1479
+ if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096)
1480
+ throw new Error("Invalid chat body limit");
1481
+ if (!(args.channelId ?? "global").trim() || (args.channelId?.length ?? 0) > 256 || (args.authorName?.length ?? 0) > 160)
1482
+ return { ok: false, reason: "invalid_channel_or_author" };
1483
+ const validation = validateChatMessage(args.body, { maxLength: args.maxBodyLength ?? 240 });
1484
+ if (!validation.ok)
1485
+ return validation;
1486
+ const gate = await rateLimit(ctx, { key: JSON.stringify(["chat", args.serverId, args.userId]), windowMs: args.minIntervalMs ?? 2000, max: 1 });
1487
+ if (!gate.ok)
1488
+ return { ok: false, reason: "rate_limited" };
1489
+ await ctx.db.insert("jgChatMessages", { serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() });
1490
+ return { ok: true };
1491
+ }
1492
+ /**
1493
+ * Bounded recent chat for a resolved server; authorization belongs to the calling query.
1494
+ * @capability host-chat-history Read bounded channel history inside an authorized host query.
1495
+ */
1496
+ export async function recentChatMessages(ctx, args) {
1497
+ const limit = args.limit ?? 50;
1498
+ if (!Number.isFinite(limit))
1499
+ throw new Error("Invalid chat limit");
1500
+ return ctx.db.query("jgChatMessages").withIndex("by_server_channel", q => q.eq("serverId", args.serverId).eq("channelId", args.channelId ?? "global"))
1501
+ .order("desc").take(Math.min(100, Math.max(1, Math.floor(limit))));
1502
+ }
1503
+ /**
1504
+ * Authenticated, bounded client diagnostics and bounded retention sweeps.
1505
+ * @capability client-error-reporting Accept authenticated bounded diagnostics with cooldown and retention sweeps.
1506
+ */
1507
+ export function createClientErrorFunctions() {
1508
+ const reportClientError = mutation({
1509
+ args: { message: v.string(), kind: v.optional(v.string()), stack: v.optional(v.string()), componentStack: v.optional(v.string()), url: v.optional(v.string()), pathname: v.optional(v.string()), userAgent: v.optional(v.string()), agentPrompt: v.optional(v.string()) },
1510
+ returns: v.object({ ok: v.boolean(), reason: v.optional(v.string()) }),
1511
+ handler: async (ctx, args) => {
1512
+ const actor = await resolveActor(ctx, undefined, "required");
1513
+ if (!actor)
1514
+ return { ok: false, reason: "unauthorized" };
1515
+ const message = JSON.stringify(args);
1516
+ if (new TextEncoder().encode(message).length > 4096)
1517
+ return { ok: false, reason: "too_long" };
1518
+ const gate = await rateLimit(ctx, { key: `client-error:${actor}`, windowMs: 10_000, max: 1 });
1519
+ if (!gate.ok)
1520
+ return { ok: false, reason: "rate_limited" };
1521
+ await ctx.db.insert("jgClientErrors", { userId: actor, message, createdAt: Date.now() });
1522
+ return { ok: true };
1523
+ },
1524
+ });
1525
+ const pruneClientErrors = internalMutation({ args: {}, handler: async (ctx) => {
1526
+ const rows = await ctx.db.query("jgClientErrors").withIndex("by_created", q => q.lt("createdAt", Date.now() - 7 * 86_400_000)).take(256);
1527
+ for (const row of rows)
1528
+ await ctx.db.delete(row._id);
1529
+ return { deleted: rows.length, remaining: rows.length === 256 };
1530
+ } });
1531
+ const pruneRateLimits = internalMutation({ args: {}, handler: async (ctx) => {
1532
+ const rows = await ctx.db.query("jgRateLimits").withIndex("by_expires", q => q.lte("expiresAt", Date.now())).take(256);
1533
+ for (const row of rows)
1534
+ await ctx.db.delete(row._id);
1535
+ return { deleted: rows.length, remaining: rows.length === 256 };
1536
+ } });
1537
+ return { reportClientError, pruneClientErrors, pruneRateLimits };
1112
1538
  }
1113
1539
  /** @internal */
1114
1540
  export function createChatFunctions(options) {
1115
1541
  const mode = options?.auth ?? "required";
1116
- const historyLimit = options?.historyLimit ?? 100;
1117
- const maxBodyLength = options?.maxBodyLength ?? 500;
1118
- const minIntervalMs = options?.minIntervalMs ?? 300;
1542
+ if (options?.historyLimit !== undefined && (!Number.isSafeInteger(options.historyLimit) || options.historyLimit < 1))
1543
+ throw new Error("Invalid chat history limit");
1544
+ const historyLimit = Math.min(100, options?.historyLimit ?? 50);
1545
+ const maxBodyLength = options?.maxBodyLength ?? 240;
1546
+ const minIntervalMs = options?.minIntervalMs ?? 2_000;
1547
+ if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096 || !Number.isFinite(minIntervalMs) || minIntervalMs <= 0)
1548
+ throw new Error("Invalid chat policy");
1119
1549
  const messages = query({
1120
1550
  args: {
1121
1551
  serverId: v.string(),
@@ -1162,28 +1592,9 @@ export function createChatFunctions(options) {
1162
1592
  if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
1163
1593
  return { ok: false, reason: "not a member of this server" };
1164
1594
  }
1165
- const body = args.body.trim();
1166
- if (body.length === 0)
1167
- return { ok: false, reason: "empty message" };
1168
- if (body.length > maxBodyLength)
1169
- return { ok: false, reason: "message too long" };
1170
- const now = Date.now();
1171
- const newestByActor = await ctx.db
1172
- .query("jgChatMessages")
1173
- .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
1174
- .order("desc")
1175
- .filter((q) => q.eq(q.field("userId"), actorUserId))
1176
- .first();
1177
- if (newestByActor && now - newestByActor.at < minIntervalMs) {
1178
- return { ok: false, reason: "sending too fast" };
1179
- }
1180
- await ctx.db.insert("jgChatMessages", {
1181
- serverId: args.serverId,
1182
- channelId: args.channelId,
1183
- userId: actorUserId,
1184
- body,
1185
- at: now,
1186
- });
1595
+ const outcome = await sendChatMessage(ctx, { serverId: args.serverId, userId: actorUserId, channelId: args.channelId, body: args.body, maxBodyLength, minIntervalMs });
1596
+ if (!outcome.ok)
1597
+ return outcome.reason === "rate_limited" ? { ok: false, reason: "sending too fast" } : outcome;
1187
1598
  const recent = await ctx.db
1188
1599
  .query("jgChatMessages")
1189
1600
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -1195,13 +1606,32 @@ export function createChatFunctions(options) {
1195
1606
  return { ok: true };
1196
1607
  },
1197
1608
  });
1198
- return { messages, sendMessage };
1609
+ const pruneChatMessages = internalMutation({ args: {}, handler: async (ctx) => {
1610
+ const rows = await ctx.db.query("jgChatMessages").withIndex("by_at", q => q.lt("at", Date.now() - 86_400_000)).take(256);
1611
+ for (const row of rows)
1612
+ await ctx.db.delete(row._id);
1613
+ return { deleted: rows.length, remaining: rows.length === 256 };
1614
+ } });
1615
+ return { messages, recent: messages, sendMessage, pruneChatMessages };
1199
1616
  }
1200
1617
  const JG_CRON_SPECS = [
1201
- { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
1202
- { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
1618
+ { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
1619
+ { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers", module: "runtime" },
1620
+ { name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
1203
1621
  ];
1204
1622
  /** @internal */
1205
- export function jgengineCronSpecs() {
1206
- return JG_CRON_SPECS;
1623
+ export function jgengineCronSpecs(options = {}) {
1624
+ const specs = JG_CRON_SPECS.filter(spec => spec.functionKey !== "tickActiveServers" || options.runtimes?.some(runtime => runtime.hasTick));
1625
+ if (options.chat)
1626
+ specs.push({ name: "jg chat prune", intervalSeconds: 3600, functionKey: "pruneChatMessages", module: "chat" });
1627
+ if (options.clientErrors) {
1628
+ specs.push({ name: "jg error prune", intervalSeconds: 3600, functionKey: "pruneClientErrors", module: "clientErrors" });
1629
+ specs.push({ name: "jg rate limit prune", intervalSeconds: 3600, functionKey: "pruneRateLimits", module: "clientErrors" });
1630
+ }
1631
+ return specs.map(spec => {
1632
+ const intervalSeconds = options.intervalSeconds?.[spec.functionKey] ?? spec.intervalSeconds;
1633
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0)
1634
+ throw new Error("Cron intervalSeconds must be positive");
1635
+ return { ...spec, intervalSeconds };
1636
+ });
1207
1637
  }