@jgengine/convex 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -2
- package/dist/convexPresenceTransport.d.ts +2 -6
- package/dist/convexPresenceTransport.js +2 -2
- package/dist/createConvexGameTransport.d.ts +13 -11
- package/dist/createConvexGameTransport.js +54 -3
- package/dist/hostedServer.d.ts +1 -1
- package/dist/hostedServer.js +12 -12
- package/dist/server.d.ts +339 -22
- package/dist/server.js +427 -118
- package/dist/territory.d.ts +20 -0
- package/dist/territory.js +47 -0
- package/dist/worldPresence.d.ts +68 -0
- package/dist/worldPresence.js +128 -0
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-multiplayer/SKILL.md +25 -0
- package/skills/jgengine-multiplayer/api.md +78 -36
- package/skills/jgengine-multiplayer/capabilities.md +37 -0
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
6
|
import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
|
|
6
|
-
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
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
|
|
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,31 @@ 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
|
+
}).index("by_server", ["serverId"]).index("by_server_and_user", ["serverId", "userId"]),
|
|
60
|
+
jgRateLimits: defineTable({
|
|
61
|
+
key: v.string(), startedAt: v.number(), count: v.number(), expiresAt: v.number(),
|
|
62
|
+
}).index("by_key", ["key"]).index("by_expires", ["expiresAt"]),
|
|
63
|
+
jgClientErrors: defineTable({
|
|
64
|
+
userId: v.string(), message: v.string(), details: v.optional(v.any()), createdAt: v.number(),
|
|
65
|
+
}).index("by_created", ["createdAt"]),
|
|
43
66
|
jgPlayerProfiles: defineTable({
|
|
44
67
|
userId: v.string(),
|
|
45
68
|
gameId: v.string(),
|
|
46
69
|
playerState: v.any(),
|
|
70
|
+
sessionState: v.optional(v.any()),
|
|
71
|
+
sessionServerId: v.optional(v.id("jgGameServers")),
|
|
47
72
|
revision: v.number(),
|
|
48
73
|
dirtyAt: v.optional(v.number()),
|
|
49
74
|
createdAt: v.number(),
|
|
@@ -77,6 +102,10 @@ export function jgengineTables() {
|
|
|
77
102
|
}).index("by_server_and_action", ["serverId", "action"]),
|
|
78
103
|
jgPoses: defineTable({
|
|
79
104
|
serverId: v.string(),
|
|
105
|
+
homeGameId: v.optional(v.string()),
|
|
106
|
+
ownerActorId: v.optional(v.string()),
|
|
107
|
+
lastWorldSnapshotAt: v.optional(v.number()),
|
|
108
|
+
chunkKey: v.optional(v.string()),
|
|
80
109
|
userId: v.string(),
|
|
81
110
|
/** One row per live session, so a second tab is a second row rather than a fight over one. */
|
|
82
111
|
sessionId: v.optional(v.string()),
|
|
@@ -95,14 +124,22 @@ export function jgengineTables() {
|
|
|
95
124
|
})
|
|
96
125
|
.index("by_server", ["serverId"])
|
|
97
126
|
.index("by_server_and_user", ["serverId", "userId"])
|
|
98
|
-
.index("
|
|
127
|
+
.index("by_server_and_chunk", ["serverId", "chunkKey"])
|
|
128
|
+
.index("by_server_user_revoked_updated", ["serverId", "userId", "revokedAt", "updatedAt"])
|
|
129
|
+
.index("by_updated", ["updatedAt"])
|
|
130
|
+
.index("by_user", ["userId"])
|
|
131
|
+
.index("by_home_game", ["homeGameId"])
|
|
132
|
+
.index("by_home_game_revoked_updated", ["homeGameId", "revokedAt", "updatedAt"])
|
|
133
|
+
.index("by_revoked", ["revokedAt"])
|
|
134
|
+
.index("by_revoked_updated", ["revokedAt", "updatedAt"]),
|
|
99
135
|
jgChatMessages: defineTable({
|
|
100
136
|
serverId: v.string(),
|
|
101
137
|
channelId: v.string(),
|
|
102
138
|
userId: v.string(),
|
|
103
139
|
body: v.string(),
|
|
140
|
+
authorName: v.optional(v.string()),
|
|
104
141
|
at: v.number(),
|
|
105
|
-
}).index("by_server_channel", ["serverId", "channelId"]),
|
|
142
|
+
}).index("by_server_channel", ["serverId", "channelId", "at"]).index("by_at", ["at"]),
|
|
106
143
|
};
|
|
107
144
|
}
|
|
108
145
|
/**
|
|
@@ -151,11 +188,19 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
|
151
188
|
}
|
|
152
189
|
return null;
|
|
153
190
|
}
|
|
191
|
+
async function hasMember(ctx, server, userId) {
|
|
192
|
+
if (server.memberUserIds.includes(userId))
|
|
193
|
+
return true;
|
|
194
|
+
if (server.topology !== "shared")
|
|
195
|
+
return false;
|
|
196
|
+
return (await ctx.db.query("jgServerMembers")
|
|
197
|
+
.withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique()) !== null;
|
|
198
|
+
}
|
|
154
199
|
async function requireServerMember(ctx, serverId, actorUserId) {
|
|
155
200
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
156
201
|
if (!server)
|
|
157
202
|
return null;
|
|
158
|
-
if (!
|
|
203
|
+
if (!(await hasMember(ctx, server, actorUserId)))
|
|
159
204
|
return null;
|
|
160
205
|
return server;
|
|
161
206
|
}
|
|
@@ -229,6 +274,21 @@ function serverDocToRecord(server) {
|
|
|
229
274
|
updatedAt: server.updatedAt,
|
|
230
275
|
};
|
|
231
276
|
}
|
|
277
|
+
async function serverCapacity(ctx, server) {
|
|
278
|
+
return server.topology === "shared"
|
|
279
|
+
? ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", server._id)).unique()
|
|
280
|
+
: null;
|
|
281
|
+
}
|
|
282
|
+
async function ensureServerCapacity(ctx, server) {
|
|
283
|
+
const existing = await serverCapacity(ctx, server);
|
|
284
|
+
if (existing)
|
|
285
|
+
return existing;
|
|
286
|
+
const id = await ctx.db.insert("jgServerCapacity", {
|
|
287
|
+
serverId: server._id, gameId: server.gameId, status: server.status, mode: server.mode,
|
|
288
|
+
memberCount: server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer,
|
|
289
|
+
});
|
|
290
|
+
return (await ctx.db.get("jgServerCapacity", id));
|
|
291
|
+
}
|
|
232
292
|
/**
|
|
233
293
|
* Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
|
|
234
294
|
* Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
@@ -238,14 +298,27 @@ function serverDocToRecord(server) {
|
|
|
238
298
|
export async function loadServerSnapshot(ctx, server, runtime, scope) {
|
|
239
299
|
const profiles = {};
|
|
240
300
|
const memberIds = new Set(server.memberUserIds);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
301
|
+
let hydrateUserIds;
|
|
302
|
+
if (server.topology === "shared") {
|
|
303
|
+
const requested = scope?.players ?? (await ctx.db.query("jgServerMembers")
|
|
304
|
+
.withIndex("by_server", q => q.eq("serverId", server._id)).collect()).map(row => row.userId);
|
|
305
|
+
hydrateUserIds = [];
|
|
306
|
+
for (const userId of new Set(requested))
|
|
307
|
+
if (await hasMember(ctx, server, userId))
|
|
308
|
+
hydrateUserIds.push(userId);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
hydrateUserIds = scope?.players === undefined ? server.memberUserIds : scope.players.filter(userId => memberIds.has(userId));
|
|
312
|
+
}
|
|
313
|
+
const sessionPlayers = {};
|
|
244
314
|
for (const userId of hydrateUserIds) {
|
|
245
315
|
const profile = await ctx.db
|
|
246
316
|
.query("jgPlayerProfiles")
|
|
247
317
|
.withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
|
|
248
318
|
.unique();
|
|
319
|
+
if (server.topology === "shared" && profile) {
|
|
320
|
+
sessionPlayers[userId] = { ...profile.playerState, ...(profile.sessionServerId === server._id ? { session: profile.sessionState ?? {} } : { session: {} }) };
|
|
321
|
+
}
|
|
249
322
|
profiles[userId] = profile
|
|
250
323
|
? {
|
|
251
324
|
userId,
|
|
@@ -256,7 +329,7 @@ export async function loadServerSnapshot(ctx, server, runtime, scope) {
|
|
|
256
329
|
}
|
|
257
330
|
: null;
|
|
258
331
|
}
|
|
259
|
-
const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
|
|
332
|
+
const playersByUserId = buildHydratePlayers(server.topology === "shared" ? { ...serverDocToRecord(server), memberUserIds: hydrateUserIds, sessionPlayers } : serverDocToRecord(server), profiles, hydrateUserIds);
|
|
260
333
|
const chunksByKey = {};
|
|
261
334
|
if (scope?.chunkKeys === undefined) {
|
|
262
335
|
const chunks = await ctx.db
|
|
@@ -323,7 +396,9 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
|
|
|
323
396
|
*/
|
|
324
397
|
export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
325
398
|
const now = Date.now();
|
|
326
|
-
const
|
|
399
|
+
const shared = server.topology === "shared";
|
|
400
|
+
const record = serverDocToRecord(server);
|
|
401
|
+
const plan = planServerPersist(shared ? { ...record, memberUserIds: Object.keys(snapshot.players), sessionPlayers: {} } : record, snapshot, save, now);
|
|
327
402
|
if (!plan.changed.any)
|
|
328
403
|
return false;
|
|
329
404
|
if (plan.leaderboard.length > 0) {
|
|
@@ -336,6 +411,7 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
336
411
|
.unique();
|
|
337
412
|
const profilePatch = {
|
|
338
413
|
playerState: profile.playerState,
|
|
414
|
+
...(shared ? { sessionState: snapshot.players[profile.userId]?.session ?? {}, sessionServerId: server._id } : {}),
|
|
339
415
|
revision: profile.revision,
|
|
340
416
|
updatedAt: profile.updatedAt,
|
|
341
417
|
};
|
|
@@ -351,6 +427,22 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
351
427
|
});
|
|
352
428
|
}
|
|
353
429
|
}
|
|
430
|
+
if (shared) {
|
|
431
|
+
const persisted = new Set(plan.profiles.map(profile => profile.userId));
|
|
432
|
+
for (const userId of snapshot.dirty.players) {
|
|
433
|
+
if (persisted.has(userId))
|
|
434
|
+
continue;
|
|
435
|
+
const player = snapshot.players[userId];
|
|
436
|
+
if (!player)
|
|
437
|
+
continue;
|
|
438
|
+
const existing = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
439
|
+
const patch = { playerState: player, sessionState: player.session, sessionServerId: server._id, revision: snapshot.revision, updatedAt: now };
|
|
440
|
+
if (existing)
|
|
441
|
+
await ctx.db.patch(existing._id, patch);
|
|
442
|
+
else
|
|
443
|
+
await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
354
446
|
for (const chunk of plan.chunks) {
|
|
355
447
|
const existing = await ctx.db
|
|
356
448
|
.query("jgWorldChunks")
|
|
@@ -380,24 +472,25 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
380
472
|
if (existing)
|
|
381
473
|
await ctx.db.delete(existing._id);
|
|
382
474
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
475
|
+
if (!shared || plan.changed.serverState)
|
|
476
|
+
await ctx.db.patch(server._id, {
|
|
477
|
+
...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
|
|
478
|
+
...(!shared && plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
|
|
479
|
+
revision: plan.server.revision,
|
|
480
|
+
dirtyAt: plan.server.dirtyAt,
|
|
481
|
+
updatedAt: plan.server.updatedAt,
|
|
482
|
+
lastSavedAt: plan.server.lastSavedAt,
|
|
483
|
+
});
|
|
391
484
|
return true;
|
|
392
485
|
}
|
|
393
486
|
/** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
|
|
394
|
-
async function flushServerIfDue(ctx, server, now,
|
|
487
|
+
async function flushServerIfDue(ctx, server, now, _runtime, loaded) {
|
|
395
488
|
if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
|
|
396
489
|
return false;
|
|
397
490
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
await
|
|
491
|
+
if (loaded)
|
|
492
|
+
await persistServerSnapshot(ctx, server, loaded, server.save);
|
|
493
|
+
await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: now });
|
|
401
494
|
return true;
|
|
402
495
|
}
|
|
403
496
|
export const JG_RUNTIME_TICK_MS = 1_000;
|
|
@@ -408,10 +501,9 @@ export const MAX_CHUNKS_PER_QUERY = 64;
|
|
|
408
501
|
/**
|
|
409
502
|
* Build the hosted-server Convex functions for a set of game runtimes.
|
|
410
503
|
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* larger value throws here, at wiring time, rather than at the write that overflows the document.
|
|
504
|
+
* Room topology keeps its roster and session map in the server document and caps capacity at
|
|
505
|
+
* {@link JG_MAX_MEMBERS_PER_SERVER}. Shared topology stores membership, capacity, and per-player
|
|
506
|
+
* session state separately; player-only commands and roster changes do not rewrite the world row.
|
|
415
507
|
* World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
|
|
416
508
|
* ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
|
|
417
509
|
*
|
|
@@ -421,11 +513,12 @@ export const MAX_CHUNKS_PER_QUERY = 64;
|
|
|
421
513
|
*/
|
|
422
514
|
export function createGameServerFunctions(options) {
|
|
423
515
|
const mode = options?.auth ?? "required";
|
|
424
|
-
const
|
|
425
|
-
const
|
|
516
|
+
const shared = options?.topology === "shared" || (options?.topology === undefined && (options?.runtimes?.some(runtime => runtime.topology === "shared") ?? false));
|
|
517
|
+
const hostSlotsPerServer = shared ? Number.MAX_SAFE_INTEGER : options?.slotsPerServer ?? 16;
|
|
518
|
+
const slotsCheck = validateSlotsPerServer(hostSlotsPerServer, shared ? "shared" : "rooms");
|
|
426
519
|
if (!slotsCheck.ok)
|
|
427
520
|
throw new Error(slotsCheck.reason);
|
|
428
|
-
const matchmaking = options?.matchmaking ?? "auto";
|
|
521
|
+
const matchmaking = options?.matchmaking ?? (shared ? "singleton" : "auto");
|
|
429
522
|
const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
|
|
430
523
|
const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
|
|
431
524
|
const registry = buildRuntimeRegistry(options?.runtimes);
|
|
@@ -442,23 +535,24 @@ export function createGameServerFunctions(options) {
|
|
|
442
535
|
joinCode: v.optional(v.string()),
|
|
443
536
|
externalId: v.optional(v.string()),
|
|
444
537
|
},
|
|
445
|
-
returns: v.object({
|
|
446
|
-
serverId: v.id("jgGameServers"),
|
|
447
|
-
isNew: v.boolean(),
|
|
448
|
-
}),
|
|
538
|
+
returns: v.union(v.object({ ok: v.literal(true), serverId: v.id("jgGameServers"), isNew: v.boolean() }), v.object({ ok: v.literal(false), reason: v.union(v.literal("full"), v.literal("closed"), v.literal("unauthorized")) })),
|
|
449
539
|
handler: async (ctx, args) => {
|
|
450
|
-
const actorUserId = await
|
|
540
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
541
|
+
if (!actorUserId)
|
|
542
|
+
return { ok: false, reason: "unauthorized" };
|
|
451
543
|
const now = Date.now();
|
|
452
544
|
const runtime = resolveRuntime(registry, args.gameId);
|
|
453
545
|
const save = runtime.save;
|
|
454
546
|
const slotsPerServer = hostSlotsPerServer;
|
|
455
547
|
let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
|
|
456
548
|
if (args.serverId !== undefined && !server) {
|
|
457
|
-
|
|
549
|
+
return { ok: false, reason: "closed" };
|
|
458
550
|
}
|
|
459
551
|
if (server && server.gameId !== args.gameId) {
|
|
460
|
-
|
|
552
|
+
return { ok: false, reason: "unauthorized" };
|
|
461
553
|
}
|
|
554
|
+
if (server?.status === "closed")
|
|
555
|
+
return { ok: false, reason: "closed" };
|
|
462
556
|
let mayCreate = true;
|
|
463
557
|
if (!server) {
|
|
464
558
|
const joinable = [];
|
|
@@ -466,7 +560,7 @@ export function createGameServerFunctions(options) {
|
|
|
466
560
|
const rows = await ctx.db
|
|
467
561
|
.query("jgGameServers")
|
|
468
562
|
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
|
|
469
|
-
.
|
|
563
|
+
.take(100);
|
|
470
564
|
joinable.push(...rows);
|
|
471
565
|
}
|
|
472
566
|
// Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
|
|
@@ -479,7 +573,7 @@ export function createGameServerFunctions(options) {
|
|
|
479
573
|
slotsPerServer,
|
|
480
574
|
});
|
|
481
575
|
if (target.kind === "refuse")
|
|
482
|
-
|
|
576
|
+
return { ok: false, reason: "full" };
|
|
483
577
|
server = target.kind === "join" ? target.row : null;
|
|
484
578
|
mayCreate = target.kind === "create";
|
|
485
579
|
}
|
|
@@ -491,6 +585,8 @@ export function createGameServerFunctions(options) {
|
|
|
491
585
|
modeConfig: args.modeConfig,
|
|
492
586
|
visibility: args.visibility ?? "public",
|
|
493
587
|
joinCode: args.joinCode,
|
|
588
|
+
topology: shared ? "shared" : "rooms",
|
|
589
|
+
memberCount: 0,
|
|
494
590
|
memberUserIds: [],
|
|
495
591
|
slotsPerServer,
|
|
496
592
|
save,
|
|
@@ -516,37 +612,64 @@ export function createGameServerFunctions(options) {
|
|
|
516
612
|
throw new ConvexError("Server missing after reconcile");
|
|
517
613
|
server = reconciled;
|
|
518
614
|
}
|
|
519
|
-
if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
520
|
-
|
|
615
|
+
if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
616
|
+
return { ok: false, reason: "full" };
|
|
521
617
|
}
|
|
522
618
|
if (isPrivateJoinBlocked({
|
|
523
619
|
visibility: server.visibility,
|
|
524
|
-
memberUserIds: server
|
|
620
|
+
memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
|
|
525
621
|
userId: actorUserId,
|
|
526
622
|
joinCode: server.joinCode,
|
|
527
623
|
suppliedCode: args.joinCode,
|
|
528
624
|
})) {
|
|
529
|
-
|
|
625
|
+
return { ok: false, reason: "unauthorized" };
|
|
530
626
|
}
|
|
531
|
-
|
|
627
|
+
let profile = await ctx.db
|
|
532
628
|
.query("jgPlayerProfiles")
|
|
533
629
|
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
534
630
|
.unique();
|
|
631
|
+
if (shared && server.topology !== "shared") {
|
|
632
|
+
for (const userId of server.memberUserIds) {
|
|
633
|
+
const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique();
|
|
634
|
+
if (!member)
|
|
635
|
+
await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now });
|
|
636
|
+
const state = server.sessionPlayers[userId];
|
|
637
|
+
if (!state)
|
|
638
|
+
continue;
|
|
639
|
+
const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
640
|
+
const patch = { sessionState: state.session ?? {}, sessionServerId: server._id };
|
|
641
|
+
if (stored)
|
|
642
|
+
await ctx.db.patch(stored._id, patch);
|
|
643
|
+
else
|
|
644
|
+
await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch });
|
|
645
|
+
}
|
|
646
|
+
await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} });
|
|
647
|
+
server = (await ctx.db.get("jgGameServers", server._id));
|
|
648
|
+
profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique();
|
|
649
|
+
}
|
|
535
650
|
const isNew = profile === null;
|
|
536
651
|
const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
652
|
+
if (shared) {
|
|
653
|
+
const capacity = await ensureServerCapacity(ctx, server);
|
|
654
|
+
const isMember = await hasMember(ctx, server, actorUserId);
|
|
655
|
+
await ctx.db.patch(capacity._id, {
|
|
656
|
+
memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer,
|
|
657
|
+
});
|
|
658
|
+
if (!isMember)
|
|
659
|
+
await ctx.db.insert("jgServerMembers", { serverId: server._id, userId: actorUserId, joinedAt: now });
|
|
660
|
+
if (server.status !== "running")
|
|
661
|
+
await ctx.db.patch(server._id, { status: "running" });
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now });
|
|
665
|
+
}
|
|
543
666
|
const refreshed = await ctx.db.get("jgGameServers", server._id);
|
|
544
667
|
if (!refreshed)
|
|
545
668
|
throw new ConvexError("Server missing after join");
|
|
546
669
|
let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
|
|
547
|
-
snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
|
|
670
|
+
snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
|
|
548
671
|
await persistServerSnapshot(ctx, refreshed, snapshot, save);
|
|
549
|
-
return { serverId: refreshed._id, isNew };
|
|
672
|
+
return { ok: true, serverId: refreshed._id, isNew };
|
|
550
673
|
},
|
|
551
674
|
});
|
|
552
675
|
const leaveServer = mutation({
|
|
@@ -571,23 +694,65 @@ export function createGameServerFunctions(options) {
|
|
|
571
694
|
const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
|
|
572
695
|
const sessionPlayers = { ...server.sessionPlayers };
|
|
573
696
|
delete sessionPlayers[actorUserId];
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
697
|
+
if (server.topology === "shared") {
|
|
698
|
+
const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", actorUserId)).unique();
|
|
699
|
+
if (member) {
|
|
700
|
+
const capacity = await ensureServerCapacity(ctx, server);
|
|
701
|
+
await ctx.db.delete(member._id);
|
|
702
|
+
await ctx.db.patch(capacity._id, { memberCount: Math.max(0, capacity.memberCount - 1) });
|
|
703
|
+
const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", server.gameId)).unique();
|
|
704
|
+
if (profile?.sessionServerId === server._id)
|
|
705
|
+
await ctx.db.patch(profile._id, { sessionState: {}, sessionServerId: undefined });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
await ctx.db.patch(server._id, { memberUserIds, sessionPlayers, updatedAt: now, status: statusAfterLeave(memberUserIds.length, server.status) });
|
|
710
|
+
}
|
|
711
|
+
const poses = await ctx.db
|
|
582
712
|
.query("jgPoses")
|
|
583
713
|
.withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
|
|
584
|
-
.
|
|
585
|
-
|
|
714
|
+
.collect();
|
|
715
|
+
for (const pose of poses)
|
|
586
716
|
await ctx.db.delete(pose._id);
|
|
587
717
|
return null;
|
|
588
718
|
},
|
|
589
719
|
});
|
|
590
720
|
const helpers = {
|
|
721
|
+
async ensureServer(ctx, gameId) {
|
|
722
|
+
if (!shared || matchmaking !== "singleton")
|
|
723
|
+
throw new Error("ensureServer requires a shared singleton host");
|
|
724
|
+
for (const status of ["running", "open"]) {
|
|
725
|
+
const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first();
|
|
726
|
+
if (existing)
|
|
727
|
+
return existing;
|
|
728
|
+
}
|
|
729
|
+
const runtime = resolveRuntime(registry, gameId);
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
const id = await ctx.db.insert("jgGameServers", {
|
|
732
|
+
gameId, topology: "shared", status: "running", memberUserIds: [], slotsPerServer: hostSlotsPerServer,
|
|
733
|
+
save: runtime.save, serverState: defaultServerStateForGame(gameId), sessionPlayers: {}, revision: 0,
|
|
734
|
+
tickAnchorMs: now, createdAt: now, updatedAt: now,
|
|
735
|
+
});
|
|
736
|
+
const server = (await ctx.db.get("jgGameServers", id));
|
|
737
|
+
await ensureServerCapacity(ctx, server);
|
|
738
|
+
return server;
|
|
739
|
+
},
|
|
740
|
+
async resetPlayerProfile(ctx, serverId, userId) {
|
|
741
|
+
const server = await requireServerMember(ctx, serverId, userId);
|
|
742
|
+
if (!server)
|
|
743
|
+
throw new ConvexError("Not a member of this server");
|
|
744
|
+
const state = initialPlayerState(resolveRuntime(registry, server.gameId), userId);
|
|
745
|
+
const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
|
|
746
|
+
const now = Date.now();
|
|
747
|
+
const patch = { playerState: state, sessionState: state.session, sessionServerId: server._id, revision: (profile?.revision ?? 0) + 1, updatedAt: now };
|
|
748
|
+
if (profile)
|
|
749
|
+
await ctx.db.patch(profile._id, patch);
|
|
750
|
+
else
|
|
751
|
+
await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
|
|
752
|
+
if (server.topology !== "shared")
|
|
753
|
+
await ctx.db.patch(server._id, { sessionPlayers: { ...server.sessionPlayers, [userId]: state } });
|
|
754
|
+
return state;
|
|
755
|
+
},
|
|
591
756
|
async loadSnapshot(ctx, serverId, scope) {
|
|
592
757
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
593
758
|
if (!server)
|
|
@@ -602,13 +767,13 @@ export function createGameServerFunctions(options) {
|
|
|
602
767
|
return persistServerSnapshot(ctx, server, snapshot, save ?? server.save);
|
|
603
768
|
},
|
|
604
769
|
async runCommand(ctx, args) {
|
|
605
|
-
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
770
|
+
const actorUserId = args.actorUserId ?? await requireActor(ctx, args.externalId, mode);
|
|
606
771
|
const serverId = args.serverId;
|
|
607
772
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
608
773
|
if (!server) {
|
|
609
774
|
return { ok: false, reason: "Server not found" };
|
|
610
775
|
}
|
|
611
|
-
if (!
|
|
776
|
+
if (!(await hasMember(ctx, server, actorUserId))) {
|
|
612
777
|
return { ok: false, reason: "Not a member of this server" };
|
|
613
778
|
}
|
|
614
779
|
const loadedRevision = server.revision;
|
|
@@ -639,6 +804,10 @@ export function createGameServerFunctions(options) {
|
|
|
639
804
|
if (!commit.ok) {
|
|
640
805
|
return commit;
|
|
641
806
|
}
|
|
807
|
+
await args.beforePersist?.(ctx, { server: latest, runtime, snapshot: result.snapshot });
|
|
808
|
+
const callbackEscape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
|
|
809
|
+
if (callbackEscape !== null)
|
|
810
|
+
throw new ConvexError(callbackEscape);
|
|
642
811
|
await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
|
|
643
812
|
return { ok: true };
|
|
644
813
|
},
|
|
@@ -655,7 +824,7 @@ export function createGameServerFunctions(options) {
|
|
|
655
824
|
})),
|
|
656
825
|
},
|
|
657
826
|
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),
|
|
827
|
+
handler: (ctx, args) => helpers.runCommand(ctx, { serverId: args.serverId, command: args.command, input: args.input, externalId: args.externalId }),
|
|
659
828
|
});
|
|
660
829
|
const flushSave = mutation({
|
|
661
830
|
args: {
|
|
@@ -668,10 +837,7 @@ export function createGameServerFunctions(options) {
|
|
|
668
837
|
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
669
838
|
if (server === null)
|
|
670
839
|
return false;
|
|
671
|
-
|
|
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);
|
|
840
|
+
await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
|
|
675
841
|
return true;
|
|
676
842
|
},
|
|
677
843
|
});
|
|
@@ -727,20 +893,38 @@ export function createGameServerFunctions(options) {
|
|
|
727
893
|
mode: v.optional(v.string()),
|
|
728
894
|
memberCount: v.number(),
|
|
729
895
|
slotsPerServer: v.number(),
|
|
730
|
-
revision: v.number(),
|
|
731
|
-
updatedAt: v.number(),
|
|
732
896
|
}), v.null()),
|
|
733
|
-
handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
|
|
897
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => ({
|
|
734
898
|
_id: server._id,
|
|
735
899
|
gameId: server.gameId,
|
|
736
900
|
status: server.status,
|
|
737
901
|
mode: server.mode,
|
|
738
|
-
memberCount: server.memberUserIds.length,
|
|
902
|
+
memberCount: (await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length,
|
|
739
903
|
slotsPerServer: server.slotsPerServer,
|
|
740
|
-
revision: server.revision,
|
|
741
|
-
updatedAt: server.updatedAt,
|
|
742
904
|
})),
|
|
743
905
|
});
|
|
906
|
+
const getServerCapacity = query({
|
|
907
|
+
args: { serverId: v.id("jgGameServers"), externalId: v.optional(v.string()) },
|
|
908
|
+
returns: v.union(v.object({
|
|
909
|
+
_id: v.id("jgGameServers"), gameId: v.string(),
|
|
910
|
+
status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
|
|
911
|
+
mode: v.optional(v.string()), memberCount: v.number(), slotsPerServer: v.number(),
|
|
912
|
+
}), v.null()),
|
|
913
|
+
handler: async (ctx, args) => {
|
|
914
|
+
const actor = await resolveActor(ctx, args.externalId, mode);
|
|
915
|
+
if (!actor)
|
|
916
|
+
return null;
|
|
917
|
+
const capacity = await ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", args.serverId)).unique();
|
|
918
|
+
if (capacity) {
|
|
919
|
+
const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actor)).unique();
|
|
920
|
+
if (!member)
|
|
921
|
+
return null;
|
|
922
|
+
return { _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer };
|
|
923
|
+
}
|
|
924
|
+
const server = await requireServerMember(ctx, args.serverId, actor);
|
|
925
|
+
return server ? { _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer } : null;
|
|
926
|
+
},
|
|
927
|
+
});
|
|
744
928
|
/**
|
|
745
929
|
* Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
|
|
746
930
|
* geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
|
|
@@ -874,17 +1058,17 @@ export function createGameServerFunctions(options) {
|
|
|
874
1058
|
.query("jgGameServers")
|
|
875
1059
|
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
|
|
876
1060
|
.take(Math.min(limit * 4, 200));
|
|
877
|
-
return rows
|
|
1061
|
+
return Promise.all(rows
|
|
878
1062
|
.filter((row) => isListablePublicly(row.visibility))
|
|
879
1063
|
.slice(0, limit)
|
|
880
|
-
.map((row) => ({
|
|
1064
|
+
.map(async (row) => ({
|
|
881
1065
|
_id: row._id,
|
|
882
1066
|
status: row.status,
|
|
883
|
-
memberCount: row.memberUserIds.length,
|
|
1067
|
+
memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length,
|
|
884
1068
|
slotsPerServer: row.slotsPerServer,
|
|
885
1069
|
mode: row.mode,
|
|
886
1070
|
updatedAt: row.updatedAt,
|
|
887
|
-
}));
|
|
1071
|
+
})));
|
|
888
1072
|
},
|
|
889
1073
|
});
|
|
890
1074
|
// Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
|
|
@@ -906,7 +1090,7 @@ export function createGameServerFunctions(options) {
|
|
|
906
1090
|
.withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
|
|
907
1091
|
.take(budget);
|
|
908
1092
|
for (const server of servers) {
|
|
909
|
-
if (server.memberUserIds.length === 0)
|
|
1093
|
+
if (((await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length) === 0)
|
|
910
1094
|
continue;
|
|
911
1095
|
const elapsedMs = now - server.tickAnchorMs;
|
|
912
1096
|
if (elapsedMs < JG_RUNTIME_TICK_MS)
|
|
@@ -915,7 +1099,7 @@ export function createGameServerFunctions(options) {
|
|
|
915
1099
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
916
1100
|
// Unscoped on purpose: onTick is handed the whole world and may touch any of it.
|
|
917
1101
|
const loaded = await loadServerSnapshot(ctx, server, runtime);
|
|
918
|
-
const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
|
|
1102
|
+
const snapshot = runtime.tick(loaded, elapsedMs / 1_000, now);
|
|
919
1103
|
await persistServerSnapshot(ctx, server, snapshot, server.save);
|
|
920
1104
|
await ctx.db.patch(server._id, { tickAnchorMs: now });
|
|
921
1105
|
ticked += 1;
|
|
@@ -937,10 +1121,7 @@ export function createGameServerFunctions(options) {
|
|
|
937
1121
|
.take(maxServersPerTick);
|
|
938
1122
|
let saved = 0;
|
|
939
1123
|
for (const server of servers) {
|
|
940
|
-
|
|
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);
|
|
1124
|
+
await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
|
|
944
1125
|
saved += 1;
|
|
945
1126
|
}
|
|
946
1127
|
return { saved };
|
|
@@ -953,6 +1134,7 @@ export function createGameServerFunctions(options) {
|
|
|
953
1134
|
flushSave,
|
|
954
1135
|
getServer,
|
|
955
1136
|
getServerMeta,
|
|
1137
|
+
getServerCapacity,
|
|
956
1138
|
getChunks,
|
|
957
1139
|
getPlayerProfile,
|
|
958
1140
|
getFeed,
|
|
@@ -1054,6 +1236,7 @@ export function createPresenceFunctions(options) {
|
|
|
1054
1236
|
const list = query({
|
|
1055
1237
|
args: {
|
|
1056
1238
|
serverId: v.string(),
|
|
1239
|
+
viewerChunkKey: v.optional(v.string()),
|
|
1057
1240
|
externalId: v.optional(v.string()),
|
|
1058
1241
|
},
|
|
1059
1242
|
returns: v.array(v.object({
|
|
@@ -1067,12 +1250,17 @@ export function createPresenceFunctions(options) {
|
|
|
1067
1250
|
lastSeenAt: v.number(),
|
|
1068
1251
|
})),
|
|
1069
1252
|
handler: async (ctx, args) => {
|
|
1070
|
-
return withMember(ctx, mode, args, [], async () => {
|
|
1253
|
+
return withMember(ctx, mode, args, [], async (_server, actorUserId) => {
|
|
1071
1254
|
const threshold = Date.now() - freshWindowMs;
|
|
1072
|
-
const
|
|
1073
|
-
.
|
|
1074
|
-
|
|
1075
|
-
|
|
1255
|
+
const own = args.viewerChunkKey === undefined ? await ctx.db.query("jgPoses")
|
|
1256
|
+
.withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actorUserId)).first() : null;
|
|
1257
|
+
const center = parseChunkKey(args.viewerChunkKey ?? (own ? chunkKeyAt([own.x, own.y, own.z]) : "0,0"));
|
|
1258
|
+
if (!center)
|
|
1259
|
+
return [];
|
|
1260
|
+
const rows = [];
|
|
1261
|
+
for (const chunkKey of chunkKeysAround([center.cx * DEFAULT_CHUNK_SIZE, 0, center.cz * DEFAULT_CHUNK_SIZE], 1)) {
|
|
1262
|
+
rows.push(...await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId).eq("chunkKey", chunkKey)).take(256));
|
|
1263
|
+
}
|
|
1076
1264
|
return rows
|
|
1077
1265
|
.filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
|
|
1078
1266
|
.map((row) => ({
|
|
@@ -1122,6 +1310,9 @@ export function createPresenceFunctions(options) {
|
|
|
1122
1310
|
? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
|
|
1123
1311
|
: rows.find((row) => row.sessionId === args.sessionId);
|
|
1124
1312
|
const rules = rulesFor(args.kind ?? existing?.kind);
|
|
1313
|
+
if (existing && now - existing.updatedAt < (rules.minIntervalMs ?? 100)) {
|
|
1314
|
+
return { pose: { x: existing.x, y: existing.y, z: existing.z, rotationY: existing.rotationY, rotationPitch: existing.rotationPitch }, lastSeenAt: existing.updatedAt, displaced: existing.revokedAt !== undefined };
|
|
1315
|
+
}
|
|
1125
1316
|
const current = existing === undefined
|
|
1126
1317
|
? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
|
|
1127
1318
|
serverId: args.serverId,
|
|
@@ -1157,7 +1348,7 @@ export function createPresenceFunctions(options) {
|
|
|
1157
1348
|
const displaced = existing?.revokedAt !== undefined;
|
|
1158
1349
|
if (existing) {
|
|
1159
1350
|
if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
|
|
1160
|
-
await ctx.db.patch(existing._id, { ...pose, ...identity, updatedAt: now, revokedAt: undefined });
|
|
1351
|
+
await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
|
|
1161
1352
|
}
|
|
1162
1353
|
}
|
|
1163
1354
|
else {
|
|
@@ -1165,6 +1356,7 @@ export function createPresenceFunctions(options) {
|
|
|
1165
1356
|
serverId: args.serverId,
|
|
1166
1357
|
userId: actorUserId,
|
|
1167
1358
|
...pose,
|
|
1359
|
+
chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
|
|
1168
1360
|
...identity,
|
|
1169
1361
|
updatedAt: now,
|
|
1170
1362
|
});
|
|
@@ -1230,12 +1422,130 @@ export function createPresenceFunctions(options) {
|
|
|
1230
1422
|
});
|
|
1231
1423
|
return { list, sync, leave, reapIdlePresence };
|
|
1232
1424
|
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Scan one bounded presence page and schedule its handler and continuation in separate transactions.
|
|
1427
|
+
* @capability online-player-batches Schedule bounded active-player or home-game work in separate transactions.
|
|
1428
|
+
*/
|
|
1429
|
+
export async function forEachOnlinePlayer(ctx, options) {
|
|
1430
|
+
const batchSize = options.batchSize ?? 25;
|
|
1431
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 256)
|
|
1432
|
+
throw new Error("batchSize must be between 1 and 256");
|
|
1433
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
1434
|
+
const freshWindowMs = options.freshWindowMs ?? 60_000;
|
|
1435
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(freshWindowMs) || freshWindowMs <= 0 || !Number.isFinite(nowMs - freshWindowMs))
|
|
1436
|
+
throw new Error("Invalid online-player time window");
|
|
1437
|
+
const page = await ctx.db.query("jgPoses").withIndex("by_updated", q => q.gte("updatedAt", nowMs - freshWindowMs))
|
|
1438
|
+
.paginate({ numItems: batchSize, cursor: options.cursor ?? null });
|
|
1439
|
+
const players = [];
|
|
1440
|
+
for (const row of page.page) {
|
|
1441
|
+
if (row.revokedAt !== undefined)
|
|
1442
|
+
continue;
|
|
1443
|
+
const canonical = options.groupBy === "homeGameId" && row.homeGameId
|
|
1444
|
+
? await ctx.db.query("jgPoses").withIndex("by_home_game_revoked_updated", q => q.eq("homeGameId", row.homeGameId).eq("revokedAt", undefined)).order("desc").first()
|
|
1445
|
+
: await ctx.db.query("jgPoses").withIndex("by_server_user_revoked_updated", q => q.eq("serverId", row.serverId).eq("userId", row.userId).eq("revokedAt", undefined)).order("desc").first();
|
|
1446
|
+
if (canonical?._id === row._id)
|
|
1447
|
+
players.push({ serverId: row.serverId, userId: row.userId, ...(row.homeGameId ? { homeGameId: row.homeGameId } : {}) });
|
|
1448
|
+
}
|
|
1449
|
+
if (players.length)
|
|
1450
|
+
await ctx.scheduler.runAfter(0, options.handler, { players, nowMs });
|
|
1451
|
+
if (!page.isDone)
|
|
1452
|
+
await ctx.scheduler.runAfter(0, options.continuation, { cursor: page.continueCursor, nowMs });
|
|
1453
|
+
return { scheduled: players.length, remaining: !page.isDone };
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Consume one request in a fixed rate window; concurrent callers serialize on the indexed key.
|
|
1457
|
+
* @capability host-rate-limit Enforce indexed per-key request windows inside authoritative mutations.
|
|
1458
|
+
*/
|
|
1459
|
+
export async function rateLimit(ctx, args) {
|
|
1460
|
+
const now = args.nowMs ?? Date.now();
|
|
1461
|
+
if (!args.key || args.key.length > 512 || !Number.isFinite(now) || !Number.isFinite(args.windowMs) || args.windowMs <= 0 || !Number.isFinite(now + args.windowMs) || !Number.isSafeInteger(args.max) || args.max < 1)
|
|
1462
|
+
throw new Error("Invalid rate limit policy");
|
|
1463
|
+
const row = await ctx.db.query("jgRateLimits").withIndex("by_key", q => q.eq("key", args.key)).unique();
|
|
1464
|
+
if (row && now < row.expiresAt && row.count >= args.max)
|
|
1465
|
+
return { ok: false, retryAfterMs: row.expiresAt - now };
|
|
1466
|
+
const patch = !row || now >= row.expiresAt ? { startedAt: now, expiresAt: now + args.windowMs, count: 1 } : { startedAt: row.startedAt, expiresAt: row.expiresAt, count: row.count + 1 };
|
|
1467
|
+
if (row)
|
|
1468
|
+
await ctx.db.patch(row._id, patch);
|
|
1469
|
+
else
|
|
1470
|
+
await ctx.db.insert("jgRateLimits", { key: args.key, ...patch });
|
|
1471
|
+
return { ok: true, retryAfterMs: 0 };
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Write validated chat in a host transaction after the caller resolves its actor and world.
|
|
1475
|
+
* @capability host-chat-send Validate, rate-limit and persist chat inside an authorized host mutation.
|
|
1476
|
+
*/
|
|
1477
|
+
export async function sendChatMessage(ctx, args) {
|
|
1478
|
+
const maxBodyLength = args.maxBodyLength ?? 240;
|
|
1479
|
+
if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096)
|
|
1480
|
+
throw new Error("Invalid chat body limit");
|
|
1481
|
+
if (!(args.channelId ?? "global").trim() || (args.channelId?.length ?? 0) > 256 || (args.authorName?.length ?? 0) > 160)
|
|
1482
|
+
return { ok: false, reason: "invalid_channel_or_author" };
|
|
1483
|
+
const validation = validateChatMessage(args.body, { maxLength: args.maxBodyLength ?? 240 });
|
|
1484
|
+
if (!validation.ok)
|
|
1485
|
+
return validation;
|
|
1486
|
+
const gate = await rateLimit(ctx, { key: JSON.stringify(["chat", args.serverId, args.userId]), windowMs: args.minIntervalMs ?? 2000, max: 1 });
|
|
1487
|
+
if (!gate.ok)
|
|
1488
|
+
return { ok: false, reason: "rate_limited" };
|
|
1489
|
+
await ctx.db.insert("jgChatMessages", { serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() });
|
|
1490
|
+
return { ok: true };
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Bounded recent chat for a resolved server; authorization belongs to the calling query.
|
|
1494
|
+
* @capability host-chat-history Read bounded channel history inside an authorized host query.
|
|
1495
|
+
*/
|
|
1496
|
+
export async function recentChatMessages(ctx, args) {
|
|
1497
|
+
const limit = args.limit ?? 50;
|
|
1498
|
+
if (!Number.isFinite(limit))
|
|
1499
|
+
throw new Error("Invalid chat limit");
|
|
1500
|
+
return ctx.db.query("jgChatMessages").withIndex("by_server_channel", q => q.eq("serverId", args.serverId).eq("channelId", args.channelId ?? "global"))
|
|
1501
|
+
.order("desc").take(Math.min(100, Math.max(1, Math.floor(limit))));
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Authenticated, bounded client diagnostics and bounded retention sweeps.
|
|
1505
|
+
* @capability client-error-reporting Accept authenticated bounded diagnostics with cooldown and retention sweeps.
|
|
1506
|
+
*/
|
|
1507
|
+
export function createClientErrorFunctions() {
|
|
1508
|
+
const reportClientError = mutation({
|
|
1509
|
+
args: { message: v.string(), kind: v.optional(v.string()), stack: v.optional(v.string()), componentStack: v.optional(v.string()), url: v.optional(v.string()), pathname: v.optional(v.string()), userAgent: v.optional(v.string()), agentPrompt: v.optional(v.string()) },
|
|
1510
|
+
returns: v.object({ ok: v.boolean(), reason: v.optional(v.string()) }),
|
|
1511
|
+
handler: async (ctx, args) => {
|
|
1512
|
+
const actor = await resolveActor(ctx, undefined, "required");
|
|
1513
|
+
if (!actor)
|
|
1514
|
+
return { ok: false, reason: "unauthorized" };
|
|
1515
|
+
const message = JSON.stringify(args);
|
|
1516
|
+
if (new TextEncoder().encode(message).length > 4096)
|
|
1517
|
+
return { ok: false, reason: "too_long" };
|
|
1518
|
+
const gate = await rateLimit(ctx, { key: `client-error:${actor}`, windowMs: 10_000, max: 1 });
|
|
1519
|
+
if (!gate.ok)
|
|
1520
|
+
return { ok: false, reason: "rate_limited" };
|
|
1521
|
+
await ctx.db.insert("jgClientErrors", { userId: actor, message, createdAt: Date.now() });
|
|
1522
|
+
return { ok: true };
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
const pruneClientErrors = internalMutation({ args: {}, handler: async (ctx) => {
|
|
1526
|
+
const rows = await ctx.db.query("jgClientErrors").withIndex("by_created", q => q.lt("createdAt", Date.now() - 7 * 86_400_000)).take(256);
|
|
1527
|
+
for (const row of rows)
|
|
1528
|
+
await ctx.db.delete(row._id);
|
|
1529
|
+
return { deleted: rows.length, remaining: rows.length === 256 };
|
|
1530
|
+
} });
|
|
1531
|
+
const pruneRateLimits = internalMutation({ args: {}, handler: async (ctx) => {
|
|
1532
|
+
const rows = await ctx.db.query("jgRateLimits").withIndex("by_expires", q => q.lte("expiresAt", Date.now())).take(256);
|
|
1533
|
+
for (const row of rows)
|
|
1534
|
+
await ctx.db.delete(row._id);
|
|
1535
|
+
return { deleted: rows.length, remaining: rows.length === 256 };
|
|
1536
|
+
} });
|
|
1537
|
+
return { reportClientError, pruneClientErrors, pruneRateLimits };
|
|
1538
|
+
}
|
|
1233
1539
|
/** @internal */
|
|
1234
1540
|
export function createChatFunctions(options) {
|
|
1235
1541
|
const mode = options?.auth ?? "required";
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
const
|
|
1542
|
+
if (options?.historyLimit !== undefined && (!Number.isSafeInteger(options.historyLimit) || options.historyLimit < 1))
|
|
1543
|
+
throw new Error("Invalid chat history limit");
|
|
1544
|
+
const historyLimit = Math.min(100, options?.historyLimit ?? 50);
|
|
1545
|
+
const maxBodyLength = options?.maxBodyLength ?? 240;
|
|
1546
|
+
const minIntervalMs = options?.minIntervalMs ?? 2_000;
|
|
1547
|
+
if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096 || !Number.isFinite(minIntervalMs) || minIntervalMs <= 0)
|
|
1548
|
+
throw new Error("Invalid chat policy");
|
|
1239
1549
|
const messages = query({
|
|
1240
1550
|
args: {
|
|
1241
1551
|
serverId: v.string(),
|
|
@@ -1282,28 +1592,9 @@ export function createChatFunctions(options) {
|
|
|
1282
1592
|
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
|
|
1283
1593
|
return { ok: false, reason: "not a member of this server" };
|
|
1284
1594
|
}
|
|
1285
|
-
const
|
|
1286
|
-
if (
|
|
1287
|
-
return { ok: false, reason: "
|
|
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
|
-
});
|
|
1595
|
+
const outcome = await sendChatMessage(ctx, { serverId: args.serverId, userId: actorUserId, channelId: args.channelId, body: args.body, maxBodyLength, minIntervalMs });
|
|
1596
|
+
if (!outcome.ok)
|
|
1597
|
+
return outcome.reason === "rate_limited" ? { ok: false, reason: "sending too fast" } : outcome;
|
|
1307
1598
|
const recent = await ctx.db
|
|
1308
1599
|
.query("jgChatMessages")
|
|
1309
1600
|
.withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
|
|
@@ -1315,7 +1606,13 @@ export function createChatFunctions(options) {
|
|
|
1315
1606
|
return { ok: true };
|
|
1316
1607
|
},
|
|
1317
1608
|
});
|
|
1318
|
-
|
|
1609
|
+
const pruneChatMessages = internalMutation({ args: {}, handler: async (ctx) => {
|
|
1610
|
+
const rows = await ctx.db.query("jgChatMessages").withIndex("by_at", q => q.lt("at", Date.now() - 86_400_000)).take(256);
|
|
1611
|
+
for (const row of rows)
|
|
1612
|
+
await ctx.db.delete(row._id);
|
|
1613
|
+
return { deleted: rows.length, remaining: rows.length === 256 };
|
|
1614
|
+
} });
|
|
1615
|
+
return { messages, recent: messages, sendMessage, pruneChatMessages };
|
|
1319
1616
|
}
|
|
1320
1617
|
const JG_CRON_SPECS = [
|
|
1321
1618
|
{ name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
|
|
@@ -1323,6 +1620,18 @@ const JG_CRON_SPECS = [
|
|
|
1323
1620
|
{ name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
|
|
1324
1621
|
];
|
|
1325
1622
|
/** @internal */
|
|
1326
|
-
export function jgengineCronSpecs() {
|
|
1327
|
-
|
|
1623
|
+
export function jgengineCronSpecs(options = {}) {
|
|
1624
|
+
const specs = JG_CRON_SPECS.filter(spec => spec.functionKey !== "tickActiveServers" || options.runtimes?.some(runtime => runtime.hasTick));
|
|
1625
|
+
if (options.chat)
|
|
1626
|
+
specs.push({ name: "jg chat prune", intervalSeconds: 3600, functionKey: "pruneChatMessages", module: "chat" });
|
|
1627
|
+
if (options.clientErrors) {
|
|
1628
|
+
specs.push({ name: "jg error prune", intervalSeconds: 3600, functionKey: "pruneClientErrors", module: "clientErrors" });
|
|
1629
|
+
specs.push({ name: "jg rate limit prune", intervalSeconds: 3600, functionKey: "pruneRateLimits", module: "clientErrors" });
|
|
1630
|
+
}
|
|
1631
|
+
return specs.map(spec => {
|
|
1632
|
+
const intervalSeconds = options.intervalSeconds?.[spec.functionKey] ?? spec.intervalSeconds;
|
|
1633
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0)
|
|
1634
|
+
throw new Error("Cron intervalSeconds must be positive");
|
|
1635
|
+
return { ...spec, intervalSeconds };
|
|
1636
|
+
});
|
|
1328
1637
|
}
|