@jgengine/convex 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
3
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
- import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
4
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
+ import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
5
6
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
- import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
7
- import { isAutoJoinCandidate, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
7
+ import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
8
+ import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
8
9
  import { createEmptyServerRow, markAllPlayersDirty } from "@jgengine/core/runtime/snapshot";
9
10
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
10
11
  /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
11
- export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
12
+ export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
12
13
  const saveConfigValidator = v.union(v.literal("none"), v.object({
13
14
  auto: v.string(),
14
15
  scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
@@ -37,6 +38,7 @@ export function jgengineTables() {
37
38
  })
38
39
  .index("by_game_and_status", ["gameId", "status"])
39
40
  .index("by_status", ["status"])
41
+ .index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
40
42
  .index("by_dirty", ["dirtyAt"]),
41
43
  jgPlayerProfiles: defineTable({
42
44
  userId: v.string(),
@@ -76,15 +78,24 @@ export function jgengineTables() {
76
78
  jgPoses: defineTable({
77
79
  serverId: v.string(),
78
80
  userId: v.string(),
81
+ /** One row per live session, so a second tab is a second row rather than a fight over one. */
82
+ sessionId: v.optional(v.string()),
83
+ /** Actor class, e.g. `"player"` / `"agent"`, which selects the pose rules applied to it. */
84
+ kind: v.optional(v.string()),
85
+ /** Display name carried on the row, so nameplates need no per-frame join against users. */
86
+ label: v.optional(v.string()),
79
87
  x: v.number(),
80
88
  y: v.number(),
81
89
  z: v.number(),
82
90
  rotationY: v.number(),
83
91
  rotationPitch: v.number(),
84
92
  updatedAt: v.number(),
93
+ /** Set when the session was displaced or left; the reaper deletes the row once it goes idle. */
94
+ revokedAt: v.optional(v.number()),
85
95
  })
86
96
  .index("by_server", ["serverId"])
87
- .index("by_server_and_user", ["serverId", "userId"]),
97
+ .index("by_server_and_user", ["serverId", "userId"])
98
+ .index("by_updated", ["updatedAt"]),
88
99
  jgChatMessages: defineTable({
89
100
  serverId: v.string(),
90
101
  channelId: v.string(),
@@ -221,11 +232,16 @@ function serverDocToRecord(server) {
221
232
  /**
222
233
  * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
223
234
  * Reach for it when writing your own mutation that must read runtime state before touching host tables.
235
+ * Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
224
236
  * @capability convex-load-server-snapshot read a hosted server's runtime snapshot inside a host-written Convex mutation
225
237
  */
226
- export async function loadServerSnapshot(ctx, server, runtime) {
238
+ export async function loadServerSnapshot(ctx, server, runtime, scope) {
227
239
  const profiles = {};
228
- for (const userId of server.memberUserIds) {
240
+ const memberIds = new Set(server.memberUserIds);
241
+ const hydrateUserIds = scope?.players === undefined
242
+ ? server.memberUserIds
243
+ : scope.players.filter((userId) => memberIds.has(userId));
244
+ for (const userId of hydrateUserIds) {
229
245
  const profile = await ctx.db
230
246
  .query("jgPlayerProfiles")
231
247
  .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
@@ -240,14 +256,25 @@ export async function loadServerSnapshot(ctx, server, runtime) {
240
256
  }
241
257
  : null;
242
258
  }
243
- const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles);
244
- const chunks = await ctx.db
245
- .query("jgWorldChunks")
246
- .withIndex("by_server", (q) => q.eq("serverId", server._id))
247
- .collect();
259
+ const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
248
260
  const chunksByKey = {};
249
- for (const chunk of chunks) {
250
- chunksByKey[chunk.chunkKey] = chunk.snapshot;
261
+ if (scope?.chunkKeys === undefined) {
262
+ const chunks = await ctx.db
263
+ .query("jgWorldChunks")
264
+ .withIndex("by_server", (q) => q.eq("serverId", server._id))
265
+ .collect();
266
+ for (const chunk of chunks)
267
+ chunksByKey[chunk.chunkKey] = chunk.snapshot;
268
+ }
269
+ else {
270
+ for (const chunkKey of new Set(scope.chunkKeys)) {
271
+ const chunk = await ctx.db
272
+ .query("jgWorldChunks")
273
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
274
+ .unique();
275
+ if (chunk)
276
+ chunksByKey[chunk.chunkKey] = chunk.snapshot;
277
+ }
251
278
  }
252
279
  return runtime.hydrate({
253
280
  gameId: server.gameId,
@@ -287,11 +314,18 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
287
314
  /**
288
315
  * Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard
289
316
  * increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
317
+ *
318
+ * A persist that would change nothing writes nothing and returns `false`. That matters beyond write
319
+ * cost: every subscriber of a query that read this row re-receives the whole result when the document
320
+ * changes, so an unconditional write on a 1 Hz tick re-pushes the entire world to every client once a
321
+ * second whether or not anything moved.
290
322
  * @capability convex-persist-server-snapshot write runtime state and host tables in one Convex transaction
291
323
  */
292
324
  export async function persistServerSnapshot(ctx, server, snapshot, save) {
293
325
  const now = Date.now();
294
326
  const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
327
+ if (!plan.changed.any)
328
+ return false;
295
329
  if (plan.leaderboard.length > 0) {
296
330
  await applyLeaderboardIncrements(ctx, server.gameId, plan.leaderboard);
297
331
  }
@@ -347,29 +381,57 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
347
381
  await ctx.db.delete(existing._id);
348
382
  }
349
383
  await ctx.db.patch(server._id, {
350
- serverState: plan.server.serverState,
351
- sessionPlayers: plan.server.sessionPlayers,
384
+ ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
385
+ ...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
352
386
  revision: plan.server.revision,
353
387
  dirtyAt: plan.server.dirtyAt,
354
388
  updatedAt: plan.server.updatedAt,
355
389
  lastSavedAt: plan.server.lastSavedAt,
356
390
  });
391
+ return true;
357
392
  }
358
- async function flushServerIfDue(ctx, server, now, runtime) {
393
+ /** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
394
+ async function flushServerIfDue(ctx, server, now, runtime, loaded) {
359
395
  if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
360
396
  return false;
361
397
  }
362
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
398
+ // Unscoped on purpose: markAllPlayersDirty writes every member, so every member has to be hydrated.
399
+ const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
363
400
  await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
364
401
  return true;
365
402
  }
366
403
  export const JG_RUNTIME_TICK_MS = 1_000;
367
- /** @internal */
404
+ /** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
405
+ export const DEFAULT_MAX_SERVERS_PER_TICK = 32;
406
+ /** How many chunk rows one `getChunks` call may return. */
407
+ export const MAX_CHUNKS_PER_QUERY = 64;
408
+ /**
409
+ * Build the hosted-server Convex functions for a set of game runtimes.
410
+ *
411
+ * **Supported world size.** One server is one Convex document holding the world row, the member
412
+ * roster, and every member's session state, and the host rewrites that document whenever the world
413
+ * changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
414
+ * larger value throws here, at wiring time, rather than at the write that overflows the document.
415
+ * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
416
+ * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
417
+ *
418
+ * `matchmaking: "singleton"` makes auto-match target one shared world per game: a join past capacity
419
+ * fails loudly instead of quietly starting a second world the player would be alone in.
420
+ * @internal
421
+ */
368
422
  export function createGameServerFunctions(options) {
369
423
  const mode = options?.auth ?? "required";
370
424
  const hostSlotsPerServer = options?.slotsPerServer ?? 16;
425
+ const slotsCheck = validateSlotsPerServer(hostSlotsPerServer);
426
+ if (!slotsCheck.ok)
427
+ throw new Error(slotsCheck.reason);
428
+ const matchmaking = options?.matchmaking ?? "auto";
429
+ const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
371
430
  const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
372
431
  const registry = buildRuntimeRegistry(options?.runtimes);
432
+ const tickableGameIds = (options?.runtimes ?? [])
433
+ .filter((runtime) => runtime.hasTick)
434
+ .map((runtime) => runtime.gameId);
373
435
  const joinServer = mutation({
374
436
  args: {
375
437
  gameId: v.string(),
@@ -397,6 +459,7 @@ export function createGameServerFunctions(options) {
397
459
  if (server && server.gameId !== args.gameId) {
398
460
  throw new ConvexError("Server belongs to a different game");
399
461
  }
462
+ let mayCreate = true;
400
463
  if (!server) {
401
464
  const joinable = [];
402
465
  for (const status of ["running", "open"]) {
@@ -406,15 +469,21 @@ export function createGameServerFunctions(options) {
406
469
  .collect();
407
470
  joinable.push(...rows);
408
471
  }
409
- server =
410
- joinable.find((row) => isAutoJoinCandidate({
411
- memberUserIds: row.memberUserIds,
412
- slotsPerServer: row.slotsPerServer,
413
- visibility: row.visibility,
414
- userId: actorUserId,
415
- })) ?? null;
472
+ // Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
473
+ // resolves it — the loser's read set is invalidated and its mutation retries against the
474
+ // winner's row — so the engine adds no lock. A `HostPersistence` backend without
475
+ // serializable-snapshot retry must supply that guarantee itself.
476
+ const target = selectJoinTarget(joinable, {
477
+ userId: actorUserId,
478
+ mode: matchmaking,
479
+ slotsPerServer,
480
+ });
481
+ if (target.kind === "refuse")
482
+ throw new ConvexError(target.reason);
483
+ server = target.kind === "join" ? target.row : null;
484
+ mayCreate = target.kind === "create";
416
485
  }
417
- if (!server) {
486
+ if (!server && mayCreate) {
418
487
  const serverId = await ctx.db.insert("jgGameServers", {
419
488
  gameId: args.gameId,
420
489
  status: "running",
@@ -436,6 +505,17 @@ export function createGameServerFunctions(options) {
436
505
  if (!server)
437
506
  throw new ConvexError("Failed to create server");
438
507
  }
508
+ if (!server)
509
+ throw new ConvexError("No joinable server");
510
+ // Capacity and save policy live in host options, not in the row. Without this a long-lived
511
+ // world keeps whatever it was created with and raising the host option silently does nothing.
512
+ if (server.slotsPerServer !== slotsPerServer || JSON.stringify(server.save) !== JSON.stringify(save)) {
513
+ await ctx.db.patch(server._id, { slotsPerServer, save, updatedAt: now });
514
+ const reconciled = await ctx.db.get("jgGameServers", server._id);
515
+ if (!reconciled)
516
+ throw new ConvexError("Server missing after reconcile");
517
+ server = reconciled;
518
+ }
439
519
  if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
440
520
  throw new ConvexError("Server is full");
441
521
  }
@@ -463,7 +543,7 @@ export function createGameServerFunctions(options) {
463
543
  const refreshed = await ctx.db.get("jgGameServers", server._id);
464
544
  if (!refreshed)
465
545
  throw new ConvexError("Server missing after join");
466
- let snapshot = await loadServerSnapshot(ctx, refreshed, runtime);
546
+ let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
467
547
  snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
468
548
  await persistServerSnapshot(ctx, refreshed, snapshot, save);
469
549
  return { serverId: refreshed._id, isNew };
@@ -482,7 +562,11 @@ export function createGameServerFunctions(options) {
482
562
  return null;
483
563
  const now = Date.now();
484
564
  const runtime = resolveRuntime(registry, server.gameId);
485
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
565
+ // Flushes the leaver's own state before the roster drops them; nobody else's rows are touched.
566
+ const snapshot = await loadServerSnapshot(ctx, server, runtime, {
567
+ players: [actorUserId],
568
+ chunkKeys: [],
569
+ });
486
570
  await persistServerSnapshot(ctx, server, snapshot, server.save);
487
571
  const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
488
572
  const sessionPlayers = { ...server.sessionPlayers };
@@ -504,12 +588,12 @@ export function createGameServerFunctions(options) {
504
588
  },
505
589
  });
506
590
  const helpers = {
507
- async loadSnapshot(ctx, serverId) {
591
+ async loadSnapshot(ctx, serverId, scope) {
508
592
  const server = await ctx.db.get("jgGameServers", serverId);
509
593
  if (!server)
510
594
  return null;
511
595
  const runtime = resolveRuntime(registry, server.gameId);
512
- return { server, runtime, snapshot: await loadServerSnapshot(ctx, server, runtime) };
596
+ return { server, runtime, snapshot: await loadServerSnapshot(ctx, server, runtime, scope) };
513
597
  },
514
598
  applyCommand(args) {
515
599
  return applyCommandWithOcc({ ...args, runtime: resolveRuntime(registry, args.gameId) });
@@ -529,7 +613,8 @@ export function createGameServerFunctions(options) {
529
613
  }
530
614
  const loadedRevision = server.revision;
531
615
  const runtime = resolveRuntime(registry, server.gameId);
532
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
616
+ const scope = scopeWithActor(args.scope ?? runtime.commandScope(args.command, args.input, actorUserId), actorUserId);
617
+ const snapshot = await loadServerSnapshot(ctx, server, runtime, scope);
533
618
  const result = applyCommandWithOcc({
534
619
  loadedRevision,
535
620
  currentRevision: server.revision,
@@ -542,6 +627,10 @@ export function createGameServerFunctions(options) {
542
627
  if (!result.ok) {
543
628
  return result;
544
629
  }
630
+ const escape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
631
+ if (escape !== null) {
632
+ return { ok: false, reason: escape };
633
+ }
545
634
  const latest = await ctx.db.get("jgGameServers", serverId);
546
635
  if (!latest) {
547
636
  return { ok: false, reason: "Server not found" };
@@ -560,6 +649,10 @@ export function createGameServerFunctions(options) {
560
649
  command: v.string(),
561
650
  input: v.any(),
562
651
  externalId: v.optional(v.string()),
652
+ scope: v.optional(v.object({
653
+ players: v.optional(v.array(v.string())),
654
+ chunkKeys: v.optional(v.array(v.string())),
655
+ })),
563
656
  },
564
657
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
565
658
  handler: (ctx, args) => helpers.runCommand(ctx, args),
@@ -576,6 +669,7 @@ export function createGameServerFunctions(options) {
576
669
  if (server === null)
577
670
  return false;
578
671
  const runtime = resolveRuntime(registry, server.gameId);
672
+ // Unscoped on purpose: markAllPlayersDirty writes every member.
579
673
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
580
674
  await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
581
675
  return true;
@@ -617,6 +711,63 @@ export function createGameServerFunctions(options) {
617
711
  };
618
712
  }),
619
713
  });
714
+ /**
715
+ * Lobby-scale view of a server: status, capacity, revision — no world state. Subscribe a menu,
716
+ * roster, or "is it up" indicator to this instead of `getServer`, whose payload is the whole world.
717
+ */
718
+ const getServerMeta = query({
719
+ args: {
720
+ serverId: v.id("jgGameServers"),
721
+ externalId: v.optional(v.string()),
722
+ },
723
+ returns: v.union(v.object({
724
+ _id: v.id("jgGameServers"),
725
+ gameId: v.string(),
726
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
727
+ mode: v.optional(v.string()),
728
+ memberCount: v.number(),
729
+ slotsPerServer: v.number(),
730
+ revision: v.number(),
731
+ updatedAt: v.number(),
732
+ }), v.null()),
733
+ handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
734
+ _id: server._id,
735
+ gameId: server.gameId,
736
+ status: server.status,
737
+ mode: server.mode,
738
+ memberCount: server.memberUserIds.length,
739
+ slotsPerServer: server.slotsPerServer,
740
+ revision: server.revision,
741
+ updatedAt: server.updatedAt,
742
+ })),
743
+ });
744
+ /**
745
+ * Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
746
+ * geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
747
+ * Bounded to {@link MAX_CHUNKS_PER_QUERY} keys; ask for the cells around the viewer
748
+ * (`chunkKeysInRadius`), not the world.
749
+ */
750
+ const getChunks = query({
751
+ args: {
752
+ serverId: v.id("jgGameServers"),
753
+ chunkKeys: v.array(v.string()),
754
+ externalId: v.optional(v.string()),
755
+ },
756
+ returns: v.array(v.object({ chunkKey: v.string(), snapshot: v.any(), updatedAt: v.number() })),
757
+ handler: (ctx, args) => withMember(ctx, mode, args, [], async (server) => {
758
+ const keys = [...new Set(args.chunkKeys)].slice(0, MAX_CHUNKS_PER_QUERY);
759
+ const out = [];
760
+ for (const chunkKey of keys) {
761
+ const row = await ctx.db
762
+ .query("jgWorldChunks")
763
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
764
+ .unique();
765
+ if (row)
766
+ out.push({ chunkKey: row.chunkKey, snapshot: row.snapshot, updatedAt: row.updatedAt });
767
+ }
768
+ return out;
769
+ }),
770
+ });
620
771
  const getPlayerProfile = query({
621
772
  args: {
622
773
  gameId: v.string(),
@@ -736,31 +887,42 @@ export function createGameServerFunctions(options) {
736
887
  }));
737
888
  },
738
889
  });
890
+ // Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
891
+ // transaction, oldest anchor first. A game with no tick hook used to pay a full hydrate + persist per
892
+ // running server per second to hand `runtime.tick` a snapshot it returned unchanged; now it pays
893
+ // nothing, and no single transaction can fan out across every world of every game.
739
894
  const tickActiveServers = internalMutation({
740
895
  args: {},
741
896
  handler: async (ctx) => {
742
897
  const now = Date.now();
743
- const servers = await ctx.db
744
- .query("jgGameServers")
745
- .withIndex("by_status", (q) => q.eq("status", "running"))
746
- .collect();
747
898
  let ticked = 0;
748
899
  let saved = 0;
749
- for (const server of servers) {
750
- if (server.memberUserIds.length === 0)
751
- continue;
752
- const elapsedMs = now - server.tickAnchorMs;
753
- if (elapsedMs < JG_RUNTIME_TICK_MS)
754
- continue;
755
- const runtime = resolveRuntime(registry, server.gameId);
756
- let snapshot = await loadServerSnapshot(ctx, server, runtime);
757
- snapshot = runtime.tick(snapshot, elapsedMs / 1_000);
758
- await persistServerSnapshot(ctx, server, snapshot, server.save);
759
- await ctx.db.patch(server._id, { tickAnchorMs: now, updatedAt: now });
760
- ticked += 1;
761
- const refreshed = await ctx.db.get("jgGameServers", server._id);
762
- if (refreshed && (await flushServerIfDue(ctx, refreshed, now, runtime))) {
763
- saved += 1;
900
+ let budget = maxServersPerTick;
901
+ for (const gameId of tickableGameIds) {
902
+ if (budget <= 0)
903
+ break;
904
+ const servers = await ctx.db
905
+ .query("jgGameServers")
906
+ .withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
907
+ .take(budget);
908
+ for (const server of servers) {
909
+ if (server.memberUserIds.length === 0)
910
+ continue;
911
+ const elapsedMs = now - server.tickAnchorMs;
912
+ if (elapsedMs < JG_RUNTIME_TICK_MS)
913
+ continue;
914
+ budget -= 1;
915
+ const runtime = resolveRuntime(registry, server.gameId);
916
+ // Unscoped on purpose: onTick is handed the whole world and may touch any of it.
917
+ const loaded = await loadServerSnapshot(ctx, server, runtime);
918
+ const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
919
+ await persistServerSnapshot(ctx, server, snapshot, server.save);
920
+ await ctx.db.patch(server._id, { tickAnchorMs: now });
921
+ ticked += 1;
922
+ const refreshed = await ctx.db.get("jgGameServers", server._id);
923
+ if (refreshed && (await flushServerIfDue(ctx, refreshed, now, runtime, snapshot))) {
924
+ saved += 1;
925
+ }
764
926
  }
765
927
  }
766
928
  return { ticked, saved };
@@ -769,18 +931,17 @@ export function createGameServerFunctions(options) {
769
931
  const flushDirtyServers = internalMutation({
770
932
  args: {},
771
933
  handler: async (ctx) => {
772
- const now = Date.now();
773
934
  const servers = await ctx.db
774
935
  .query("jgGameServers")
775
936
  .withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
776
- .collect();
937
+ .take(maxServersPerTick);
777
938
  let saved = 0;
778
939
  for (const server of servers) {
779
940
  const runtime = resolveRuntime(registry, server.gameId);
941
+ // Unscoped on purpose: markAllPlayersDirty writes every member.
780
942
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
781
943
  await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
782
944
  saved += 1;
783
- void now;
784
945
  }
785
946
  return { saved };
786
947
  },
@@ -791,6 +952,8 @@ export function createGameServerFunctions(options) {
791
952
  runCommand,
792
953
  flushSave,
793
954
  getServer,
955
+ getServerMeta,
956
+ getChunks,
794
957
  getPlayerProfile,
795
958
  getFeed,
796
959
  pushFeedEntry,
@@ -866,11 +1029,28 @@ export function createLeaderboardFunctions(options) {
866
1029
  });
867
1030
  return { getTop, getProfile, incrementMany };
868
1031
  }
869
- /** @internal */
1032
+ /**
1033
+ * The pose lane of a hosted server: the backend behind `PresenceSync`, which replicates where each
1034
+ * member of one `jgGameServers` server is standing. It is not the backend for `PresenceTransport` —
1035
+ * that contract (`ConvexPresenceFunctions` in `./convexPresenceTransport`) is a game-owned world's
1036
+ * own presence, keyed by the game's own ids, with its own notion of who counts as a resident. A game
1037
+ * on `createConvexPresenceTransport` supplies those functions itself; these do not substitute for them.
1038
+ * @internal
1039
+ */
870
1040
  export function createPresenceFunctions(options) {
871
1041
  const mode = options?.auth ?? "required";
872
1042
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
873
- const poseRules = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
1043
+ const idleCutoffMs = options?.idleCutoffMs ?? 60_000;
1044
+ const maxReapPerRun = options?.maxReapPerRun ?? 256;
1045
+ const rulesOption = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
1046
+ const rulesFor = (kind) => typeof rulesOption === "function" ? rulesOption(kind) : rulesOption;
1047
+ const poseValidator = v.object({
1048
+ x: v.number(),
1049
+ y: v.number(),
1050
+ z: v.number(),
1051
+ rotationY: v.number(),
1052
+ rotationPitch: v.number(),
1053
+ });
874
1054
  const list = query({
875
1055
  args: {
876
1056
  serverId: v.string(),
@@ -878,6 +1058,9 @@ export function createPresenceFunctions(options) {
878
1058
  },
879
1059
  returns: v.array(v.object({
880
1060
  userId: v.string(),
1061
+ sessionId: v.optional(v.string()),
1062
+ kind: v.optional(v.string()),
1063
+ label: v.optional(v.string()),
881
1064
  position: v.object({ x: v.number(), y: v.number(), z: v.number() }),
882
1065
  rotationY: v.number(),
883
1066
  rotationPitch: v.number(),
@@ -891,9 +1074,12 @@ export function createPresenceFunctions(options) {
891
1074
  .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
892
1075
  .collect();
893
1076
  return rows
894
- .filter((row) => row.updatedAt >= threshold)
1077
+ .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
895
1078
  .map((row) => ({
896
1079
  userId: row.userId,
1080
+ sessionId: row.sessionId,
1081
+ kind: row.kind,
1082
+ label: row.label,
897
1083
  position: { x: row.x, y: row.y, z: row.z },
898
1084
  rotationY: row.rotationY,
899
1085
  rotationPitch: row.rotationPitch,
@@ -902,60 +1088,101 @@ export function createPresenceFunctions(options) {
902
1088
  });
903
1089
  },
904
1090
  });
1091
+ /**
1092
+ * Upload a pose, or keep the session alive without one. Returns the pose the server actually holds
1093
+ * after clamping, so a corrected client can reconcile, plus whether this session had been revoked
1094
+ * by another of the same user's sessions since its last sync.
1095
+ */
905
1096
  const sync = mutation({
906
1097
  args: {
907
1098
  serverId: v.string(),
908
- pose: v.object({
909
- x: v.number(),
910
- y: v.number(),
911
- z: v.number(),
912
- rotationY: v.number(),
913
- rotationPitch: v.number(),
914
- }),
1099
+ pose: v.optional(poseValidator),
1100
+ sessionId: v.optional(v.string()),
1101
+ kind: v.optional(v.string()),
1102
+ label: v.optional(v.string()),
915
1103
  externalId: v.optional(v.string()),
916
1104
  },
917
- returns: v.null(),
1105
+ returns: v.union(v.null(), v.object({
1106
+ pose: poseValidator,
1107
+ lastSeenAt: v.number(),
1108
+ displaced: v.boolean(),
1109
+ })),
918
1110
  handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
919
1111
  const now = Date.now();
920
- const existing = await ctx.db
1112
+ const rows = await ctx.db
921
1113
  .query("jgPoses")
922
1114
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
923
- .unique();
924
- const current = existing === null
925
- ? spawnPresenceState(undefined, now, poseRules)
1115
+ .collect();
1116
+ const resolvable = rows.map((row) => ({
1117
+ row,
1118
+ revokedAt: row.revokedAt,
1119
+ lastSeenAt: row.updatedAt,
1120
+ }));
1121
+ const existing = args.sessionId === undefined
1122
+ ? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
1123
+ : rows.find((row) => row.sessionId === args.sessionId);
1124
+ const rules = rulesFor(args.kind ?? existing?.kind);
1125
+ const current = existing === undefined
1126
+ ? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
1127
+ serverId: args.serverId,
1128
+ userId: actorUserId,
1129
+ kind: args.kind,
1130
+ })) ?? undefined, now, rules)
926
1131
  : {
927
1132
  position: { x: existing.x, y: existing.y, z: existing.z },
928
1133
  rotationY: existing.rotationY,
929
1134
  rotationPitch: existing.rotationPitch,
930
1135
  lastSeenAtMs: existing.updatedAt,
931
1136
  };
932
- const decision = decidePoseSync(current, {
933
- position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
934
- rotationY: args.pose.rotationY,
935
- rotationPitch: args.pose.rotationPitch,
936
- }, poseRules, now);
937
- const patch = {
1137
+ // A pose-less call is a keep-alive: hold the stored pose and only move the stamp forward.
1138
+ const decision = decidePoseSync(current, args.pose === undefined
1139
+ ? { position: current.position, rotationY: current.rotationY, rotationPitch: current.rotationPitch }
1140
+ : {
1141
+ position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
1142
+ rotationY: args.pose.rotationY,
1143
+ rotationPitch: args.pose.rotationPitch,
1144
+ }, rules, now);
1145
+ const pose = {
938
1146
  x: decision.position.x,
939
1147
  y: decision.position.y,
940
1148
  z: decision.position.z,
941
1149
  rotationY: decision.rotationY,
942
1150
  rotationPitch: decision.rotationPitch,
943
- updatedAt: now,
944
1151
  };
1152
+ const identity = {
1153
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
1154
+ ...(args.kind === undefined ? {} : { kind: args.kind }),
1155
+ ...(args.label === undefined ? {} : { label: args.label }),
1156
+ };
1157
+ const displaced = existing?.revokedAt !== undefined;
945
1158
  if (existing) {
946
- if (decision.changed || decision.refreshKeepAlive) {
947
- await ctx.db.patch(existing._id, patch);
1159
+ if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1160
+ await ctx.db.patch(existing._id, { ...pose, ...identity, updatedAt: now, revokedAt: undefined });
948
1161
  }
949
1162
  }
950
1163
  else {
951
- await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
1164
+ await ctx.db.insert("jgPoses", {
1165
+ serverId: args.serverId,
1166
+ userId: actorUserId,
1167
+ ...pose,
1168
+ ...identity,
1169
+ updatedAt: now,
1170
+ });
952
1171
  }
953
- return null;
1172
+ // This session is the live one; any other session of the same user is revoked, not deleted,
1173
+ // so it learns it was displaced on its next sync instead of silently reappearing.
1174
+ for (const row of rows) {
1175
+ if (row._id === existing?._id || row.revokedAt !== undefined)
1176
+ continue;
1177
+ await ctx.db.patch(row._id, { revokedAt: now });
1178
+ }
1179
+ return { pose, lastSeenAt: now, displaced };
954
1180
  }),
955
1181
  });
956
1182
  const leave = mutation({
957
1183
  args: {
958
1184
  serverId: v.string(),
1185
+ sessionId: v.optional(v.string()),
959
1186
  externalId: v.optional(v.string()),
960
1187
  },
961
1188
  returns: v.null(),
@@ -963,16 +1190,45 @@ export function createPresenceFunctions(options) {
963
1190
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
964
1191
  if (!actorUserId)
965
1192
  return null;
966
- const existing = await ctx.db
1193
+ const now = Date.now();
1194
+ const rows = await ctx.db
967
1195
  .query("jgPoses")
968
1196
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
969
- .unique();
970
- if (existing)
971
- await ctx.db.delete(existing._id);
1197
+ .collect();
1198
+ for (const row of rows) {
1199
+ if (args.sessionId !== undefined && row.sessionId !== args.sessionId)
1200
+ continue;
1201
+ if (row.revokedAt === undefined)
1202
+ await ctx.db.patch(row._id, { revokedAt: now });
1203
+ }
972
1204
  return null;
973
1205
  },
974
1206
  });
975
- return { list, sync, leave };
1207
+ /**
1208
+ * Delete rows nobody has written for `idleCutoffMs`. `list` filters stale rows at read time but
1209
+ * nothing removed them, so a player who closed the tab left a row behind forever. Register it on a
1210
+ * cron — {@link jgengineCronSpecs} carries the interval.
1211
+ */
1212
+ const reapIdlePresence = internalMutation({
1213
+ args: {},
1214
+ returns: v.object({ reaped: v.number() }),
1215
+ handler: async (ctx) => {
1216
+ const now = Date.now();
1217
+ const stale = await ctx.db
1218
+ .query("jgPoses")
1219
+ .withIndex("by_updated", (q) => q.lt("updatedAt", now - idleCutoffMs))
1220
+ .take(maxReapPerRun);
1221
+ let reaped = 0;
1222
+ for (const row of stale) {
1223
+ if (!isPresenceExpired(row.updatedAt, now, idleCutoffMs))
1224
+ continue;
1225
+ await ctx.db.delete(row._id);
1226
+ reaped += 1;
1227
+ }
1228
+ return { reaped };
1229
+ },
1230
+ });
1231
+ return { list, sync, leave, reapIdlePresence };
976
1232
  }
977
1233
  /** @internal */
978
1234
  export function createChatFunctions(options) {
@@ -1062,8 +1318,9 @@ export function createChatFunctions(options) {
1062
1318
  return { messages, sendMessage };
1063
1319
  }
1064
1320
  const JG_CRON_SPECS = [
1065
- { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
1066
- { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
1321
+ { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
1322
+ { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers", module: "runtime" },
1323
+ { name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
1067
1324
  ];
1068
1325
  /** @internal */
1069
1326
  export function jgengineCronSpecs() {