@jgengine/convex 0.8.0 → 0.9.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 CHANGED
@@ -11,6 +11,22 @@ Agents building on the published SDK can also read this programmatically:
11
11
  same data as typed values, so an updater can diff its installed version against
12
12
  the latest and surface the migration steps.
13
13
 
14
+ ## Unreleased
15
+
16
+ ## 0.9.0
17
+
18
+ ### Added
19
+
20
+ - Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
21
+ - Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
22
+ - HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
23
+ - Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
24
+ - Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
25
+
26
+ ### Changed
27
+
28
+ - Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
29
+
14
30
  ## 0.8.0
15
31
 
16
32
  Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
@@ -14,9 +14,9 @@ export type ConvexGameApi = {
14
14
  joinServer: FunctionReference<"mutation", "public", {
15
15
  gameId: string;
16
16
  serverId?: string;
17
- slotsPerServer?: number;
18
- save?: unknown;
19
17
  mode?: string;
18
+ visibility?: "public" | "private";
19
+ joinCode?: string;
20
20
  externalId?: string;
21
21
  }, {
22
22
  serverId: string;
@@ -60,6 +60,7 @@ export type ConvexGameApi = {
60
60
  presence: {
61
61
  list: FunctionReference<"query", "public", {
62
62
  serverId: string;
63
+ externalId?: string;
63
64
  }, PresencePoseRow[]>;
64
65
  sync: FunctionReference<"mutation", "public", {
65
66
  serverId: string;
@@ -73,7 +73,7 @@ export function createConvexPresenceSync(client, api, config, tuning) {
73
73
  const gate = createPoseSyncGate(tuning ?? DEFAULT_CONVEX_POSE_TUNING);
74
74
  return {
75
75
  subscribe(serverId, onChange) {
76
- return watchConvexQuery(client, api.presence.list, { serverId }, (rows) => rows, onChange);
76
+ return watchConvexQuery(client, api.presence.list, { serverId, externalId: config.userId }, (rows) => rows, onChange);
77
77
  },
78
78
  syncPose(serverId, pose) {
79
79
  if (!gate.evaluate(pose, Date.now()))
@@ -0,0 +1,7 @@
1
+ export * from "./convexChatTransport.js";
2
+ export * from "./convexPresenceTransport.js";
3
+ export * from "./createConvexBackend.js";
4
+ export * from "./createConvexGameTransport.js";
5
+ export * from "./occ.js";
6
+ export * from "./resolveConvexMultiplayer.js";
7
+ export * from "./server.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./convexChatTransport.js";
2
+ export * from "./convexPresenceTransport.js";
3
+ export * from "./createConvexBackend.js";
4
+ export * from "./createConvexGameTransport.js";
5
+ export * from "./occ.js";
6
+ export * from "./resolveConvexMultiplayer.js";
7
+ export * from "./server.js";
package/dist/occ.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
+ import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
3
+ export declare const REVISION_CONFLICT_REASON = "Revision conflict";
4
+ export declare function commitIfRevisionMatch(loadedRevision: number, currentRevision: number): {
5
+ ok: true;
6
+ } | {
7
+ ok: false;
8
+ reason: string;
9
+ };
10
+ export declare function applyCommandWithOcc(args: {
11
+ loadedRevision: number;
12
+ currentRevision: number;
13
+ snapshot: GameRuntimeSnapshot;
14
+ runtime: GameRuntime;
15
+ actorUserId: string;
16
+ command: string;
17
+ input: unknown;
18
+ }): {
19
+ ok: true;
20
+ snapshot: GameRuntimeSnapshot;
21
+ } | {
22
+ ok: false;
23
+ reason: string;
24
+ };
package/dist/occ.js ADDED
@@ -0,0 +1,16 @@
1
+ export const REVISION_CONFLICT_REASON = "Revision conflict";
2
+ export function commitIfRevisionMatch(loadedRevision, currentRevision) {
3
+ if (currentRevision !== loadedRevision) {
4
+ return { ok: false, reason: REVISION_CONFLICT_REASON };
5
+ }
6
+ return { ok: true };
7
+ }
8
+ export function applyCommandWithOcc(args) {
9
+ const gate = commitIfRevisionMatch(args.loadedRevision, args.currentRevision);
10
+ if (!gate.ok)
11
+ return gate;
12
+ if (args.snapshot.revision !== args.loadedRevision) {
13
+ return { ok: false, reason: REVISION_CONFLICT_REASON };
14
+ }
15
+ return args.runtime.runCommand(args.snapshot, args.actorUserId, args.command, args.input);
16
+ }
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/s
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<{
@@ -163,6 +168,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
163
168
  createdAt: number;
164
169
  dirtyAt?: number | undefined;
165
170
  gameId: string;
171
+ joinCode?: string | undefined;
166
172
  lastSavedAt?: number | undefined;
167
173
  memberUserIds: string[];
168
174
  mode?: string | undefined;
@@ -178,11 +184,14 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
178
184
  status: "closed" | "open" | "running";
179
185
  tickAnchorMs: number;
180
186
  updatedAt: number;
187
+ visibility?: "private" | "public" | undefined;
181
188
  }, {
182
189
  gameId: import("convex/values").VString<string, "required">;
183
190
  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
191
  mode: import("convex/values").VString<string | undefined, "optional">;
185
192
  modeConfig: import("convex/values").VAny<any, "optional", string>;
193
+ visibility: import("convex/values").VUnion<"private" | "public" | undefined, [import("convex/values").VLiteral<"public", "required">, import("convex/values").VLiteral<"private", "required">], "optional", never>;
194
+ joinCode: import("convex/values").VString<string | undefined, "optional">;
186
195
  memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
187
196
  slotsPerServer: import("convex/values").VFloat64<number, "required">;
188
197
  save: import("convex/values").VUnion<"none" | {
@@ -203,7 +212,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
203
212
  dirtyAt: import("convex/values").VFloat64<number | undefined, "optional">;
204
213
  createdAt: import("convex/values").VFloat64<number, "required">;
205
214
  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}`>, {
215
+ }, "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
216
  by_game_and_status: ["gameId", "status", "_creationTime"];
208
217
  }, {}, {}>;
209
218
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -315,23 +324,23 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
315
324
  type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
316
325
  type JGMutationCtx = GenericMutationCtx<JGDataModel>;
317
326
  export type JgAuthMode = "anonymous" | "required";
327
+ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
318
328
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
319
329
  export declare const JG_RUNTIME_TICK_MS = 1000;
320
330
  export declare function createGameServerFunctions(options?: {
321
331
  runtimes?: GameRuntime[];
322
332
  auth?: JgAuthMode;
333
+ slotsPerServer?: number;
334
+ allowedFeedActions?: readonly string[];
323
335
  }): {
324
336
  joinServer: import("convex/server").RegisteredMutation<"public", {
325
337
  externalId?: string | undefined;
326
338
  gameId: string;
339
+ joinCode?: string | undefined;
327
340
  mode?: string | undefined;
328
341
  modeConfig?: any;
329
- save?: "none" | {
330
- auto: string;
331
- scope: "chunks" | "player" | "player+chunks";
332
- } | undefined;
333
342
  serverId?: GenericId<"jgGameServers"> | undefined;
334
- slotsPerServer?: number | undefined;
343
+ visibility?: "private" | "public" | undefined;
335
344
  }, Promise<{
336
345
  serverId: GenericId<"jgGameServers">;
337
346
  isNew: boolean;
@@ -371,7 +380,9 @@ export declare function createGameServerFunctions(options?: {
371
380
  };
372
381
  revision: number;
373
382
  serverState: any;
374
- sessionPlayers: any;
383
+ sessionPlayers: {
384
+ [x: string]: {} | null;
385
+ };
375
386
  updatedAt: number;
376
387
  } | null>>;
377
388
  getPlayerProfile: import("convex/server").RegisteredQuery<"public", {
@@ -448,6 +459,7 @@ export declare function createLeaderboardFunctions(options?: {
448
459
  export declare function createPresenceFunctions(options?: {
449
460
  auth?: JgAuthMode;
450
461
  freshWindowMs?: number;
462
+ poseRules?: PoseSyncRules;
451
463
  }): {
452
464
  list: import("convex/server").RegisteredQuery<"public", {
453
465
  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,
@@ -86,6 +91,13 @@ const schemaForTypes = defineSchema(jgengineTables());
86
91
  const query = queryGeneric;
87
92
  const mutation = mutationGeneric;
88
93
  const internalMutation = internalMutationGeneric;
94
+ export const DEFAULT_CONVEX_POSE_RULES = {
95
+ maxSpeed: 12,
96
+ maxVerticalOffset: 3,
97
+ minElapsedSec: 0.05,
98
+ maxElapsedSec: 0.5,
99
+ keepAliveRefreshMs: 10_000,
100
+ };
89
101
  async function resolveActor(ctx, claimedExternalId, mode) {
90
102
  const identity = await ctx.auth.getUserIdentity();
91
103
  if (identity !== null) {
@@ -101,6 +113,14 @@ async function resolveActor(ctx, claimedExternalId, mode) {
101
113
  }
102
114
  return null;
103
115
  }
116
+ async function requireServerMember(ctx, serverId, actorUserId) {
117
+ const server = await ctx.db.get("jgGameServers", serverId);
118
+ if (!server)
119
+ return null;
120
+ if (!server.memberUserIds.includes(actorUserId))
121
+ return null;
122
+ return server;
123
+ }
104
124
  const builtinRuntimeCommands = {
105
125
  "engine.ping": {
106
126
  validate: (_snapshot) => null,
@@ -132,6 +152,8 @@ function serverDocToRecord(server) {
132
152
  status: server.status,
133
153
  mode: server.mode,
134
154
  modeConfig: server.modeConfig,
155
+ visibility: server.visibility,
156
+ joinCode: server.joinCode,
135
157
  memberUserIds: server.memberUserIds,
136
158
  slotsPerServer: server.slotsPerServer,
137
159
  save: server.save,
@@ -177,6 +199,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
177
199
  serverRow: server.serverState,
178
200
  playersByUserId,
179
201
  chunksByKey,
202
+ revision: server.revision,
180
203
  });
181
204
  }
182
205
  export async function applyLeaderboardIncrements(ctx, gameId, entries) {
@@ -254,6 +277,14 @@ async function persistServerSnapshot(ctx, server, snapshot, save) {
254
277
  });
255
278
  }
256
279
  }
280
+ for (const chunkKey of plan.deletedChunks) {
281
+ const existing = await ctx.db
282
+ .query("jgWorldChunks")
283
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
284
+ .unique();
285
+ if (existing)
286
+ await ctx.db.delete(existing._id);
287
+ }
257
288
  await ctx.db.patch(server._id, {
258
289
  serverState: plan.server.serverState,
259
290
  sessionPlayers: plan.server.sessionPlayers,
@@ -273,16 +304,18 @@ async function flushServerIfDue(ctx, server, now, runtime) {
273
304
  }
274
305
  export const JG_RUNTIME_TICK_MS = 1_000;
275
306
  export function createGameServerFunctions(options) {
276
- const mode = options?.auth ?? "anonymous";
307
+ const mode = options?.auth ?? "required";
308
+ const hostSlotsPerServer = options?.slotsPerServer ?? 16;
309
+ const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
277
310
  const registry = buildRuntimeRegistry(options?.runtimes);
278
311
  const joinServer = mutation({
279
312
  args: {
280
313
  gameId: v.string(),
281
314
  serverId: v.optional(v.id("jgGameServers")),
282
- slotsPerServer: v.optional(v.number()),
283
- save: v.optional(saveConfigValidator),
284
315
  mode: v.optional(v.string()),
285
316
  modeConfig: v.optional(v.any()),
317
+ visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
318
+ joinCode: v.optional(v.string()),
286
319
  externalId: v.optional(v.string()),
287
320
  },
288
321
  returns: v.object({
@@ -296,9 +329,12 @@ export function createGameServerFunctions(options) {
296
329
  }
297
330
  const now = Date.now();
298
331
  const runtime = resolveRuntime(registry, args.gameId);
299
- const save = (args.save ?? runtime.save);
300
- const slotsPerServer = args.slotsPerServer ?? 16;
332
+ const save = runtime.save;
333
+ const slotsPerServer = hostSlotsPerServer;
301
334
  let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
335
+ if (args.serverId !== undefined && !server) {
336
+ throw new ConvexError("Server not found");
337
+ }
302
338
  if (server && server.gameId !== args.gameId) {
303
339
  throw new ConvexError("Server belongs to a different game");
304
340
  }
@@ -312,8 +348,13 @@ export function createGameServerFunctions(options) {
312
348
  joinable.push(...rows);
313
349
  }
314
350
  server =
315
- joinable.find((row) => row.memberUserIds.includes(actorUserId) ||
316
- row.memberUserIds.length < row.slotsPerServer) ?? null;
351
+ joinable.find((row) => {
352
+ if (row.memberUserIds.includes(actorUserId))
353
+ return true;
354
+ if (row.memberUserIds.length >= row.slotsPerServer)
355
+ return false;
356
+ return (row.visibility ?? "public") !== "private";
357
+ }) ?? null;
317
358
  }
318
359
  if (!server) {
319
360
  const serverId = await ctx.db.insert("jgGameServers", {
@@ -321,6 +362,8 @@ export function createGameServerFunctions(options) {
321
362
  status: "running",
322
363
  mode: args.mode,
323
364
  modeConfig: args.modeConfig,
365
+ visibility: args.visibility ?? "public",
366
+ joinCode: args.joinCode,
324
367
  memberUserIds: [],
325
368
  slotsPerServer,
326
369
  save,
@@ -339,6 +382,11 @@ export function createGameServerFunctions(options) {
339
382
  !server.memberUserIds.includes(actorUserId)) {
340
383
  throw new ConvexError("Server is full");
341
384
  }
385
+ if ((server.visibility ?? "public") === "private" &&
386
+ !server.memberUserIds.includes(actorUserId) &&
387
+ args.serverId === undefined) {
388
+ throw new ConvexError("Server is private");
389
+ }
342
390
  const profile = await ctx.db
343
391
  .query("jgPlayerProfiles")
344
392
  .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
@@ -421,13 +469,30 @@ export function createGameServerFunctions(options) {
421
469
  if (!server.memberUserIds.includes(actorUserId)) {
422
470
  return { ok: false, reason: "Not a member of this server" };
423
471
  }
472
+ const loadedRevision = server.revision;
424
473
  const runtime = resolveRuntime(registry, server.gameId);
425
474
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
426
- const result = runtime.runCommand(snapshot, actorUserId, args.command, args.input);
475
+ const result = applyCommandWithOcc({
476
+ loadedRevision,
477
+ currentRevision: server.revision,
478
+ snapshot,
479
+ runtime,
480
+ actorUserId,
481
+ command: args.command,
482
+ input: args.input,
483
+ });
427
484
  if (!result.ok) {
428
485
  return result;
429
486
  }
430
- await persistServerSnapshot(ctx, server, result.snapshot, server.save);
487
+ const latest = await ctx.db.get("jgGameServers", args.serverId);
488
+ if (!latest) {
489
+ return { ok: false, reason: "Server not found" };
490
+ }
491
+ const commit = commitIfRevisionMatch(loadedRevision, latest.revision);
492
+ if (!commit.ok) {
493
+ return commit;
494
+ }
495
+ await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
431
496
  return { ok: true };
432
497
  },
433
498
  });
@@ -480,6 +545,8 @@ export function createGameServerFunctions(options) {
480
545
  return null;
481
546
  if (!server.memberUserIds.includes(actorUserId))
482
547
  return null;
548
+ const sessionPlayers = server.sessionPlayers;
549
+ const ownSession = sessionPlayers[actorUserId];
483
550
  return {
484
551
  _id: server._id,
485
552
  gameId: server.gameId,
@@ -490,7 +557,7 @@ export function createGameServerFunctions(options) {
490
557
  save: server.save,
491
558
  revision: server.revision,
492
559
  serverState: server.serverState,
493
- sessionPlayers: server.sessionPlayers,
560
+ sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
494
561
  updatedAt: server.updatedAt,
495
562
  };
496
563
  },
@@ -560,6 +627,10 @@ export function createGameServerFunctions(options) {
560
627
  if (!actorUserId) {
561
628
  throw new ConvexError("Not authenticated");
562
629
  }
630
+ const allowed = validateFeedWrite(feedWriteGate, args.action);
631
+ if (!allowed.ok) {
632
+ throw new ConvexError(allowed.reason);
633
+ }
563
634
  const server = await ctx.db.get("jgGameServers", args.serverId);
564
635
  if (!server || !server.memberUserIds.includes(actorUserId)) {
565
636
  throw new ConvexError("Not a member of this server");
@@ -740,8 +811,9 @@ export function createLeaderboardFunctions(options) {
740
811
  return { getTop, getProfile, incrementMany };
741
812
  }
742
813
  export function createPresenceFunctions(options) {
743
- const mode = options?.auth ?? "anonymous";
814
+ const mode = options?.auth ?? "required";
744
815
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
816
+ const poseRules = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
745
817
  const list = query({
746
818
  args: {
747
819
  serverId: v.string(),
@@ -755,6 +827,11 @@ export function createPresenceFunctions(options) {
755
827
  lastSeenAt: v.number(),
756
828
  })),
757
829
  handler: async (ctx, args) => {
830
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
831
+ if (!actorUserId)
832
+ return [];
833
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
834
+ return [];
758
835
  const threshold = Date.now() - freshWindowMs;
759
836
  const rows = await ctx.db
760
837
  .query("jgPoses")
@@ -788,21 +865,38 @@ export function createPresenceFunctions(options) {
788
865
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
789
866
  if (!actorUserId)
790
867
  return null;
868
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
869
+ return null;
791
870
  const now = Date.now();
792
871
  const existing = await ctx.db
793
872
  .query("jgPoses")
794
873
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
795
874
  .unique();
796
- const patch = {
797
- x: args.pose.x,
798
- y: args.pose.y,
799
- z: args.pose.z,
875
+ const current = existing === null
876
+ ? spawnPresenceState(undefined, now, poseRules)
877
+ : {
878
+ position: { x: existing.x, y: existing.y, z: existing.z },
879
+ rotationY: existing.rotationY,
880
+ rotationPitch: existing.rotationPitch,
881
+ lastSeenAtMs: existing.updatedAt,
882
+ };
883
+ const decision = decidePoseSync(current, {
884
+ position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
800
885
  rotationY: args.pose.rotationY,
801
886
  rotationPitch: args.pose.rotationPitch,
887
+ }, poseRules, now);
888
+ const patch = {
889
+ x: decision.position.x,
890
+ y: decision.position.y,
891
+ z: decision.position.z,
892
+ rotationY: decision.rotationY,
893
+ rotationPitch: decision.rotationPitch,
802
894
  updatedAt: now,
803
895
  };
804
896
  if (existing) {
805
- await ctx.db.patch(existing._id, patch);
897
+ if (decision.changed || decision.refreshKeepAlive) {
898
+ await ctx.db.patch(existing._id, patch);
899
+ }
806
900
  }
807
901
  else {
808
902
  await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
@@ -832,7 +926,7 @@ export function createPresenceFunctions(options) {
832
926
  return { list, sync, leave };
833
927
  }
834
928
  export function createChatFunctions(options) {
835
- const mode = options?.auth ?? "anonymous";
929
+ const mode = options?.auth ?? "required";
836
930
  const historyLimit = options?.historyLimit ?? 100;
837
931
  const maxBodyLength = options?.maxBodyLength ?? 500;
838
932
  const minIntervalMs = options?.minIntervalMs ?? 300;
@@ -850,6 +944,11 @@ export function createChatFunctions(options) {
850
944
  at: v.number(),
851
945
  })),
852
946
  handler: async (ctx, args) => {
947
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
948
+ if (!actorUserId)
949
+ return [];
950
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
951
+ return [];
853
952
  const rows = await ctx.db
854
953
  .query("jgChatMessages")
855
954
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -879,6 +978,9 @@ export function createChatFunctions(options) {
879
978
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
880
979
  if (!actorUserId)
881
980
  return { ok: false, reason: "not signed in" };
981
+ if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
982
+ return { ok: false, reason: "not a member of this server" };
983
+ }
882
984
  const body = args.body.trim();
883
985
  if (body.length === 0)
884
986
  return { ok: false, reason: "empty message" };