@jgengine/convex 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,12 +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
5
  import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
- import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
6
- import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
+ import { SERVER_ONLY_COMMAND_REASON, commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
7
+ import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
7
8
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
8
9
  import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
9
- 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";
10
12
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
11
13
  /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
12
14
  export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
@@ -24,6 +26,8 @@ export function jgengineTables() {
24
26
  modeConfig: v.optional(v.any()),
25
27
  visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
26
28
  joinCode: v.optional(v.string()),
29
+ topology: v.optional(v.union(v.literal("shared"), v.literal("rooms"))),
30
+ memberCount: v.optional(v.number()),
27
31
  memberUserIds: v.array(v.string()),
28
32
  slotsPerServer: v.number(),
29
33
  save: saveConfigValidator,
@@ -40,10 +44,33 @@ export function jgengineTables() {
40
44
  .index("by_status", ["status"])
41
45
  .index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
42
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
+ /** Live client sessions holding this membership; absent on legacy and host-driven joins. */
60
+ sessionIds: v.optional(v.array(v.string())),
61
+ }).index("by_server", ["serverId"]).index("by_server_and_user", ["serverId", "userId"]),
62
+ jgRateLimits: defineTable({
63
+ key: v.string(), startedAt: v.number(), count: v.number(), expiresAt: v.number(),
64
+ }).index("by_key", ["key"]).index("by_expires", ["expiresAt"]),
65
+ jgClientErrors: defineTable({
66
+ userId: v.string(), message: v.string(), details: v.optional(v.any()), createdAt: v.number(),
67
+ }).index("by_created", ["createdAt"]),
43
68
  jgPlayerProfiles: defineTable({
44
69
  userId: v.string(),
45
70
  gameId: v.string(),
46
71
  playerState: v.any(),
72
+ sessionState: v.optional(v.any()),
73
+ sessionServerId: v.optional(v.id("jgGameServers")),
47
74
  revision: v.number(),
48
75
  dirtyAt: v.optional(v.number()),
49
76
  createdAt: v.number(),
@@ -77,6 +104,10 @@ export function jgengineTables() {
77
104
  }).index("by_server_and_action", ["serverId", "action"]),
78
105
  jgPoses: defineTable({
79
106
  serverId: v.string(),
107
+ homeGameId: v.optional(v.string()),
108
+ ownerActorId: v.optional(v.string()),
109
+ lastWorldSnapshotAt: v.optional(v.number()),
110
+ chunkKey: v.optional(v.string()),
80
111
  userId: v.string(),
81
112
  /** One row per live session, so a second tab is a second row rather than a fight over one. */
82
113
  sessionId: v.optional(v.string()),
@@ -95,14 +126,22 @@ export function jgengineTables() {
95
126
  })
96
127
  .index("by_server", ["serverId"])
97
128
  .index("by_server_and_user", ["serverId", "userId"])
98
- .index("by_updated", ["updatedAt"]),
129
+ .index("by_server_and_chunk", ["serverId", "chunkKey"])
130
+ .index("by_server_user_revoked_updated", ["serverId", "userId", "revokedAt", "updatedAt"])
131
+ .index("by_updated", ["updatedAt"])
132
+ .index("by_user", ["userId"])
133
+ .index("by_home_game", ["homeGameId"])
134
+ .index("by_home_game_revoked_updated", ["homeGameId", "revokedAt", "updatedAt"])
135
+ .index("by_revoked", ["revokedAt"])
136
+ .index("by_revoked_updated", ["revokedAt", "updatedAt"]),
99
137
  jgChatMessages: defineTable({
100
138
  serverId: v.string(),
101
139
  channelId: v.string(),
102
140
  userId: v.string(),
103
141
  body: v.string(),
142
+ authorName: v.optional(v.string()),
104
143
  at: v.number(),
105
- }).index("by_server_channel", ["serverId", "channelId"]),
144
+ }).index("by_server_channel", ["serverId", "channelId", "at"]).index("by_at", ["at"]),
106
145
  };
107
146
  }
108
147
  /**
@@ -151,11 +190,34 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
151
190
  }
152
191
  return null;
153
192
  }
193
+ async function hasMember(ctx, server, userId) {
194
+ if (server.memberUserIds.includes(userId))
195
+ return true;
196
+ if (server.topology !== "shared")
197
+ return false;
198
+ return (await ctx.db.query("jgServerMembers")
199
+ .withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique()) !== null;
200
+ }
201
+ async function memberRow(ctx, serverId, userId) {
202
+ return ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", serverId).eq("userId", userId)).unique();
203
+ }
204
+ /** Most live client sessions one shared-topology membership remembers; the oldest drop first. */
205
+ export const MAX_MEMBER_SESSIONS = 16;
206
+ /** Longest accepted client `sessionId`. */
207
+ export const MAX_SESSION_ID_LENGTH = 128;
208
+ function withSession(sessionIds, sessionId) {
209
+ return [...(sessionIds ?? []).filter((id) => id !== sessionId), sessionId].slice(-MAX_MEMBER_SESSIONS);
210
+ }
211
+ function assertSessionId(sessionId) {
212
+ if (sessionId !== undefined && (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH)) {
213
+ throw new ConvexError("Invalid sessionId");
214
+ }
215
+ }
154
216
  async function requireServerMember(ctx, serverId, actorUserId) {
155
217
  const server = await ctx.db.get("jgGameServers", serverId);
156
218
  if (!server)
157
219
  return null;
158
- if (!isServerMember(server.memberUserIds, actorUserId))
220
+ if (!(await hasMember(ctx, server, actorUserId)))
159
221
  return null;
160
222
  return server;
161
223
  }
@@ -229,6 +291,21 @@ function serverDocToRecord(server) {
229
291
  updatedAt: server.updatedAt,
230
292
  };
231
293
  }
294
+ async function serverCapacity(ctx, server) {
295
+ return server.topology === "shared"
296
+ ? ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", server._id)).unique()
297
+ : null;
298
+ }
299
+ async function ensureServerCapacity(ctx, server) {
300
+ const existing = await serverCapacity(ctx, server);
301
+ if (existing)
302
+ return existing;
303
+ const id = await ctx.db.insert("jgServerCapacity", {
304
+ serverId: server._id, gameId: server.gameId, status: server.status, mode: server.mode,
305
+ memberCount: server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer,
306
+ });
307
+ return (await ctx.db.get("jgServerCapacity", id));
308
+ }
232
309
  /**
233
310
  * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
234
311
  * Reach for it when writing your own mutation that must read runtime state before touching host tables.
@@ -238,14 +315,27 @@ function serverDocToRecord(server) {
238
315
  export async function loadServerSnapshot(ctx, server, runtime, scope) {
239
316
  const profiles = {};
240
317
  const memberIds = new Set(server.memberUserIds);
241
- const hydrateUserIds = scope?.players === undefined
242
- ? server.memberUserIds
243
- : scope.players.filter((userId) => memberIds.has(userId));
318
+ let hydrateUserIds;
319
+ if (server.topology === "shared") {
320
+ const requested = scope?.players ?? (await ctx.db.query("jgServerMembers")
321
+ .withIndex("by_server", q => q.eq("serverId", server._id)).collect()).map(row => row.userId);
322
+ hydrateUserIds = [];
323
+ for (const userId of new Set(requested))
324
+ if (await hasMember(ctx, server, userId))
325
+ hydrateUserIds.push(userId);
326
+ }
327
+ else {
328
+ hydrateUserIds = scope?.players === undefined ? server.memberUserIds : scope.players.filter(userId => memberIds.has(userId));
329
+ }
330
+ const sessionPlayers = {};
244
331
  for (const userId of hydrateUserIds) {
245
332
  const profile = await ctx.db
246
333
  .query("jgPlayerProfiles")
247
334
  .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
248
335
  .unique();
336
+ if (server.topology === "shared" && profile) {
337
+ sessionPlayers[userId] = { ...profile.playerState, ...(profile.sessionServerId === server._id ? { session: profile.sessionState ?? {} } : { session: {} }) };
338
+ }
249
339
  profiles[userId] = profile
250
340
  ? {
251
341
  userId,
@@ -256,7 +346,7 @@ export async function loadServerSnapshot(ctx, server, runtime, scope) {
256
346
  }
257
347
  : null;
258
348
  }
259
- const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
349
+ const playersByUserId = buildHydratePlayers(server.topology === "shared" ? { ...serverDocToRecord(server), memberUserIds: hydrateUserIds, sessionPlayers } : serverDocToRecord(server), profiles, hydrateUserIds);
260
350
  const chunksByKey = {};
261
351
  if (scope?.chunkKeys === undefined) {
262
352
  const chunks = await ctx.db
@@ -323,7 +413,9 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
323
413
  */
324
414
  export async function persistServerSnapshot(ctx, server, snapshot, save) {
325
415
  const now = Date.now();
326
- const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
416
+ const shared = server.topology === "shared";
417
+ const record = serverDocToRecord(server);
418
+ const plan = planServerPersist(shared ? { ...record, memberUserIds: Object.keys(snapshot.players), sessionPlayers: {} } : record, snapshot, save, now);
327
419
  if (!plan.changed.any)
328
420
  return false;
329
421
  if (plan.leaderboard.length > 0) {
@@ -336,6 +428,7 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
336
428
  .unique();
337
429
  const profilePatch = {
338
430
  playerState: profile.playerState,
431
+ ...(shared ? { sessionState: snapshot.players[profile.userId]?.session ?? {}, sessionServerId: server._id } : {}),
339
432
  revision: profile.revision,
340
433
  updatedAt: profile.updatedAt,
341
434
  };
@@ -351,6 +444,22 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
351
444
  });
352
445
  }
353
446
  }
447
+ if (shared) {
448
+ const persisted = new Set(plan.profiles.map(profile => profile.userId));
449
+ for (const userId of snapshot.dirty.players) {
450
+ if (persisted.has(userId))
451
+ continue;
452
+ const player = snapshot.players[userId];
453
+ if (!player)
454
+ continue;
455
+ const existing = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
456
+ const patch = { playerState: player, sessionState: player.session, sessionServerId: server._id, revision: snapshot.revision, updatedAt: now };
457
+ if (existing)
458
+ await ctx.db.patch(existing._id, patch);
459
+ else
460
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
461
+ }
462
+ }
354
463
  for (const chunk of plan.chunks) {
355
464
  const existing = await ctx.db
356
465
  .query("jgWorldChunks")
@@ -380,24 +489,25 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
380
489
  if (existing)
381
490
  await ctx.db.delete(existing._id);
382
491
  }
383
- await ctx.db.patch(server._id, {
384
- ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
385
- ...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
386
- revision: plan.server.revision,
387
- dirtyAt: plan.server.dirtyAt,
388
- updatedAt: plan.server.updatedAt,
389
- lastSavedAt: plan.server.lastSavedAt,
390
- });
492
+ if (!shared || plan.changed.serverState)
493
+ await ctx.db.patch(server._id, {
494
+ ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
495
+ ...(!shared && plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
496
+ revision: plan.server.revision,
497
+ dirtyAt: plan.server.dirtyAt,
498
+ updatedAt: plan.server.updatedAt,
499
+ lastSavedAt: plan.server.lastSavedAt,
500
+ });
391
501
  return true;
392
502
  }
393
503
  /** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
394
- async function flushServerIfDue(ctx, server, now, runtime, loaded) {
504
+ async function flushServerIfDue(ctx, server, now, _runtime, loaded) {
395
505
  if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
396
506
  return false;
397
507
  }
398
- // Unscoped on purpose: markAllPlayersDirty writes every member, so every member has to be hydrated.
399
- const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
400
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
508
+ if (loaded)
509
+ await persistServerSnapshot(ctx, server, loaded, server.save);
510
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: now });
401
511
  return true;
402
512
  }
403
513
  export const JG_RUNTIME_TICK_MS = 1_000;
@@ -408,10 +518,9 @@ export const MAX_CHUNKS_PER_QUERY = 64;
408
518
  /**
409
519
  * Build the hosted-server Convex functions for a set of game runtimes.
410
520
  *
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.
521
+ * Room topology keeps its roster and session map in the server document and caps capacity at
522
+ * {@link JG_MAX_MEMBERS_PER_SERVER}. Shared topology stores membership, capacity, and per-player
523
+ * session state separately; player-only commands and roster changes do not rewrite the world row.
415
524
  * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
416
525
  * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
417
526
  *
@@ -421,17 +530,157 @@ export const MAX_CHUNKS_PER_QUERY = 64;
421
530
  */
422
531
  export function createGameServerFunctions(options) {
423
532
  const mode = options?.auth ?? "required";
424
- const hostSlotsPerServer = options?.slotsPerServer ?? 16;
425
- const slotsCheck = validateSlotsPerServer(hostSlotsPerServer);
533
+ const shared = options?.topology === "shared" || (options?.topology === undefined && (options?.runtimes?.some(runtime => runtime.topology === "shared") ?? false));
534
+ const hostSlotsPerServer = shared ? Number.MAX_SAFE_INTEGER : options?.slotsPerServer ?? 16;
535
+ const slotsCheck = validateSlotsPerServer(hostSlotsPerServer, shared ? "shared" : "rooms");
426
536
  if (!slotsCheck.ok)
427
537
  throw new Error(slotsCheck.reason);
428
- const matchmaking = options?.matchmaking ?? "auto";
538
+ const matchmaking = options?.matchmaking ?? (shared ? "singleton" : "auto");
429
539
  const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
430
540
  const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
431
541
  const registry = buildRuntimeRegistry(options?.runtimes);
432
542
  const tickableGameIds = (options?.runtimes ?? [])
433
543
  .filter((runtime) => runtime.hasTick)
434
544
  .map((runtime) => runtime.gameId);
545
+ const joinAs = async (ctx, actorUserId, args, sessionId) => {
546
+ const now = Date.now();
547
+ const runtime = resolveRuntime(registry, args.gameId);
548
+ const save = runtime.save;
549
+ const slotsPerServer = hostSlotsPerServer;
550
+ let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
551
+ if (args.serverId !== undefined && !server) {
552
+ return { ok: false, reason: "closed" };
553
+ }
554
+ if (server && server.gameId !== args.gameId) {
555
+ return { ok: false, reason: "unauthorized" };
556
+ }
557
+ if (server?.status === "closed")
558
+ return { ok: false, reason: "closed" };
559
+ let mayCreate = true;
560
+ if (!server) {
561
+ const joinable = [];
562
+ for (const status of ["running", "open"]) {
563
+ const rows = await ctx.db
564
+ .query("jgGameServers")
565
+ .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
566
+ .take(100);
567
+ joinable.push(...rows);
568
+ }
569
+ // Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
570
+ // resolves it — the loser's read set is invalidated and its mutation retries against the
571
+ // winner's row — so the engine adds no lock. A `HostPersistence` backend without
572
+ // serializable-snapshot retry must supply that guarantee itself.
573
+ const target = selectJoinTarget(joinable, {
574
+ userId: actorUserId,
575
+ mode: matchmaking,
576
+ slotsPerServer,
577
+ });
578
+ if (target.kind === "refuse")
579
+ return { ok: false, reason: "full" };
580
+ server = target.kind === "join" ? target.row : null;
581
+ mayCreate = target.kind === "create";
582
+ }
583
+ if (!server && mayCreate) {
584
+ const serverId = await ctx.db.insert("jgGameServers", {
585
+ gameId: args.gameId,
586
+ status: "running",
587
+ mode: args.mode,
588
+ modeConfig: args.modeConfig,
589
+ visibility: args.visibility ?? "public",
590
+ joinCode: args.joinCode,
591
+ topology: shared ? "shared" : "rooms",
592
+ memberCount: 0,
593
+ memberUserIds: [],
594
+ slotsPerServer,
595
+ save,
596
+ serverState: defaultServerStateForGame(args.gameId),
597
+ sessionPlayers: {},
598
+ revision: 0,
599
+ tickAnchorMs: now,
600
+ createdAt: now,
601
+ updatedAt: now,
602
+ });
603
+ server = await ctx.db.get("jgGameServers", serverId);
604
+ if (!server)
605
+ throw new ConvexError("Failed to create server");
606
+ }
607
+ if (!server)
608
+ throw new ConvexError("No joinable server");
609
+ // Capacity and save policy live in host options, not in the row. Without this a long-lived
610
+ // world keeps whatever it was created with and raising the host option silently does nothing.
611
+ if (server.slotsPerServer !== slotsPerServer || JSON.stringify(server.save) !== JSON.stringify(save)) {
612
+ await ctx.db.patch(server._id, { slotsPerServer, save, updatedAt: now });
613
+ const reconciled = await ctx.db.get("jgGameServers", server._id);
614
+ if (!reconciled)
615
+ throw new ConvexError("Server missing after reconcile");
616
+ server = reconciled;
617
+ }
618
+ if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
619
+ return { ok: false, reason: "full" };
620
+ }
621
+ if (isPrivateJoinBlocked({
622
+ visibility: server.visibility,
623
+ memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
624
+ userId: actorUserId,
625
+ joinCode: server.joinCode,
626
+ suppliedCode: args.joinCode,
627
+ })) {
628
+ return { ok: false, reason: "unauthorized" };
629
+ }
630
+ let profile = await ctx.db
631
+ .query("jgPlayerProfiles")
632
+ .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
633
+ .unique();
634
+ if (shared && server.topology !== "shared") {
635
+ for (const userId of server.memberUserIds) {
636
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique();
637
+ if (!member)
638
+ await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now });
639
+ const state = server.sessionPlayers[userId];
640
+ if (!state)
641
+ continue;
642
+ const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
643
+ const patch = { sessionState: state.session ?? {}, sessionServerId: server._id };
644
+ if (stored)
645
+ await ctx.db.patch(stored._id, patch);
646
+ else
647
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch });
648
+ }
649
+ await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} });
650
+ server = (await ctx.db.get("jgGameServers", server._id));
651
+ profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique();
652
+ }
653
+ const isNew = profile === null;
654
+ const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
655
+ if (shared) {
656
+ const capacity = await ensureServerCapacity(ctx, server);
657
+ const member = await memberRow(ctx, server._id, actorUserId);
658
+ const isMember = member !== null || server.memberUserIds.includes(actorUserId);
659
+ await ctx.db.patch(capacity._id, {
660
+ memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer,
661
+ });
662
+ if (!isMember) {
663
+ await ctx.db.insert("jgServerMembers", {
664
+ serverId: server._id, userId: actorUserId, joinedAt: now, ...(sessionId === undefined ? {} : { sessionIds: [sessionId] }),
665
+ });
666
+ }
667
+ else if (member !== null && sessionId !== undefined) {
668
+ await ctx.db.patch(member._id, { sessionIds: withSession(member.sessionIds, sessionId) });
669
+ }
670
+ if (server.status !== "running")
671
+ await ctx.db.patch(server._id, { status: "running" });
672
+ }
673
+ else {
674
+ await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now });
675
+ }
676
+ const refreshed = await ctx.db.get("jgGameServers", server._id);
677
+ if (!refreshed)
678
+ throw new ConvexError("Server missing after join");
679
+ let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
680
+ snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
681
+ await persistServerSnapshot(ctx, refreshed, snapshot, save);
682
+ return { ok: true, serverId: refreshed._id, isNew };
683
+ };
435
684
  const joinServer = mutation({
436
685
  args: {
437
686
  gameId: v.string(),
@@ -441,125 +690,45 @@ export function createGameServerFunctions(options) {
441
690
  visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
442
691
  joinCode: v.optional(v.string()),
443
692
  externalId: v.optional(v.string()),
693
+ sessionId: v.optional(v.string()),
444
694
  },
445
- returns: v.object({
446
- serverId: v.id("jgGameServers"),
447
- isNew: v.boolean(),
448
- }),
695
+ 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")) })),
449
696
  handler: async (ctx, args) => {
450
- const actorUserId = await requireActor(ctx, args.externalId, mode);
451
- const now = Date.now();
452
- const runtime = resolveRuntime(registry, args.gameId);
453
- const save = runtime.save;
454
- const slotsPerServer = hostSlotsPerServer;
455
- let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
456
- if (args.serverId !== undefined && !server) {
457
- throw new ConvexError("Server not found");
458
- }
459
- if (server && server.gameId !== args.gameId) {
460
- throw new ConvexError("Server belongs to a different game");
461
- }
462
- let mayCreate = true;
463
- if (!server) {
464
- const joinable = [];
465
- for (const status of ["running", "open"]) {
466
- const rows = await ctx.db
467
- .query("jgGameServers")
468
- .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
469
- .collect();
470
- joinable.push(...rows);
471
- }
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";
485
- }
486
- if (!server && mayCreate) {
487
- const serverId = await ctx.db.insert("jgGameServers", {
488
- gameId: args.gameId,
489
- status: "running",
490
- mode: args.mode,
491
- modeConfig: args.modeConfig,
492
- visibility: args.visibility ?? "public",
493
- joinCode: args.joinCode,
494
- memberUserIds: [],
495
- slotsPerServer,
496
- save,
497
- serverState: defaultServerStateForGame(args.gameId),
498
- sessionPlayers: {},
499
- revision: 0,
500
- tickAnchorMs: now,
501
- createdAt: now,
502
- updatedAt: now,
503
- });
504
- server = await ctx.db.get("jgGameServers", serverId);
505
- if (!server)
506
- throw new ConvexError("Failed to create server");
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
- }
519
- if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
520
- throw new ConvexError("Server is full");
521
- }
522
- if (isPrivateJoinBlocked({
523
- visibility: server.visibility,
524
- memberUserIds: server.memberUserIds,
525
- userId: actorUserId,
526
- joinCode: server.joinCode,
527
- suppliedCode: args.joinCode,
528
- })) {
529
- throw new ConvexError("Server is private");
530
- }
531
- const profile = await ctx.db
532
- .query("jgPlayerProfiles")
533
- .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
534
- .unique();
535
- const isNew = profile === null;
536
- const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
537
- await ctx.db.patch(server._id, {
538
- memberUserIds,
539
- status: "running",
540
- updatedAt: now,
541
- dirtyAt: now,
542
- });
543
- const refreshed = await ctx.db.get("jgGameServers", server._id);
544
- if (!refreshed)
545
- throw new ConvexError("Server missing after join");
546
- let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
547
- snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
548
- await persistServerSnapshot(ctx, refreshed, snapshot, save);
549
- return { serverId: refreshed._id, isNew };
697
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
698
+ if (!actorUserId)
699
+ return { ok: false, reason: "unauthorized" };
700
+ assertSessionId(args.sessionId);
701
+ const { externalId: _externalId, sessionId, ...joinArgs } = args;
702
+ return joinAs(ctx, actorUserId, joinArgs, sessionId);
550
703
  },
551
704
  });
552
705
  const leaveServer = mutation({
553
706
  args: {
554
707
  serverId: v.id("jgGameServers"),
555
708
  externalId: v.optional(v.string()),
709
+ sessionId: v.optional(v.string()),
556
710
  },
557
711
  returns: v.null(),
558
712
  handler: async (ctx, args) => {
559
713
  const actorUserId = await requireActor(ctx, args.externalId, mode);
714
+ assertSessionId(args.sessionId);
560
715
  const server = await requireServerMember(ctx, args.serverId, actorUserId);
561
716
  if (server === null)
562
717
  return null;
718
+ if (args.sessionId !== undefined && server.topology === "shared") {
719
+ const member = await memberRow(ctx, server._id, actorUserId);
720
+ const live = member?.sessionIds ?? [];
721
+ // A leave for a session that is not live (a disposed join, or one trimmed by the bound) must not evict the sessions that are.
722
+ if (live.length > 0) {
723
+ const remaining = live.filter((id) => id !== args.sessionId);
724
+ if (remaining.length === live.length)
725
+ return null;
726
+ if (remaining.length > 0) {
727
+ await ctx.db.patch(member._id, { sessionIds: remaining });
728
+ return null;
729
+ }
730
+ }
731
+ }
563
732
  const now = Date.now();
564
733
  const runtime = resolveRuntime(registry, server.gameId);
565
734
  // Flushes the leaver's own state before the roster drops them; nobody else's rows are touched.
@@ -571,23 +740,79 @@ export function createGameServerFunctions(options) {
571
740
  const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
572
741
  const sessionPlayers = { ...server.sessionPlayers };
573
742
  delete sessionPlayers[actorUserId];
574
- await ctx.db.patch(server._id, {
575
- memberUserIds,
576
- sessionPlayers,
577
- updatedAt: now,
578
- dirtyAt: now,
579
- status: statusAfterLeave(memberUserIds.length, server.status),
580
- });
581
- const pose = await ctx.db
743
+ if (server.topology === "shared") {
744
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", actorUserId)).unique();
745
+ if (member) {
746
+ const capacity = await ensureServerCapacity(ctx, server);
747
+ await ctx.db.delete(member._id);
748
+ await ctx.db.patch(capacity._id, { memberCount: Math.max(0, capacity.memberCount - 1) });
749
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", server.gameId)).unique();
750
+ if (profile?.sessionServerId === server._id)
751
+ await ctx.db.patch(profile._id, { sessionState: {}, sessionServerId: undefined });
752
+ }
753
+ }
754
+ else {
755
+ await ctx.db.patch(server._id, { memberUserIds, sessionPlayers, updatedAt: now, status: statusAfterLeave(memberUserIds.length, server.status) });
756
+ }
757
+ const poses = await ctx.db
582
758
  .query("jgPoses")
583
759
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
584
- .unique();
585
- if (pose)
760
+ .collect();
761
+ for (const pose of poses)
586
762
  await ctx.db.delete(pose._id);
587
763
  return null;
588
764
  },
589
765
  });
590
766
  const helpers = {
767
+ async ensureJoined(ctx, args) {
768
+ const serverId = args.serverId;
769
+ if (serverId !== undefined) {
770
+ const server = await ctx.db.get("jgGameServers", serverId);
771
+ if (server && server.gameId === args.gameId && server.status !== "closed" && await hasMember(ctx, server, args.userId)) {
772
+ return { ok: true, serverId: server._id, isNew: false };
773
+ }
774
+ }
775
+ return joinAs(ctx, args.userId, { gameId: args.gameId, serverId }, undefined);
776
+ },
777
+ async ensureServer(ctx, gameId) {
778
+ if (!shared || matchmaking !== "singleton")
779
+ throw new Error("ensureServer requires a shared singleton host");
780
+ for (const status of ["running", "open"]) {
781
+ const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first();
782
+ if (existing)
783
+ return existing;
784
+ }
785
+ const runtime = resolveRuntime(registry, gameId);
786
+ const now = Date.now();
787
+ const id = await ctx.db.insert("jgGameServers", {
788
+ gameId, topology: "shared", status: "running", memberUserIds: [], slotsPerServer: hostSlotsPerServer,
789
+ save: runtime.save, serverState: defaultServerStateForGame(gameId), sessionPlayers: {}, revision: 0,
790
+ tickAnchorMs: now, createdAt: now, updatedAt: now,
791
+ });
792
+ const server = (await ctx.db.get("jgGameServers", id));
793
+ await ensureServerCapacity(ctx, server);
794
+ return server;
795
+ },
796
+ async resetPlayerProfile(ctx, serverId, userId) {
797
+ const server = await ctx.db.get("jgGameServers", serverId);
798
+ if (!server)
799
+ throw new ConvexError("Server not found");
800
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
801
+ const member = await hasMember(ctx, server, userId);
802
+ // A player who left keeps a profile that host code (a scheduled wipe, an insolvency reset) must still be able to reset.
803
+ if (!member && profile === null)
804
+ throw new ConvexError("Not a member of this server");
805
+ const state = initialPlayerState(resolveRuntime(registry, server.gameId), userId);
806
+ const now = Date.now();
807
+ const patch = { playerState: state, sessionState: state.session, sessionServerId: server._id, revision: (profile?.revision ?? 0) + 1, updatedAt: now };
808
+ if (profile)
809
+ await ctx.db.patch(profile._id, patch);
810
+ else
811
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
812
+ if (server.topology !== "shared" && member)
813
+ await ctx.db.patch(server._id, { sessionPlayers: { ...server.sessionPlayers, [userId]: state } });
814
+ return state;
815
+ },
591
816
  async loadSnapshot(ctx, serverId, scope) {
592
817
  const server = await ctx.db.get("jgGameServers", serverId);
593
818
  if (!server)
@@ -602,13 +827,13 @@ export function createGameServerFunctions(options) {
602
827
  return persistServerSnapshot(ctx, server, snapshot, save ?? server.save);
603
828
  },
604
829
  async runCommand(ctx, args) {
605
- const actorUserId = await requireActor(ctx, args.externalId, mode);
830
+ const actorUserId = args.actorUserId ?? await requireActor(ctx, args.externalId, mode);
606
831
  const serverId = args.serverId;
607
832
  const server = await ctx.db.get("jgGameServers", serverId);
608
833
  if (!server) {
609
834
  return { ok: false, reason: "Server not found" };
610
835
  }
611
- if (!isServerMember(server.memberUserIds, actorUserId)) {
836
+ if (!(await hasMember(ctx, server, actorUserId))) {
612
837
  return { ok: false, reason: "Not a member of this server" };
613
838
  }
614
839
  const loadedRevision = server.revision;
@@ -639,6 +864,10 @@ export function createGameServerFunctions(options) {
639
864
  if (!commit.ok) {
640
865
  return commit;
641
866
  }
867
+ await args.beforePersist?.(ctx, { server: latest, runtime, snapshot: result.snapshot });
868
+ const callbackEscape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
869
+ if (callbackEscape !== null)
870
+ throw new ConvexError(callbackEscape);
642
871
  await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
643
872
  return { ok: true };
644
873
  },
@@ -655,7 +884,13 @@ export function createGameServerFunctions(options) {
655
884
  })),
656
885
  },
657
886
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
658
- handler: (ctx, args) => helpers.runCommand(ctx, args),
887
+ handler: async (ctx, args) => {
888
+ const server = await ctx.db.get("jgGameServers", args.serverId);
889
+ if (server !== null && resolveRuntime(registry, server.gameId).commandAccess(args.command) === "server") {
890
+ return { ok: false, reason: SERVER_ONLY_COMMAND_REASON };
891
+ }
892
+ return helpers.runCommand(ctx, { serverId: args.serverId, command: args.command, input: args.input, externalId: args.externalId });
893
+ },
659
894
  });
660
895
  const flushSave = mutation({
661
896
  args: {
@@ -668,10 +903,7 @@ export function createGameServerFunctions(options) {
668
903
  const server = await requireServerMember(ctx, args.serverId, actorUserId);
669
904
  if (server === null)
670
905
  return false;
671
- const runtime = resolveRuntime(registry, server.gameId);
672
- // Unscoped on purpose: markAllPlayersDirty writes every member.
673
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
674
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
906
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
675
907
  return true;
676
908
  },
677
909
  });
@@ -727,20 +959,38 @@ export function createGameServerFunctions(options) {
727
959
  mode: v.optional(v.string()),
728
960
  memberCount: v.number(),
729
961
  slotsPerServer: v.number(),
730
- revision: v.number(),
731
- updatedAt: v.number(),
732
962
  }), v.null()),
733
- handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
963
+ handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => ({
734
964
  _id: server._id,
735
965
  gameId: server.gameId,
736
966
  status: server.status,
737
967
  mode: server.mode,
738
- memberCount: server.memberUserIds.length,
968
+ memberCount: (await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length,
739
969
  slotsPerServer: server.slotsPerServer,
740
- revision: server.revision,
741
- updatedAt: server.updatedAt,
742
970
  })),
743
971
  });
972
+ const getServerCapacity = query({
973
+ args: { serverId: v.id("jgGameServers"), externalId: v.optional(v.string()) },
974
+ returns: v.union(v.object({
975
+ _id: v.id("jgGameServers"), gameId: v.string(),
976
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
977
+ mode: v.optional(v.string()), memberCount: v.number(), slotsPerServer: v.number(),
978
+ }), v.null()),
979
+ handler: async (ctx, args) => {
980
+ const actor = await resolveActor(ctx, args.externalId, mode);
981
+ if (!actor)
982
+ return null;
983
+ const capacity = await ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", args.serverId)).unique();
984
+ if (capacity) {
985
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actor)).unique();
986
+ if (!member)
987
+ return null;
988
+ return { _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer };
989
+ }
990
+ const server = await requireServerMember(ctx, args.serverId, actor);
991
+ return server ? { _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer } : null;
992
+ },
993
+ });
744
994
  /**
745
995
  * Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
746
996
  * geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
@@ -874,17 +1124,17 @@ export function createGameServerFunctions(options) {
874
1124
  .query("jgGameServers")
875
1125
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
876
1126
  .take(Math.min(limit * 4, 200));
877
- return rows
1127
+ return Promise.all(rows
878
1128
  .filter((row) => isListablePublicly(row.visibility))
879
1129
  .slice(0, limit)
880
- .map((row) => ({
1130
+ .map(async (row) => ({
881
1131
  _id: row._id,
882
1132
  status: row.status,
883
- memberCount: row.memberUserIds.length,
1133
+ memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length,
884
1134
  slotsPerServer: row.slotsPerServer,
885
1135
  mode: row.mode,
886
1136
  updatedAt: row.updatedAt,
887
- }));
1137
+ })));
888
1138
  },
889
1139
  });
890
1140
  // Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
@@ -906,7 +1156,7 @@ export function createGameServerFunctions(options) {
906
1156
  .withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
907
1157
  .take(budget);
908
1158
  for (const server of servers) {
909
- if (server.memberUserIds.length === 0)
1159
+ if (((await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length) === 0)
910
1160
  continue;
911
1161
  const elapsedMs = now - server.tickAnchorMs;
912
1162
  if (elapsedMs < JG_RUNTIME_TICK_MS)
@@ -915,7 +1165,7 @@ export function createGameServerFunctions(options) {
915
1165
  const runtime = resolveRuntime(registry, server.gameId);
916
1166
  // Unscoped on purpose: onTick is handed the whole world and may touch any of it.
917
1167
  const loaded = await loadServerSnapshot(ctx, server, runtime);
918
- const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
1168
+ const snapshot = runtime.tick(loaded, elapsedMs / 1_000, now);
919
1169
  await persistServerSnapshot(ctx, server, snapshot, server.save);
920
1170
  await ctx.db.patch(server._id, { tickAnchorMs: now });
921
1171
  ticked += 1;
@@ -937,10 +1187,7 @@ export function createGameServerFunctions(options) {
937
1187
  .take(maxServersPerTick);
938
1188
  let saved = 0;
939
1189
  for (const server of servers) {
940
- const runtime = resolveRuntime(registry, server.gameId);
941
- // Unscoped on purpose: markAllPlayersDirty writes every member.
942
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
943
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
1190
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
944
1191
  saved += 1;
945
1192
  }
946
1193
  return { saved };
@@ -953,6 +1200,7 @@ export function createGameServerFunctions(options) {
953
1200
  flushSave,
954
1201
  getServer,
955
1202
  getServerMeta,
1203
+ getServerCapacity,
956
1204
  getChunks,
957
1205
  getPlayerProfile,
958
1206
  getFeed,
@@ -1054,6 +1302,7 @@ export function createPresenceFunctions(options) {
1054
1302
  const list = query({
1055
1303
  args: {
1056
1304
  serverId: v.string(),
1305
+ viewerChunkKey: v.optional(v.string()),
1057
1306
  externalId: v.optional(v.string()),
1058
1307
  },
1059
1308
  returns: v.array(v.object({
@@ -1067,12 +1316,17 @@ export function createPresenceFunctions(options) {
1067
1316
  lastSeenAt: v.number(),
1068
1317
  })),
1069
1318
  handler: async (ctx, args) => {
1070
- return withMember(ctx, mode, args, [], async () => {
1319
+ return withMember(ctx, mode, args, [], async (_server, actorUserId) => {
1071
1320
  const threshold = Date.now() - freshWindowMs;
1072
- const rows = await ctx.db
1073
- .query("jgPoses")
1074
- .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
1075
- .collect();
1321
+ const own = args.viewerChunkKey === undefined ? await ctx.db.query("jgPoses")
1322
+ .withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actorUserId)).first() : null;
1323
+ const center = parseChunkKey(args.viewerChunkKey ?? (own ? chunkKeyAt([own.x, own.y, own.z]) : "0,0"));
1324
+ if (!center)
1325
+ return [];
1326
+ const rows = [];
1327
+ for (const chunkKey of chunkKeysAround([center.cx * DEFAULT_CHUNK_SIZE, 0, center.cz * DEFAULT_CHUNK_SIZE], 1)) {
1328
+ rows.push(...await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId).eq("chunkKey", chunkKey)).take(256));
1329
+ }
1076
1330
  return rows
1077
1331
  .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
1078
1332
  .map((row) => ({
@@ -1122,6 +1376,9 @@ export function createPresenceFunctions(options) {
1122
1376
  ? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
1123
1377
  : rows.find((row) => row.sessionId === args.sessionId);
1124
1378
  const rules = rulesFor(args.kind ?? existing?.kind);
1379
+ if (existing && now - existing.updatedAt < (rules.minIntervalMs ?? 100)) {
1380
+ return { pose: { x: existing.x, y: existing.y, z: existing.z, rotationY: existing.rotationY, rotationPitch: existing.rotationPitch }, lastSeenAt: existing.updatedAt, displaced: existing.revokedAt !== undefined };
1381
+ }
1125
1382
  const current = existing === undefined
1126
1383
  ? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
1127
1384
  serverId: args.serverId,
@@ -1157,7 +1414,7 @@ export function createPresenceFunctions(options) {
1157
1414
  const displaced = existing?.revokedAt !== undefined;
1158
1415
  if (existing) {
1159
1416
  if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1160
- await ctx.db.patch(existing._id, { ...pose, ...identity, updatedAt: now, revokedAt: undefined });
1417
+ await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
1161
1418
  }
1162
1419
  }
1163
1420
  else {
@@ -1165,6 +1422,7 @@ export function createPresenceFunctions(options) {
1165
1422
  serverId: args.serverId,
1166
1423
  userId: actorUserId,
1167
1424
  ...pose,
1425
+ chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
1168
1426
  ...identity,
1169
1427
  updatedAt: now,
1170
1428
  });
@@ -1230,12 +1488,130 @@ export function createPresenceFunctions(options) {
1230
1488
  });
1231
1489
  return { list, sync, leave, reapIdlePresence };
1232
1490
  }
1491
+ /**
1492
+ * Scan one bounded presence page and schedule its handler and continuation in separate transactions.
1493
+ * @capability online-player-batches Schedule bounded active-player or home-game work in separate transactions.
1494
+ */
1495
+ export async function forEachOnlinePlayer(ctx, options) {
1496
+ const batchSize = options.batchSize ?? 25;
1497
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 256)
1498
+ throw new Error("batchSize must be between 1 and 256");
1499
+ const nowMs = options.nowMs ?? Date.now();
1500
+ const freshWindowMs = options.freshWindowMs ?? 60_000;
1501
+ if (!Number.isFinite(nowMs) || !Number.isFinite(freshWindowMs) || freshWindowMs <= 0 || !Number.isFinite(nowMs - freshWindowMs))
1502
+ throw new Error("Invalid online-player time window");
1503
+ const page = await ctx.db.query("jgPoses").withIndex("by_updated", q => q.gte("updatedAt", nowMs - freshWindowMs))
1504
+ .paginate({ numItems: batchSize, cursor: options.cursor ?? null });
1505
+ const players = [];
1506
+ for (const row of page.page) {
1507
+ if (row.revokedAt !== undefined)
1508
+ continue;
1509
+ const canonical = options.groupBy === "homeGameId" && row.homeGameId
1510
+ ? await ctx.db.query("jgPoses").withIndex("by_home_game_revoked_updated", q => q.eq("homeGameId", row.homeGameId).eq("revokedAt", undefined)).order("desc").first()
1511
+ : 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();
1512
+ if (canonical?._id === row._id)
1513
+ players.push({ serverId: row.serverId, userId: row.userId, ...(row.homeGameId ? { homeGameId: row.homeGameId } : {}) });
1514
+ }
1515
+ if (players.length)
1516
+ await ctx.scheduler.runAfter(0, options.handler, { players, nowMs });
1517
+ if (!page.isDone)
1518
+ await ctx.scheduler.runAfter(0, options.continuation, { cursor: page.continueCursor, nowMs });
1519
+ return { scheduled: players.length, remaining: !page.isDone };
1520
+ }
1521
+ /**
1522
+ * Consume one request in a fixed rate window; concurrent callers serialize on the indexed key.
1523
+ * @capability host-rate-limit Enforce indexed per-key request windows inside authoritative mutations.
1524
+ */
1525
+ export async function rateLimit(ctx, args) {
1526
+ const now = args.nowMs ?? Date.now();
1527
+ 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)
1528
+ throw new Error("Invalid rate limit policy");
1529
+ const row = await ctx.db.query("jgRateLimits").withIndex("by_key", q => q.eq("key", args.key)).unique();
1530
+ if (row && now < row.expiresAt && row.count >= args.max)
1531
+ return { ok: false, retryAfterMs: row.expiresAt - now };
1532
+ const patch = !row || now >= row.expiresAt ? { startedAt: now, expiresAt: now + args.windowMs, count: 1 } : { startedAt: row.startedAt, expiresAt: row.expiresAt, count: row.count + 1 };
1533
+ if (row)
1534
+ await ctx.db.patch(row._id, patch);
1535
+ else
1536
+ await ctx.db.insert("jgRateLimits", { key: args.key, ...patch });
1537
+ return { ok: true, retryAfterMs: 0 };
1538
+ }
1539
+ /**
1540
+ * Write validated chat in a host transaction after the caller resolves its actor and world.
1541
+ * @capability host-chat-send Validate, rate-limit and persist chat inside an authorized host mutation.
1542
+ */
1543
+ export async function sendChatMessage(ctx, args) {
1544
+ const maxBodyLength = args.maxBodyLength ?? 240;
1545
+ if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096)
1546
+ throw new Error("Invalid chat body limit");
1547
+ if (!(args.channelId ?? "global").trim() || (args.channelId?.length ?? 0) > 256 || (args.authorName?.length ?? 0) > 160)
1548
+ return { ok: false, reason: "invalid_channel_or_author" };
1549
+ const validation = validateChatMessage(args.body, { maxLength: args.maxBodyLength ?? 240 });
1550
+ if (!validation.ok)
1551
+ return validation;
1552
+ const gate = await rateLimit(ctx, { key: JSON.stringify(["chat", args.serverId, args.userId]), windowMs: args.minIntervalMs ?? 2000, max: 1 });
1553
+ if (!gate.ok)
1554
+ return { ok: false, reason: "rate_limited" };
1555
+ await ctx.db.insert("jgChatMessages", { serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() });
1556
+ return { ok: true };
1557
+ }
1558
+ /**
1559
+ * Bounded recent chat for a resolved server; authorization belongs to the calling query.
1560
+ * @capability host-chat-history Read bounded channel history inside an authorized host query.
1561
+ */
1562
+ export async function recentChatMessages(ctx, args) {
1563
+ const limit = args.limit ?? 50;
1564
+ if (!Number.isFinite(limit))
1565
+ throw new Error("Invalid chat limit");
1566
+ return ctx.db.query("jgChatMessages").withIndex("by_server_channel", q => q.eq("serverId", args.serverId).eq("channelId", args.channelId ?? "global"))
1567
+ .order("desc").take(Math.min(100, Math.max(1, Math.floor(limit))));
1568
+ }
1569
+ /**
1570
+ * Authenticated, bounded client diagnostics and bounded retention sweeps.
1571
+ * @capability client-error-reporting Accept authenticated bounded diagnostics with cooldown and retention sweeps.
1572
+ */
1573
+ export function createClientErrorFunctions() {
1574
+ const reportClientError = mutation({
1575
+ 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()) },
1576
+ returns: v.object({ ok: v.boolean(), reason: v.optional(v.string()) }),
1577
+ handler: async (ctx, args) => {
1578
+ const actor = await resolveActor(ctx, undefined, "required");
1579
+ if (!actor)
1580
+ return { ok: false, reason: "unauthorized" };
1581
+ const message = JSON.stringify(args);
1582
+ if (new TextEncoder().encode(message).length > 4096)
1583
+ return { ok: false, reason: "too_long" };
1584
+ const gate = await rateLimit(ctx, { key: `client-error:${actor}`, windowMs: 10_000, max: 1 });
1585
+ if (!gate.ok)
1586
+ return { ok: false, reason: "rate_limited" };
1587
+ await ctx.db.insert("jgClientErrors", { userId: actor, message, createdAt: Date.now() });
1588
+ return { ok: true };
1589
+ },
1590
+ });
1591
+ const pruneClientErrors = internalMutation({ args: {}, handler: async (ctx) => {
1592
+ const rows = await ctx.db.query("jgClientErrors").withIndex("by_created", q => q.lt("createdAt", Date.now() - 7 * 86_400_000)).take(256);
1593
+ for (const row of rows)
1594
+ await ctx.db.delete(row._id);
1595
+ return { deleted: rows.length, remaining: rows.length === 256 };
1596
+ } });
1597
+ const pruneRateLimits = internalMutation({ args: {}, handler: async (ctx) => {
1598
+ const rows = await ctx.db.query("jgRateLimits").withIndex("by_expires", q => q.lte("expiresAt", Date.now())).take(256);
1599
+ for (const row of rows)
1600
+ await ctx.db.delete(row._id);
1601
+ return { deleted: rows.length, remaining: rows.length === 256 };
1602
+ } });
1603
+ return { reportClientError, pruneClientErrors, pruneRateLimits };
1604
+ }
1233
1605
  /** @internal */
1234
1606
  export function createChatFunctions(options) {
1235
1607
  const mode = options?.auth ?? "required";
1236
- const historyLimit = options?.historyLimit ?? 100;
1237
- const maxBodyLength = options?.maxBodyLength ?? 500;
1238
- const minIntervalMs = options?.minIntervalMs ?? 300;
1608
+ if (options?.historyLimit !== undefined && (!Number.isSafeInteger(options.historyLimit) || options.historyLimit < 1))
1609
+ throw new Error("Invalid chat history limit");
1610
+ const historyLimit = Math.min(100, options?.historyLimit ?? 50);
1611
+ const maxBodyLength = options?.maxBodyLength ?? 240;
1612
+ const minIntervalMs = options?.minIntervalMs ?? 2_000;
1613
+ if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096 || !Number.isFinite(minIntervalMs) || minIntervalMs <= 0)
1614
+ throw new Error("Invalid chat policy");
1239
1615
  const messages = query({
1240
1616
  args: {
1241
1617
  serverId: v.string(),
@@ -1282,28 +1658,9 @@ export function createChatFunctions(options) {
1282
1658
  if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
1283
1659
  return { ok: false, reason: "not a member of this server" };
1284
1660
  }
1285
- const body = args.body.trim();
1286
- if (body.length === 0)
1287
- return { ok: false, reason: "empty message" };
1288
- if (body.length > maxBodyLength)
1289
- return { ok: false, reason: "message too long" };
1290
- const now = Date.now();
1291
- const newestByActor = await ctx.db
1292
- .query("jgChatMessages")
1293
- .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
1294
- .order("desc")
1295
- .filter((q) => q.eq(q.field("userId"), actorUserId))
1296
- .first();
1297
- if (newestByActor && now - newestByActor.at < minIntervalMs) {
1298
- return { ok: false, reason: "sending too fast" };
1299
- }
1300
- await ctx.db.insert("jgChatMessages", {
1301
- serverId: args.serverId,
1302
- channelId: args.channelId,
1303
- userId: actorUserId,
1304
- body,
1305
- at: now,
1306
- });
1661
+ const outcome = await sendChatMessage(ctx, { serverId: args.serverId, userId: actorUserId, channelId: args.channelId, body: args.body, maxBodyLength, minIntervalMs });
1662
+ if (!outcome.ok)
1663
+ return outcome.reason === "rate_limited" ? { ok: false, reason: "sending too fast" } : outcome;
1307
1664
  const recent = await ctx.db
1308
1665
  .query("jgChatMessages")
1309
1666
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -1315,7 +1672,13 @@ export function createChatFunctions(options) {
1315
1672
  return { ok: true };
1316
1673
  },
1317
1674
  });
1318
- return { messages, sendMessage };
1675
+ const pruneChatMessages = internalMutation({ args: {}, handler: async (ctx) => {
1676
+ const rows = await ctx.db.query("jgChatMessages").withIndex("by_at", q => q.lt("at", Date.now() - 86_400_000)).take(256);
1677
+ for (const row of rows)
1678
+ await ctx.db.delete(row._id);
1679
+ return { deleted: rows.length, remaining: rows.length === 256 };
1680
+ } });
1681
+ return { messages, recent: messages, sendMessage, pruneChatMessages };
1319
1682
  }
1320
1683
  const JG_CRON_SPECS = [
1321
1684
  { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
@@ -1323,6 +1686,18 @@ const JG_CRON_SPECS = [
1323
1686
  { name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
1324
1687
  ];
1325
1688
  /** @internal */
1326
- export function jgengineCronSpecs() {
1327
- return JG_CRON_SPECS;
1689
+ export function jgengineCronSpecs(options = {}) {
1690
+ const specs = JG_CRON_SPECS.filter(spec => spec.functionKey !== "tickActiveServers" || options.runtimes?.some(runtime => runtime.hasTick));
1691
+ if (options.chat)
1692
+ specs.push({ name: "jg chat prune", intervalSeconds: 3600, functionKey: "pruneChatMessages", module: "chat" });
1693
+ if (options.clientErrors) {
1694
+ specs.push({ name: "jg error prune", intervalSeconds: 3600, functionKey: "pruneClientErrors", module: "clientErrors" });
1695
+ specs.push({ name: "jg rate limit prune", intervalSeconds: 3600, functionKey: "pruneRateLimits", module: "clientErrors" });
1696
+ }
1697
+ return specs.map(spec => {
1698
+ const intervalSeconds = options.intervalSeconds?.[spec.functionKey] ?? spec.intervalSeconds;
1699
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0)
1700
+ throw new Error("Cron intervalSeconds must be positive");
1701
+ return { ...spec, intervalSeconds };
1702
+ });
1328
1703
  }