@jgengine/convex 0.16.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 +46 -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 +178 -25
- package/dist/server.js +344 -87
- package/package.json +2 -2
- package/skills/jgengine/api.md +29 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-multiplayer/api.md +28 -12
- package/skills/jgengine-multiplayer/reference.md +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -26,14 +26,60 @@ 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
|
+
|
|
53
|
+
## 0.17.0
|
|
54
|
+
|
|
55
|
+
### Migrate
|
|
56
|
+
|
|
57
|
+
- **Bump lockstep SDK packages to `^0.17.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.
|
|
58
|
+
- **A deleted player profile stays deleted.** When a game persists players (`save.scope` includes `player`), hydrating a member with no `jgPlayerProfiles` row no longer falls back to the copy of their state kept on the server row — that copy was resurrected and then written back as authoritative, so a world reset that cleared profiles but not servers silently restored every player's old balance. A game whose save scope excludes players (`"none"`, `"chunks"`) is unchanged: the server-row copy is still their state. If you seed members onto a server row without going through `joinServer`, write their profile row too.
|
|
59
|
+
- **`slotsPerServer` is capped at 256** (`JG_MAX_MEMBERS_PER_SERVER`). `createGameServerFunctions` now throws at wiring time on a larger value instead of accepting it and failing at the write that overflows the document. One server is one Convex document holding the world row, the roster, and every member's session state; a bigger number was never a bigger world. Shard across servers, or move world state into `jgWorldChunks`.
|
|
60
|
+
- **The tick cron only sweeps games that declare `loop.onTick`.** A game with no tick hook used to pay a full hydrate plus a full persist every second per running server, for a snapshot `runtime.tick` returned unchanged; it now costs nothing while idle. If you relied on the tick cron to autosave a hookless game, the 60s `flushDirtyServers` cron and the `flushSave` mutation still do it.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **Walking NPCs now stop at the geometry that stops the player.** `wander`, `patrol`, and `ctx.scene.entity.moveToward` consulted no obstacles at all, so a pedestrian walked through the building the player cannot enter. All three now slide their step against the scene's blocking physical colliders — the same query `stepPlayerMovement` reads. A patrol's path state still advances on its own clock (an authored route is not rewound by contact), but the entity can no longer end a tick inside a solid. Pass `avoidSolids: false` to `moveToward` for a mover meant to ignore the world (a flier, a camera rig).
|
|
65
|
+
- **`@jgengine/assets` verify fails a model source that contributes no index entries.** A model pack that has never been pulled is a pin nothing has ever checked — that is how a Street Pack archive sat in the catalogue titled "Downtown City MegaKit". Record why a pack is not pulled in the new `AssetSource.unpulled` string; materials and sprites pull on demand and are exempt.
|
|
66
|
+
- **`persistServerSnapshot` returns `boolean` and skips a write that would change nothing.** Every subscriber of a query that read a server row re-receives the whole result when that document changes, so an unconditional write on the 1 Hz tick re-pushed the entire world to every client once a second whether or not anything moved. `ServerPersistPlan` gains `changed` (`serverState` / `sessionPlayers` / `revision` / `any`) for hosts that write field-by-field.
|
|
67
|
+
- **`sessionPlayers` is merged over the stored map, not rebuilt from the snapshot**, and entries for non-members are pruned. This is what makes a partially hydrated snapshot safe to persist.
|
|
68
|
+
- **`tickActiveServers` is bounded** to `maxServersPerTick` (default 32) servers per transaction, oldest tick anchor first, instead of pulling every running server of every game into one transaction. `flushDirtyServers` is bounded the same way.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **`solidObstaclesNear` / `resolveWalkerStep` (`@jgengine/core/movement/solidObstacles`)** — the engine's single answer to "what is solid here", now shared rather than re-derived per consumer. `solidObstaclesNear(ctx, position, reachX, reachZ)` returns the blocking physical colliders near a point through a bounded `inBox` broadphase; `resolveWalkerStep(ctx, position, stepX, stepZ)` slides a horizontal step against them with the player's axis-separated sliding. Reach for these instead of deriving obstacles from a game catalog's own solidity flags.
|
|
73
|
+
- **Bounded hydration and a client chunk read** — `loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })` hydrates only the members and chunks a mutation will touch instead of a document read per member plus one per chunk, and the generated `getChunks` query lets a client read chunk state by key (capped at `MAX_CHUNKS_PER_QUERY`). Chunks were the documented escape hatch for world state too large for one row, but reads were unbounded and clients could not see them at all, so moving geometry there made things worse. `getServerMeta` returns lobby state (status, member count, revision) without the world payload.
|
|
74
|
+
- **`@jgengine/core/runtime/worldChunks`** — `chunkKeyAt` / `chunkCoordAt` / `chunkKeyOf` / `parseChunkKey` / `chunkKeysInRadius` at a declared cell size, plus `setChunk` / `updateChunk` / `deleteChunk`, which mark the key dirty so the host actually persists it. `chunkKey` was an opaque game-chosen string and the engine never wrote `dirty.chunks`, so every game reimplemented the coordinate mapping and a game that forgot to push a key silently never saved that chunk.
|
|
75
|
+
- **Placed objects cull to `replication.aoiRadius`.** `objects` is the one large spatial replicated set and had no projector, so a world with a few thousand placements sent all of them to every viewer. It now joins the same area-of-interest policy as `entities` and `stats`. Unset `aoiRadius` still replicates everything.
|
|
76
|
+
- **`createGameServerFunctions({ matchmaking: "singleton" })`** — one shared world per game: auto-match always targets the existing world and a join past capacity fails loudly, 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, which previously made raising the option a no-op on worlds that already existed. `selectJoinTarget` (`@jgengine/core/runtime/hostPolicy`) is the pure decision behind it.
|
|
77
|
+
- **`GameRuntime.hasTick`** reports whether a runtime declares `loop.onTick`, so any host's scheduler can skip a server whose tick is a no-op by construction.
|
|
78
|
+
|
|
79
|
+
### Removed
|
|
80
|
+
|
|
81
|
+
- **Asset source `quaternius-downtown-city`.** It was titled "Downtown City MegaKit" but pinned at the Street Pack archive, which ships Blends/FBX/OBJ only — a pull found no models. Nothing referenced it.
|
|
82
|
+
|
|
37
83
|
## 0.16.0
|
|
38
84
|
|
|
39
85
|
### 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,12 +3,14 @@ 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";
|
|
9
|
+
import { type MatchmakingMode } from "@jgengine/core/runtime/hostPolicy";
|
|
8
10
|
import type { SaveConfig } from "@jgengine/core/runtime/save";
|
|
9
11
|
import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
|
|
10
12
|
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
11
|
-
export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
|
|
13
|
+
export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, type MatchmakingMode, } from "@jgengine/core/runtime/hostPolicy";
|
|
12
14
|
/** @internal */
|
|
13
15
|
export declare function jgengineTables(): {
|
|
14
16
|
jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -62,6 +64,7 @@ export declare function jgengineTables(): {
|
|
|
62
64
|
}, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
|
|
63
65
|
by_dirty: ["dirtyAt", "_creationTime"];
|
|
64
66
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
67
|
+
by_game_status_tick: ["gameId", "status", "tickAnchorMs", "_creationTime"];
|
|
65
68
|
by_status: ["status", "_creationTime"];
|
|
66
69
|
}, {}, {}>;
|
|
67
70
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -133,9 +136,13 @@ export declare function jgengineTables(): {
|
|
|
133
136
|
by_server_and_action: ["serverId", "action", "_creationTime"];
|
|
134
137
|
}, {}, {}>;
|
|
135
138
|
jgPoses: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
139
|
+
kind?: string | undefined;
|
|
140
|
+
label?: string | undefined;
|
|
141
|
+
revokedAt?: number | undefined;
|
|
136
142
|
rotationPitch: number;
|
|
137
143
|
rotationY: number;
|
|
138
144
|
serverId: string;
|
|
145
|
+
sessionId?: string | undefined;
|
|
139
146
|
updatedAt: number;
|
|
140
147
|
userId: string;
|
|
141
148
|
x: number;
|
|
@@ -144,15 +151,24 @@ export declare function jgengineTables(): {
|
|
|
144
151
|
}, {
|
|
145
152
|
serverId: import("convex/values").VString<string, "required">;
|
|
146
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">;
|
|
147
160
|
x: import("convex/values").VFloat64<number, "required">;
|
|
148
161
|
y: import("convex/values").VFloat64<number, "required">;
|
|
149
162
|
z: import("convex/values").VFloat64<number, "required">;
|
|
150
163
|
rotationY: import("convex/values").VFloat64<number, "required">;
|
|
151
164
|
rotationPitch: import("convex/values").VFloat64<number, "required">;
|
|
152
165
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
153
|
-
|
|
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">, {
|
|
154
169
|
by_server: ["serverId", "_creationTime"];
|
|
155
170
|
by_server_and_user: ["serverId", "userId", "_creationTime"];
|
|
171
|
+
by_updated: ["updatedAt", "_creationTime"];
|
|
156
172
|
}, {}, {}>;
|
|
157
173
|
jgChatMessages: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
158
174
|
at: number;
|
|
@@ -254,6 +270,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
254
270
|
}, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
|
|
255
271
|
by_dirty: ["dirtyAt", "_creationTime"];
|
|
256
272
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
273
|
+
by_game_status_tick: ["gameId", "status", "tickAnchorMs", "_creationTime"];
|
|
257
274
|
by_status: ["status", "_creationTime"];
|
|
258
275
|
}, {}, {}>;
|
|
259
276
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -325,9 +342,13 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
325
342
|
by_server_and_action: ["serverId", "action", "_creationTime"];
|
|
326
343
|
}, {}, {}>;
|
|
327
344
|
jgPoses: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
345
|
+
kind?: string | undefined;
|
|
346
|
+
label?: string | undefined;
|
|
347
|
+
revokedAt?: number | undefined;
|
|
328
348
|
rotationPitch: number;
|
|
329
349
|
rotationY: number;
|
|
330
350
|
serverId: string;
|
|
351
|
+
sessionId?: string | undefined;
|
|
331
352
|
updatedAt: number;
|
|
332
353
|
userId: string;
|
|
333
354
|
x: number;
|
|
@@ -336,15 +357,24 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
336
357
|
}, {
|
|
337
358
|
serverId: import("convex/values").VString<string, "required">;
|
|
338
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">;
|
|
339
366
|
x: import("convex/values").VFloat64<number, "required">;
|
|
340
367
|
y: import("convex/values").VFloat64<number, "required">;
|
|
341
368
|
z: import("convex/values").VFloat64<number, "required">;
|
|
342
369
|
rotationY: import("convex/values").VFloat64<number, "required">;
|
|
343
370
|
rotationPitch: import("convex/values").VFloat64<number, "required">;
|
|
344
371
|
updatedAt: import("convex/values").VFloat64<number, "required">;
|
|
345
|
-
|
|
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">, {
|
|
346
375
|
by_server: ["serverId", "_creationTime"];
|
|
347
376
|
by_server_and_user: ["serverId", "userId", "_creationTime"];
|
|
377
|
+
by_updated: ["updatedAt", "_creationTime"];
|
|
348
378
|
}, {}, {}>;
|
|
349
379
|
jgChatMessages: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
350
380
|
at: number;
|
|
@@ -377,20 +407,40 @@ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
|
|
|
377
407
|
export declare function resolveActor(ctx: {
|
|
378
408
|
auth: Auth;
|
|
379
409
|
}, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
|
|
410
|
+
/**
|
|
411
|
+
* How much of a server to hydrate. Both fields default to "everything", which is a document read per
|
|
412
|
+
* member plus one per chunk — the cost that makes a large shared world unaffordable per mutation.
|
|
413
|
+
* Narrow them when the caller knows what it will touch: a command that only moves the actor's own
|
|
414
|
+
* state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes
|
|
415
|
+
* (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
|
|
416
|
+
*
|
|
417
|
+
* Persisting a narrowed snapshot is safe: the plan merges session state over the stored map and
|
|
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.
|
|
422
|
+
*/
|
|
423
|
+
export type LoadSnapshotScope = CommandScope;
|
|
380
424
|
/**
|
|
381
425
|
* Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
|
|
382
426
|
* Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
427
|
+
* Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
|
|
383
428
|
* @capability convex-load-server-snapshot read a hosted server's runtime snapshot inside a host-written Convex mutation
|
|
384
429
|
*/
|
|
385
|
-
export declare function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime): Promise<GameRuntimeSnapshot>;
|
|
430
|
+
export declare function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot>;
|
|
386
431
|
/** @internal */
|
|
387
432
|
export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
|
|
388
433
|
/**
|
|
389
434
|
* Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard
|
|
390
435
|
* increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
|
|
436
|
+
*
|
|
437
|
+
* A persist that would change nothing writes nothing and returns `false`. That matters beyond write
|
|
438
|
+
* cost: every subscriber of a query that read this row re-receives the whole result when the document
|
|
439
|
+
* changes, so an unconditional write on a 1 Hz tick re-pushes the entire world to every client once a
|
|
440
|
+
* second whether or not anything moved.
|
|
391
441
|
* @capability convex-persist-server-snapshot write runtime state and host tables in one Convex transaction
|
|
392
442
|
*/
|
|
393
|
-
export declare function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<
|
|
443
|
+
export declare function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean>;
|
|
394
444
|
export declare const JG_RUNTIME_TICK_MS = 1000;
|
|
395
445
|
/** Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict). */
|
|
396
446
|
export type RunCommandOutcome = {
|
|
@@ -405,6 +455,11 @@ export type RunCommandArgs = {
|
|
|
405
455
|
command: string;
|
|
406
456
|
input: unknown;
|
|
407
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;
|
|
408
463
|
};
|
|
409
464
|
/** A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. */
|
|
410
465
|
export type LoadedServerSnapshot = {
|
|
@@ -418,7 +473,7 @@ export type LoadedServerSnapshot = {
|
|
|
418
473
|
* table writes in one transaction, which a pre-registered mutation cannot do.
|
|
419
474
|
*/
|
|
420
475
|
export type GameServerHelpers = {
|
|
421
|
-
loadSnapshot: (ctx: JGMutationCtx, serverId: string) => Promise<LoadedServerSnapshot | null>;
|
|
476
|
+
loadSnapshot: (ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope) => Promise<LoadedServerSnapshot | null>;
|
|
422
477
|
applyCommand: (args: {
|
|
423
478
|
gameId: string;
|
|
424
479
|
loadedRevision: number;
|
|
@@ -434,14 +489,33 @@ export type GameServerHelpers = {
|
|
|
434
489
|
ok: false;
|
|
435
490
|
reason: string;
|
|
436
491
|
};
|
|
437
|
-
persistSnapshot: (ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save?: SaveConfig) => Promise<
|
|
492
|
+
persistSnapshot: (ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save?: SaveConfig) => Promise<boolean>;
|
|
438
493
|
runCommand: (ctx: JGMutationCtx, args: RunCommandArgs) => Promise<RunCommandOutcome>;
|
|
439
494
|
};
|
|
440
|
-
/**
|
|
495
|
+
/** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
|
|
496
|
+
export declare const DEFAULT_MAX_SERVERS_PER_TICK = 32;
|
|
497
|
+
/** How many chunk rows one `getChunks` call may return. */
|
|
498
|
+
export declare const MAX_CHUNKS_PER_QUERY = 64;
|
|
499
|
+
/**
|
|
500
|
+
* Build the hosted-server Convex functions for a set of game runtimes.
|
|
501
|
+
*
|
|
502
|
+
* **Supported world size.** One server is one Convex document holding the world row, the member
|
|
503
|
+
* roster, and every member's session state, and the host rewrites that document whenever the world
|
|
504
|
+
* changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
|
|
505
|
+
* larger value throws here, at wiring time, rather than at the write that overflows the document.
|
|
506
|
+
* World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
|
|
507
|
+
* ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
|
|
508
|
+
*
|
|
509
|
+
* `matchmaking: "singleton"` makes auto-match target one shared world per game: a join past capacity
|
|
510
|
+
* fails loudly instead of quietly starting a second world the player would be alone in.
|
|
511
|
+
* @internal
|
|
512
|
+
*/
|
|
441
513
|
export declare function createGameServerFunctions(options?: {
|
|
442
514
|
runtimes?: GameRuntime[];
|
|
443
515
|
auth?: JgAuthMode;
|
|
444
516
|
slotsPerServer?: number;
|
|
517
|
+
matchmaking?: MatchmakingMode;
|
|
518
|
+
maxServersPerTick?: number;
|
|
445
519
|
allowedFeedActions?: readonly string[];
|
|
446
520
|
}): {
|
|
447
521
|
joinServer: import("convex/server").RegisteredMutation<"public", {
|
|
@@ -464,6 +538,10 @@ export declare function createGameServerFunctions(options?: {
|
|
|
464
538
|
command: string;
|
|
465
539
|
externalId?: string | undefined;
|
|
466
540
|
input: any;
|
|
541
|
+
scope?: {
|
|
542
|
+
chunkKeys?: string[] | undefined;
|
|
543
|
+
players?: string[] | undefined;
|
|
544
|
+
} | undefined;
|
|
467
545
|
serverId: GenericId<"jgGameServers">;
|
|
468
546
|
}, Promise<RunCommandOutcome>>;
|
|
469
547
|
flushSave: import("convex/server").RegisteredMutation<"public", {
|
|
@@ -491,6 +569,28 @@ export declare function createGameServerFunctions(options?: {
|
|
|
491
569
|
};
|
|
492
570
|
updatedAt: number;
|
|
493
571
|
} | null>>;
|
|
572
|
+
getServerMeta: import("convex/server").RegisteredQuery<"public", {
|
|
573
|
+
externalId?: string | undefined;
|
|
574
|
+
serverId: GenericId<"jgGameServers">;
|
|
575
|
+
}, Promise<{
|
|
576
|
+
_id: GenericId<"jgGameServers">;
|
|
577
|
+
gameId: string;
|
|
578
|
+
status: "closed" | "open" | "running";
|
|
579
|
+
mode: string | undefined;
|
|
580
|
+
memberCount: number;
|
|
581
|
+
slotsPerServer: number;
|
|
582
|
+
revision: number;
|
|
583
|
+
updatedAt: number;
|
|
584
|
+
} | null>>;
|
|
585
|
+
getChunks: import("convex/server").RegisteredQuery<"public", {
|
|
586
|
+
chunkKeys: string[];
|
|
587
|
+
externalId?: string | undefined;
|
|
588
|
+
serverId: GenericId<"jgGameServers">;
|
|
589
|
+
}, Promise<{
|
|
590
|
+
chunkKey: string;
|
|
591
|
+
snapshot: unknown;
|
|
592
|
+
updatedAt: number;
|
|
593
|
+
}[]>>;
|
|
494
594
|
getPlayerProfile: import("convex/server").RegisteredQuery<"public", {
|
|
495
595
|
externalId?: string | undefined;
|
|
496
596
|
gameId: string;
|
|
@@ -564,41 +664,92 @@ export declare function createLeaderboardFunctions(options?: {
|
|
|
564
664
|
gameId: string;
|
|
565
665
|
}, Promise<null>>;
|
|
566
666
|
};
|
|
567
|
-
/**
|
|
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
|
+
*/
|
|
568
702
|
export declare function createPresenceFunctions(options?: {
|
|
569
703
|
auth?: JgAuthMode;
|
|
704
|
+
/** How recently a row must have been written to appear in `list`. */
|
|
570
705
|
freshWindowMs?: number;
|
|
571
|
-
|
|
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;
|
|
572
726
|
}): {
|
|
573
727
|
list: import("convex/server").RegisteredQuery<"public", {
|
|
574
728
|
externalId?: string | undefined;
|
|
575
729
|
serverId: string;
|
|
576
|
-
}, Promise<
|
|
577
|
-
userId: string;
|
|
578
|
-
position: {
|
|
579
|
-
x: number;
|
|
580
|
-
y: number;
|
|
581
|
-
z: number;
|
|
582
|
-
};
|
|
583
|
-
rotationY: number;
|
|
584
|
-
rotationPitch: number;
|
|
585
|
-
lastSeenAt: number;
|
|
586
|
-
}[]>>;
|
|
730
|
+
}, Promise<PresenceListRow[]>>;
|
|
587
731
|
sync: import("convex/server").RegisteredMutation<"public", {
|
|
588
732
|
externalId?: string | undefined;
|
|
589
|
-
|
|
733
|
+
kind?: string | undefined;
|
|
734
|
+
label?: string | undefined;
|
|
735
|
+
pose?: {
|
|
590
736
|
rotationPitch: number;
|
|
591
737
|
rotationY: number;
|
|
592
738
|
x: number;
|
|
593
739
|
y: number;
|
|
594
740
|
z: number;
|
|
595
|
-
};
|
|
741
|
+
} | undefined;
|
|
596
742
|
serverId: string;
|
|
597
|
-
|
|
743
|
+
sessionId?: string | undefined;
|
|
744
|
+
}, Promise<PresenceSyncResult | null>>;
|
|
598
745
|
leave: import("convex/server").RegisteredMutation<"public", {
|
|
599
746
|
externalId?: string | undefined;
|
|
600
747
|
serverId: string;
|
|
748
|
+
sessionId?: string | undefined;
|
|
601
749
|
}, Promise<null>>;
|
|
750
|
+
reapIdlePresence: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
751
|
+
reaped: number;
|
|
752
|
+
}>>;
|
|
602
753
|
};
|
|
603
754
|
/** @internal */
|
|
604
755
|
export declare function createChatFunctions(options?: {
|
|
@@ -622,7 +773,9 @@ export declare function createChatFunctions(options?: {
|
|
|
622
773
|
export type JgCronSpec = {
|
|
623
774
|
name: string;
|
|
624
775
|
intervalSeconds: number;
|
|
625
|
-
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";
|
|
626
779
|
};
|
|
627
780
|
/** @internal */
|
|
628
781
|
export declare function jgengineCronSpecs(): readonly JgCronSpec[];
|