@jgengine/convex 0.6.0 → 0.8.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 ADDED
@@ -0,0 +1,923 @@
1
+ import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
+ import { ConvexError, v } from "convex/values";
3
+ import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
4
+ import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
5
+ import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
6
+ const saveConfigValidator = v.union(v.literal("none"), v.object({
7
+ auto: v.string(),
8
+ scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
9
+ }));
10
+ export function jgengineTables() {
11
+ return {
12
+ jgGameServers: defineTable({
13
+ gameId: v.string(),
14
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
15
+ mode: v.optional(v.string()),
16
+ modeConfig: v.optional(v.any()),
17
+ memberUserIds: v.array(v.string()),
18
+ slotsPerServer: v.number(),
19
+ save: saveConfigValidator,
20
+ serverState: v.any(),
21
+ sessionPlayers: v.any(),
22
+ revision: v.number(),
23
+ tickAnchorMs: v.number(),
24
+ lastSavedAt: v.optional(v.number()),
25
+ dirtyAt: v.optional(v.number()),
26
+ createdAt: v.number(),
27
+ updatedAt: v.number(),
28
+ }).index("by_game_and_status", ["gameId", "status"]),
29
+ jgPlayerProfiles: defineTable({
30
+ userId: v.string(),
31
+ gameId: v.string(),
32
+ playerState: v.any(),
33
+ revision: v.number(),
34
+ dirtyAt: v.optional(v.number()),
35
+ createdAt: v.number(),
36
+ updatedAt: v.number(),
37
+ }).index("by_user_and_game", ["userId", "gameId"]),
38
+ jgWorldChunks: defineTable({
39
+ serverId: v.id("jgGameServers"),
40
+ chunkKey: v.string(),
41
+ snapshot: v.any(),
42
+ dirtyAt: v.optional(v.number()),
43
+ updatedAt: v.number(),
44
+ })
45
+ .index("by_server_and_chunk", ["serverId", "chunkKey"])
46
+ .index("by_server", ["serverId"]),
47
+ jgLeaderboardRows: defineTable({
48
+ gameId: v.string(),
49
+ stat: v.string(),
50
+ scope: v.union(v.literal("global"), v.literal("server"), v.literal("profile")),
51
+ serverId: v.optional(v.id("jgGameServers")),
52
+ userId: v.string(),
53
+ value: v.number(),
54
+ updatedAt: v.number(),
55
+ })
56
+ .index("by_game_scope_stat_value", ["gameId", "scope", "stat", "value"])
57
+ .index("by_user_scope_stat", ["userId", "scope", "stat"]),
58
+ jgFeedBuffers: defineTable({
59
+ serverId: v.id("jgGameServers"),
60
+ action: v.string(),
61
+ entries: v.array(v.any()),
62
+ updatedAt: v.number(),
63
+ }).index("by_server_and_action", ["serverId", "action"]),
64
+ jgPoses: defineTable({
65
+ serverId: v.string(),
66
+ userId: v.string(),
67
+ x: v.number(),
68
+ y: v.number(),
69
+ z: v.number(),
70
+ rotationY: v.number(),
71
+ rotationPitch: v.number(),
72
+ updatedAt: v.number(),
73
+ })
74
+ .index("by_server", ["serverId"])
75
+ .index("by_server_and_user", ["serverId", "userId"]),
76
+ jgChatMessages: defineTable({
77
+ serverId: v.string(),
78
+ channelId: v.string(),
79
+ userId: v.string(),
80
+ body: v.string(),
81
+ at: v.number(),
82
+ }).index("by_server_channel", ["serverId", "channelId"]),
83
+ };
84
+ }
85
+ const schemaForTypes = defineSchema(jgengineTables());
86
+ const query = queryGeneric;
87
+ const mutation = mutationGeneric;
88
+ const internalMutation = internalMutationGeneric;
89
+ async function resolveActor(ctx, claimedExternalId, mode) {
90
+ const identity = await ctx.auth.getUserIdentity();
91
+ if (identity !== null) {
92
+ if (claimedExternalId !== undefined &&
93
+ claimedExternalId.length > 0 &&
94
+ claimedExternalId !== identity.subject) {
95
+ return null;
96
+ }
97
+ return identity.subject;
98
+ }
99
+ if (mode === "anonymous" && claimedExternalId !== undefined && claimedExternalId.length > 0) {
100
+ return claimedExternalId;
101
+ }
102
+ return null;
103
+ }
104
+ const builtinRuntimeCommands = {
105
+ "engine.ping": {
106
+ validate: (_snapshot) => null,
107
+ apply: (snapshot) => snapshot,
108
+ },
109
+ };
110
+ function buildRuntimeRegistry(runtimes) {
111
+ const registry = new Map();
112
+ for (const runtime of runtimes ?? [])
113
+ registry.set(runtime.gameId, runtime);
114
+ return registry;
115
+ }
116
+ function resolveRuntime(registry, gameId) {
117
+ const existing = registry.get(gameId);
118
+ if (existing)
119
+ return existing;
120
+ const fallback = createGameRuntime({ gameId, save: "none", commands: builtinRuntimeCommands });
121
+ registry.set(gameId, fallback);
122
+ return fallback;
123
+ }
124
+ function defaultServerStateForGame(gameId) {
125
+ void gameId;
126
+ return createEmptyServerRow();
127
+ }
128
+ function serverDocToRecord(server) {
129
+ return {
130
+ serverId: server._id,
131
+ gameId: server.gameId,
132
+ status: server.status,
133
+ mode: server.mode,
134
+ modeConfig: server.modeConfig,
135
+ memberUserIds: server.memberUserIds,
136
+ slotsPerServer: server.slotsPerServer,
137
+ save: server.save,
138
+ serverState: server.serverState,
139
+ sessionPlayers: server.sessionPlayers,
140
+ revision: server.revision,
141
+ tickAnchorMs: server.tickAnchorMs,
142
+ lastSavedAt: server.lastSavedAt,
143
+ dirtyAt: server.dirtyAt,
144
+ createdAt: server.createdAt,
145
+ updatedAt: server.updatedAt,
146
+ };
147
+ }
148
+ async function loadServerSnapshot(ctx, server, runtime) {
149
+ const profiles = {};
150
+ for (const userId of server.memberUserIds) {
151
+ const profile = await ctx.db
152
+ .query("jgPlayerProfiles")
153
+ .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
154
+ .unique();
155
+ profiles[userId] = profile
156
+ ? {
157
+ userId,
158
+ gameId: server.gameId,
159
+ playerState: profile.playerState,
160
+ revision: profile.revision,
161
+ updatedAt: profile.updatedAt,
162
+ }
163
+ : null;
164
+ }
165
+ const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles);
166
+ const chunks = await ctx.db
167
+ .query("jgWorldChunks")
168
+ .withIndex("by_server", (q) => q.eq("serverId", server._id))
169
+ .collect();
170
+ const chunksByKey = {};
171
+ for (const chunk of chunks) {
172
+ chunksByKey[chunk.chunkKey] = chunk.snapshot;
173
+ }
174
+ return runtime.hydrate({
175
+ gameId: server.gameId,
176
+ serverId: server._id,
177
+ serverRow: server.serverState,
178
+ playersByUserId,
179
+ chunksByKey,
180
+ });
181
+ }
182
+ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
183
+ const now = Date.now();
184
+ for (const entry of entries) {
185
+ const serverId = entry.serverId;
186
+ const candidates = await ctx.db
187
+ .query("jgLeaderboardRows")
188
+ .withIndex("by_user_scope_stat", (q) => q.eq("userId", entry.userId).eq("scope", entry.scope).eq("stat", entry.stat))
189
+ .collect();
190
+ const existing = candidates.find((row) => row.gameId === gameId && row.serverId === serverId);
191
+ if (existing) {
192
+ await ctx.db.patch(existing._id, { value: existing.value + entry.by, updatedAt: now });
193
+ }
194
+ else {
195
+ await ctx.db.insert("jgLeaderboardRows", {
196
+ gameId,
197
+ stat: entry.stat,
198
+ scope: entry.scope,
199
+ serverId,
200
+ userId: entry.userId,
201
+ value: entry.by,
202
+ updatedAt: now,
203
+ });
204
+ }
205
+ }
206
+ }
207
+ async function persistServerSnapshot(ctx, server, snapshot, save) {
208
+ const now = Date.now();
209
+ const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
210
+ if (plan.leaderboard.length > 0) {
211
+ await applyLeaderboardIncrements(ctx, server.gameId, plan.leaderboard);
212
+ }
213
+ for (const profile of plan.profiles) {
214
+ const existing = await ctx.db
215
+ .query("jgPlayerProfiles")
216
+ .withIndex("by_user_and_game", (q) => q.eq("userId", profile.userId).eq("gameId", profile.gameId))
217
+ .unique();
218
+ const profilePatch = {
219
+ playerState: profile.playerState,
220
+ revision: profile.revision,
221
+ dirtyAt: undefined,
222
+ updatedAt: profile.updatedAt,
223
+ };
224
+ if (existing) {
225
+ await ctx.db.patch(existing._id, profilePatch);
226
+ }
227
+ else {
228
+ await ctx.db.insert("jgPlayerProfiles", {
229
+ userId: profile.userId,
230
+ gameId: profile.gameId,
231
+ ...profilePatch,
232
+ createdAt: now,
233
+ });
234
+ }
235
+ }
236
+ for (const chunk of plan.chunks) {
237
+ const existing = await ctx.db
238
+ .query("jgWorldChunks")
239
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunk.chunkKey))
240
+ .unique();
241
+ const chunkPatch = {
242
+ snapshot: chunk.snapshot,
243
+ dirtyAt: undefined,
244
+ updatedAt: chunk.updatedAt,
245
+ };
246
+ if (existing) {
247
+ await ctx.db.patch(existing._id, chunkPatch);
248
+ }
249
+ else {
250
+ await ctx.db.insert("jgWorldChunks", {
251
+ serverId: server._id,
252
+ chunkKey: chunk.chunkKey,
253
+ ...chunkPatch,
254
+ });
255
+ }
256
+ }
257
+ await ctx.db.patch(server._id, {
258
+ serverState: plan.server.serverState,
259
+ sessionPlayers: plan.server.sessionPlayers,
260
+ revision: plan.server.revision,
261
+ dirtyAt: plan.server.dirtyAt,
262
+ updatedAt: plan.server.updatedAt,
263
+ lastSavedAt: plan.server.lastSavedAt,
264
+ });
265
+ }
266
+ async function flushServerIfDue(ctx, server, now, runtime) {
267
+ if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
268
+ return false;
269
+ }
270
+ const snapshot = await loadServerSnapshot(ctx, server, runtime);
271
+ await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
272
+ return true;
273
+ }
274
+ export const JG_RUNTIME_TICK_MS = 1_000;
275
+ export function createGameServerFunctions(options) {
276
+ const mode = options?.auth ?? "anonymous";
277
+ const registry = buildRuntimeRegistry(options?.runtimes);
278
+ const joinServer = mutation({
279
+ args: {
280
+ gameId: v.string(),
281
+ serverId: v.optional(v.id("jgGameServers")),
282
+ slotsPerServer: v.optional(v.number()),
283
+ save: v.optional(saveConfigValidator),
284
+ mode: v.optional(v.string()),
285
+ modeConfig: v.optional(v.any()),
286
+ externalId: v.optional(v.string()),
287
+ },
288
+ returns: v.object({
289
+ serverId: v.id("jgGameServers"),
290
+ isNew: v.boolean(),
291
+ }),
292
+ handler: async (ctx, args) => {
293
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
294
+ if (!actorUserId) {
295
+ throw new ConvexError("Not authenticated");
296
+ }
297
+ const now = Date.now();
298
+ const runtime = resolveRuntime(registry, args.gameId);
299
+ const save = (args.save ?? runtime.save);
300
+ const slotsPerServer = args.slotsPerServer ?? 16;
301
+ let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
302
+ if (server && server.gameId !== args.gameId) {
303
+ throw new ConvexError("Server belongs to a different game");
304
+ }
305
+ if (!server) {
306
+ const joinable = [];
307
+ for (const status of ["running", "open"]) {
308
+ const rows = await ctx.db
309
+ .query("jgGameServers")
310
+ .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
311
+ .collect();
312
+ joinable.push(...rows);
313
+ }
314
+ server =
315
+ joinable.find((row) => row.memberUserIds.includes(actorUserId) ||
316
+ row.memberUserIds.length < row.slotsPerServer) ?? null;
317
+ }
318
+ if (!server) {
319
+ const serverId = await ctx.db.insert("jgGameServers", {
320
+ gameId: args.gameId,
321
+ status: "running",
322
+ mode: args.mode,
323
+ modeConfig: args.modeConfig,
324
+ memberUserIds: [],
325
+ slotsPerServer,
326
+ save,
327
+ serverState: defaultServerStateForGame(args.gameId),
328
+ sessionPlayers: {},
329
+ revision: 0,
330
+ tickAnchorMs: now,
331
+ createdAt: now,
332
+ updatedAt: now,
333
+ });
334
+ server = await ctx.db.get("jgGameServers", serverId);
335
+ if (!server)
336
+ throw new ConvexError("Failed to create server");
337
+ }
338
+ if (server.memberUserIds.length >= server.slotsPerServer &&
339
+ !server.memberUserIds.includes(actorUserId)) {
340
+ throw new ConvexError("Server is full");
341
+ }
342
+ const profile = await ctx.db
343
+ .query("jgPlayerProfiles")
344
+ .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
345
+ .unique();
346
+ const isNew = profile === null;
347
+ const memberUserIds = server.memberUserIds.includes(actorUserId)
348
+ ? server.memberUserIds
349
+ : [...server.memberUserIds, actorUserId];
350
+ await ctx.db.patch(server._id, {
351
+ memberUserIds,
352
+ status: "running",
353
+ updatedAt: now,
354
+ dirtyAt: now,
355
+ });
356
+ const refreshed = await ctx.db.get("jgGameServers", server._id);
357
+ if (!refreshed)
358
+ throw new ConvexError("Server missing after join");
359
+ let snapshot = await loadServerSnapshot(ctx, refreshed, runtime);
360
+ snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
361
+ await persistServerSnapshot(ctx, refreshed, clearDirtyFlags(snapshot), save);
362
+ return { serverId: refreshed._id, isNew };
363
+ },
364
+ });
365
+ const leaveServer = mutation({
366
+ args: {
367
+ serverId: v.id("jgGameServers"),
368
+ externalId: v.optional(v.string()),
369
+ },
370
+ returns: v.null(),
371
+ handler: async (ctx, args) => {
372
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
373
+ if (!actorUserId) {
374
+ throw new ConvexError("Not authenticated");
375
+ }
376
+ const server = await ctx.db.get("jgGameServers", args.serverId);
377
+ if (!server)
378
+ return null;
379
+ if (!server.memberUserIds.includes(actorUserId))
380
+ return null;
381
+ const now = Date.now();
382
+ const runtime = resolveRuntime(registry, server.gameId);
383
+ const snapshot = await loadServerSnapshot(ctx, server, runtime);
384
+ await persistServerSnapshot(ctx, server, snapshot, server.save);
385
+ const memberUserIds = server.memberUserIds.filter((id) => id !== actorUserId);
386
+ const sessionPlayers = { ...server.sessionPlayers };
387
+ delete sessionPlayers[actorUserId];
388
+ await ctx.db.patch(server._id, {
389
+ memberUserIds,
390
+ sessionPlayers,
391
+ updatedAt: now,
392
+ dirtyAt: now,
393
+ status: memberUserIds.length === 0 ? "open" : server.status,
394
+ });
395
+ const pose = await ctx.db
396
+ .query("jgPoses")
397
+ .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
398
+ .unique();
399
+ if (pose)
400
+ await ctx.db.delete(pose._id);
401
+ return null;
402
+ },
403
+ });
404
+ const runCommand = mutation({
405
+ args: {
406
+ serverId: v.id("jgGameServers"),
407
+ command: v.string(),
408
+ input: v.any(),
409
+ externalId: v.optional(v.string()),
410
+ },
411
+ returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
412
+ handler: async (ctx, args) => {
413
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
414
+ if (!actorUserId) {
415
+ throw new ConvexError("Not authenticated");
416
+ }
417
+ const server = await ctx.db.get("jgGameServers", args.serverId);
418
+ if (!server) {
419
+ return { ok: false, reason: "Server not found" };
420
+ }
421
+ if (!server.memberUserIds.includes(actorUserId)) {
422
+ return { ok: false, reason: "Not a member of this server" };
423
+ }
424
+ const runtime = resolveRuntime(registry, server.gameId);
425
+ const snapshot = await loadServerSnapshot(ctx, server, runtime);
426
+ const result = runtime.runCommand(snapshot, actorUserId, args.command, args.input);
427
+ if (!result.ok) {
428
+ return result;
429
+ }
430
+ await persistServerSnapshot(ctx, server, result.snapshot, server.save);
431
+ return { ok: true };
432
+ },
433
+ });
434
+ const flushSave = mutation({
435
+ args: {
436
+ serverId: v.id("jgGameServers"),
437
+ externalId: v.optional(v.string()),
438
+ },
439
+ returns: v.boolean(),
440
+ handler: async (ctx, args) => {
441
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
442
+ if (!actorUserId) {
443
+ throw new ConvexError("Not authenticated");
444
+ }
445
+ const server = await ctx.db.get("jgGameServers", args.serverId);
446
+ if (!server)
447
+ return false;
448
+ if (!server.memberUserIds.includes(actorUserId))
449
+ return false;
450
+ const runtime = resolveRuntime(registry, server.gameId);
451
+ const snapshot = await loadServerSnapshot(ctx, server, runtime);
452
+ await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
453
+ return true;
454
+ },
455
+ });
456
+ const getServer = query({
457
+ args: {
458
+ serverId: v.id("jgGameServers"),
459
+ externalId: v.optional(v.string()),
460
+ },
461
+ returns: v.union(v.object({
462
+ _id: v.id("jgGameServers"),
463
+ gameId: v.string(),
464
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
465
+ mode: v.optional(v.string()),
466
+ memberUserIds: v.array(v.string()),
467
+ slotsPerServer: v.number(),
468
+ save: saveConfigValidator,
469
+ revision: v.number(),
470
+ serverState: v.any(),
471
+ sessionPlayers: v.any(),
472
+ updatedAt: v.number(),
473
+ }), v.null()),
474
+ handler: async (ctx, args) => {
475
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
476
+ if (!actorUserId)
477
+ return null;
478
+ const server = await ctx.db.get("jgGameServers", args.serverId);
479
+ if (!server)
480
+ return null;
481
+ if (!server.memberUserIds.includes(actorUserId))
482
+ return null;
483
+ return {
484
+ _id: server._id,
485
+ gameId: server.gameId,
486
+ status: server.status,
487
+ mode: server.mode,
488
+ memberUserIds: server.memberUserIds,
489
+ slotsPerServer: server.slotsPerServer,
490
+ save: server.save,
491
+ revision: server.revision,
492
+ serverState: server.serverState,
493
+ sessionPlayers: server.sessionPlayers,
494
+ updatedAt: server.updatedAt,
495
+ };
496
+ },
497
+ });
498
+ const getPlayerProfile = query({
499
+ args: {
500
+ gameId: v.string(),
501
+ externalId: v.optional(v.string()),
502
+ },
503
+ returns: v.union(v.object({
504
+ userId: v.string(),
505
+ gameId: v.string(),
506
+ playerState: v.any(),
507
+ revision: v.number(),
508
+ updatedAt: v.number(),
509
+ }), v.null()),
510
+ handler: async (ctx, args) => {
511
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
512
+ if (!actorUserId)
513
+ return null;
514
+ const profile = await ctx.db
515
+ .query("jgPlayerProfiles")
516
+ .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
517
+ .unique();
518
+ if (!profile)
519
+ return null;
520
+ return {
521
+ userId: profile.userId,
522
+ gameId: profile.gameId,
523
+ playerState: profile.playerState,
524
+ revision: profile.revision,
525
+ updatedAt: profile.updatedAt,
526
+ };
527
+ },
528
+ });
529
+ const getFeed = query({
530
+ args: {
531
+ serverId: v.id("jgGameServers"),
532
+ action: v.string(),
533
+ externalId: v.optional(v.string()),
534
+ },
535
+ returns: v.array(v.any()),
536
+ handler: async (ctx, args) => {
537
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
538
+ if (!actorUserId)
539
+ return [];
540
+ const server = await ctx.db.get("jgGameServers", args.serverId);
541
+ if (!server || !server.memberUserIds.includes(actorUserId))
542
+ return [];
543
+ const row = await ctx.db
544
+ .query("jgFeedBuffers")
545
+ .withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
546
+ .unique();
547
+ return row?.entries ?? [];
548
+ },
549
+ });
550
+ const pushFeedEntry = mutation({
551
+ args: {
552
+ serverId: v.id("jgGameServers"),
553
+ action: v.string(),
554
+ entry: v.any(),
555
+ externalId: v.optional(v.string()),
556
+ },
557
+ returns: v.null(),
558
+ handler: async (ctx, args) => {
559
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
560
+ if (!actorUserId) {
561
+ throw new ConvexError("Not authenticated");
562
+ }
563
+ const server = await ctx.db.get("jgGameServers", args.serverId);
564
+ if (!server || !server.memberUserIds.includes(actorUserId)) {
565
+ throw new ConvexError("Not a member of this server");
566
+ }
567
+ const now = Date.now();
568
+ const existing = await ctx.db
569
+ .query("jgFeedBuffers")
570
+ .withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
571
+ .unique();
572
+ const entries = trimFeedEntries([...(existing?.entries ?? []), args.entry]);
573
+ if (existing) {
574
+ await ctx.db.patch(existing._id, { entries, updatedAt: now });
575
+ }
576
+ else {
577
+ await ctx.db.insert("jgFeedBuffers", {
578
+ serverId: args.serverId,
579
+ action: args.action,
580
+ entries,
581
+ updatedAt: now,
582
+ });
583
+ }
584
+ return null;
585
+ },
586
+ });
587
+ const listOpenServers = query({
588
+ args: {
589
+ gameId: v.string(),
590
+ limit: v.optional(v.number()),
591
+ externalId: v.optional(v.string()),
592
+ },
593
+ returns: v.array(v.object({
594
+ _id: v.id("jgGameServers"),
595
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
596
+ memberCount: v.number(),
597
+ slotsPerServer: v.number(),
598
+ mode: v.optional(v.string()),
599
+ updatedAt: v.number(),
600
+ })),
601
+ handler: async (ctx, args) => {
602
+ const rows = await ctx.db
603
+ .query("jgGameServers")
604
+ .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
605
+ .take(args.limit ?? 20);
606
+ return rows.map((row) => ({
607
+ _id: row._id,
608
+ status: row.status,
609
+ memberCount: row.memberUserIds.length,
610
+ slotsPerServer: row.slotsPerServer,
611
+ mode: row.mode,
612
+ updatedAt: row.updatedAt,
613
+ }));
614
+ },
615
+ });
616
+ const tickActiveServers = internalMutation({
617
+ args: {},
618
+ handler: async (ctx) => {
619
+ const now = Date.now();
620
+ const servers = await ctx.db.query("jgGameServers").collect();
621
+ let ticked = 0;
622
+ let saved = 0;
623
+ for (const server of servers) {
624
+ if (server.status !== "running")
625
+ continue;
626
+ if (server.memberUserIds.length === 0)
627
+ continue;
628
+ const elapsedMs = now - server.tickAnchorMs;
629
+ if (elapsedMs < JG_RUNTIME_TICK_MS)
630
+ continue;
631
+ const runtime = resolveRuntime(registry, server.gameId);
632
+ let snapshot = await loadServerSnapshot(ctx, server, runtime);
633
+ snapshot = runtime.tick(snapshot, elapsedMs / 1_000);
634
+ await persistServerSnapshot(ctx, server, snapshot, server.save);
635
+ await ctx.db.patch(server._id, { tickAnchorMs: now, updatedAt: now });
636
+ ticked += 1;
637
+ const refreshed = await ctx.db.get("jgGameServers", server._id);
638
+ if (refreshed && (await flushServerIfDue(ctx, refreshed, now, runtime))) {
639
+ saved += 1;
640
+ }
641
+ }
642
+ return { ticked, saved };
643
+ },
644
+ });
645
+ const flushDirtyServers = internalMutation({
646
+ args: {},
647
+ handler: async (ctx) => {
648
+ const now = Date.now();
649
+ const servers = await ctx.db.query("jgGameServers").collect();
650
+ let saved = 0;
651
+ for (const server of servers) {
652
+ if (server.dirtyAt === undefined)
653
+ continue;
654
+ const runtime = resolveRuntime(registry, server.gameId);
655
+ const snapshot = await loadServerSnapshot(ctx, server, runtime);
656
+ await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
657
+ saved += 1;
658
+ void now;
659
+ }
660
+ return { saved };
661
+ },
662
+ });
663
+ return {
664
+ joinServer,
665
+ leaveServer,
666
+ runCommand,
667
+ flushSave,
668
+ getServer,
669
+ getPlayerProfile,
670
+ getFeed,
671
+ pushFeedEntry,
672
+ listOpenServers,
673
+ tickActiveServers,
674
+ flushDirtyServers,
675
+ };
676
+ }
677
+ const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
678
+ const MAX_LEADERBOARD_TOP_LIMIT = 100;
679
+ export function createLeaderboardFunctions(options) {
680
+ void options;
681
+ const getTop = query({
682
+ args: {
683
+ gameId: v.string(),
684
+ stat: v.string(),
685
+ scope: leaderboardScopeValidator,
686
+ serverId: v.optional(v.id("jgGameServers")),
687
+ limit: v.optional(v.number()),
688
+ externalId: v.optional(v.string()),
689
+ },
690
+ returns: v.array(v.object({ userId: v.string(), value: v.number() })),
691
+ handler: async (ctx, args) => {
692
+ const limit = Math.min(Math.max(args.limit ?? MAX_LEADERBOARD_TOP_LIMIT, 1), MAX_LEADERBOARD_TOP_LIMIT);
693
+ const ordered = ctx.db
694
+ .query("jgLeaderboardRows")
695
+ .withIndex("by_game_scope_stat_value", (q) => q.eq("gameId", args.gameId).eq("scope", args.scope).eq("stat", args.stat))
696
+ .order("desc");
697
+ const rows = args.serverId !== undefined
698
+ ? await ordered.filter((q) => q.eq(q.field("serverId"), args.serverId)).take(limit)
699
+ : await ordered.take(limit);
700
+ return rows.map((row) => ({ userId: row.userId, value: row.value }));
701
+ },
702
+ });
703
+ const getProfile = query({
704
+ args: {
705
+ gameId: v.string(),
706
+ userId: v.string(),
707
+ externalId: v.optional(v.string()),
708
+ },
709
+ returns: v.record(v.string(), v.number()),
710
+ handler: async (ctx, args) => {
711
+ const rows = await ctx.db
712
+ .query("jgLeaderboardRows")
713
+ .withIndex("by_user_scope_stat", (q) => q.eq("userId", args.userId).eq("scope", "profile"))
714
+ .collect();
715
+ const profile = {};
716
+ for (const row of rows) {
717
+ if (row.gameId === args.gameId)
718
+ profile[row.stat] = row.value;
719
+ }
720
+ return profile;
721
+ },
722
+ });
723
+ const incrementMany = internalMutation({
724
+ args: {
725
+ gameId: v.string(),
726
+ entries: v.array(v.object({
727
+ userId: v.string(),
728
+ stat: v.string(),
729
+ scope: leaderboardScopeValidator,
730
+ serverId: v.optional(v.id("jgGameServers")),
731
+ by: v.number(),
732
+ })),
733
+ },
734
+ returns: v.null(),
735
+ handler: async (ctx, args) => {
736
+ await applyLeaderboardIncrements(ctx, args.gameId, args.entries);
737
+ return null;
738
+ },
739
+ });
740
+ return { getTop, getProfile, incrementMany };
741
+ }
742
+ export function createPresenceFunctions(options) {
743
+ const mode = options?.auth ?? "anonymous";
744
+ const freshWindowMs = options?.freshWindowMs ?? 10_000;
745
+ const list = query({
746
+ args: {
747
+ serverId: v.string(),
748
+ externalId: v.optional(v.string()),
749
+ },
750
+ returns: v.array(v.object({
751
+ userId: v.string(),
752
+ position: v.object({ x: v.number(), y: v.number(), z: v.number() }),
753
+ rotationY: v.number(),
754
+ rotationPitch: v.number(),
755
+ lastSeenAt: v.number(),
756
+ })),
757
+ handler: async (ctx, args) => {
758
+ const threshold = Date.now() - freshWindowMs;
759
+ const rows = await ctx.db
760
+ .query("jgPoses")
761
+ .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
762
+ .collect();
763
+ return rows
764
+ .filter((row) => row.updatedAt >= threshold)
765
+ .map((row) => ({
766
+ userId: row.userId,
767
+ position: { x: row.x, y: row.y, z: row.z },
768
+ rotationY: row.rotationY,
769
+ rotationPitch: row.rotationPitch,
770
+ lastSeenAt: row.updatedAt,
771
+ }));
772
+ },
773
+ });
774
+ const sync = mutation({
775
+ args: {
776
+ serverId: v.string(),
777
+ pose: v.object({
778
+ x: v.number(),
779
+ y: v.number(),
780
+ z: v.number(),
781
+ rotationY: v.number(),
782
+ rotationPitch: v.number(),
783
+ }),
784
+ externalId: v.optional(v.string()),
785
+ },
786
+ returns: v.null(),
787
+ handler: async (ctx, args) => {
788
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
789
+ if (!actorUserId)
790
+ return null;
791
+ const now = Date.now();
792
+ const existing = await ctx.db
793
+ .query("jgPoses")
794
+ .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
795
+ .unique();
796
+ const patch = {
797
+ x: args.pose.x,
798
+ y: args.pose.y,
799
+ z: args.pose.z,
800
+ rotationY: args.pose.rotationY,
801
+ rotationPitch: args.pose.rotationPitch,
802
+ updatedAt: now,
803
+ };
804
+ if (existing) {
805
+ await ctx.db.patch(existing._id, patch);
806
+ }
807
+ else {
808
+ await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
809
+ }
810
+ return null;
811
+ },
812
+ });
813
+ const leave = mutation({
814
+ args: {
815
+ serverId: v.string(),
816
+ externalId: v.optional(v.string()),
817
+ },
818
+ returns: v.null(),
819
+ handler: async (ctx, args) => {
820
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
821
+ if (!actorUserId)
822
+ return null;
823
+ const existing = await ctx.db
824
+ .query("jgPoses")
825
+ .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
826
+ .unique();
827
+ if (existing)
828
+ await ctx.db.delete(existing._id);
829
+ return null;
830
+ },
831
+ });
832
+ return { list, sync, leave };
833
+ }
834
+ export function createChatFunctions(options) {
835
+ const mode = options?.auth ?? "anonymous";
836
+ const historyLimit = options?.historyLimit ?? 100;
837
+ const maxBodyLength = options?.maxBodyLength ?? 500;
838
+ const minIntervalMs = options?.minIntervalMs ?? 300;
839
+ const messages = query({
840
+ args: {
841
+ serverId: v.string(),
842
+ channelId: v.string(),
843
+ externalId: v.optional(v.string()),
844
+ },
845
+ returns: v.array(v.object({
846
+ id: v.string(),
847
+ channelId: v.string(),
848
+ fromUserId: v.string(),
849
+ body: v.string(),
850
+ at: v.number(),
851
+ })),
852
+ handler: async (ctx, args) => {
853
+ const rows = await ctx.db
854
+ .query("jgChatMessages")
855
+ .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
856
+ .order("desc")
857
+ .take(historyLimit);
858
+ return rows
859
+ .slice()
860
+ .reverse()
861
+ .map((row) => ({
862
+ id: row._id,
863
+ channelId: row.channelId,
864
+ fromUserId: row.userId,
865
+ body: row.body,
866
+ at: row.at,
867
+ }));
868
+ },
869
+ });
870
+ const sendMessage = mutation({
871
+ args: {
872
+ serverId: v.string(),
873
+ channelId: v.string(),
874
+ body: v.string(),
875
+ externalId: v.optional(v.string()),
876
+ },
877
+ returns: v.object({ ok: v.boolean(), reason: v.optional(v.string()) }),
878
+ handler: async (ctx, args) => {
879
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
880
+ if (!actorUserId)
881
+ return { ok: false, reason: "not signed in" };
882
+ const body = args.body.trim();
883
+ if (body.length === 0)
884
+ return { ok: false, reason: "empty message" };
885
+ if (body.length > maxBodyLength)
886
+ return { ok: false, reason: "message too long" };
887
+ const now = Date.now();
888
+ const newestByActor = await ctx.db
889
+ .query("jgChatMessages")
890
+ .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
891
+ .order("desc")
892
+ .filter((q) => q.eq(q.field("userId"), actorUserId))
893
+ .first();
894
+ if (newestByActor && now - newestByActor.at < minIntervalMs) {
895
+ return { ok: false, reason: "sending too fast" };
896
+ }
897
+ await ctx.db.insert("jgChatMessages", {
898
+ serverId: args.serverId,
899
+ channelId: args.channelId,
900
+ userId: actorUserId,
901
+ body,
902
+ at: now,
903
+ });
904
+ const recent = await ctx.db
905
+ .query("jgChatMessages")
906
+ .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
907
+ .order("desc")
908
+ .take(historyLimit * 2 + 16);
909
+ for (let i = historyLimit * 2; i < recent.length; i += 1) {
910
+ await ctx.db.delete(recent[i]._id);
911
+ }
912
+ return { ok: true };
913
+ },
914
+ });
915
+ return { messages, sendMessage };
916
+ }
917
+ const JG_CRON_SPECS = [
918
+ { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
919
+ { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
920
+ ];
921
+ export function jgengineCronSpecs() {
922
+ return JG_CRON_SPECS;
923
+ }