@jgengine/convex 0.17.0 → 0.18.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
@@ -26,14 +26,30 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.18.0
30
+
29
31
  ### Migrate
30
32
 
33
+ - **Bump lockstep SDK packages to `^0.18.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.
34
+ - **Register the presence reaper and redeploy your schema.** `jgPoses` gains `sessionId` / `kind` / `label` / `revokedAt` (all optional) and a `by_updated` index, and `createPresenceFunctions` now also returns `reapIdlePresence`. Re-export it (`export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions(...)`) and add `crons.interval("jg presence reap", { seconds: 60 }, internal.presence.reapIdlePresence, {})`. Without it nothing deletes the row of a player who closed the tab — `list` filtered stale rows at read time but the table grew forever. `jgengineCronSpecs()` carries the new entry, and every spec now names the `module` (`"runtime"` / `"presence"`) it is exported from.
35
+ - **`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`,** and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.
36
+
31
37
  ### Changed
32
38
 
39
+ - **`presence.sync` accepts a call with no `pose` at all,** which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.
40
+ - **`PresencePoseRow` carries optional `sessionId` / `kind` / `label`,** so a nameplate reads off the presence row instead of joining against users per frame.
41
+
33
42
  ### Added
34
43
 
44
+ - **`createPresenceFunctions` gains the policy hooks the pose lane was missing** — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.
45
+
46
+ - **A command declares what it touches, so `runCommand` hydrates that and nothing else.** `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.
47
+ - **`GameRuntime.commandScope` / `GameRuntime.joinScope`** expose those declarations to any host, alongside the pure helpers `resolveCommandScope` / `scopeWithActor` / `commandScopeEscape` (`@jgengine/core/runtime/commandRunner`).
48
+
35
49
  ### Fixed
36
50
 
51
+ - **`runCommand` no longer reads the whole world on every command.** `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.
52
+
37
53
  ## 0.17.0
38
54
 
39
55
  ### Migrate
package/README.md CHANGED
@@ -25,7 +25,7 @@ Once a session resolves, `GamePlayerShell` wires it up with no game code changes
25
25
 
26
26
  ## Server
27
27
 
28
- `@jgengine/convex/server` exports factories, not a template to copy: `jgengineTables()`, `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()`. A consumer's `convex/` directory is ~25 lines total:
28
+ `@jgengine/convex/server` exports factories, not a template to copy: `jgengineTables()`, `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs?, idleCutoffMs?, poseRules?, resolveSpawn? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()`. A consumer's `convex/` directory is ~25 lines total:
29
29
 
30
30
  ```ts
31
31
  // convex/schema.ts
@@ -37,7 +37,7 @@ export default defineSchema({ ...jgengineTables() });
37
37
  import { createGameServerFunctions } from "@jgengine/convex/server";
38
38
  export const { joinServer, leaveServer, runCommand, flushSave, getServer, getPlayerProfile, getFeed, pushFeedEntry, listOpenServers, tickActiveServers, flushDirtyServers } = createGameServerFunctions();
39
39
 
40
- // convex/crons.ts registers tickActiveServers (1s) + flushDirtyServers (60s)
40
+ // convex/crons.ts registers tickActiveServers (1s) + flushDirtyServers (60s) + reapIdlePresence (60s)
41
41
  ```
42
42
 
43
43
  No game-specific code lives there — any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand`/tick/save actually do something.
@@ -1,5 +1,11 @@
1
1
  import type { FunctionReference } from "convex/server";
2
2
  import type { PresenceTransport } from "@jgengine/core/multiplayer/presenceContract";
3
+ /**
4
+ * The Convex functions a **game** supplies for a world it owns: presence keyed by the game's own ids,
5
+ * including which residents exist while offline, which is game content and has no engine analogue.
6
+ * Not satisfied by `createPresenceFunctions` (`@jgengine/convex/server`) — that is the pose lane of a
7
+ * hosted `jgGameServers` server, backing `PresenceSync` rather than `PresenceTransport`.
8
+ */
3
9
  export interface ConvexPresenceFunctions {
4
10
  snapshot: FunctionReference<"query">;
5
11
  cityResidents: FunctionReference<"query">;
@@ -64,17 +64,31 @@ export type ConvexGameApi = {
64
64
  }, PresencePoseRow[]>;
65
65
  sync: FunctionReference<"mutation", "public", {
66
66
  serverId: string;
67
- pose: {
67
+ pose?: {
68
68
  x: number;
69
69
  y: number;
70
70
  z: number;
71
71
  rotationY: number;
72
72
  rotationPitch: number;
73
73
  };
74
+ sessionId?: string;
75
+ kind?: string;
76
+ label?: string;
74
77
  externalId?: string;
75
- }, null>;
78
+ }, {
79
+ pose: {
80
+ x: number;
81
+ y: number;
82
+ z: number;
83
+ rotationY: number;
84
+ rotationPitch: number;
85
+ };
86
+ lastSeenAt: number;
87
+ displaced: boolean;
88
+ } | null>;
76
89
  leave: FunctionReference<"mutation", "public", {
77
90
  serverId: string;
91
+ sessionId?: string;
78
92
  externalId?: string;
79
93
  }, null>;
80
94
  };
@@ -69,17 +69,30 @@ const DEFAULT_CONVEX_POSE_TUNING = {
69
69
  verticalEpsilon: 0.01,
70
70
  rotationEpsilon: 0.01,
71
71
  };
72
+ /** How long a suppressed pose stream may go quiet before a position-less keep-alive is sent. */
73
+ const KEEP_ALIVE_MS = 10_000;
72
74
  export function createConvexPresenceSync(client, api, config, tuning) {
73
75
  const gate = createPoseSyncGate(tuning ?? DEFAULT_CONVEX_POSE_TUNING);
76
+ const lastSentAt = new Map();
74
77
  return {
75
78
  subscribe(serverId, onChange) {
76
79
  return watchConvexQuery(client, api.presence.list, { serverId, externalId: config.userId }, (rows) => rows, onChange);
77
80
  },
78
81
  syncPose(serverId, pose) {
79
- if (!gate.evaluate(pose, Date.now()))
82
+ const now = Date.now();
83
+ const moved = gate.evaluate(pose, now);
84
+ // The gate drops an unchanged pose, so a standing player would otherwise send nothing at all
85
+ // and the reaper would eventually collect a live session. Keep the row alive without a write.
86
+ const keepAlive = !moved && now - (lastSentAt.get(serverId) ?? 0) >= KEEP_ALIVE_MS;
87
+ if (!moved && !keepAlive)
80
88
  return;
89
+ lastSentAt.set(serverId, now);
81
90
  void client
82
- .mutation(api.presence.sync, { serverId, pose, externalId: config.userId })
91
+ .mutation(api.presence.sync, {
92
+ serverId,
93
+ ...(moved ? { pose } : {}),
94
+ externalId: config.userId,
95
+ })
83
96
  .catch(() => undefined);
84
97
  },
85
98
  };
package/dist/server.d.ts CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
6
+ import type { CommandScope } from "@jgengine/core/runtime/commandRunner";
6
7
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
7
8
  import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
8
9
  import { type MatchmakingMode } from "@jgengine/core/runtime/hostPolicy";
@@ -135,9 +136,13 @@ export declare function jgengineTables(): {
135
136
  by_server_and_action: ["serverId", "action", "_creationTime"];
136
137
  }, {}, {}>;
137
138
  jgPoses: import("convex/server").TableDefinition<import("convex/values").VObject<{
139
+ kind?: string | undefined;
140
+ label?: string | undefined;
141
+ revokedAt?: number | undefined;
138
142
  rotationPitch: number;
139
143
  rotationY: number;
140
144
  serverId: string;
145
+ sessionId?: string | undefined;
141
146
  updatedAt: number;
142
147
  userId: string;
143
148
  x: number;
@@ -146,15 +151,24 @@ export declare function jgengineTables(): {
146
151
  }, {
147
152
  serverId: import("convex/values").VString<string, "required">;
148
153
  userId: import("convex/values").VString<string, "required">;
154
+ /** One row per live session, so a second tab is a second row rather than a fight over one. */
155
+ sessionId: import("convex/values").VString<string | undefined, "optional">;
156
+ /** Actor class, e.g. `"player"` / `"agent"`, which selects the pose rules applied to it. */
157
+ kind: import("convex/values").VString<string | undefined, "optional">;
158
+ /** Display name carried on the row, so nameplates need no per-frame join against users. */
159
+ label: import("convex/values").VString<string | undefined, "optional">;
149
160
  x: import("convex/values").VFloat64<number, "required">;
150
161
  y: import("convex/values").VFloat64<number, "required">;
151
162
  z: import("convex/values").VFloat64<number, "required">;
152
163
  rotationY: import("convex/values").VFloat64<number, "required">;
153
164
  rotationPitch: import("convex/values").VFloat64<number, "required">;
154
165
  updatedAt: import("convex/values").VFloat64<number, "required">;
155
- }, "required", "rotationPitch" | "rotationY" | "serverId" | "updatedAt" | "userId" | "x" | "y" | "z">, {
166
+ /** Set when the session was displaced or left; the reaper deletes the row once it goes idle. */
167
+ revokedAt: import("convex/values").VFloat64<number | undefined, "optional">;
168
+ }, "required", "kind" | "label" | "revokedAt" | "rotationPitch" | "rotationY" | "serverId" | "sessionId" | "updatedAt" | "userId" | "x" | "y" | "z">, {
156
169
  by_server: ["serverId", "_creationTime"];
157
170
  by_server_and_user: ["serverId", "userId", "_creationTime"];
171
+ by_updated: ["updatedAt", "_creationTime"];
158
172
  }, {}, {}>;
159
173
  jgChatMessages: import("convex/server").TableDefinition<import("convex/values").VObject<{
160
174
  at: number;
@@ -328,9 +342,13 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
328
342
  by_server_and_action: ["serverId", "action", "_creationTime"];
329
343
  }, {}, {}>;
330
344
  jgPoses: import("convex/server").TableDefinition<import("convex/values").VObject<{
345
+ kind?: string | undefined;
346
+ label?: string | undefined;
347
+ revokedAt?: number | undefined;
331
348
  rotationPitch: number;
332
349
  rotationY: number;
333
350
  serverId: string;
351
+ sessionId?: string | undefined;
334
352
  updatedAt: number;
335
353
  userId: string;
336
354
  x: number;
@@ -339,15 +357,24 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
339
357
  }, {
340
358
  serverId: import("convex/values").VString<string, "required">;
341
359
  userId: import("convex/values").VString<string, "required">;
360
+ /** One row per live session, so a second tab is a second row rather than a fight over one. */
361
+ sessionId: import("convex/values").VString<string | undefined, "optional">;
362
+ /** Actor class, e.g. `"player"` / `"agent"`, which selects the pose rules applied to it. */
363
+ kind: import("convex/values").VString<string | undefined, "optional">;
364
+ /** Display name carried on the row, so nameplates need no per-frame join against users. */
365
+ label: import("convex/values").VString<string | undefined, "optional">;
342
366
  x: import("convex/values").VFloat64<number, "required">;
343
367
  y: import("convex/values").VFloat64<number, "required">;
344
368
  z: import("convex/values").VFloat64<number, "required">;
345
369
  rotationY: import("convex/values").VFloat64<number, "required">;
346
370
  rotationPitch: import("convex/values").VFloat64<number, "required">;
347
371
  updatedAt: import("convex/values").VFloat64<number, "required">;
348
- }, "required", "rotationPitch" | "rotationY" | "serverId" | "updatedAt" | "userId" | "x" | "y" | "z">, {
372
+ /** Set when the session was displaced or left; the reaper deletes the row once it goes idle. */
373
+ revokedAt: import("convex/values").VFloat64<number | undefined, "optional">;
374
+ }, "required", "kind" | "label" | "revokedAt" | "rotationPitch" | "rotationY" | "serverId" | "sessionId" | "updatedAt" | "userId" | "x" | "y" | "z">, {
349
375
  by_server: ["serverId", "_creationTime"];
350
376
  by_server_and_user: ["serverId", "userId", "_creationTime"];
377
+ by_updated: ["updatedAt", "_creationTime"];
351
378
  }, {}, {}>;
352
379
  jgChatMessages: import("convex/server").TableDefinition<import("convex/values").VObject<{
353
380
  at: number;
@@ -389,13 +416,11 @@ export declare function resolveActor(ctx: {
389
416
  *
390
417
  * Persisting a narrowed snapshot is safe: the plan merges session state over the stored map and
391
418
  * writes only dirty profiles and dirty chunks, so members and chunks left unhydrated are untouched.
419
+ *
420
+ * The registered `runCommand` mutation takes one, and a {@link CommandDef} can derive its own from
421
+ * its input via `scope`, so a game gets bounded hydration without writing its own mutation.
392
422
  */
393
- export type LoadSnapshotScope = {
394
- /** Member ids to hydrate; omit for the whole roster. */
395
- players?: readonly string[];
396
- /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */
397
- chunkKeys?: readonly string[];
398
- };
423
+ export type LoadSnapshotScope = CommandScope;
399
424
  /**
400
425
  * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
401
426
  * Reach for it when writing your own mutation that must read runtime state before touching host tables.
@@ -430,6 +455,11 @@ export type RunCommandArgs = {
430
455
  command: string;
431
456
  input: unknown;
432
457
  externalId?: string;
458
+ /**
459
+ * How much of the server to hydrate before applying the command. Overrides whatever the
460
+ * {@link CommandDef} declares through its own `scope`; omit both to hydrate the whole world.
461
+ */
462
+ scope?: LoadSnapshotScope;
433
463
  };
434
464
  /** A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. */
435
465
  export type LoadedServerSnapshot = {
@@ -508,6 +538,10 @@ export declare function createGameServerFunctions(options?: {
508
538
  command: string;
509
539
  externalId?: string | undefined;
510
540
  input: any;
541
+ scope?: {
542
+ chunkKeys?: string[] | undefined;
543
+ players?: string[] | undefined;
544
+ } | undefined;
511
545
  serverId: GenericId<"jgGameServers">;
512
546
  }, Promise<RunCommandOutcome>>;
513
547
  flushSave: import("convex/server").RegisteredMutation<"public", {
@@ -630,41 +664,92 @@ export declare function createLeaderboardFunctions(options?: {
630
664
  gameId: string;
631
665
  }, Promise<null>>;
632
666
  };
633
- /** @internal */
667
+ /** One member's pose as `list` reports it. */
668
+ export type PresenceListRow = {
669
+ userId: string;
670
+ sessionId?: string;
671
+ kind?: string;
672
+ label?: string;
673
+ position: {
674
+ x: number;
675
+ y: number;
676
+ z: number;
677
+ };
678
+ rotationY: number;
679
+ rotationPitch: number;
680
+ lastSeenAt: number;
681
+ };
682
+ /** What `sync` hands back: the pose the server holds after clamping, and whether this session had been displaced. */
683
+ export type PresenceSyncResult = {
684
+ pose: {
685
+ x: number;
686
+ y: number;
687
+ z: number;
688
+ rotationY: number;
689
+ rotationPitch: number;
690
+ };
691
+ lastSeenAt: number;
692
+ displaced: boolean;
693
+ };
694
+ /**
695
+ * The pose lane of a hosted server: the backend behind `PresenceSync`, which replicates where each
696
+ * member of one `jgGameServers` server is standing. It is not the backend for `PresenceTransport` —
697
+ * that contract (`ConvexPresenceFunctions` in `./convexPresenceTransport`) is a game-owned world's
698
+ * own presence, keyed by the game's own ids, with its own notion of who counts as a resident. A game
699
+ * on `createConvexPresenceTransport` supplies those functions itself; these do not substitute for them.
700
+ * @internal
701
+ */
634
702
  export declare function createPresenceFunctions(options?: {
635
703
  auth?: JgAuthMode;
704
+ /** How recently a row must have been written to appear in `list`. */
636
705
  freshWindowMs?: number;
637
- poseRules?: PoseSyncRules;
706
+ /** How long a row may go unwritten before `reapIdlePresence` deletes it. */
707
+ idleCutoffMs?: number;
708
+ /** Rows deleted per reaper run, so one sweep is a bounded transaction. */
709
+ maxReapPerRun?: number;
710
+ /** Movement clamping, optionally per actor `kind` — an agent need not obey a human's speed cap. */
711
+ poseRules?: PoseSyncRules | ((kind: string | undefined) => PoseSyncRules);
712
+ /** Where a session with no row starts. Async and ctx-bearing, so a spawn can read the world. */
713
+ resolveSpawn?: (ctx: JGMutationCtx, args: {
714
+ serverId: string;
715
+ userId: string;
716
+ kind?: string;
717
+ }) => Promise<{
718
+ x: number;
719
+ y: number;
720
+ z: number;
721
+ } | null> | {
722
+ x: number;
723
+ y: number;
724
+ z: number;
725
+ } | null;
638
726
  }): {
639
727
  list: import("convex/server").RegisteredQuery<"public", {
640
728
  externalId?: string | undefined;
641
729
  serverId: string;
642
- }, Promise<{
643
- userId: string;
644
- position: {
645
- x: number;
646
- y: number;
647
- z: number;
648
- };
649
- rotationY: number;
650
- rotationPitch: number;
651
- lastSeenAt: number;
652
- }[]>>;
730
+ }, Promise<PresenceListRow[]>>;
653
731
  sync: import("convex/server").RegisteredMutation<"public", {
654
732
  externalId?: string | undefined;
655
- pose: {
733
+ kind?: string | undefined;
734
+ label?: string | undefined;
735
+ pose?: {
656
736
  rotationPitch: number;
657
737
  rotationY: number;
658
738
  x: number;
659
739
  y: number;
660
740
  z: number;
661
- };
741
+ } | undefined;
662
742
  serverId: string;
663
- }, Promise<null>>;
743
+ sessionId?: string | undefined;
744
+ }, Promise<PresenceSyncResult | null>>;
664
745
  leave: import("convex/server").RegisteredMutation<"public", {
665
746
  externalId?: string | undefined;
666
747
  serverId: string;
748
+ sessionId?: string | undefined;
667
749
  }, Promise<null>>;
750
+ reapIdlePresence: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
751
+ reaped: number;
752
+ }>>;
668
753
  };
669
754
  /** @internal */
670
755
  export declare function createChatFunctions(options?: {
@@ -688,7 +773,9 @@ export declare function createChatFunctions(options?: {
688
773
  export type JgCronSpec = {
689
774
  name: string;
690
775
  intervalSeconds: number;
691
- functionKey: "tickActiveServers" | "flushDirtyServers";
776
+ functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence";
777
+ /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through. */
778
+ module: "runtime" | "presence";
692
779
  };
693
780
  /** @internal */
694
781
  export declare function jgengineCronSpecs(): readonly JgCronSpec[];
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
3
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
- import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
4
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
+ import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
5
6
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
7
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
7
8
  import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
@@ -77,15 +78,24 @@ export function jgengineTables() {
77
78
  jgPoses: defineTable({
78
79
  serverId: v.string(),
79
80
  userId: v.string(),
81
+ /** One row per live session, so a second tab is a second row rather than a fight over one. */
82
+ sessionId: v.optional(v.string()),
83
+ /** Actor class, e.g. `"player"` / `"agent"`, which selects the pose rules applied to it. */
84
+ kind: v.optional(v.string()),
85
+ /** Display name carried on the row, so nameplates need no per-frame join against users. */
86
+ label: v.optional(v.string()),
80
87
  x: v.number(),
81
88
  y: v.number(),
82
89
  z: v.number(),
83
90
  rotationY: v.number(),
84
91
  rotationPitch: v.number(),
85
92
  updatedAt: v.number(),
93
+ /** Set when the session was displaced or left; the reaper deletes the row once it goes idle. */
94
+ revokedAt: v.optional(v.number()),
86
95
  })
87
96
  .index("by_server", ["serverId"])
88
- .index("by_server_and_user", ["serverId", "userId"]),
97
+ .index("by_server_and_user", ["serverId", "userId"])
98
+ .index("by_updated", ["updatedAt"]),
89
99
  jgChatMessages: defineTable({
90
100
  serverId: v.string(),
91
101
  channelId: v.string(),
@@ -385,6 +395,7 @@ async function flushServerIfDue(ctx, server, now, runtime, loaded) {
385
395
  if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
386
396
  return false;
387
397
  }
398
+ // Unscoped on purpose: markAllPlayersDirty writes every member, so every member has to be hydrated.
388
399
  const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
389
400
  await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
390
401
  return true;
@@ -532,7 +543,7 @@ export function createGameServerFunctions(options) {
532
543
  const refreshed = await ctx.db.get("jgGameServers", server._id);
533
544
  if (!refreshed)
534
545
  throw new ConvexError("Server missing after join");
535
- let snapshot = await loadServerSnapshot(ctx, refreshed, runtime);
546
+ let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
536
547
  snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
537
548
  await persistServerSnapshot(ctx, refreshed, snapshot, save);
538
549
  return { serverId: refreshed._id, isNew };
@@ -551,7 +562,11 @@ export function createGameServerFunctions(options) {
551
562
  return null;
552
563
  const now = Date.now();
553
564
  const runtime = resolveRuntime(registry, server.gameId);
554
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
565
+ // Flushes the leaver's own state before the roster drops them; nobody else's rows are touched.
566
+ const snapshot = await loadServerSnapshot(ctx, server, runtime, {
567
+ players: [actorUserId],
568
+ chunkKeys: [],
569
+ });
555
570
  await persistServerSnapshot(ctx, server, snapshot, server.save);
556
571
  const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
557
572
  const sessionPlayers = { ...server.sessionPlayers };
@@ -598,7 +613,8 @@ export function createGameServerFunctions(options) {
598
613
  }
599
614
  const loadedRevision = server.revision;
600
615
  const runtime = resolveRuntime(registry, server.gameId);
601
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
616
+ const scope = scopeWithActor(args.scope ?? runtime.commandScope(args.command, args.input, actorUserId), actorUserId);
617
+ const snapshot = await loadServerSnapshot(ctx, server, runtime, scope);
602
618
  const result = applyCommandWithOcc({
603
619
  loadedRevision,
604
620
  currentRevision: server.revision,
@@ -611,6 +627,10 @@ export function createGameServerFunctions(options) {
611
627
  if (!result.ok) {
612
628
  return result;
613
629
  }
630
+ const escape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
631
+ if (escape !== null) {
632
+ return { ok: false, reason: escape };
633
+ }
614
634
  const latest = await ctx.db.get("jgGameServers", serverId);
615
635
  if (!latest) {
616
636
  return { ok: false, reason: "Server not found" };
@@ -629,6 +649,10 @@ export function createGameServerFunctions(options) {
629
649
  command: v.string(),
630
650
  input: v.any(),
631
651
  externalId: v.optional(v.string()),
652
+ scope: v.optional(v.object({
653
+ players: v.optional(v.array(v.string())),
654
+ chunkKeys: v.optional(v.array(v.string())),
655
+ })),
632
656
  },
633
657
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
634
658
  handler: (ctx, args) => helpers.runCommand(ctx, args),
@@ -645,6 +669,7 @@ export function createGameServerFunctions(options) {
645
669
  if (server === null)
646
670
  return false;
647
671
  const runtime = resolveRuntime(registry, server.gameId);
672
+ // Unscoped on purpose: markAllPlayersDirty writes every member.
648
673
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
649
674
  await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
650
675
  return true;
@@ -888,6 +913,7 @@ export function createGameServerFunctions(options) {
888
913
  continue;
889
914
  budget -= 1;
890
915
  const runtime = resolveRuntime(registry, server.gameId);
916
+ // Unscoped on purpose: onTick is handed the whole world and may touch any of it.
891
917
  const loaded = await loadServerSnapshot(ctx, server, runtime);
892
918
  const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
893
919
  await persistServerSnapshot(ctx, server, snapshot, server.save);
@@ -912,6 +938,7 @@ export function createGameServerFunctions(options) {
912
938
  let saved = 0;
913
939
  for (const server of servers) {
914
940
  const runtime = resolveRuntime(registry, server.gameId);
941
+ // Unscoped on purpose: markAllPlayersDirty writes every member.
915
942
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
916
943
  await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
917
944
  saved += 1;
@@ -1002,11 +1029,28 @@ export function createLeaderboardFunctions(options) {
1002
1029
  });
1003
1030
  return { getTop, getProfile, incrementMany };
1004
1031
  }
1005
- /** @internal */
1032
+ /**
1033
+ * The pose lane of a hosted server: the backend behind `PresenceSync`, which replicates where each
1034
+ * member of one `jgGameServers` server is standing. It is not the backend for `PresenceTransport` —
1035
+ * that contract (`ConvexPresenceFunctions` in `./convexPresenceTransport`) is a game-owned world's
1036
+ * own presence, keyed by the game's own ids, with its own notion of who counts as a resident. A game
1037
+ * on `createConvexPresenceTransport` supplies those functions itself; these do not substitute for them.
1038
+ * @internal
1039
+ */
1006
1040
  export function createPresenceFunctions(options) {
1007
1041
  const mode = options?.auth ?? "required";
1008
1042
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
1009
- const poseRules = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
1043
+ const idleCutoffMs = options?.idleCutoffMs ?? 60_000;
1044
+ const maxReapPerRun = options?.maxReapPerRun ?? 256;
1045
+ const rulesOption = options?.poseRules ?? DEFAULT_CONVEX_POSE_RULES;
1046
+ const rulesFor = (kind) => typeof rulesOption === "function" ? rulesOption(kind) : rulesOption;
1047
+ const poseValidator = v.object({
1048
+ x: v.number(),
1049
+ y: v.number(),
1050
+ z: v.number(),
1051
+ rotationY: v.number(),
1052
+ rotationPitch: v.number(),
1053
+ });
1010
1054
  const list = query({
1011
1055
  args: {
1012
1056
  serverId: v.string(),
@@ -1014,6 +1058,9 @@ export function createPresenceFunctions(options) {
1014
1058
  },
1015
1059
  returns: v.array(v.object({
1016
1060
  userId: v.string(),
1061
+ sessionId: v.optional(v.string()),
1062
+ kind: v.optional(v.string()),
1063
+ label: v.optional(v.string()),
1017
1064
  position: v.object({ x: v.number(), y: v.number(), z: v.number() }),
1018
1065
  rotationY: v.number(),
1019
1066
  rotationPitch: v.number(),
@@ -1027,9 +1074,12 @@ export function createPresenceFunctions(options) {
1027
1074
  .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
1028
1075
  .collect();
1029
1076
  return rows
1030
- .filter((row) => row.updatedAt >= threshold)
1077
+ .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
1031
1078
  .map((row) => ({
1032
1079
  userId: row.userId,
1080
+ sessionId: row.sessionId,
1081
+ kind: row.kind,
1082
+ label: row.label,
1033
1083
  position: { x: row.x, y: row.y, z: row.z },
1034
1084
  rotationY: row.rotationY,
1035
1085
  rotationPitch: row.rotationPitch,
@@ -1038,60 +1088,101 @@ export function createPresenceFunctions(options) {
1038
1088
  });
1039
1089
  },
1040
1090
  });
1091
+ /**
1092
+ * Upload a pose, or keep the session alive without one. Returns the pose the server actually holds
1093
+ * after clamping, so a corrected client can reconcile, plus whether this session had been revoked
1094
+ * by another of the same user's sessions since its last sync.
1095
+ */
1041
1096
  const sync = mutation({
1042
1097
  args: {
1043
1098
  serverId: v.string(),
1044
- pose: v.object({
1045
- x: v.number(),
1046
- y: v.number(),
1047
- z: v.number(),
1048
- rotationY: v.number(),
1049
- rotationPitch: v.number(),
1050
- }),
1099
+ pose: v.optional(poseValidator),
1100
+ sessionId: v.optional(v.string()),
1101
+ kind: v.optional(v.string()),
1102
+ label: v.optional(v.string()),
1051
1103
  externalId: v.optional(v.string()),
1052
1104
  },
1053
- returns: v.null(),
1105
+ returns: v.union(v.null(), v.object({
1106
+ pose: poseValidator,
1107
+ lastSeenAt: v.number(),
1108
+ displaced: v.boolean(),
1109
+ })),
1054
1110
  handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
1055
1111
  const now = Date.now();
1056
- const existing = await ctx.db
1112
+ const rows = await ctx.db
1057
1113
  .query("jgPoses")
1058
1114
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
1059
- .unique();
1060
- const current = existing === null
1061
- ? spawnPresenceState(undefined, now, poseRules)
1115
+ .collect();
1116
+ const resolvable = rows.map((row) => ({
1117
+ row,
1118
+ revokedAt: row.revokedAt,
1119
+ lastSeenAt: row.updatedAt,
1120
+ }));
1121
+ const existing = args.sessionId === undefined
1122
+ ? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
1123
+ : rows.find((row) => row.sessionId === args.sessionId);
1124
+ const rules = rulesFor(args.kind ?? existing?.kind);
1125
+ const current = existing === undefined
1126
+ ? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
1127
+ serverId: args.serverId,
1128
+ userId: actorUserId,
1129
+ kind: args.kind,
1130
+ })) ?? undefined, now, rules)
1062
1131
  : {
1063
1132
  position: { x: existing.x, y: existing.y, z: existing.z },
1064
1133
  rotationY: existing.rotationY,
1065
1134
  rotationPitch: existing.rotationPitch,
1066
1135
  lastSeenAtMs: existing.updatedAt,
1067
1136
  };
1068
- const decision = decidePoseSync(current, {
1069
- position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
1070
- rotationY: args.pose.rotationY,
1071
- rotationPitch: args.pose.rotationPitch,
1072
- }, poseRules, now);
1073
- const patch = {
1137
+ // A pose-less call is a keep-alive: hold the stored pose and only move the stamp forward.
1138
+ const decision = decidePoseSync(current, args.pose === undefined
1139
+ ? { position: current.position, rotationY: current.rotationY, rotationPitch: current.rotationPitch }
1140
+ : {
1141
+ position: { x: args.pose.x, y: args.pose.y, z: args.pose.z },
1142
+ rotationY: args.pose.rotationY,
1143
+ rotationPitch: args.pose.rotationPitch,
1144
+ }, rules, now);
1145
+ const pose = {
1074
1146
  x: decision.position.x,
1075
1147
  y: decision.position.y,
1076
1148
  z: decision.position.z,
1077
1149
  rotationY: decision.rotationY,
1078
1150
  rotationPitch: decision.rotationPitch,
1079
- updatedAt: now,
1080
1151
  };
1152
+ const identity = {
1153
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
1154
+ ...(args.kind === undefined ? {} : { kind: args.kind }),
1155
+ ...(args.label === undefined ? {} : { label: args.label }),
1156
+ };
1157
+ const displaced = existing?.revokedAt !== undefined;
1081
1158
  if (existing) {
1082
- if (decision.changed || decision.refreshKeepAlive) {
1083
- await ctx.db.patch(existing._id, patch);
1159
+ if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1160
+ await ctx.db.patch(existing._id, { ...pose, ...identity, updatedAt: now, revokedAt: undefined });
1084
1161
  }
1085
1162
  }
1086
1163
  else {
1087
- await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
1164
+ await ctx.db.insert("jgPoses", {
1165
+ serverId: args.serverId,
1166
+ userId: actorUserId,
1167
+ ...pose,
1168
+ ...identity,
1169
+ updatedAt: now,
1170
+ });
1088
1171
  }
1089
- return null;
1172
+ // This session is the live one; any other session of the same user is revoked, not deleted,
1173
+ // so it learns it was displaced on its next sync instead of silently reappearing.
1174
+ for (const row of rows) {
1175
+ if (row._id === existing?._id || row.revokedAt !== undefined)
1176
+ continue;
1177
+ await ctx.db.patch(row._id, { revokedAt: now });
1178
+ }
1179
+ return { pose, lastSeenAt: now, displaced };
1090
1180
  }),
1091
1181
  });
1092
1182
  const leave = mutation({
1093
1183
  args: {
1094
1184
  serverId: v.string(),
1185
+ sessionId: v.optional(v.string()),
1095
1186
  externalId: v.optional(v.string()),
1096
1187
  },
1097
1188
  returns: v.null(),
@@ -1099,16 +1190,45 @@ export function createPresenceFunctions(options) {
1099
1190
  const actorUserId = await resolveActor(ctx, args.externalId, mode);
1100
1191
  if (!actorUserId)
1101
1192
  return null;
1102
- const existing = await ctx.db
1193
+ const now = Date.now();
1194
+ const rows = await ctx.db
1103
1195
  .query("jgPoses")
1104
1196
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
1105
- .unique();
1106
- if (existing)
1107
- await ctx.db.delete(existing._id);
1197
+ .collect();
1198
+ for (const row of rows) {
1199
+ if (args.sessionId !== undefined && row.sessionId !== args.sessionId)
1200
+ continue;
1201
+ if (row.revokedAt === undefined)
1202
+ await ctx.db.patch(row._id, { revokedAt: now });
1203
+ }
1108
1204
  return null;
1109
1205
  },
1110
1206
  });
1111
- return { list, sync, leave };
1207
+ /**
1208
+ * Delete rows nobody has written for `idleCutoffMs`. `list` filters stale rows at read time but
1209
+ * nothing removed them, so a player who closed the tab left a row behind forever. Register it on a
1210
+ * cron — {@link jgengineCronSpecs} carries the interval.
1211
+ */
1212
+ const reapIdlePresence = internalMutation({
1213
+ args: {},
1214
+ returns: v.object({ reaped: v.number() }),
1215
+ handler: async (ctx) => {
1216
+ const now = Date.now();
1217
+ const stale = await ctx.db
1218
+ .query("jgPoses")
1219
+ .withIndex("by_updated", (q) => q.lt("updatedAt", now - idleCutoffMs))
1220
+ .take(maxReapPerRun);
1221
+ let reaped = 0;
1222
+ for (const row of stale) {
1223
+ if (!isPresenceExpired(row.updatedAt, now, idleCutoffMs))
1224
+ continue;
1225
+ await ctx.db.delete(row._id);
1226
+ reaped += 1;
1227
+ }
1228
+ return { reaped };
1229
+ },
1230
+ });
1231
+ return { list, sync, leave, reapIdlePresence };
1112
1232
  }
1113
1233
  /** @internal */
1114
1234
  export function createChatFunctions(options) {
@@ -1198,8 +1318,9 @@ export function createChatFunctions(options) {
1198
1318
  return { messages, sendMessage };
1199
1319
  }
1200
1320
  const JG_CRON_SPECS = [
1201
- { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
1202
- { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
1321
+ { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
1322
+ { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers", module: "runtime" },
1323
+ { name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
1203
1324
  ];
1204
1325
  /** @internal */
1205
1326
  export function jgengineCronSpecs() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json"
31
31
  },
32
32
  "dependencies": {
33
- "@jgengine/core": "^0.17.0"
33
+ "@jgengine/core": "^0.18.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.18.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -228,7 +228,7 @@
228
228
 
229
229
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
230
230
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
231
- - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
231
+ - `VERSION` (const): const VERSION: "0.18.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
232
232
 
233
233
  ## @jgengine/core/runtime/adapter
234
234
 
@@ -260,7 +260,8 @@
260
260
 
261
261
  ## @jgengine/core/runtime/commandRunner
262
262
 
263
- - `CommandDef` (type): type CommandDef<TInput = unknown> = { validate: ( snapshot: GameRuntimeSnapshot, input: TInput, actorUserId: string, ) => CommandValidationError | null; apply: ( snapshot: GameRuntimeSnapshot, input: TInput, actorUserId: string, ) => GameRuntimeSnapshot; } — ⚠ undocumented
263
+ - `CommandDef` (type): type CommandDef<TInput = unknown> = { /** * What this command reads and writes, derived from its own input. A host that hydrates through a * scope loads only this slice instead of the whole world, and refuses the command if `apply` then * dirties a player or chunk the scope did not name — writing an… — ⚠ undocumented
264
+ - `CommandScope` (type): type CommandScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — Which slice of a server has to be hydrated before some work runs against it. Both fields default to "everything", which costs a read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch.
264
265
  - `CommandValidationError` (type): type CommandValidationError = { reason: string } — ⚠ undocumented
265
266
  - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string } — ⚠ undocumented
266
267
 
@@ -340,7 +341,7 @@
340
341
  - `RuntimeInitContext` (type): type RuntimeInitContext = { snapshot: GameRuntimeSnapshot; setSnapshot: (snapshot: GameRuntimeSnapshot) => void; } — ⚠ undocumented
341
342
  - `RuntimeLoopContext` (type): type RuntimeLoopContext = RuntimeInitContext & { player: { userId: string; isNew: boolean; }; } — ⚠ undocumented
342
343
  - `RuntimeWorldContext` (type): type RuntimeWorldContext = RuntimeInitContext & { playerIds: readonly string[]; } — ⚠ undocumented
343
- - `ServerLoopHooks` (type): type ServerLoopHooks = { onInit?: (ctx: RuntimeInitContext) => void; onNewPlayer?: (ctx: RuntimeLoopContext) => void; onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void; } — ⚠ undocumented
344
+ - `ServerLoopHooks` (type): type ServerLoopHooks = { onInit?: (ctx: RuntimeInitContext) => void; onNewPlayer?: (ctx: RuntimeLoopContext) => void; onTick?: (ctx: RuntimeWorldContext, dtSeconds: number) => void; /** * What `onNewPlayer` touches, so a join hydrates that instead of the whole world. Only consulted * when `onNewPlay… — ⚠ undocumented
344
345
 
345
346
  ## @jgengine/core/runtime/headlessRunner
346
347
 
@@ -477,7 +478,7 @@
477
478
  - `JoinServerResult` (type): type JoinServerResult = { serverId: string; isNew: boolean; } — ⚠ undocumented
478
479
  - `LiveGameBackend` (type): type LiveGameBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGameId extends string = string> = GameBackend<TPresenceRow, TPresenceLocation, TGameId> & { presenceSync: PresenceSync; pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise<void>; chatSyncFor… — ⚠ undocumented
479
480
  - `MultiplayerSession` (type): type MultiplayerSession = { gameId: string; userId: string; backend: LiveGameBackend; feedActions: string[]; } — ⚠ undocumented
480
- - `PresencePoseRow` (type): type PresencePoseRow = { userId: string; position: { x: number; y: number; z: number }; rotationY: number; rotationPitch: number; lastSeenAt: number; } — ⚠ undocumented
481
+ - `PresencePoseRow` (type): type PresencePoseRow = { userId: string; /** Set when the host tracks presence per session, so one user can hold two rows. */ sessionId?: string; /** Actor class, e.g. `"player"` / `"agent"` — a host may clamp each differently. */ kind?: string; /** Display name carried on the row, so a nameplate ne… — ⚠ undocumented
481
482
  - `PresenceSync` (type): type PresenceSync = { subscribe: (serverId: string, onChange: (rows: PresencePoseRow[]) => void) => FeedUnsubscribe; syncPose: (serverId: string, pose: PlayerPose) => void; } — ⚠ undocumented
482
483
  - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; } — ⚠ undocumented
483
484
  - `TransportRunCommandResult` (type): type TransportRunCommandResult = | { ok: true } | { ok: false; reason: string } — ⚠ undocumented
@@ -10,7 +10,7 @@
10
10
  - `ConvexGameApi` (type): type ConvexGameApi = { runtime: { joinServer: FunctionReference< "mutation", "public", { gameId: string; serverId?: string; mode?: string; visibility?: "public" | "private"; joinCode?: string; externalId?: string; }, { serverId: string; isNew: boolean } >; leaveServer: FunctionReference<"mutation", … — ⚠ undocumented
11
11
  - `ConvexGameTransportConfig` (type): type ConvexGameTransportConfig = { gameId: string; userId?: string; } — ⚠ undocumented
12
12
  - `ConvexLeaderboardReads` (type): type ConvexLeaderboardReads = ReturnType<typeof createConvexLeaderboardReads> — ⚠ undocumented
13
- - `ConvexPresenceFunctions` (interface): interface ConvexPresenceFunctions — ⚠ undocumented
13
+ - `ConvexPresenceFunctions` (interface): interface ConvexPresenceFunctions — The Convex functions a **game** supplies for a world it owns: presence keyed by the game's own ids, including which residents exist while offline, which is game content and has no engine analogue. Not satisfied by `createPresenceFunctions` (`@jgengine/convex/server`) — that is the pose lane of a hosted `jgGameServers` server, backing `PresenceSync` rather than `PresenceTransport`.
14
14
  - `ConvexSaveBackendOptions` (interface): interface ConvexSaveBackendOptions — Config for {@link createConvexSaveBackend} — the Convex client, optional function refs (defaults to the `saves.*` convention), and an optional key `namespace`.
15
15
  - `ConvexSaveFunctions` (interface): interface ConvexSaveFunctions — The three Convex functions a save backend calls — a `query` that returns the stored string (or `null`) and two `mutation`s. Point them at your app's own module, or lean on the default `saves.*` convention.
16
16
  - `DEFAULT_CONVEX_POSE_RULES` (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules — ⚠ undocumented
@@ -25,13 +25,15 @@
25
25
  - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
26
26
  - `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented
27
27
  - `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented
28
- - `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; } — ⚠ undocumented
29
- - `LoadSnapshotScope` (type): type LoadSnapshotScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
28
+ - `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through. */ module: "runtime" | "presence"; } — ⚠ undocumented
29
+ - `LoadSnapshotScope` (type): type LoadSnapshotScope = CommandScope — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
30
30
  - `LoadedServerSnapshot` (type): type LoadedServerSnapshot = { server: ServerDoc; runtime: GameRuntime; snapshot: GameRuntimeSnapshot; } — A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot.
31
31
  - `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return.
32
32
  - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
33
+ - `PresenceListRow` (type): type PresenceListRow = { userId: string; sessionId?: string; kind?: string; label?: string; position: { x: number; y: number; z: number }; rotationY: number; rotationPitch: number; lastSeenAt: number; } — One member's pose as `list` reports it.
34
+ - `PresenceSyncResult` (type): type PresenceSyncResult = { pose: { x: number; y: number; z: number; rotationY: number; rotationPitch: number }; lastSeenAt: number; displaced: boolean; } — What `sync` hands back: the pose the server holds after clamping, and whether this session had been displaced.
33
35
  - `REVISION_CONFLICT_REASON` (const): const REVISION_CONFLICT_REASON: "Revision conflict" — ⚠ undocumented
34
- - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; } — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
36
+ - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; /** * How much of the server to hydrate before applying the command. Overrides whatever the * {@link CommandDef} declares through its own `scope`; omit both to hydrate the whole world. */ scope?: LoadSnap… — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
35
37
  - `RunCommandOutcome` (type): type RunCommandOutcome = { ok: true } | { ok: false; reason: string } — Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict).
36
38
  - `ServerDoc` (type): type ServerDoc = DocumentByName<JGDataModel, "jgGameServers"> — A `jgGameServers` row — the server handle the persistence helpers load from and write back to.
37
39
  - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half.
@@ -62,7 +64,7 @@
62
64
 
63
65
  ## @jgengine/convex/convexPresenceTransport
64
66
 
65
- - `ConvexPresenceFunctions` (interface): interface ConvexPresenceFunctions — ⚠ undocumented
67
+ - `ConvexPresenceFunctions` (interface): interface ConvexPresenceFunctions — The Convex functions a **game** supplies for a world it owns: presence keyed by the game's own ids, including which residents exist while offline, which is game content and has no engine analogue. Not satisfied by `createPresenceFunctions` (`@jgengine/convex/server`) — that is the pose lane of a hosted `jgGameServers` server, backing `PresenceSync` rather than `PresenceTransport`.
66
68
  - `createConvexPresenceTransport` (function): function createConvexPresenceTransport<TRawRow extends RawPresenceActor, TRow, TLocation, TGameId extends string = string>(functions: ConvexPresenceFunctions, mapRow: (row: TRawRow) => TRow): PresenceTransport<TRow, TLocation, TGameId> — Wires a game's Convex presence functions into the engine's PresenceTransport contract: one snapshot subscription (my location + online players), one residents subscription (dormant candidates, decoupled from pose writes), and one tick mutation for pose upload + keep-alive. Dormant rows are derived client-side by subtracting the snapshot's online actors, so pose ticks never re-execute the residents query. mapRow converts backend rows into the game's row type (e.g. branding positions into its coordinate space).
67
69
 
68
70
  ## @jgengine/convex/convexSaveBackend
@@ -118,12 +120,14 @@
118
120
  - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
119
121
  - `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented
120
122
  - `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented
121
- - `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; } — ⚠ undocumented
122
- - `LoadSnapshotScope` (type): type LoadSnapshotScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
123
+ - `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers" | "reapIdlePresence"; /** Which factory's exports the function lives in, i.e. which `convex/*.ts` file to reach it through. */ module: "runtime" | "presence"; } — ⚠ undocumented
124
+ - `LoadSnapshotScope` (type): type LoadSnapshotScope = CommandScope — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
123
125
  - `LoadedServerSnapshot` (type): type LoadedServerSnapshot = { server: ServerDoc; runtime: GameRuntime; snapshot: GameRuntimeSnapshot; } — A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot.
124
126
  - `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return.
125
127
  - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
126
- - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; } — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
128
+ - `PresenceListRow` (type): type PresenceListRow = { userId: string; sessionId?: string; kind?: string; label?: string; position: { x: number; y: number; z: number }; rotationY: number; rotationPitch: number; lastSeenAt: number; } — One member's pose as `list` reports it.
129
+ - `PresenceSyncResult` (type): type PresenceSyncResult = { pose: { x: number; y: number; z: number; rotationY: number; rotationPitch: number }; lastSeenAt: number; displaced: boolean; } — What `sync` hands back: the pose the server holds after clamping, and whether this session had been displaced.
130
+ - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; /** * How much of the server to hydrate before applying the command. Overrides whatever the * {@link CommandDef} declares through its own `scope`; omit both to hydrate the whole world. */ scope?: LoadSnap… — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
127
131
  - `RunCommandOutcome` (type): type RunCommandOutcome = { ok: true } | { ok: false; reason: string } — Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict).
128
132
  - `ServerDoc` (type): type ServerDoc = DocumentByName<JGDataModel, "jgGameServers"> — A `jgGameServers` row — the server handle the persistence helpers load from and write back to.
129
133
  - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half.
@@ -33,10 +33,14 @@ Once a session resolves, the shell (`GameHost`'s internal player) wires it up wi
33
33
 
34
34
  A synced pose (`@jgengine/ws/protocol`'s `WsPose`/`WsPresenceRow`) carries an optional `appearance: Record<string, string | number | boolean>` alongside position/rotation — primitive-valued client-set tags (skin id, mount id, active emote, team color) riding the same pose-sync frames as position, so a remote player's cosmetic state updates on the existing presence channel instead of a second round-trip.
35
35
 
36
- **Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something. When a host mutation must pair snapshot work with its own table writes in one transaction, use the factory's `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand` — same registry and auth mode) or the standalone `loadServerSnapshot` / `persistServerSnapshot` exports.
36
+ **Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs?, idleCutoffMs?, poseRules?, resolveSpawn? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something. When a host mutation must pair snapshot work with its own table writes in one transaction, use the factory's `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand` — same registry and auth mode) or the standalone `loadServerSnapshot` / `persistServerSnapshot` exports.
37
37
 
38
38
  **How big a hosted world gets.** One server is one Convex document holding the world row, the roster, and every member's session state, so member count is a per-transaction cost. `slotsPerServer` is capped at `JG_MAX_MEMBERS_PER_SERVER` (256) and `createGameServerFunctions` throws at wiring time on anything larger — a bigger number was never a bigger world, just a document that overflows later. World geometry that outgrows one row goes in `jgWorldChunks`: hydrate by key (`loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })`), read from clients with the generated `getChunks`, and key cells with `chunkKeyAt` / `chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`. Mutate chunks through `setChunk` / `updateChunk` / `deleteChunk` — a raw `snapshot.chunks[key] = …` is never marked dirty and never persists.
39
39
 
40
+ **Two presence lanes, and which one you want.** They are not interchangeable and neither replaces the other. `createPresenceFunctions` backs `PresenceSync` — where each member of one hosted `jgGameServers` server is standing, over `jgPoses`, membership-gated, and what the shell wires automatically. It takes `poseRules` (a `PoseSyncRules`, or a function of the actor `kind` so an agent need not obey a human's speed cap), `resolveSpawn(ctx, { serverId, userId, kind })` for where a new session starts, and `idleCutoffMs` for its `reapIdlePresence` cron; `sync` returns the clamped pose so a corrected client can reconcile, takes no `pose` at all for a keep-alive, and carries optional `sessionId` / `kind` / `label` (a second tab is a second row, and syncing revokes the others, which the displaced tab learns as `displaced: true`). `createConvexPresenceTransport` backs `PresenceTransport`, a different contract for a game-owned world: it needs the game's own `ConvexPresenceFunctions` (`snapshot`, `cityResidents`, `ensurePresence`, `leavePresence`, `tick`) keyed by the game's own ids, because who counts as a resident is game content. Reach for the first for members of a hosted server; supply the second yourself when presence spans your own world model.
41
+
42
+ **Bounded hydration from the ordinary command path.** Give a `CommandDef` a `scope(input, actorUserId) => { players?, chunkKeys? }` and the registered `runCommand` mutation hydrates that slice instead of the whole world — no hand-rolled mutation needed. The mutation also takes a `scope` argument directly, which wins over the declared one; a caller that knows the answer at call time can skip the declaration. Declare only what the command actually writes: `apply` dirtying a player or chunk the scope never named is refused, because persisting an unhydrated chunk writes a blank over the stored one. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`; a runtime with no `onNewPlayer` already hydrates the joining member alone. Leave both off and hydration stays whole-world.
43
+
40
44
  **What the host does per second.** The tick cron sweeps only games whose runtime declares `loop.onTick`, at most `maxServersPerTick` (default 32) servers per transaction, oldest anchor first; a game with no tick hook costs nothing while idle. A persist that would change nothing writes nothing, so an idle world stops re-pushing itself to every subscriber once a second. Subscribe lobby UI to `getServerMeta` (status, member count, revision) rather than `getServer`, whose payload is the whole world, and set `replication: { aoiRadius }` so `entities`, `stats`, and `objects` all cull to each viewer.
41
45
 
42
46
  **One shared world.** `createGameServerFunctions({ matchmaking: "singleton" })` makes auto-match always target the existing world and refuse a join past capacity, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, so raising the option takes effect on worlds that already exist. Auto-match relies on Convex OCC to resolve two concurrent first-joins (the loser's read set is invalidated and its mutation retries); a non-Convex `HostPersistence` must supply that guarantee itself.