@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 +16 -0
- package/README.md +2 -2
- package/dist/convexPresenceTransport.d.ts +6 -0
- package/dist/createConvexGameTransport.d.ts +16 -2
- package/dist/createConvexGameTransport.js +15 -2
- package/dist/server.d.ts +112 -25
- package/dist/server.js +159 -38
- package/package.json +2 -2
- package/skills/jgengine/api.md +6 -5
- package/skills/jgengine-multiplayer/api.md +12 -8
- package/skills/jgengine-multiplayer/reference.md +5 -1
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
|
-
},
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
|
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
|
-
.
|
|
1060
|
-
const
|
|
1061
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
rotationY:
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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,
|
|
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", {
|
|
1164
|
+
await ctx.db.insert("jgPoses", {
|
|
1165
|
+
serverId: args.serverId,
|
|
1166
|
+
userId: actorUserId,
|
|
1167
|
+
...pose,
|
|
1168
|
+
...identity,
|
|
1169
|
+
updatedAt: now,
|
|
1170
|
+
});
|
|
1088
1171
|
}
|
|
1089
|
-
|
|
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
|
|
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
|
-
.
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
33
|
+
"@jgengine/core": "^0.18.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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> = {
|
|
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;
|
|
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;
|
|
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 —
|
|
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 =
|
|
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 —
|
|
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 =
|
|
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
|
-
- `
|
|
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.
|