@jgengine/convex 0.17.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 +130 -2
- package/README.md +2 -2
- package/dist/convexPresenceTransport.d.ts +8 -6
- package/dist/convexPresenceTransport.js +2 -2
- package/dist/createConvexGameTransport.d.ts +29 -13
- package/dist/createConvexGameTransport.js +69 -5
- package/dist/hostedServer.d.ts +1 -1
- package/dist/hostedServer.js +12 -12
- package/dist/server.d.ts +447 -43
- package/dist/server.js +581 -151
- 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 +98 -22
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-multiplayer/SKILL.md +25 -0
- package/skills/jgengine-multiplayer/api.md +88 -42
- package/skills/jgengine-multiplayer/capabilities.md +37 -0
- package/skills/jgengine-multiplayer/reference.md +5 -1
package/dist/server.js
CHANGED
|
@@ -1,11 +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
|
-
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
6
|
+
import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
|
|
7
|
+
import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
|
|
6
8
|
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
7
9
|
import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
8
|
-
import { createEmptyServerRow
|
|
10
|
+
import { createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
|
|
11
|
+
import { chunkKeyAt, parseChunkKey, chunkKeysAround, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks";
|
|
9
12
|
import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
|
|
10
13
|
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
11
14
|
export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
|
|
@@ -23,6 +26,8 @@ export function jgengineTables() {
|
|
|
23
26
|
modeConfig: v.optional(v.any()),
|
|
24
27
|
visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
|
|
25
28
|
joinCode: v.optional(v.string()),
|
|
29
|
+
topology: v.optional(v.union(v.literal("shared"), v.literal("rooms"))),
|
|
30
|
+
memberCount: v.optional(v.number()),
|
|
26
31
|
memberUserIds: v.array(v.string()),
|
|
27
32
|
slotsPerServer: v.number(),
|
|
28
33
|
save: saveConfigValidator,
|
|
@@ -39,10 +44,31 @@ export function jgengineTables() {
|
|
|
39
44
|
.index("by_status", ["status"])
|
|
40
45
|
.index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
|
|
41
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"]),
|
|
42
66
|
jgPlayerProfiles: defineTable({
|
|
43
67
|
userId: v.string(),
|
|
44
68
|
gameId: v.string(),
|
|
45
69
|
playerState: v.any(),
|
|
70
|
+
sessionState: v.optional(v.any()),
|
|
71
|
+
sessionServerId: v.optional(v.id("jgGameServers")),
|
|
46
72
|
revision: v.number(),
|
|
47
73
|
dirtyAt: v.optional(v.number()),
|
|
48
74
|
createdAt: v.number(),
|
|
@@ -76,23 +102,44 @@ export function jgengineTables() {
|
|
|
76
102
|
}).index("by_server_and_action", ["serverId", "action"]),
|
|
77
103
|
jgPoses: defineTable({
|
|
78
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()),
|
|
79
109
|
userId: v.string(),
|
|
110
|
+
/** One row per live session, so a second tab is a second row rather than a fight over one. */
|
|
111
|
+
sessionId: v.optional(v.string()),
|
|
112
|
+
/** Actor class, e.g. `"player"` / `"agent"`, which selects the pose rules applied to it. */
|
|
113
|
+
kind: v.optional(v.string()),
|
|
114
|
+
/** Display name carried on the row, so nameplates need no per-frame join against users. */
|
|
115
|
+
label: v.optional(v.string()),
|
|
80
116
|
x: v.number(),
|
|
81
117
|
y: v.number(),
|
|
82
118
|
z: v.number(),
|
|
83
119
|
rotationY: v.number(),
|
|
84
120
|
rotationPitch: v.number(),
|
|
85
121
|
updatedAt: v.number(),
|
|
122
|
+
/** Set when the session was displaced or left; the reaper deletes the row once it goes idle. */
|
|
123
|
+
revokedAt: v.optional(v.number()),
|
|
86
124
|
})
|
|
87
125
|
.index("by_server", ["serverId"])
|
|
88
|
-
.index("by_server_and_user", ["serverId", "userId"])
|
|
126
|
+
.index("by_server_and_user", ["serverId", "userId"])
|
|
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"]),
|
|
89
135
|
jgChatMessages: defineTable({
|
|
90
136
|
serverId: v.string(),
|
|
91
137
|
channelId: v.string(),
|
|
92
138
|
userId: v.string(),
|
|
93
139
|
body: v.string(),
|
|
140
|
+
authorName: v.optional(v.string()),
|
|
94
141
|
at: v.number(),
|
|
95
|
-
}).index("by_server_channel", ["serverId", "channelId"]),
|
|
142
|
+
}).index("by_server_channel", ["serverId", "channelId", "at"]).index("by_at", ["at"]),
|
|
96
143
|
};
|
|
97
144
|
}
|
|
98
145
|
/**
|
|
@@ -141,11 +188,19 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
|
|
|
141
188
|
}
|
|
142
189
|
return null;
|
|
143
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
|
+
}
|
|
144
199
|
async function requireServerMember(ctx, serverId, actorUserId) {
|
|
145
200
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
146
201
|
if (!server)
|
|
147
202
|
return null;
|
|
148
|
-
if (!
|
|
203
|
+
if (!(await hasMember(ctx, server, actorUserId)))
|
|
149
204
|
return null;
|
|
150
205
|
return server;
|
|
151
206
|
}
|
|
@@ -219,6 +274,21 @@ function serverDocToRecord(server) {
|
|
|
219
274
|
updatedAt: server.updatedAt,
|
|
220
275
|
};
|
|
221
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
|
+
}
|
|
222
292
|
/**
|
|
223
293
|
* Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
|
|
224
294
|
* Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
@@ -228,14 +298,27 @@ function serverDocToRecord(server) {
|
|
|
228
298
|
export async function loadServerSnapshot(ctx, server, runtime, scope) {
|
|
229
299
|
const profiles = {};
|
|
230
300
|
const memberIds = new Set(server.memberUserIds);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 = {};
|
|
234
314
|
for (const userId of hydrateUserIds) {
|
|
235
315
|
const profile = await ctx.db
|
|
236
316
|
.query("jgPlayerProfiles")
|
|
237
317
|
.withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
|
|
238
318
|
.unique();
|
|
319
|
+
if (server.topology === "shared" && profile) {
|
|
320
|
+
sessionPlayers[userId] = { ...profile.playerState, ...(profile.sessionServerId === server._id ? { session: profile.sessionState ?? {} } : { session: {} }) };
|
|
321
|
+
}
|
|
239
322
|
profiles[userId] = profile
|
|
240
323
|
? {
|
|
241
324
|
userId,
|
|
@@ -246,7 +329,7 @@ export async function loadServerSnapshot(ctx, server, runtime, scope) {
|
|
|
246
329
|
}
|
|
247
330
|
: null;
|
|
248
331
|
}
|
|
249
|
-
const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
|
|
332
|
+
const playersByUserId = buildHydratePlayers(server.topology === "shared" ? { ...serverDocToRecord(server), memberUserIds: hydrateUserIds, sessionPlayers } : serverDocToRecord(server), profiles, hydrateUserIds);
|
|
250
333
|
const chunksByKey = {};
|
|
251
334
|
if (scope?.chunkKeys === undefined) {
|
|
252
335
|
const chunks = await ctx.db
|
|
@@ -313,7 +396,9 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
|
|
|
313
396
|
*/
|
|
314
397
|
export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
315
398
|
const now = Date.now();
|
|
316
|
-
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);
|
|
317
402
|
if (!plan.changed.any)
|
|
318
403
|
return false;
|
|
319
404
|
if (plan.leaderboard.length > 0) {
|
|
@@ -326,6 +411,7 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
326
411
|
.unique();
|
|
327
412
|
const profilePatch = {
|
|
328
413
|
playerState: profile.playerState,
|
|
414
|
+
...(shared ? { sessionState: snapshot.players[profile.userId]?.session ?? {}, sessionServerId: server._id } : {}),
|
|
329
415
|
revision: profile.revision,
|
|
330
416
|
updatedAt: profile.updatedAt,
|
|
331
417
|
};
|
|
@@ -341,6 +427,22 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
341
427
|
});
|
|
342
428
|
}
|
|
343
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
|
+
}
|
|
344
446
|
for (const chunk of plan.chunks) {
|
|
345
447
|
const existing = await ctx.db
|
|
346
448
|
.query("jgWorldChunks")
|
|
@@ -370,23 +472,25 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
370
472
|
if (existing)
|
|
371
473
|
await ctx.db.delete(existing._id);
|
|
372
474
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
+
});
|
|
381
484
|
return true;
|
|
382
485
|
}
|
|
383
486
|
/** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
|
|
384
|
-
async function flushServerIfDue(ctx, server, now,
|
|
487
|
+
async function flushServerIfDue(ctx, server, now, _runtime, loaded) {
|
|
385
488
|
if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
|
|
386
489
|
return false;
|
|
387
490
|
}
|
|
388
|
-
|
|
389
|
-
|
|
491
|
+
if (loaded)
|
|
492
|
+
await persistServerSnapshot(ctx, server, loaded, server.save);
|
|
493
|
+
await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: now });
|
|
390
494
|
return true;
|
|
391
495
|
}
|
|
392
496
|
export const JG_RUNTIME_TICK_MS = 1_000;
|
|
@@ -397,10 +501,9 @@ export const MAX_CHUNKS_PER_QUERY = 64;
|
|
|
397
501
|
/**
|
|
398
502
|
* Build the hosted-server Convex functions for a set of game runtimes.
|
|
399
503
|
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* 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.
|
|
404
507
|
* World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
|
|
405
508
|
* ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
|
|
406
509
|
*
|
|
@@ -410,11 +513,12 @@ export const MAX_CHUNKS_PER_QUERY = 64;
|
|
|
410
513
|
*/
|
|
411
514
|
export function createGameServerFunctions(options) {
|
|
412
515
|
const mode = options?.auth ?? "required";
|
|
413
|
-
const
|
|
414
|
-
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");
|
|
415
519
|
if (!slotsCheck.ok)
|
|
416
520
|
throw new Error(slotsCheck.reason);
|
|
417
|
-
const matchmaking = options?.matchmaking ?? "auto";
|
|
521
|
+
const matchmaking = options?.matchmaking ?? (shared ? "singleton" : "auto");
|
|
418
522
|
const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
|
|
419
523
|
const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
|
|
420
524
|
const registry = buildRuntimeRegistry(options?.runtimes);
|
|
@@ -431,23 +535,24 @@ export function createGameServerFunctions(options) {
|
|
|
431
535
|
joinCode: v.optional(v.string()),
|
|
432
536
|
externalId: v.optional(v.string()),
|
|
433
537
|
},
|
|
434
|
-
returns: v.object({
|
|
435
|
-
serverId: v.id("jgGameServers"),
|
|
436
|
-
isNew: v.boolean(),
|
|
437
|
-
}),
|
|
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")) })),
|
|
438
539
|
handler: async (ctx, args) => {
|
|
439
|
-
const actorUserId = await
|
|
540
|
+
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
541
|
+
if (!actorUserId)
|
|
542
|
+
return { ok: false, reason: "unauthorized" };
|
|
440
543
|
const now = Date.now();
|
|
441
544
|
const runtime = resolveRuntime(registry, args.gameId);
|
|
442
545
|
const save = runtime.save;
|
|
443
546
|
const slotsPerServer = hostSlotsPerServer;
|
|
444
547
|
let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
|
|
445
548
|
if (args.serverId !== undefined && !server) {
|
|
446
|
-
|
|
549
|
+
return { ok: false, reason: "closed" };
|
|
447
550
|
}
|
|
448
551
|
if (server && server.gameId !== args.gameId) {
|
|
449
|
-
|
|
552
|
+
return { ok: false, reason: "unauthorized" };
|
|
450
553
|
}
|
|
554
|
+
if (server?.status === "closed")
|
|
555
|
+
return { ok: false, reason: "closed" };
|
|
451
556
|
let mayCreate = true;
|
|
452
557
|
if (!server) {
|
|
453
558
|
const joinable = [];
|
|
@@ -455,7 +560,7 @@ export function createGameServerFunctions(options) {
|
|
|
455
560
|
const rows = await ctx.db
|
|
456
561
|
.query("jgGameServers")
|
|
457
562
|
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
|
|
458
|
-
.
|
|
563
|
+
.take(100);
|
|
459
564
|
joinable.push(...rows);
|
|
460
565
|
}
|
|
461
566
|
// Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
|
|
@@ -468,7 +573,7 @@ export function createGameServerFunctions(options) {
|
|
|
468
573
|
slotsPerServer,
|
|
469
574
|
});
|
|
470
575
|
if (target.kind === "refuse")
|
|
471
|
-
|
|
576
|
+
return { ok: false, reason: "full" };
|
|
472
577
|
server = target.kind === "join" ? target.row : null;
|
|
473
578
|
mayCreate = target.kind === "create";
|
|
474
579
|
}
|
|
@@ -480,6 +585,8 @@ export function createGameServerFunctions(options) {
|
|
|
480
585
|
modeConfig: args.modeConfig,
|
|
481
586
|
visibility: args.visibility ?? "public",
|
|
482
587
|
joinCode: args.joinCode,
|
|
588
|
+
topology: shared ? "shared" : "rooms",
|
|
589
|
+
memberCount: 0,
|
|
483
590
|
memberUserIds: [],
|
|
484
591
|
slotsPerServer,
|
|
485
592
|
save,
|
|
@@ -505,37 +612,64 @@ export function createGameServerFunctions(options) {
|
|
|
505
612
|
throw new ConvexError("Server missing after reconcile");
|
|
506
613
|
server = reconciled;
|
|
507
614
|
}
|
|
508
|
-
if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
509
|
-
|
|
615
|
+
if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
616
|
+
return { ok: false, reason: "full" };
|
|
510
617
|
}
|
|
511
618
|
if (isPrivateJoinBlocked({
|
|
512
619
|
visibility: server.visibility,
|
|
513
|
-
memberUserIds: server
|
|
620
|
+
memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
|
|
514
621
|
userId: actorUserId,
|
|
515
622
|
joinCode: server.joinCode,
|
|
516
623
|
suppliedCode: args.joinCode,
|
|
517
624
|
})) {
|
|
518
|
-
|
|
625
|
+
return { ok: false, reason: "unauthorized" };
|
|
519
626
|
}
|
|
520
|
-
|
|
627
|
+
let profile = await ctx.db
|
|
521
628
|
.query("jgPlayerProfiles")
|
|
522
629
|
.withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
|
|
523
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
|
+
}
|
|
524
650
|
const isNew = profile === null;
|
|
525
651
|
const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
}
|
|
532
666
|
const refreshed = await ctx.db.get("jgGameServers", server._id);
|
|
533
667
|
if (!refreshed)
|
|
534
668
|
throw new ConvexError("Server missing after join");
|
|
535
|
-
let snapshot = await loadServerSnapshot(ctx, refreshed, runtime);
|
|
536
|
-
snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
|
|
669
|
+
let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
|
|
670
|
+
snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
|
|
537
671
|
await persistServerSnapshot(ctx, refreshed, snapshot, save);
|
|
538
|
-
return { serverId: refreshed._id, isNew };
|
|
672
|
+
return { ok: true, serverId: refreshed._id, isNew };
|
|
539
673
|
},
|
|
540
674
|
});
|
|
541
675
|
const leaveServer = mutation({
|
|
@@ -551,28 +685,74 @@ export function createGameServerFunctions(options) {
|
|
|
551
685
|
return null;
|
|
552
686
|
const now = Date.now();
|
|
553
687
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
554
|
-
|
|
688
|
+
// Flushes the leaver's own state before the roster drops them; nobody else's rows are touched.
|
|
689
|
+
const snapshot = await loadServerSnapshot(ctx, server, runtime, {
|
|
690
|
+
players: [actorUserId],
|
|
691
|
+
chunkKeys: [],
|
|
692
|
+
});
|
|
555
693
|
await persistServerSnapshot(ctx, server, snapshot, server.save);
|
|
556
694
|
const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
|
|
557
695
|
const sessionPlayers = { ...server.sessionPlayers };
|
|
558
696
|
delete sessionPlayers[actorUserId];
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
567
712
|
.query("jgPoses")
|
|
568
713
|
.withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
|
|
569
|
-
.
|
|
570
|
-
|
|
714
|
+
.collect();
|
|
715
|
+
for (const pose of poses)
|
|
571
716
|
await ctx.db.delete(pose._id);
|
|
572
717
|
return null;
|
|
573
718
|
},
|
|
574
719
|
});
|
|
575
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
|
+
},
|
|
576
756
|
async loadSnapshot(ctx, serverId, scope) {
|
|
577
757
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
578
758
|
if (!server)
|
|
@@ -587,18 +767,19 @@ export function createGameServerFunctions(options) {
|
|
|
587
767
|
return persistServerSnapshot(ctx, server, snapshot, save ?? server.save);
|
|
588
768
|
},
|
|
589
769
|
async runCommand(ctx, args) {
|
|
590
|
-
const actorUserId = await requireActor(ctx, args.externalId, mode);
|
|
770
|
+
const actorUserId = args.actorUserId ?? await requireActor(ctx, args.externalId, mode);
|
|
591
771
|
const serverId = args.serverId;
|
|
592
772
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
593
773
|
if (!server) {
|
|
594
774
|
return { ok: false, reason: "Server not found" };
|
|
595
775
|
}
|
|
596
|
-
if (!
|
|
776
|
+
if (!(await hasMember(ctx, server, actorUserId))) {
|
|
597
777
|
return { ok: false, reason: "Not a member of this server" };
|
|
598
778
|
}
|
|
599
779
|
const loadedRevision = server.revision;
|
|
600
780
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
601
|
-
const
|
|
781
|
+
const scope = scopeWithActor(args.scope ?? runtime.commandScope(args.command, args.input, actorUserId), actorUserId);
|
|
782
|
+
const snapshot = await loadServerSnapshot(ctx, server, runtime, scope);
|
|
602
783
|
const result = applyCommandWithOcc({
|
|
603
784
|
loadedRevision,
|
|
604
785
|
currentRevision: server.revision,
|
|
@@ -611,6 +792,10 @@ export function createGameServerFunctions(options) {
|
|
|
611
792
|
if (!result.ok) {
|
|
612
793
|
return result;
|
|
613
794
|
}
|
|
795
|
+
const escape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
|
|
796
|
+
if (escape !== null) {
|
|
797
|
+
return { ok: false, reason: escape };
|
|
798
|
+
}
|
|
614
799
|
const latest = await ctx.db.get("jgGameServers", serverId);
|
|
615
800
|
if (!latest) {
|
|
616
801
|
return { ok: false, reason: "Server not found" };
|
|
@@ -619,6 +804,10 @@ export function createGameServerFunctions(options) {
|
|
|
619
804
|
if (!commit.ok) {
|
|
620
805
|
return commit;
|
|
621
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);
|
|
622
811
|
await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
|
|
623
812
|
return { ok: true };
|
|
624
813
|
},
|
|
@@ -629,9 +818,13 @@ export function createGameServerFunctions(options) {
|
|
|
629
818
|
command: v.string(),
|
|
630
819
|
input: v.any(),
|
|
631
820
|
externalId: v.optional(v.string()),
|
|
821
|
+
scope: v.optional(v.object({
|
|
822
|
+
players: v.optional(v.array(v.string())),
|
|
823
|
+
chunkKeys: v.optional(v.array(v.string())),
|
|
824
|
+
})),
|
|
632
825
|
},
|
|
633
826
|
returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
|
|
634
|
-
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 }),
|
|
635
828
|
});
|
|
636
829
|
const flushSave = mutation({
|
|
637
830
|
args: {
|
|
@@ -644,9 +837,7 @@ export function createGameServerFunctions(options) {
|
|
|
644
837
|
const server = await requireServerMember(ctx, args.serverId, actorUserId);
|
|
645
838
|
if (server === null)
|
|
646
839
|
return false;
|
|
647
|
-
|
|
648
|
-
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
649
|
-
await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
|
|
840
|
+
await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
|
|
650
841
|
return true;
|
|
651
842
|
},
|
|
652
843
|
});
|
|
@@ -702,20 +893,38 @@ export function createGameServerFunctions(options) {
|
|
|
702
893
|
mode: v.optional(v.string()),
|
|
703
894
|
memberCount: v.number(),
|
|
704
895
|
slotsPerServer: v.number(),
|
|
705
|
-
revision: v.number(),
|
|
706
|
-
updatedAt: v.number(),
|
|
707
896
|
}), v.null()),
|
|
708
|
-
handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
|
|
897
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => ({
|
|
709
898
|
_id: server._id,
|
|
710
899
|
gameId: server.gameId,
|
|
711
900
|
status: server.status,
|
|
712
901
|
mode: server.mode,
|
|
713
|
-
memberCount: server.memberUserIds.length,
|
|
902
|
+
memberCount: (await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length,
|
|
714
903
|
slotsPerServer: server.slotsPerServer,
|
|
715
|
-
revision: server.revision,
|
|
716
|
-
updatedAt: server.updatedAt,
|
|
717
904
|
})),
|
|
718
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
|
+
});
|
|
719
928
|
/**
|
|
720
929
|
* Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
|
|
721
930
|
* geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
|
|
@@ -849,17 +1058,17 @@ export function createGameServerFunctions(options) {
|
|
|
849
1058
|
.query("jgGameServers")
|
|
850
1059
|
.withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
|
|
851
1060
|
.take(Math.min(limit * 4, 200));
|
|
852
|
-
return rows
|
|
1061
|
+
return Promise.all(rows
|
|
853
1062
|
.filter((row) => isListablePublicly(row.visibility))
|
|
854
1063
|
.slice(0, limit)
|
|
855
|
-
.map((row) => ({
|
|
1064
|
+
.map(async (row) => ({
|
|
856
1065
|
_id: row._id,
|
|
857
1066
|
status: row.status,
|
|
858
|
-
memberCount: row.memberUserIds.length,
|
|
1067
|
+
memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length,
|
|
859
1068
|
slotsPerServer: row.slotsPerServer,
|
|
860
1069
|
mode: row.mode,
|
|
861
1070
|
updatedAt: row.updatedAt,
|
|
862
|
-
}));
|
|
1071
|
+
})));
|
|
863
1072
|
},
|
|
864
1073
|
});
|
|
865
1074
|
// Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
|
|
@@ -881,15 +1090,16 @@ export function createGameServerFunctions(options) {
|
|
|
881
1090
|
.withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
|
|
882
1091
|
.take(budget);
|
|
883
1092
|
for (const server of servers) {
|
|
884
|
-
if (server.memberUserIds.length === 0)
|
|
1093
|
+
if (((await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length) === 0)
|
|
885
1094
|
continue;
|
|
886
1095
|
const elapsedMs = now - server.tickAnchorMs;
|
|
887
1096
|
if (elapsedMs < JG_RUNTIME_TICK_MS)
|
|
888
1097
|
continue;
|
|
889
1098
|
budget -= 1;
|
|
890
1099
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
1100
|
+
// Unscoped on purpose: onTick is handed the whole world and may touch any of it.
|
|
891
1101
|
const loaded = await loadServerSnapshot(ctx, server, runtime);
|
|
892
|
-
const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
|
|
1102
|
+
const snapshot = runtime.tick(loaded, elapsedMs / 1_000, now);
|
|
893
1103
|
await persistServerSnapshot(ctx, server, snapshot, server.save);
|
|
894
1104
|
await ctx.db.patch(server._id, { tickAnchorMs: now });
|
|
895
1105
|
ticked += 1;
|
|
@@ -911,9 +1121,7 @@ export function createGameServerFunctions(options) {
|
|
|
911
1121
|
.take(maxServersPerTick);
|
|
912
1122
|
let saved = 0;
|
|
913
1123
|
for (const server of servers) {
|
|
914
|
-
|
|
915
|
-
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
916
|
-
await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
|
|
1124
|
+
await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
|
|
917
1125
|
saved += 1;
|
|
918
1126
|
}
|
|
919
1127
|
return { saved };
|
|
@@ -926,6 +1134,7 @@ export function createGameServerFunctions(options) {
|
|
|
926
1134
|
flushSave,
|
|
927
1135
|
getServer,
|
|
928
1136
|
getServerMeta,
|
|
1137
|
+
getServerCapacity,
|
|
929
1138
|
getChunks,
|
|
930
1139
|
getPlayerProfile,
|
|
931
1140
|
getFeed,
|
|
@@ -1002,34 +1211,63 @@ export function createLeaderboardFunctions(options) {
|
|
|
1002
1211
|
});
|
|
1003
1212
|
return { getTop, getProfile, incrementMany };
|
|
1004
1213
|
}
|
|
1005
|
-
/**
|
|
1214
|
+
/**
|
|
1215
|
+
* The pose lane of a hosted server: the backend behind `PresenceSync`, which replicates where each
|
|
1216
|
+
* member of one `jgGameServers` server is standing. It is not the backend for `PresenceTransport` —
|
|
1217
|
+
* that contract (`ConvexPresenceFunctions` in `./convexPresenceTransport`) is a game-owned world's
|
|
1218
|
+
* own presence, keyed by the game's own ids, with its own notion of who counts as a resident. A game
|
|
1219
|
+
* on `createConvexPresenceTransport` supplies those functions itself; these do not substitute for them.
|
|
1220
|
+
* @internal
|
|
1221
|
+
*/
|
|
1006
1222
|
export function createPresenceFunctions(options) {
|
|
1007
1223
|
const mode = options?.auth ?? "required";
|
|
1008
1224
|
const freshWindowMs = options?.freshWindowMs ?? 10_000;
|
|
1009
|
-
const
|
|
1225
|
+
const idleCutoffMs = options?.idleCutoffMs ?? 60_000;
|
|
1226
|
+
const maxReapPerRun = options?.maxReapPerRun ?? 256;
|
|
1227
|
+
const rulesOption = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
|
|
1228
|
+
const rulesFor = (kind) => typeof rulesOption === "function" ? rulesOption(kind) : rulesOption;
|
|
1229
|
+
const poseValidator = v.object({
|
|
1230
|
+
x: v.number(),
|
|
1231
|
+
y: v.number(),
|
|
1232
|
+
z: v.number(),
|
|
1233
|
+
rotationY: v.number(),
|
|
1234
|
+
rotationPitch: v.number(),
|
|
1235
|
+
});
|
|
1010
1236
|
const list = query({
|
|
1011
1237
|
args: {
|
|
1012
1238
|
serverId: v.string(),
|
|
1239
|
+
viewerChunkKey: v.optional(v.string()),
|
|
1013
1240
|
externalId: v.optional(v.string()),
|
|
1014
1241
|
},
|
|
1015
1242
|
returns: v.array(v.object({
|
|
1016
1243
|
userId: v.string(),
|
|
1244
|
+
sessionId: v.optional(v.string()),
|
|
1245
|
+
kind: v.optional(v.string()),
|
|
1246
|
+
label: v.optional(v.string()),
|
|
1017
1247
|
position: v.object({ x: v.number(), y: v.number(), z: v.number() }),
|
|
1018
1248
|
rotationY: v.number(),
|
|
1019
1249
|
rotationPitch: v.number(),
|
|
1020
1250
|
lastSeenAt: v.number(),
|
|
1021
1251
|
})),
|
|
1022
1252
|
handler: async (ctx, args) => {
|
|
1023
|
-
return withMember(ctx, mode, args, [], async () => {
|
|
1253
|
+
return withMember(ctx, mode, args, [], async (_server, actorUserId) => {
|
|
1024
1254
|
const threshold = Date.now() - freshWindowMs;
|
|
1025
|
-
const
|
|
1026
|
-
.
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
+
}
|
|
1029
1264
|
return rows
|
|
1030
|
-
.filter((row) => row.updatedAt >= threshold)
|
|
1265
|
+
.filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
|
|
1031
1266
|
.map((row) => ({
|
|
1032
1267
|
userId: row.userId,
|
|
1268
|
+
sessionId: row.sessionId,
|
|
1269
|
+
kind: row.kind,
|
|
1270
|
+
label: row.label,
|
|
1033
1271
|
position: { x: row.x, y: row.y, z: row.z },
|
|
1034
1272
|
rotationY: row.rotationY,
|
|
1035
1273
|
rotationPitch: row.rotationPitch,
|
|
@@ -1038,60 +1276,105 @@ export function createPresenceFunctions(options) {
|
|
|
1038
1276
|
});
|
|
1039
1277
|
},
|
|
1040
1278
|
});
|
|
1279
|
+
/**
|
|
1280
|
+
* Upload a pose, or keep the session alive without one. Returns the pose the server actually holds
|
|
1281
|
+
* after clamping, so a corrected client can reconcile, plus whether this session had been revoked
|
|
1282
|
+
* by another of the same user's sessions since its last sync.
|
|
1283
|
+
*/
|
|
1041
1284
|
const sync = mutation({
|
|
1042
1285
|
args: {
|
|
1043
1286
|
serverId: v.string(),
|
|
1044
|
-
pose: v.
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
rotationY: v.number(),
|
|
1049
|
-
rotationPitch: v.number(),
|
|
1050
|
-
}),
|
|
1287
|
+
pose: v.optional(poseValidator),
|
|
1288
|
+
sessionId: v.optional(v.string()),
|
|
1289
|
+
kind: v.optional(v.string()),
|
|
1290
|
+
label: v.optional(v.string()),
|
|
1051
1291
|
externalId: v.optional(v.string()),
|
|
1052
1292
|
},
|
|
1053
|
-
returns: v.null(),
|
|
1293
|
+
returns: v.union(v.null(), v.object({
|
|
1294
|
+
pose: poseValidator,
|
|
1295
|
+
lastSeenAt: v.number(),
|
|
1296
|
+
displaced: v.boolean(),
|
|
1297
|
+
})),
|
|
1054
1298
|
handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
|
|
1055
1299
|
const now = Date.now();
|
|
1056
|
-
const
|
|
1300
|
+
const rows = await ctx.db
|
|
1057
1301
|
.query("jgPoses")
|
|
1058
1302
|
.withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
|
|
1059
|
-
.
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1303
|
+
.collect();
|
|
1304
|
+
const resolvable = rows.map((row) => ({
|
|
1305
|
+
row,
|
|
1306
|
+
revokedAt: row.revokedAt,
|
|
1307
|
+
lastSeenAt: row.updatedAt,
|
|
1308
|
+
}));
|
|
1309
|
+
const existing = args.sessionId === undefined
|
|
1310
|
+
? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
|
|
1311
|
+
: rows.find((row) => row.sessionId === args.sessionId);
|
|
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
|
+
}
|
|
1316
|
+
const current = existing === undefined
|
|
1317
|
+
? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
|
|
1318
|
+
serverId: args.serverId,
|
|
1319
|
+
userId: actorUserId,
|
|
1320
|
+
kind: args.kind,
|
|
1321
|
+
})) ?? undefined, now, rules)
|
|
1062
1322
|
: {
|
|
1063
1323
|
position: { x: existing.x, y: existing.y, z: existing.z },
|
|
1064
1324
|
rotationY: existing.rotationY,
|
|
1065
1325
|
rotationPitch: existing.rotationPitch,
|
|
1066
1326
|
lastSeenAtMs: existing.updatedAt,
|
|
1067
1327
|
};
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
rotationY:
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1328
|
+
// A pose-less call is a keep-alive: hold the stored pose and only move the stamp forward.
|
|
1329
|
+
const decision = decidePoseSync(current, args.pose === undefined
|
|
1330
|
+
? { position: current.position, rotationY: current.rotationY, rotationPitch: current.rotationPitch }
|
|
1331
|
+
: {
|
|
1332
|
+
position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
|
|
1333
|
+
rotationY: args.pose.rotationY,
|
|
1334
|
+
rotationPitch: args.pose.rotationPitch,
|
|
1335
|
+
}, rules, now);
|
|
1336
|
+
const pose = {
|
|
1074
1337
|
x: decision.position.x,
|
|
1075
1338
|
y: decision.position.y,
|
|
1076
1339
|
z: decision.position.z,
|
|
1077
1340
|
rotationY: decision.rotationY,
|
|
1078
1341
|
rotationPitch: decision.rotationPitch,
|
|
1079
|
-
updatedAt: now,
|
|
1080
1342
|
};
|
|
1343
|
+
const identity = {
|
|
1344
|
+
...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
|
|
1345
|
+
...(args.kind === undefined ? {} : { kind: args.kind }),
|
|
1346
|
+
...(args.label === undefined ? {} : { label: args.label }),
|
|
1347
|
+
};
|
|
1348
|
+
const displaced = existing?.revokedAt !== undefined;
|
|
1081
1349
|
if (existing) {
|
|
1082
|
-
if (decision.changed || decision.refreshKeepAlive) {
|
|
1083
|
-
await ctx.db.patch(existing._id,
|
|
1350
|
+
if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
|
|
1351
|
+
await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
|
|
1084
1352
|
}
|
|
1085
1353
|
}
|
|
1086
1354
|
else {
|
|
1087
|
-
await ctx.db.insert("jgPoses", {
|
|
1355
|
+
await ctx.db.insert("jgPoses", {
|
|
1356
|
+
serverId: args.serverId,
|
|
1357
|
+
userId: actorUserId,
|
|
1358
|
+
...pose,
|
|
1359
|
+
chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
|
|
1360
|
+
...identity,
|
|
1361
|
+
updatedAt: now,
|
|
1362
|
+
});
|
|
1088
1363
|
}
|
|
1089
|
-
|
|
1364
|
+
// This session is the live one; any other session of the same user is revoked, not deleted,
|
|
1365
|
+
// so it learns it was displaced on its next sync instead of silently reappearing.
|
|
1366
|
+
for (const row of rows) {
|
|
1367
|
+
if (row._id === existing?._id || row.revokedAt !== undefined)
|
|
1368
|
+
continue;
|
|
1369
|
+
await ctx.db.patch(row._id, { revokedAt: now });
|
|
1370
|
+
}
|
|
1371
|
+
return { pose, lastSeenAt: now, displaced };
|
|
1090
1372
|
}),
|
|
1091
1373
|
});
|
|
1092
1374
|
const leave = mutation({
|
|
1093
1375
|
args: {
|
|
1094
1376
|
serverId: v.string(),
|
|
1377
|
+
sessionId: v.optional(v.string()),
|
|
1095
1378
|
externalId: v.optional(v.string()),
|
|
1096
1379
|
},
|
|
1097
1380
|
returns: v.null(),
|
|
@@ -1099,23 +1382,170 @@ export function createPresenceFunctions(options) {
|
|
|
1099
1382
|
const actorUserId = await resolveActor(ctx, args.externalId, mode);
|
|
1100
1383
|
if (!actorUserId)
|
|
1101
1384
|
return null;
|
|
1102
|
-
const
|
|
1385
|
+
const now = Date.now();
|
|
1386
|
+
const rows = await ctx.db
|
|
1103
1387
|
.query("jgPoses")
|
|
1104
1388
|
.withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
|
|
1105
|
-
.
|
|
1106
|
-
|
|
1107
|
-
|
|
1389
|
+
.collect();
|
|
1390
|
+
for (const row of rows) {
|
|
1391
|
+
if (args.sessionId !== undefined && row.sessionId !== args.sessionId)
|
|
1392
|
+
continue;
|
|
1393
|
+
if (row.revokedAt === undefined)
|
|
1394
|
+
await ctx.db.patch(row._id, { revokedAt: now });
|
|
1395
|
+
}
|
|
1108
1396
|
return null;
|
|
1109
1397
|
},
|
|
1110
1398
|
});
|
|
1111
|
-
|
|
1399
|
+
/**
|
|
1400
|
+
* Delete rows nobody has written for `idleCutoffMs`. `list` filters stale rows at read time but
|
|
1401
|
+
* nothing removed them, so a player who closed the tab left a row behind forever. Register it on a
|
|
1402
|
+
* cron — {@link jgengineCronSpecs} carries the interval.
|
|
1403
|
+
*/
|
|
1404
|
+
const reapIdlePresence = internalMutation({
|
|
1405
|
+
args: {},
|
|
1406
|
+
returns: v.object({ reaped: v.number() }),
|
|
1407
|
+
handler: async (ctx) => {
|
|
1408
|
+
const now = Date.now();
|
|
1409
|
+
const stale = await ctx.db
|
|
1410
|
+
.query("jgPoses")
|
|
1411
|
+
.withIndex("by_updated", (q) => q.lt("updatedAt", now - idleCutoffMs))
|
|
1412
|
+
.take(maxReapPerRun);
|
|
1413
|
+
let reaped = 0;
|
|
1414
|
+
for (const row of stale) {
|
|
1415
|
+
if (!isPresenceExpired(row.updatedAt, now, idleCutoffMs))
|
|
1416
|
+
continue;
|
|
1417
|
+
await ctx.db.delete(row._id);
|
|
1418
|
+
reaped += 1;
|
|
1419
|
+
}
|
|
1420
|
+
return { reaped };
|
|
1421
|
+
},
|
|
1422
|
+
});
|
|
1423
|
+
return { list, sync, leave, reapIdlePresence };
|
|
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 };
|
|
1112
1538
|
}
|
|
1113
1539
|
/** @internal */
|
|
1114
1540
|
export function createChatFunctions(options) {
|
|
1115
1541
|
const mode = options?.auth ?? "required";
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
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");
|
|
1119
1549
|
const messages = query({
|
|
1120
1550
|
args: {
|
|
1121
1551
|
serverId: v.string(),
|
|
@@ -1162,28 +1592,9 @@ export function createChatFunctions(options) {
|
|
|
1162
1592
|
if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
|
|
1163
1593
|
return { ok: false, reason: "not a member of this server" };
|
|
1164
1594
|
}
|
|
1165
|
-
const
|
|
1166
|
-
if (
|
|
1167
|
-
return { ok: false, reason: "
|
|
1168
|
-
if (body.length > maxBodyLength)
|
|
1169
|
-
return { ok: false, reason: "message too long" };
|
|
1170
|
-
const now = Date.now();
|
|
1171
|
-
const newestByActor = await ctx.db
|
|
1172
|
-
.query("jgChatMessages")
|
|
1173
|
-
.withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
|
|
1174
|
-
.order("desc")
|
|
1175
|
-
.filter((q) => q.eq(q.field("userId"), actorUserId))
|
|
1176
|
-
.first();
|
|
1177
|
-
if (newestByActor && now - newestByActor.at < minIntervalMs) {
|
|
1178
|
-
return { ok: false, reason: "sending too fast" };
|
|
1179
|
-
}
|
|
1180
|
-
await ctx.db.insert("jgChatMessages", {
|
|
1181
|
-
serverId: args.serverId,
|
|
1182
|
-
channelId: args.channelId,
|
|
1183
|
-
userId: actorUserId,
|
|
1184
|
-
body,
|
|
1185
|
-
at: now,
|
|
1186
|
-
});
|
|
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;
|
|
1187
1598
|
const recent = await ctx.db
|
|
1188
1599
|
.query("jgChatMessages")
|
|
1189
1600
|
.withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
|
|
@@ -1195,13 +1606,32 @@ export function createChatFunctions(options) {
|
|
|
1195
1606
|
return { ok: true };
|
|
1196
1607
|
},
|
|
1197
1608
|
});
|
|
1198
|
-
|
|
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 };
|
|
1199
1616
|
}
|
|
1200
1617
|
const JG_CRON_SPECS = [
|
|
1201
|
-
{ name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
|
|
1202
|
-
{ name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
|
|
1618
|
+
{ name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
|
|
1619
|
+
{ name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers", module: "runtime" },
|
|
1620
|
+
{ name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
|
|
1203
1621
|
];
|
|
1204
1622
|
/** @internal */
|
|
1205
|
-
export function jgengineCronSpecs() {
|
|
1206
|
-
|
|
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
|
+
});
|
|
1207
1637
|
}
|