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