@jgengine/convex 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
1
+ import type { Auth, DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
2
2
  import type { GenericId } from "convex/values";
3
3
  import type { ChatMessage } from "@jgengine/core/game/chat";
4
4
  import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
5
+ import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
5
6
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
7
  import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
7
8
  export declare function jgengineTables(): {
@@ -9,6 +10,7 @@ export declare function jgengineTables(): {
9
10
  createdAt: number;
10
11
  dirtyAt?: number | undefined;
11
12
  gameId: string;
13
+ joinCode?: string | undefined;
12
14
  lastSavedAt?: number | undefined;
13
15
  memberUserIds: string[];
14
16
  mode?: string | undefined;
@@ -24,11 +26,14 @@ export declare function jgengineTables(): {
24
26
  status: "closed" | "open" | "running";
25
27
  tickAnchorMs: number;
26
28
  updatedAt: number;
29
+ visibility?: "private" | "public" | undefined;
27
30
  }, {
28
31
  gameId: import("convex/values").VString<string, "required">;
29
32
  status: import("convex/values").VUnion<"closed" | "open" | "running", [import("convex/values").VLiteral<"open", "required">, import("convex/values").VLiteral<"running", "required">, import("convex/values").VLiteral<"closed", "required">], "required", never>;
30
33
  mode: import("convex/values").VString<string | undefined, "optional">;
31
34
  modeConfig: import("convex/values").VAny<any, "optional", string>;
35
+ visibility: import("convex/values").VUnion<"private" | "public" | undefined, [import("convex/values").VLiteral<"public", "required">, import("convex/values").VLiteral<"private", "required">], "optional", never>;
36
+ joinCode: import("convex/values").VString<string | undefined, "optional">;
32
37
  memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
33
38
  slotsPerServer: import("convex/values").VFloat64<number, "required">;
34
39
  save: import("convex/values").VUnion<"none" | {
@@ -49,7 +54,7 @@ export declare function jgengineTables(): {
49
54
  dirtyAt: import("convex/values").VFloat64<number | undefined, "optional">;
50
55
  createdAt: import("convex/values").VFloat64<number, "required">;
51
56
  updatedAt: import("convex/values").VFloat64<number, "required">;
52
- }, "required", "createdAt" | "dirtyAt" | "gameId" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
57
+ }, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
53
58
  by_game_and_status: ["gameId", "status", "_creationTime"];
54
59
  }, {}, {}>;
55
60
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -158,11 +163,42 @@ export declare function jgengineTables(): {
158
163
  by_server_channel: ["serverId", "channelId", "_creationTime"];
159
164
  }, {}, {}>;
160
165
  };
166
+ /**
167
+ * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
168
+ * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
169
+ * {@link jgengineTables} — spread either (or both) into `defineSchema`.
170
+ */
171
+ export declare function jgengineHostedTables(): {
172
+ jgHostedWorlds: import("convex/server").TableDefinition<import("convex/values").VObject<{
173
+ createdAt: number;
174
+ gameId: string;
175
+ inputs: any;
176
+ memberUserIds: string[];
177
+ revision: number;
178
+ serverId: string;
179
+ snapshot: any;
180
+ tickAnchorMs: number;
181
+ updatedAt: number;
182
+ }, {
183
+ gameId: import("convex/values").VString<string, "required">;
184
+ serverId: import("convex/values").VString<string, "required">;
185
+ snapshot: import("convex/values").VAny<any, "required", string>;
186
+ revision: import("convex/values").VFloat64<number, "required">;
187
+ memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
188
+ inputs: import("convex/values").VAny<any, "required", string>;
189
+ tickAnchorMs: import("convex/values").VFloat64<number, "required">;
190
+ createdAt: import("convex/values").VFloat64<number, "required">;
191
+ updatedAt: import("convex/values").VFloat64<number, "required">;
192
+ }, "required", "createdAt" | "gameId" | "inputs" | "memberUserIds" | "revision" | "serverId" | "snapshot" | "tickAnchorMs" | "updatedAt" | `inputs.${string}` | `snapshot.${string}`>, {
193
+ by_game_and_server: ["gameId", "serverId", "_creationTime"];
194
+ }, {}, {}>;
195
+ };
161
196
  declare const schemaForTypes: import("convex/server").SchemaDefinition<{
162
197
  jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
163
198
  createdAt: number;
164
199
  dirtyAt?: number | undefined;
165
200
  gameId: string;
201
+ joinCode?: string | undefined;
166
202
  lastSavedAt?: number | undefined;
167
203
  memberUserIds: string[];
168
204
  mode?: string | undefined;
@@ -178,11 +214,14 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
178
214
  status: "closed" | "open" | "running";
179
215
  tickAnchorMs: number;
180
216
  updatedAt: number;
217
+ visibility?: "private" | "public" | undefined;
181
218
  }, {
182
219
  gameId: import("convex/values").VString<string, "required">;
183
220
  status: import("convex/values").VUnion<"closed" | "open" | "running", [import("convex/values").VLiteral<"open", "required">, import("convex/values").VLiteral<"running", "required">, import("convex/values").VLiteral<"closed", "required">], "required", never>;
184
221
  mode: import("convex/values").VString<string | undefined, "optional">;
185
222
  modeConfig: import("convex/values").VAny<any, "optional", string>;
223
+ visibility: import("convex/values").VUnion<"private" | "public" | undefined, [import("convex/values").VLiteral<"public", "required">, import("convex/values").VLiteral<"private", "required">], "optional", never>;
224
+ joinCode: import("convex/values").VString<string | undefined, "optional">;
186
225
  memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
187
226
  slotsPerServer: import("convex/values").VFloat64<number, "required">;
188
227
  save: import("convex/values").VUnion<"none" | {
@@ -203,7 +242,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
203
242
  dirtyAt: import("convex/values").VFloat64<number | undefined, "optional">;
204
243
  createdAt: import("convex/values").VFloat64<number, "required">;
205
244
  updatedAt: import("convex/values").VFloat64<number, "required">;
206
- }, "required", "createdAt" | "dirtyAt" | "gameId" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
245
+ }, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
207
246
  by_game_and_status: ["gameId", "status", "_creationTime"];
208
247
  }, {}, {}>;
209
248
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -315,23 +354,27 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
315
354
  type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
316
355
  type JGMutationCtx = GenericMutationCtx<JGDataModel>;
317
356
  export type JgAuthMode = "anonymous" | "required";
357
+ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
358
+ /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`. */
359
+ export declare function resolveActor(ctx: {
360
+ auth: Auth;
361
+ }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
318
362
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
319
363
  export declare const JG_RUNTIME_TICK_MS = 1000;
320
364
  export declare function createGameServerFunctions(options?: {
321
365
  runtimes?: GameRuntime[];
322
366
  auth?: JgAuthMode;
367
+ slotsPerServer?: number;
368
+ allowedFeedActions?: readonly string[];
323
369
  }): {
324
370
  joinServer: import("convex/server").RegisteredMutation<"public", {
325
371
  externalId?: string | undefined;
326
372
  gameId: string;
373
+ joinCode?: string | undefined;
327
374
  mode?: string | undefined;
328
375
  modeConfig?: any;
329
- save?: "none" | {
330
- auto: string;
331
- scope: "chunks" | "player" | "player+chunks";
332
- } | undefined;
333
376
  serverId?: GenericId<"jgGameServers"> | undefined;
334
- slotsPerServer?: number | undefined;
377
+ visibility?: "private" | "public" | undefined;
335
378
  }, Promise<{
336
379
  serverId: GenericId<"jgGameServers">;
337
380
  isNew: boolean;
@@ -371,7 +414,9 @@ export declare function createGameServerFunctions(options?: {
371
414
  };
372
415
  revision: number;
373
416
  serverState: any;
374
- sessionPlayers: any;
417
+ sessionPlayers: {
418
+ [x: string]: {} | null;
419
+ };
375
420
  updatedAt: number;
376
421
  } | null>>;
377
422
  getPlayerProfile: import("convex/server").RegisteredQuery<"public", {
@@ -448,6 +493,7 @@ export declare function createLeaderboardFunctions(options?: {
448
493
  export declare function createPresenceFunctions(options?: {
449
494
  auth?: JgAuthMode;
450
495
  freshWindowMs?: number;
496
+ poseRules?: PoseSyncRules;
451
497
  }): {
452
498
  list: import("convex/server").RegisteredQuery<"public", {
453
499
  externalId?: string | undefined;
package/dist/server.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
+ import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
+ import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
3
5
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
4
6
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
5
7
  import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
8
+ import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
6
9
  const saveConfigValidator = v.union(v.literal("none"), v.object({
7
10
  auto: v.string(),
8
11
  scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
@@ -14,6 +17,8 @@ export function jgengineTables() {
14
17
  status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
15
18
  mode: v.optional(v.string()),
16
19
  modeConfig: v.optional(v.any()),
20
+ visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
21
+ joinCode: v.optional(v.string()),
17
22
  memberUserIds: v.array(v.string()),
18
23
  slotsPerServer: v.number(),
19
24
  save: saveConfigValidator,
@@ -82,11 +87,39 @@ export function jgengineTables() {
82
87
  }).index("by_server_channel", ["serverId", "channelId"]),
83
88
  };
84
89
  }
90
+ /**
91
+ * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
92
+ * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
93
+ * {@link jgengineTables} — spread either (or both) into `defineSchema`.
94
+ */
95
+ export function jgengineHostedTables() {
96
+ return {
97
+ jgHostedWorlds: defineTable({
98
+ gameId: v.string(),
99
+ serverId: v.string(),
100
+ snapshot: v.any(),
101
+ revision: v.number(),
102
+ memberUserIds: v.array(v.string()),
103
+ inputs: v.any(),
104
+ tickAnchorMs: v.number(),
105
+ createdAt: v.number(),
106
+ updatedAt: v.number(),
107
+ }).index("by_game_and_server", ["gameId", "serverId"]),
108
+ };
109
+ }
85
110
  const schemaForTypes = defineSchema(jgengineTables());
86
111
  const query = queryGeneric;
87
112
  const mutation = mutationGeneric;
88
113
  const internalMutation = internalMutationGeneric;
89
- async function resolveActor(ctx, claimedExternalId, mode) {
114
+ export const DEFAULT_CONVEX_POSE_RULES = {
115
+ maxSpeed: 12,
116
+ maxVerticalOffset: 3,
117
+ minElapsedSec: 0.05,
118
+ maxElapsedSec: 0.5,
119
+ keepAliveRefreshMs: 10_000,
120
+ };
121
+ /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`. */
122
+ export async function resolveActor(ctx, claimedExternalId, mode) {
90
123
  const identity = await ctx.auth.getUserIdentity();
91
124
  if (identity !== null) {
92
125
  if (claimedExternalId !== undefined &&
@@ -101,6 +134,14 @@ async function resolveActor(ctx, claimedExternalId, mode) {
101
134
  }
102
135
  return null;
103
136
  }
137
+ async function requireServerMember(ctx, serverId, actorUserId) {
138
+ const server = await ctx.db.get("jgGameServers", serverId);
139
+ if (!server)
140
+ return null;
141
+ if (!server.memberUserIds.includes(actorUserId))
142
+ return null;
143
+ return server;
144
+ }
104
145
  const builtinRuntimeCommands = {
105
146
  "engine.ping": {
106
147
  validate: (_snapshot) => null,
@@ -132,6 +173,8 @@ function serverDocToRecord(server) {
132
173
  status: server.status,
133
174
  mode: server.mode,
134
175
  modeConfig: server.modeConfig,
176
+ visibility: server.visibility,
177
+ joinCode: server.joinCode,
135
178
  memberUserIds: server.memberUserIds,
136
179
  slotsPerServer: server.slotsPerServer,
137
180
  save: server.save,
@@ -177,6 +220,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
177
220
  serverRow: server.serverState,
178
221
  playersByUserId,
179
222
  chunksByKey,
223
+ revision: server.revision,
180
224
  });
181
225
  }
182
226
  export async function applyLeaderboardIncrements(ctx, gameId, entries) {
@@ -254,6 +298,14 @@ async function persistServerSnapshot(ctx, server, snapshot, save) {
254
298
  });
255
299
  }
256
300
  }
301
+ for (const chunkKey of plan.deletedChunks) {
302
+ const existing = await ctx.db
303
+ .query("jgWorldChunks")
304
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
305
+ .unique();
306
+ if (existing)
307
+ await ctx.db.delete(existing._id);
308
+ }
257
309
  await ctx.db.patch(server._id, {
258
310
  serverState: plan.server.serverState,
259
311
  sessionPlayers: plan.server.sessionPlayers,
@@ -273,16 +325,18 @@ async function flushServerIfDue(ctx, server, now, runtime) {
273
325
  }
274
326
  export const JG_RUNTIME_TICK_MS = 1_000;
275
327
  export function createGameServerFunctions(options) {
276
- const mode = options?.auth ?? "anonymous";
328
+ const mode = options?.auth ?? "required";
329
+ const hostSlotsPerServer = options?.slotsPerServer ?? 16;
330
+ const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
277
331
  const registry = buildRuntimeRegistry(options?.runtimes);
278
332
  const joinServer = mutation({
279
333
  args: {
280
334
  gameId: v.string(),
281
335
  serverId: v.optional(v.id("jgGameServers")),
282
- slotsPerServer: v.optional(v.number()),
283
- save: v.optional(saveConfigValidator),
284
336
  mode: v.optional(v.string()),
285
337
  modeConfig: v.optional(v.any()),
338
+ visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
339
+ joinCode: v.optional(v.string()),
286
340
  externalId: v.optional(v.string()),
287
341
  },
288
342
  returns: v.object({
@@ -296,9 +350,12 @@ export function createGameServerFunctions(options) {
296
350
  }
297
351
  const now = Date.now();
298
352
  const runtime = resolveRuntime(registry, args.gameId);
299
- const save = (args.save ?? runtime.save);
300
- const slotsPerServer = args.slotsPerServer ?? 16;
353
+ const save = runtime.save;
354
+ const slotsPerServer = hostSlotsPerServer;
301
355
  let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
356
+ if (args.serverId !== undefined && !server) {
357
+ throw new ConvexError("Server not found");
358
+ }
302
359
  if (server && server.gameId !== args.gameId) {
303
360
  throw new ConvexError("Server belongs to a different game");
304
361
  }
@@ -312,8 +369,13 @@ export function createGameServerFunctions(options) {
312
369
  joinable.push(...rows);
313
370
  }
314
371
  server =
315
- joinable.find((row) => row.memberUserIds.includes(actorUserId) ||
316
- row.memberUserIds.length < row.slotsPerServer) ?? null;
372
+ joinable.find((row) => {
373
+ if (row.memberUserIds.includes(actorUserId))
374
+ return true;
375
+ if (row.memberUserIds.length >= row.slotsPerServer)
376
+ return false;
377
+ return (row.visibility ?? "public") !== "private";
378
+ }) ?? null;
317
379
  }
318
380
  if (!server) {
319
381
  const serverId = await ctx.db.insert("jgGameServers", {
@@ -321,6 +383,8 @@ export function createGameServerFunctions(options) {
321
383
  status: "running",
322
384
  mode: args.mode,
323
385
  modeConfig: args.modeConfig,
386
+ visibility: args.visibility ?? "public",
387
+ joinCode: args.joinCode,
324
388
  memberUserIds: [],
325
389
  slotsPerServer,
326
390
  save,
@@ -339,6 +403,11 @@ export function createGameServerFunctions(options) {
339
403
  !server.memberUserIds.includes(actorUserId)) {
340
404
  throw new ConvexError("Server is full");
341
405
  }
406
+ if ((server.visibility ?? "public") === "private" &&
407
+ !server.memberUserIds.includes(actorUserId) &&
408
+ args.serverId === undefined) {
409
+ throw new ConvexError("Server is private");
410
+ }
342
411
  const profile = await ctx.db
343
412
  .query("jgPlayerProfiles")
344
413
  .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
@@ -421,13 +490,30 @@ export function createGameServerFunctions(options) {
421
490
  if (!server.memberUserIds.includes(actorUserId)) {
422
491
  return { ok: false, reason: "Not a member of this server" };
423
492
  }
493
+ const loadedRevision = server.revision;
424
494
  const runtime = resolveRuntime(registry, server.gameId);
425
495
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
426
- const result = runtime.runCommand(snapshot, actorUserId, args.command, args.input);
496
+ const result = applyCommandWithOcc({
497
+ loadedRevision,
498
+ currentRevision: server.revision,
499
+ snapshot,
500
+ runtime,
501
+ actorUserId,
502
+ command: args.command,
503
+ input: args.input,
504
+ });
427
505
  if (!result.ok) {
428
506
  return result;
429
507
  }
430
- await persistServerSnapshot(ctx, server, result.snapshot, server.save);
508
+ const latest = await ctx.db.get("jgGameServers", args.serverId);
509
+ if (!latest) {
510
+ return { ok: false, reason: "Server not found" };
511
+ }
512
+ const commit = commitIfRevisionMatch(loadedRevision, latest.revision);
513
+ if (!commit.ok) {
514
+ return commit;
515
+ }
516
+ await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
431
517
  return { ok: true };
432
518
  },
433
519
  });
@@ -480,6 +566,8 @@ export function createGameServerFunctions(options) {
480
566
  return null;
481
567
  if (!server.memberUserIds.includes(actorUserId))
482
568
  return null;
569
+ const sessionPlayers = server.sessionPlayers;
570
+ const ownSession = sessionPlayers[actorUserId];
483
571
  return {
484
572
  _id: server._id,
485
573
  gameId: server.gameId,
@@ -490,7 +578,7 @@ export function createGameServerFunctions(options) {
490
578
  save: server.save,
491
579
  revision: server.revision,
492
580
  serverState: server.serverState,
493
- sessionPlayers: server.sessionPlayers,
581
+ sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
494
582
  updatedAt: server.updatedAt,
495
583
  };
496
584
  },
@@ -560,6 +648,10 @@ export function createGameServerFunctions(options) {
560
648
  if (!actorUserId) {
561
649
  throw new ConvexError("Not authenticated");
562
650
  }
651
+ const allowed = validateFeedWrite(feedWriteGate, args.action);
652
+ if (!allowed.ok) {
653
+ throw new ConvexError(allowed.reason);
654
+ }
563
655
  const server = await ctx.db.get("jgGameServers", args.serverId);
564
656
  if (!server || !server.memberUserIds.includes(actorUserId)) {
565
657
  throw new ConvexError("Not a member of this server");
@@ -740,8 +832,9 @@ export function createLeaderboardFunctions(options) {
740
832
  return { getTop, getProfile, incrementMany };
741
833
  }
742
834
  export function createPresenceFunctions(options) {
743
- const mode = options?.auth ?? "anonymous";
835
+ const mode = options?.auth ?? "required";
744
836
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
837
+ const poseRules = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
745
838
  const list = query({
746
839
  args: {
747
840
  serverId: v.string(),
@@ -755,6 +848,11 @@ export function createPresenceFunctions(options) {
755
848
  lastSeenAt: v.number(),
756
849
  })),
757
850
  handler: async (ctx, args) => {
851
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
852
+ if (!actorUserId)
853
+ return [];
854
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
855
+ return [];
758
856
  const threshold = Date.now() - freshWindowMs;
759
857
  const rows = await ctx.db
760
858
  .query("jgPoses")
@@ -788,21 +886,38 @@ export function createPresenceFunctions(options) {
788
886
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
789
887
  if (!actorUserId)
790
888
  return null;
889
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
890
+ return null;
791
891
  const now = Date.now();
792
892
  const existing = await ctx.db
793
893
  .query("jgPoses")
794
894
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
795
895
  .unique();
796
- const patch = {
797
- x: args.pose.x,
798
- y: args.pose.y,
799
- z: args.pose.z,
896
+ const current = existing === null
897
+ ? spawnPresenceState(undefined, now, poseRules)
898
+ : {
899
+ position: { x: existing.x, y: existing.y, z: existing.z },
900
+ rotationY: existing.rotationY,
901
+ rotationPitch: existing.rotationPitch,
902
+ lastSeenAtMs: existing.updatedAt,
903
+ };
904
+ const decision = decidePoseSync(current, {
905
+ position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
800
906
  rotationY: args.pose.rotationY,
801
907
  rotationPitch: args.pose.rotationPitch,
908
+ }, poseRules, now);
909
+ const patch = {
910
+ x: decision.position.x,
911
+ y: decision.position.y,
912
+ z: decision.position.z,
913
+ rotationY: decision.rotationY,
914
+ rotationPitch: decision.rotationPitch,
802
915
  updatedAt: now,
803
916
  };
804
917
  if (existing) {
805
- await ctx.db.patch(existing._id, patch);
918
+ if (decision.changed || decision.refreshKeepAlive) {
919
+ await ctx.db.patch(existing._id, patch);
920
+ }
806
921
  }
807
922
  else {
808
923
  await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
@@ -832,7 +947,7 @@ export function createPresenceFunctions(options) {
832
947
  return { list, sync, leave };
833
948
  }
834
949
  export function createChatFunctions(options) {
835
- const mode = options?.auth ?? "anonymous";
950
+ const mode = options?.auth ?? "required";
836
951
  const historyLimit = options?.historyLimit ?? 100;
837
952
  const maxBodyLength = options?.maxBodyLength ?? 500;
838
953
  const minIntervalMs = options?.minIntervalMs ?? 300;
@@ -850,6 +965,11 @@ export function createChatFunctions(options) {
850
965
  at: v.number(),
851
966
  })),
852
967
  handler: async (ctx, args) => {
968
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
969
+ if (!actorUserId)
970
+ return [];
971
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
972
+ return [];
853
973
  const rows = await ctx.db
854
974
  .query("jgChatMessages")
855
975
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -879,6 +999,9 @@ export function createChatFunctions(options) {
879
999
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
880
1000
  if (!actorUserId)
881
1001
  return { ok: false, reason: "not signed in" };
1002
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
1003
+ return { ok: false, reason: "not a member of this server" };
1004
+ }
882
1005
  const body = args.body.trim();
883
1006
  if (body.length === 0)
884
1007
  return { ok: false, reason: "empty message" };