@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/CHANGELOG.md +46 -0
- package/README.md +2 -2
- package/dist/convexPresenceTransport.d.ts +6 -0
- package/dist/createConvexGameTransport.d.ts +16 -2
- package/dist/createConvexGameTransport.js +15 -2
- package/dist/server.d.ts +178 -25
- package/dist/server.js +344 -87
- package/package.json +2 -2
- package/skills/jgengine/api.md +29 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-multiplayer/api.md +28 -12
- package/skills/jgengine-multiplayer/reference.md +11 -1
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 {
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
.
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
|
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
|
-
.
|
|
924
|
-
const
|
|
925
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
rotationY:
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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,
|
|
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", {
|
|
1164
|
+
await ctx.db.insert("jgPoses", {
|
|
1165
|
+
serverId: args.serverId,
|
|
1166
|
+
userId: actorUserId,
|
|
1167
|
+
...pose,
|
|
1168
|
+
...identity,
|
|
1169
|
+
updatedAt: now,
|
|
1170
|
+
});
|
|
952
1171
|
}
|
|
953
|
-
|
|
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
|
|
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
|
-
.
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
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() {
|