@jgengine/convex 0.16.0 → 0.17.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 +30 -0
- package/dist/server.d.ts +72 -6
- package/dist/server.js +185 -49
- package/package.json +2 -2
- package/skills/jgengine/api.md +25 -4
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-multiplayer/api.md +18 -6
- package/skills/jgengine-multiplayer/reference.md +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,36 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
34
34
|
|
|
35
35
|
### Fixed
|
|
36
36
|
|
|
37
|
+
## 0.17.0
|
|
38
|
+
|
|
39
|
+
### Migrate
|
|
40
|
+
|
|
41
|
+
- **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`.
|
|
42
|
+
- **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.
|
|
43
|
+
- **`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`.
|
|
44
|
+
- **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.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **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).
|
|
49
|
+
- **`@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.
|
|
50
|
+
- **`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.
|
|
51
|
+
- **`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.
|
|
52
|
+
- **`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.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **`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.
|
|
57
|
+
- **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.
|
|
58
|
+
- **`@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.
|
|
59
|
+
- **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.
|
|
60
|
+
- **`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.
|
|
61
|
+
- **`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.
|
|
62
|
+
|
|
63
|
+
### Removed
|
|
64
|
+
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
37
67
|
## 0.16.0
|
|
38
68
|
|
|
39
69
|
### Migrate
|
package/dist/server.d.ts
CHANGED
|
@@ -5,10 +5,11 @@ import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
|
|
|
5
5
|
import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
|
|
6
6
|
import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
7
7
|
import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
|
|
8
|
+
import { type MatchmakingMode } from "@jgengine/core/runtime/hostPolicy";
|
|
8
9
|
import type { SaveConfig } from "@jgengine/core/runtime/save";
|
|
9
10
|
import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
|
|
10
11
|
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
11
|
-
export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
|
|
12
|
+
export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, type MatchmakingMode, } from "@jgengine/core/runtime/hostPolicy";
|
|
12
13
|
/** @internal */
|
|
13
14
|
export declare function jgengineTables(): {
|
|
14
15
|
jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -62,6 +63,7 @@ export declare function jgengineTables(): {
|
|
|
62
63
|
}, "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
64
|
by_dirty: ["dirtyAt", "_creationTime"];
|
|
64
65
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
66
|
+
by_game_status_tick: ["gameId", "status", "tickAnchorMs", "_creationTime"];
|
|
65
67
|
by_status: ["status", "_creationTime"];
|
|
66
68
|
}, {}, {}>;
|
|
67
69
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -254,6 +256,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
|
|
|
254
256
|
}, "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
257
|
by_dirty: ["dirtyAt", "_creationTime"];
|
|
256
258
|
by_game_and_status: ["gameId", "status", "_creationTime"];
|
|
259
|
+
by_game_status_tick: ["gameId", "status", "tickAnchorMs", "_creationTime"];
|
|
257
260
|
by_status: ["status", "_creationTime"];
|
|
258
261
|
}, {}, {}>;
|
|
259
262
|
jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
@@ -377,20 +380,42 @@ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
|
|
|
377
380
|
export declare function resolveActor(ctx: {
|
|
378
381
|
auth: Auth;
|
|
379
382
|
}, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
|
|
383
|
+
/**
|
|
384
|
+
* How much of a server to hydrate. Both fields default to "everything", which is a document read per
|
|
385
|
+
* member plus one per chunk — the cost that makes a large shared world unaffordable per mutation.
|
|
386
|
+
* Narrow them when the caller knows what it will touch: a command that only moves the actor's own
|
|
387
|
+
* state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes
|
|
388
|
+
* (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
|
|
389
|
+
*
|
|
390
|
+
* Persisting a narrowed snapshot is safe: the plan merges session state over the stored map and
|
|
391
|
+
* writes only dirty profiles and dirty chunks, so members and chunks left unhydrated are untouched.
|
|
392
|
+
*/
|
|
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
|
+
};
|
|
380
399
|
/**
|
|
381
400
|
* Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
|
|
382
401
|
* Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
402
|
+
* Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
|
|
383
403
|
* @capability convex-load-server-snapshot read a hosted server's runtime snapshot inside a host-written Convex mutation
|
|
384
404
|
*/
|
|
385
|
-
export declare function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime): Promise<GameRuntimeSnapshot>;
|
|
405
|
+
export declare function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot>;
|
|
386
406
|
/** @internal */
|
|
387
407
|
export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
|
|
388
408
|
/**
|
|
389
409
|
* Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard
|
|
390
410
|
* increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
|
|
411
|
+
*
|
|
412
|
+
* A persist that would change nothing writes nothing and returns `false`. That matters beyond write
|
|
413
|
+
* cost: every subscriber of a query that read this row re-receives the whole result when the document
|
|
414
|
+
* changes, so an unconditional write on a 1 Hz tick re-pushes the entire world to every client once a
|
|
415
|
+
* second whether or not anything moved.
|
|
391
416
|
* @capability convex-persist-server-snapshot write runtime state and host tables in one Convex transaction
|
|
392
417
|
*/
|
|
393
|
-
export declare function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<
|
|
418
|
+
export declare function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean>;
|
|
394
419
|
export declare const JG_RUNTIME_TICK_MS = 1000;
|
|
395
420
|
/** Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict). */
|
|
396
421
|
export type RunCommandOutcome = {
|
|
@@ -418,7 +443,7 @@ export type LoadedServerSnapshot = {
|
|
|
418
443
|
* table writes in one transaction, which a pre-registered mutation cannot do.
|
|
419
444
|
*/
|
|
420
445
|
export type GameServerHelpers = {
|
|
421
|
-
loadSnapshot: (ctx: JGMutationCtx, serverId: string) => Promise<LoadedServerSnapshot | null>;
|
|
446
|
+
loadSnapshot: (ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope) => Promise<LoadedServerSnapshot | null>;
|
|
422
447
|
applyCommand: (args: {
|
|
423
448
|
gameId: string;
|
|
424
449
|
loadedRevision: number;
|
|
@@ -434,14 +459,33 @@ export type GameServerHelpers = {
|
|
|
434
459
|
ok: false;
|
|
435
460
|
reason: string;
|
|
436
461
|
};
|
|
437
|
-
persistSnapshot: (ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save?: SaveConfig) => Promise<
|
|
462
|
+
persistSnapshot: (ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save?: SaveConfig) => Promise<boolean>;
|
|
438
463
|
runCommand: (ctx: JGMutationCtx, args: RunCommandArgs) => Promise<RunCommandOutcome>;
|
|
439
464
|
};
|
|
440
|
-
/**
|
|
465
|
+
/** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
|
|
466
|
+
export declare const DEFAULT_MAX_SERVERS_PER_TICK = 32;
|
|
467
|
+
/** How many chunk rows one `getChunks` call may return. */
|
|
468
|
+
export declare const MAX_CHUNKS_PER_QUERY = 64;
|
|
469
|
+
/**
|
|
470
|
+
* Build the hosted-server Convex functions for a set of game runtimes.
|
|
471
|
+
*
|
|
472
|
+
* **Supported world size.** One server is one Convex document holding the world row, the member
|
|
473
|
+
* roster, and every member's session state, and the host rewrites that document whenever the world
|
|
474
|
+
* changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
|
|
475
|
+
* larger value throws here, at wiring time, rather than at the write that overflows the document.
|
|
476
|
+
* World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
|
|
477
|
+
* ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
|
|
478
|
+
*
|
|
479
|
+
* `matchmaking: "singleton"` makes auto-match target one shared world per game: a join past capacity
|
|
480
|
+
* fails loudly instead of quietly starting a second world the player would be alone in.
|
|
481
|
+
* @internal
|
|
482
|
+
*/
|
|
441
483
|
export declare function createGameServerFunctions(options?: {
|
|
442
484
|
runtimes?: GameRuntime[];
|
|
443
485
|
auth?: JgAuthMode;
|
|
444
486
|
slotsPerServer?: number;
|
|
487
|
+
matchmaking?: MatchmakingMode;
|
|
488
|
+
maxServersPerTick?: number;
|
|
445
489
|
allowedFeedActions?: readonly string[];
|
|
446
490
|
}): {
|
|
447
491
|
joinServer: import("convex/server").RegisteredMutation<"public", {
|
|
@@ -491,6 +535,28 @@ export declare function createGameServerFunctions(options?: {
|
|
|
491
535
|
};
|
|
492
536
|
updatedAt: number;
|
|
493
537
|
} | null>>;
|
|
538
|
+
getServerMeta: import("convex/server").RegisteredQuery<"public", {
|
|
539
|
+
externalId?: string | undefined;
|
|
540
|
+
serverId: GenericId<"jgGameServers">;
|
|
541
|
+
}, Promise<{
|
|
542
|
+
_id: GenericId<"jgGameServers">;
|
|
543
|
+
gameId: string;
|
|
544
|
+
status: "closed" | "open" | "running";
|
|
545
|
+
mode: string | undefined;
|
|
546
|
+
memberCount: number;
|
|
547
|
+
slotsPerServer: number;
|
|
548
|
+
revision: number;
|
|
549
|
+
updatedAt: number;
|
|
550
|
+
} | null>>;
|
|
551
|
+
getChunks: import("convex/server").RegisteredQuery<"public", {
|
|
552
|
+
chunkKeys: string[];
|
|
553
|
+
externalId?: string | undefined;
|
|
554
|
+
serverId: GenericId<"jgGameServers">;
|
|
555
|
+
}, Promise<{
|
|
556
|
+
chunkKey: string;
|
|
557
|
+
snapshot: unknown;
|
|
558
|
+
updatedAt: number;
|
|
559
|
+
}[]>>;
|
|
494
560
|
getPlayerProfile: import("convex/server").RegisteredQuery<"public", {
|
|
495
561
|
externalId?: string | undefined;
|
|
496
562
|
gameId: string;
|
package/dist/server.js
CHANGED
|
@@ -3,12 +3,12 @@ import { ConvexError, v } from "convex/values";
|
|
|
3
3
|
import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
|
|
4
4
|
import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
|
|
5
5
|
import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
|
|
6
|
-
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
|
|
7
|
-
import {
|
|
6
|
+
import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
|
|
7
|
+
import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
|
|
8
8
|
import { createEmptyServerRow, markAllPlayersDirty } from "@jgengine/core/runtime/snapshot";
|
|
9
9
|
import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
|
|
10
10
|
/** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
|
|
11
|
-
export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
|
|
11
|
+
export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
|
|
12
12
|
const saveConfigValidator = v.union(v.literal("none"), v.object({
|
|
13
13
|
auto: v.string(),
|
|
14
14
|
scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
|
|
@@ -37,6 +37,7 @@ export function jgengineTables() {
|
|
|
37
37
|
})
|
|
38
38
|
.index("by_game_and_status", ["gameId", "status"])
|
|
39
39
|
.index("by_status", ["status"])
|
|
40
|
+
.index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
|
|
40
41
|
.index("by_dirty", ["dirtyAt"]),
|
|
41
42
|
jgPlayerProfiles: defineTable({
|
|
42
43
|
userId: v.string(),
|
|
@@ -221,11 +222,16 @@ function serverDocToRecord(server) {
|
|
|
221
222
|
/**
|
|
222
223
|
* Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
|
|
223
224
|
* Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
225
|
+
* Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
|
|
224
226
|
* @capability convex-load-server-snapshot read a hosted server's runtime snapshot inside a host-written Convex mutation
|
|
225
227
|
*/
|
|
226
|
-
export async function loadServerSnapshot(ctx, server, runtime) {
|
|
228
|
+
export async function loadServerSnapshot(ctx, server, runtime, scope) {
|
|
227
229
|
const profiles = {};
|
|
228
|
-
|
|
230
|
+
const memberIds = new Set(server.memberUserIds);
|
|
231
|
+
const hydrateUserIds = scope?.players === undefined
|
|
232
|
+
? server.memberUserIds
|
|
233
|
+
: scope.players.filter((userId) => memberIds.has(userId));
|
|
234
|
+
for (const userId of hydrateUserIds) {
|
|
229
235
|
const profile = await ctx.db
|
|
230
236
|
.query("jgPlayerProfiles")
|
|
231
237
|
.withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
|
|
@@ -240,14 +246,25 @@ export async function loadServerSnapshot(ctx, server, runtime) {
|
|
|
240
246
|
}
|
|
241
247
|
: null;
|
|
242
248
|
}
|
|
243
|
-
const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles);
|
|
244
|
-
const chunks = await ctx.db
|
|
245
|
-
.query("jgWorldChunks")
|
|
246
|
-
.withIndex("by_server", (q) => q.eq("serverId", server._id))
|
|
247
|
-
.collect();
|
|
249
|
+
const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
|
|
248
250
|
const chunksByKey = {};
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
if (scope?.chunkKeys === undefined) {
|
|
252
|
+
const chunks = await ctx.db
|
|
253
|
+
.query("jgWorldChunks")
|
|
254
|
+
.withIndex("by_server", (q) => q.eq("serverId", server._id))
|
|
255
|
+
.collect();
|
|
256
|
+
for (const chunk of chunks)
|
|
257
|
+
chunksByKey[chunk.chunkKey] = chunk.snapshot;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
for (const chunkKey of new Set(scope.chunkKeys)) {
|
|
261
|
+
const chunk = await ctx.db
|
|
262
|
+
.query("jgWorldChunks")
|
|
263
|
+
.withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
|
|
264
|
+
.unique();
|
|
265
|
+
if (chunk)
|
|
266
|
+
chunksByKey[chunk.chunkKey] = chunk.snapshot;
|
|
267
|
+
}
|
|
251
268
|
}
|
|
252
269
|
return runtime.hydrate({
|
|
253
270
|
gameId: server.gameId,
|
|
@@ -287,11 +304,18 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
|
|
|
287
304
|
/**
|
|
288
305
|
* Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard
|
|
289
306
|
* increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
|
|
307
|
+
*
|
|
308
|
+
* A persist that would change nothing writes nothing and returns `false`. That matters beyond write
|
|
309
|
+
* cost: every subscriber of a query that read this row re-receives the whole result when the document
|
|
310
|
+
* changes, so an unconditional write on a 1 Hz tick re-pushes the entire world to every client once a
|
|
311
|
+
* second whether or not anything moved.
|
|
290
312
|
* @capability convex-persist-server-snapshot write runtime state and host tables in one Convex transaction
|
|
291
313
|
*/
|
|
292
314
|
export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
293
315
|
const now = Date.now();
|
|
294
316
|
const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
|
|
317
|
+
if (!plan.changed.any)
|
|
318
|
+
return false;
|
|
295
319
|
if (plan.leaderboard.length > 0) {
|
|
296
320
|
await applyLeaderboardIncrements(ctx, server.gameId, plan.leaderboard);
|
|
297
321
|
}
|
|
@@ -347,29 +371,56 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
|
|
|
347
371
|
await ctx.db.delete(existing._id);
|
|
348
372
|
}
|
|
349
373
|
await ctx.db.patch(server._id, {
|
|
350
|
-
serverState: plan.server.serverState,
|
|
351
|
-
sessionPlayers: plan.server.sessionPlayers,
|
|
374
|
+
...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
|
|
375
|
+
...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
|
|
352
376
|
revision: plan.server.revision,
|
|
353
377
|
dirtyAt: plan.server.dirtyAt,
|
|
354
378
|
updatedAt: plan.server.updatedAt,
|
|
355
379
|
lastSavedAt: plan.server.lastSavedAt,
|
|
356
380
|
});
|
|
381
|
+
return true;
|
|
357
382
|
}
|
|
358
|
-
|
|
383
|
+
/** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
|
|
384
|
+
async function flushServerIfDue(ctx, server, now, runtime, loaded) {
|
|
359
385
|
if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
|
|
360
386
|
return false;
|
|
361
387
|
}
|
|
362
|
-
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
388
|
+
const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
|
|
363
389
|
await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
|
|
364
390
|
return true;
|
|
365
391
|
}
|
|
366
392
|
export const JG_RUNTIME_TICK_MS = 1_000;
|
|
367
|
-
/**
|
|
393
|
+
/** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
|
|
394
|
+
export const DEFAULT_MAX_SERVERS_PER_TICK = 32;
|
|
395
|
+
/** How many chunk rows one `getChunks` call may return. */
|
|
396
|
+
export const MAX_CHUNKS_PER_QUERY = 64;
|
|
397
|
+
/**
|
|
398
|
+
* Build the hosted-server Convex functions for a set of game runtimes.
|
|
399
|
+
*
|
|
400
|
+
* **Supported world size.** One server is one Convex document holding the world row, the member
|
|
401
|
+
* roster, and every member's session state, and the host rewrites that document whenever the world
|
|
402
|
+
* changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
|
|
403
|
+
* larger value throws here, at wiring time, rather than at the write that overflows the document.
|
|
404
|
+
* World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
|
|
405
|
+
* ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
|
|
406
|
+
*
|
|
407
|
+
* `matchmaking: "singleton"` makes auto-match target one shared world per game: a join past capacity
|
|
408
|
+
* fails loudly instead of quietly starting a second world the player would be alone in.
|
|
409
|
+
* @internal
|
|
410
|
+
*/
|
|
368
411
|
export function createGameServerFunctions(options) {
|
|
369
412
|
const mode = options?.auth ?? "required";
|
|
370
413
|
const hostSlotsPerServer = options?.slotsPerServer ?? 16;
|
|
414
|
+
const slotsCheck = validateSlotsPerServer(hostSlotsPerServer);
|
|
415
|
+
if (!slotsCheck.ok)
|
|
416
|
+
throw new Error(slotsCheck.reason);
|
|
417
|
+
const matchmaking = options?.matchmaking ?? "auto";
|
|
418
|
+
const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
|
|
371
419
|
const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
|
|
372
420
|
const registry = buildRuntimeRegistry(options?.runtimes);
|
|
421
|
+
const tickableGameIds = (options?.runtimes ?? [])
|
|
422
|
+
.filter((runtime) => runtime.hasTick)
|
|
423
|
+
.map((runtime) => runtime.gameId);
|
|
373
424
|
const joinServer = mutation({
|
|
374
425
|
args: {
|
|
375
426
|
gameId: v.string(),
|
|
@@ -397,6 +448,7 @@ export function createGameServerFunctions(options) {
|
|
|
397
448
|
if (server && server.gameId !== args.gameId) {
|
|
398
449
|
throw new ConvexError("Server belongs to a different game");
|
|
399
450
|
}
|
|
451
|
+
let mayCreate = true;
|
|
400
452
|
if (!server) {
|
|
401
453
|
const joinable = [];
|
|
402
454
|
for (const status of ["running", "open"]) {
|
|
@@ -406,15 +458,21 @@ export function createGameServerFunctions(options) {
|
|
|
406
458
|
.collect();
|
|
407
459
|
joinable.push(...rows);
|
|
408
460
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
461
|
+
// Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
|
|
462
|
+
// resolves it — the loser's read set is invalidated and its mutation retries against the
|
|
463
|
+
// winner's row — so the engine adds no lock. A `HostPersistence` backend without
|
|
464
|
+
// serializable-snapshot retry must supply that guarantee itself.
|
|
465
|
+
const target = selectJoinTarget(joinable, {
|
|
466
|
+
userId: actorUserId,
|
|
467
|
+
mode: matchmaking,
|
|
468
|
+
slotsPerServer,
|
|
469
|
+
});
|
|
470
|
+
if (target.kind === "refuse")
|
|
471
|
+
throw new ConvexError(target.reason);
|
|
472
|
+
server = target.kind === "join" ? target.row : null;
|
|
473
|
+
mayCreate = target.kind === "create";
|
|
416
474
|
}
|
|
417
|
-
if (!server) {
|
|
475
|
+
if (!server && mayCreate) {
|
|
418
476
|
const serverId = await ctx.db.insert("jgGameServers", {
|
|
419
477
|
gameId: args.gameId,
|
|
420
478
|
status: "running",
|
|
@@ -436,6 +494,17 @@ export function createGameServerFunctions(options) {
|
|
|
436
494
|
if (!server)
|
|
437
495
|
throw new ConvexError("Failed to create server");
|
|
438
496
|
}
|
|
497
|
+
if (!server)
|
|
498
|
+
throw new ConvexError("No joinable server");
|
|
499
|
+
// Capacity and save policy live in host options, not in the row. Without this a long-lived
|
|
500
|
+
// world keeps whatever it was created with and raising the host option silently does nothing.
|
|
501
|
+
if (server.slotsPerServer !== slotsPerServer || JSON.stringify(server.save) !== JSON.stringify(save)) {
|
|
502
|
+
await ctx.db.patch(server._id, { slotsPerServer, save, updatedAt: now });
|
|
503
|
+
const reconciled = await ctx.db.get("jgGameServers", server._id);
|
|
504
|
+
if (!reconciled)
|
|
505
|
+
throw new ConvexError("Server missing after reconcile");
|
|
506
|
+
server = reconciled;
|
|
507
|
+
}
|
|
439
508
|
if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
|
|
440
509
|
throw new ConvexError("Server is full");
|
|
441
510
|
}
|
|
@@ -504,12 +573,12 @@ export function createGameServerFunctions(options) {
|
|
|
504
573
|
},
|
|
505
574
|
});
|
|
506
575
|
const helpers = {
|
|
507
|
-
async loadSnapshot(ctx, serverId) {
|
|
576
|
+
async loadSnapshot(ctx, serverId, scope) {
|
|
508
577
|
const server = await ctx.db.get("jgGameServers", serverId);
|
|
509
578
|
if (!server)
|
|
510
579
|
return null;
|
|
511
580
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
512
|
-
return { server, runtime, snapshot: await loadServerSnapshot(ctx, server, runtime) };
|
|
581
|
+
return { server, runtime, snapshot: await loadServerSnapshot(ctx, server, runtime, scope) };
|
|
513
582
|
},
|
|
514
583
|
applyCommand(args) {
|
|
515
584
|
return applyCommandWithOcc({ ...args, runtime: resolveRuntime(registry, args.gameId) });
|
|
@@ -617,6 +686,63 @@ export function createGameServerFunctions(options) {
|
|
|
617
686
|
};
|
|
618
687
|
}),
|
|
619
688
|
});
|
|
689
|
+
/**
|
|
690
|
+
* Lobby-scale view of a server: status, capacity, revision — no world state. Subscribe a menu,
|
|
691
|
+
* roster, or "is it up" indicator to this instead of `getServer`, whose payload is the whole world.
|
|
692
|
+
*/
|
|
693
|
+
const getServerMeta = query({
|
|
694
|
+
args: {
|
|
695
|
+
serverId: v.id("jgGameServers"),
|
|
696
|
+
externalId: v.optional(v.string()),
|
|
697
|
+
},
|
|
698
|
+
returns: v.union(v.object({
|
|
699
|
+
_id: v.id("jgGameServers"),
|
|
700
|
+
gameId: v.string(),
|
|
701
|
+
status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
|
|
702
|
+
mode: v.optional(v.string()),
|
|
703
|
+
memberCount: v.number(),
|
|
704
|
+
slotsPerServer: v.number(),
|
|
705
|
+
revision: v.number(),
|
|
706
|
+
updatedAt: v.number(),
|
|
707
|
+
}), v.null()),
|
|
708
|
+
handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
|
|
709
|
+
_id: server._id,
|
|
710
|
+
gameId: server.gameId,
|
|
711
|
+
status: server.status,
|
|
712
|
+
mode: server.mode,
|
|
713
|
+
memberCount: server.memberUserIds.length,
|
|
714
|
+
slotsPerServer: server.slotsPerServer,
|
|
715
|
+
revision: server.revision,
|
|
716
|
+
updatedAt: server.updatedAt,
|
|
717
|
+
})),
|
|
718
|
+
});
|
|
719
|
+
/**
|
|
720
|
+
* Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
|
|
721
|
+
* geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
|
|
722
|
+
* Bounded to {@link MAX_CHUNKS_PER_QUERY} keys; ask for the cells around the viewer
|
|
723
|
+
* (`chunkKeysInRadius`), not the world.
|
|
724
|
+
*/
|
|
725
|
+
const getChunks = query({
|
|
726
|
+
args: {
|
|
727
|
+
serverId: v.id("jgGameServers"),
|
|
728
|
+
chunkKeys: v.array(v.string()),
|
|
729
|
+
externalId: v.optional(v.string()),
|
|
730
|
+
},
|
|
731
|
+
returns: v.array(v.object({ chunkKey: v.string(), snapshot: v.any(), updatedAt: v.number() })),
|
|
732
|
+
handler: (ctx, args) => withMember(ctx, mode, args, [], async (server) => {
|
|
733
|
+
const keys = [...new Set(args.chunkKeys)].slice(0, MAX_CHUNKS_PER_QUERY);
|
|
734
|
+
const out = [];
|
|
735
|
+
for (const chunkKey of keys) {
|
|
736
|
+
const row = await ctx.db
|
|
737
|
+
.query("jgWorldChunks")
|
|
738
|
+
.withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
|
|
739
|
+
.unique();
|
|
740
|
+
if (row)
|
|
741
|
+
out.push({ chunkKey: row.chunkKey, snapshot: row.snapshot, updatedAt: row.updatedAt });
|
|
742
|
+
}
|
|
743
|
+
return out;
|
|
744
|
+
}),
|
|
745
|
+
});
|
|
620
746
|
const getPlayerProfile = query({
|
|
621
747
|
args: {
|
|
622
748
|
gameId: v.string(),
|
|
@@ -736,31 +862,41 @@ export function createGameServerFunctions(options) {
|
|
|
736
862
|
}));
|
|
737
863
|
},
|
|
738
864
|
});
|
|
865
|
+
// Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
|
|
866
|
+
// transaction, oldest anchor first. A game with no tick hook used to pay a full hydrate + persist per
|
|
867
|
+
// running server per second to hand `runtime.tick` a snapshot it returned unchanged; now it pays
|
|
868
|
+
// nothing, and no single transaction can fan out across every world of every game.
|
|
739
869
|
const tickActiveServers = internalMutation({
|
|
740
870
|
args: {},
|
|
741
871
|
handler: async (ctx) => {
|
|
742
872
|
const now = Date.now();
|
|
743
|
-
const servers = await ctx.db
|
|
744
|
-
.query("jgGameServers")
|
|
745
|
-
.withIndex("by_status", (q) => q.eq("status", "running"))
|
|
746
|
-
.collect();
|
|
747
873
|
let ticked = 0;
|
|
748
874
|
let saved = 0;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
875
|
+
let budget = maxServersPerTick;
|
|
876
|
+
for (const gameId of tickableGameIds) {
|
|
877
|
+
if (budget <= 0)
|
|
878
|
+
break;
|
|
879
|
+
const servers = await ctx.db
|
|
880
|
+
.query("jgGameServers")
|
|
881
|
+
.withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
|
|
882
|
+
.take(budget);
|
|
883
|
+
for (const server of servers) {
|
|
884
|
+
if (server.memberUserIds.length === 0)
|
|
885
|
+
continue;
|
|
886
|
+
const elapsedMs = now - server.tickAnchorMs;
|
|
887
|
+
if (elapsedMs < JG_RUNTIME_TICK_MS)
|
|
888
|
+
continue;
|
|
889
|
+
budget -= 1;
|
|
890
|
+
const runtime = resolveRuntime(registry, server.gameId);
|
|
891
|
+
const loaded = await loadServerSnapshot(ctx, server, runtime);
|
|
892
|
+
const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
|
|
893
|
+
await persistServerSnapshot(ctx, server, snapshot, server.save);
|
|
894
|
+
await ctx.db.patch(server._id, { tickAnchorMs: now });
|
|
895
|
+
ticked += 1;
|
|
896
|
+
const refreshed = await ctx.db.get("jgGameServers", server._id);
|
|
897
|
+
if (refreshed && (await flushServerIfDue(ctx, refreshed, now, runtime, snapshot))) {
|
|
898
|
+
saved += 1;
|
|
899
|
+
}
|
|
764
900
|
}
|
|
765
901
|
}
|
|
766
902
|
return { ticked, saved };
|
|
@@ -769,18 +905,16 @@ export function createGameServerFunctions(options) {
|
|
|
769
905
|
const flushDirtyServers = internalMutation({
|
|
770
906
|
args: {},
|
|
771
907
|
handler: async (ctx) => {
|
|
772
|
-
const now = Date.now();
|
|
773
908
|
const servers = await ctx.db
|
|
774
909
|
.query("jgGameServers")
|
|
775
910
|
.withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
|
|
776
|
-
.
|
|
911
|
+
.take(maxServersPerTick);
|
|
777
912
|
let saved = 0;
|
|
778
913
|
for (const server of servers) {
|
|
779
914
|
const runtime = resolveRuntime(registry, server.gameId);
|
|
780
915
|
const snapshot = await loadServerSnapshot(ctx, server, runtime);
|
|
781
916
|
await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
|
|
782
917
|
saved += 1;
|
|
783
|
-
void now;
|
|
784
918
|
}
|
|
785
919
|
return { saved };
|
|
786
920
|
},
|
|
@@ -791,6 +925,8 @@ export function createGameServerFunctions(options) {
|
|
|
791
925
|
runCommand,
|
|
792
926
|
flushSave,
|
|
793
927
|
getServer,
|
|
928
|
+
getServerMeta,
|
|
929
|
+
getChunks,
|
|
794
930
|
getPlayerProfile,
|
|
795
931
|
getFeed,
|
|
796
932
|
pushFeedEntry,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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.17.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.17.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.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
232
232
|
|
|
233
233
|
## @jgengine/core/runtime/adapter
|
|
234
234
|
|
|
@@ -334,7 +334,7 @@
|
|
|
334
334
|
|
|
335
335
|
## @jgengine/core/runtime/gameRuntime
|
|
336
336
|
|
|
337
|
-
- `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig;
|
|
337
|
+
- `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig; /** * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and * persisting a server whose `tick` is a no-op by construction — the difference between a world that * costs a full read/write every second while… — ⚠ undocumented
|
|
338
338
|
- `GameRuntimeDefinition` (type): type GameRuntimeDefinition = { gameId: string; save: SaveConfig; commands: Record<string, CommandDef>; loop?: ServerLoopHooks; } — ⚠ undocumented
|
|
339
339
|
- `HydrateInput` (type): type HydrateInput = { gameId: string; serverId: string; serverRow: RuntimeServerRow; playersByUserId: Record<string, RuntimePlayerRow>; chunksByKey: Record<string, RuntimeChunkRow>; revision?: number; } — ⚠ undocumented
|
|
340
340
|
- `RuntimeInitContext` (type): type RuntimeInitContext = { snapshot: GameRuntimeSnapshot; setSnapshot: (snapshot: GameRuntimeSnapshot) => void; } — ⚠ undocumented
|
|
@@ -365,21 +365,28 @@
|
|
|
365
365
|
- `OPEN_SERVER_LISTING_MAX` (const): const OPEN_SERVER_LISTING_MAX: 100 — ⚠ undocumented
|
|
366
366
|
- `PlayerProfileRecord` (type): type PlayerProfileRecord = { userId: string; gameId: string; playerState: RuntimePlayerRow; revision: number; updatedAt: number; } — ⚠ undocumented
|
|
367
367
|
- `ServerListing` (type): type ServerListing = { serverId: string; status: GameServerStatus; memberCount: number; slotsPerServer: number; mode?: string; label?: string; visibility?: SessionVisibility; joinCode?: string; tags?: string[]; updatedAt: number; } — ⚠ undocumented
|
|
368
|
-
- `ServerPersistPlan` (type): type ServerPersistPlan = { server: GameServerRecord; profiles: PlayerProfileRecord[]; chunks: WorldChunkRecord[]; deletedChunks: string[]; leaderboard: LeaderboardIncrement[];
|
|
368
|
+
- `ServerPersistPlan` (type): type ServerPersistPlan = { server: GameServerRecord; profiles: PlayerProfileRecord[]; chunks: WorldChunkRecord[]; deletedChunks: string[]; leaderboard: LeaderboardIncrement[]; /** * Which fields of the server row the plan actually moved. A store that pays per written field — * or whose subscribers r… — ⚠ undocumented
|
|
369
369
|
- `SessionAttributes` (type): type SessionAttributes = { label?: string; mode?: string; visibility?: SessionVisibility; joinCode?: string; tags?: string[]; } — ⚠ undocumented
|
|
370
370
|
- `SessionVisibility` (type): type SessionVisibility = "public" | "private" — ⚠ undocumented
|
|
371
371
|
- `ToServerListingOptions` (type): type ToServerListingOptions = { includeJoinCode?: boolean; } — ⚠ undocumented
|
|
372
372
|
- `WorldChunkRecord` (type): type WorldChunkRecord = { serverId: string; chunkKey: string; snapshot: RuntimeChunkRow; updatedAt: number; } — ⚠ undocumented
|
|
373
|
+
- `isSnapshotClean` (function): function isSnapshotClean(snapshot: GameRuntimeSnapshot): boolean — True when a snapshot carries no unwritten mutation — nothing for a persist to do.
|
|
373
374
|
|
|
374
375
|
## @jgengine/core/runtime/hostPolicy
|
|
375
376
|
|
|
377
|
+
- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
|
|
378
|
+
- `JoinCandidate` (type): type JoinCandidate = { memberUserIds: readonly string[]; slotsPerServer: number; visibility?: SessionVisibility | undefined; createdAt: number; } — A row `selectJoinTarget` can choose between — the matchmaking-relevant fields of a server record.
|
|
379
|
+
- `JoinTarget` (type): type JoinTarget<T extends JoinCandidate> = | { kind: "join"; row: T } | { kind: "create" } | { kind: "refuse"; reason: string } — What a join should do with the candidate rows it found.
|
|
380
|
+
- `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
|
|
376
381
|
- `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.
|
|
377
382
|
- `isAutoJoinCandidate` (function): function isAutoJoinCandidate(args: { memberUserIds: readonly string[]; slotsPerServer: number; visibility: SessionVisibility | undefined; userId: string; }): boolean — Auto-match candidate when no `serverId` is supplied: already a member, or a public room with free capacity. Private rooms are never auto-picked (join-by-code / direct id only).
|
|
378
383
|
- `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
|
|
379
384
|
- `isPrivateJoinBlocked` (function): function isPrivateJoinBlocked(args: { visibility: SessionVisibility | undefined; memberUserIds: readonly string[]; userId: string; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Whether a private-visibility server blocks this join (non-member without a matching code). Public / undefined visibility never blocks.
|
|
380
385
|
- `isServerFull` (function): function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean — True when the server has no free slots for a non-member. Existing members never count as "full" so rejoin/leave cycles keep working.
|
|
381
386
|
- `isServerMember` (function): function isServerMember(memberUserIds: readonly string[], userId: string): boolean — Whether `userId` is already on the server's member roster.
|
|
387
|
+
- `selectJoinTarget` (function): function selectJoinTarget<T extends JoinCandidate>(rows: readonly T[], args: { userId: string; mode?: MatchmakingMode; /** Capacity to judge rows against — the host's current option, when it supersedes the stored value. */ slotsPerServer?: number; }): JoinTarget<T> — Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}. Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is where a singleton world silently shards into per-player copies.
|
|
382
388
|
- `statusAfterLeave` (function): function statusAfterLeave(remainingMemberCount: number, currentStatus: GameServerStatus): GameServerStatus — Status after a leave: empty rooms reopen; non-empty rooms keep their current status.
|
|
389
|
+
- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true } | { ok: false; reason: string } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
|
|
383
390
|
- `withJoinedMember` (function): function withJoinedMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a successful join: unchanged when already a member, else appended.
|
|
384
391
|
- `withoutMember` (function): function withoutMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a leave: `userId` removed; order of remaining members preserved.
|
|
385
392
|
|
|
@@ -495,6 +502,20 @@
|
|
|
495
502
|
- `WorldHostConnection` (interface): interface WorldHostConnection — One client's link on the host side: routes its upstream frames into the shared session, pushes it sync frames.
|
|
496
503
|
- `WorldServerFrame` (type): type WorldServerFrame = | { t: "baseline"; revision: number; snapshot: WorldSnapshot } | { t: "diff"; diff: WorldDiff } — Host→client frame: the full baseline a joiner needs, then per-tick diffs. What a transport marshals downstream.
|
|
497
504
|
|
|
505
|
+
## @jgengine/core/runtime/worldChunks
|
|
506
|
+
|
|
507
|
+
- `ChunkCoord` (type): type ChunkCoord = { cx: number; cz: number } — Integer cell coordinates of a chunk on the ground plane.
|
|
508
|
+
- `DEFAULT_CHUNK_SIZE` (const): const DEFAULT_CHUNK_SIZE: 64 — Default edge length, in world units, of one persisted world chunk.
|
|
509
|
+
- `chunkCoordAt` (function): function chunkCoordAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): ChunkCoord — Chunk coordinates containing a world position at `size` units per cell.
|
|
510
|
+
- `chunkKeyAt` (function): function chunkKeyAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): string — Chunk key containing a world position at `size` units per cell.
|
|
511
|
+
- `chunkKeyOf` (function): function chunkKeyOf(coord: ChunkCoord): string — Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under. Games used to invent this mapping each time, which made two systems in one game disagree about which row a position belongs to.
|
|
512
|
+
- `chunkKeysInRadius` (function): function chunkKeysInRadius(center: readonly [number, number, number], radius: number, size: number = DEFAULT_CHUNK_SIZE): string[] — Every chunk key whose cell intersects a radius around a world position — the key list a viewer's bounded hydration or client chunk query asks for, instead of loading a whole world.
|
|
513
|
+
- `createEmptyChunkRow` (function): function createEmptyChunkRow(chunkKey: string): RuntimeChunkRow — An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet.
|
|
514
|
+
- `deleteChunk` (function): function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot — Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row.
|
|
515
|
+
- `parseChunkKey` (function): function parseChunkKey(key: string): ChunkCoord | null — Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form.
|
|
516
|
+
- `setChunk` (function): function setChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, chunk: RuntimeChunkRow): GameRuntimeSnapshot — Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it. The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command that mutated the map by hand silently never saved that chunk.
|
|
517
|
+
- `updateChunk` (function): function updateChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, update: (chunk: RuntimeChunkRow) => RuntimeChunkRow): GameRuntimeSnapshot — Read-modify-write a chunk (creating it empty when absent), marking it dirty.
|
|
518
|
+
|
|
498
519
|
## @jgengine/core/runtime/worldMirror
|
|
499
520
|
|
|
500
521
|
- `WorldMirror` (interface): interface WorldMirror — The client end of host-authoritative replication: folds a host's baseline + {@link WorldDiff} stream onto a local {@link GameContext}. It keeps the last full {@link WorldSnapshot}, advances it with each diff, and pushes the result through `ctx.hydrate` — so the client mirrors exactly the subsystems its own game opted into and silently ignores host modules it lacks. This is the inverse of a {@link HostedWorldSession}; the transport in between (loopback, ws, Convex) is irrelevant.
|
|
@@ -48,6 +48,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
48
48
|
|
|
49
49
|
- `canJoinPrivateServer` (function) · `import { canJoinPrivateServer } from "@jgengine/core/runtime/hostPolicy"`
|
|
50
50
|
|
|
51
|
+
## host-join-target — choose or refuse an auto-matched server under a matchmaking mode
|
|
52
|
+
|
|
53
|
+
- `selectJoinTarget` (function) · `import { selectJoinTarget } from "@jgengine/core/runtime/hostPolicy"`
|
|
54
|
+
|
|
51
55
|
## host-listing-filter — exclude private sessions from public browse results
|
|
52
56
|
|
|
53
57
|
- `isListablePublicly` (function) · `import { isListablePublicly } from "@jgengine/core/runtime/hostPolicy"`
|
|
@@ -146,6 +150,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
146
150
|
|
|
147
151
|
- `defineStore` (function) · `import { defineStore } from "@jgengine/shell/gameKit"`
|
|
148
152
|
|
|
153
|
+
## world-chunk-key — map world positions to persisted chunk keys at a declared cell size
|
|
154
|
+
|
|
155
|
+
- `chunkKeyOf` (function) · `import { chunkKeyOf } from "@jgengine/core/runtime/worldChunks"`
|
|
156
|
+
|
|
157
|
+
## world-chunk-write — mutate a snapshot's chunk and have the host persist it
|
|
158
|
+
|
|
159
|
+
- `setChunk` (function) · `import { setChunk } from "@jgengine/core/runtime/worldChunks"`
|
|
160
|
+
|
|
149
161
|
## world-convergence — derive environment coordinate content from the scene document
|
|
150
162
|
|
|
151
163
|
- `environmentContentFromDocument` (function) · `import { environmentContentFromDocument } from "@jgengine/shell/gameKit"`
|
|
@@ -14,17 +14,22 @@
|
|
|
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
|
|
17
|
-
- `
|
|
17
|
+
- `DEFAULT_MAX_SERVERS_PER_TICK` (const): const DEFAULT_MAX_SERVERS_PER_TICK: 32 — How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist.
|
|
18
|
+
- `GameServerHelpers` (type): type GameServerHelpers = { loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise<LoadedServerSnapshot | null>; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevision: number; snapshot: GameRuntimeSnapshot; actorUserId: string; command: … — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do.
|
|
18
19
|
- `HostedGameConfig` (interface): interface HostedGameConfig — One game the hosted path can serve, bound per `gameId`: its definition plus content lookup.
|
|
19
20
|
- `HostedWorldInvocation` (interface): interface HostedWorldInvocation<T> — Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`, plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
|
|
20
21
|
- `HostedWorldOutcome` (interface): interface HostedWorldOutcome<T> — What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster.
|
|
21
22
|
- `JGDataModel` (type): type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes> — Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to call the exported persistence helpers.
|
|
22
23
|
- `JGMutationCtx` (type): type JGMutationCtx = GenericMutationCtx<JGDataModel> — Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}.
|
|
23
24
|
- `JG_HOSTED_TICK_MS` (const): const JG_HOSTED_TICK_MS: 1000 — Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`.
|
|
25
|
+
- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
|
|
24
26
|
- `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented
|
|
25
27
|
- `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented
|
|
26
28
|
- `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; } — ⚠ undocumented
|
|
29
|
+
- `LoadSnapshotScope` (type): type LoadSnapshotScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
|
|
27
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
|
+
- `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return.
|
|
32
|
+
- `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
|
|
28
33
|
- `REVISION_CONFLICT_REASON` (const): const REVISION_CONFLICT_REASON: "Revision conflict" — ⚠ undocumented
|
|
29
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.
|
|
30
35
|
- `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).
|
|
@@ -43,10 +48,11 @@
|
|
|
43
48
|
- `defaultConvexGameApi` (function): function defaultConvexGameApi(): ConvexGameApi — ⚠ undocumented
|
|
44
49
|
- `defaultConvexSaveFunctions` (function): function defaultConvexSaveFunctions(): ConvexSaveFunctions — Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override.
|
|
45
50
|
- `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
|
|
46
|
-
- `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime): Promise<GameRuntimeSnapshot> — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
47
|
-
- `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<
|
|
51
|
+
- `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot> — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables. Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
|
|
52
|
+
- `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean> — Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
|
|
48
53
|
- `randomConvexPlayerId` (function): function randomConvexPlayerId(): string — ⚠ undocumented
|
|
49
54
|
- `resolveConvexMultiplayer` (function): function resolveConvexMultiplayer(args: { game: GameDefinition; gameId: string; url?: string; client?: ConvexReactClient; api?: ConvexGameApi; userId?: string; force?: boolean; feedActions?: string[]; poseTuning?: PoseSyncTuning; }): MultiplayerSession | null — ⚠ undocumented
|
|
55
|
+
- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
|
|
50
56
|
- `watchConvexQuery` (function): function watchConvexQuery<TArgs extends DefaultFunctionArgs, TResult, TView>(client: ConvexReactClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void — ⚠ undocumented
|
|
51
57
|
|
|
52
58
|
## @jgengine/convex/convexChatTransport
|
|
@@ -105,20 +111,26 @@
|
|
|
105
111
|
## @jgengine/convex/server
|
|
106
112
|
|
|
107
113
|
- `DEFAULT_CONVEX_POSE_RULES` (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules — ⚠ undocumented
|
|
108
|
-
- `
|
|
114
|
+
- `DEFAULT_MAX_SERVERS_PER_TICK` (const): const DEFAULT_MAX_SERVERS_PER_TICK: 32 — How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist.
|
|
115
|
+
- `GameServerHelpers` (type): type GameServerHelpers = { loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise<LoadedServerSnapshot | null>; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevision: number; snapshot: GameRuntimeSnapshot; actorUserId: string; command: … — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do.
|
|
109
116
|
- `JGDataModel` (type): type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes> — Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to call the exported persistence helpers.
|
|
110
117
|
- `JGMutationCtx` (type): type JGMutationCtx = GenericMutationCtx<JGDataModel> — Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}.
|
|
118
|
+
- `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
|
|
111
119
|
- `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented
|
|
112
120
|
- `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented
|
|
113
121
|
- `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; } — ⚠ undocumented
|
|
122
|
+
- `LoadSnapshotScope` (type): type LoadSnapshotScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
|
|
114
123
|
- `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
|
+
- `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return.
|
|
125
|
+
- `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
|
|
115
126
|
- `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; } — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
|
|
116
127
|
- `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).
|
|
117
128
|
- `ServerDoc` (type): type ServerDoc = DocumentByName<JGDataModel, "jgGameServers"> — A `jgGameServers` row — the server handle the persistence helpers load from and write back to.
|
|
118
129
|
- `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.
|
|
119
130
|
- `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
|
|
120
|
-
- `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime): Promise<GameRuntimeSnapshot> — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables.
|
|
121
|
-
- `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<
|
|
131
|
+
- `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot> — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables. Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
|
|
132
|
+
- `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean> — Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
|
|
133
|
+
- `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
|
|
122
134
|
|
|
123
135
|
## @jgengine/core/multiplayer
|
|
124
136
|
|
|
@@ -35,6 +35,12 @@ A synced pose (`@jgengine/ws/protocol`'s `WsPose`/`WsPresenceRow`) carries an op
|
|
|
35
35
|
|
|
36
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.
|
|
37
37
|
|
|
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
|
+
|
|
40
|
+
**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
|
+
|
|
42
|
+
**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.
|
|
43
|
+
|
|
38
44
|
Auth defaults to `"anonymous"` (`JgAuthMode`) on every factory — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject` and `externalId` is only cross-checked against it, never trusted alone.
|
|
39
45
|
|
|
40
46
|
**Flip a game online, step by step:** (1) add `multiplayer: convexPresence({ topology: "shared" })` to `game.config.ts` (any game with that adapter) — or `convex({ topology: "shared", authority: "server" })` for a genuinely shared sim, see `examples/HOSTED.md`; (2) stand up a Convex deployment — `bunx convex dev` inside `examples/convex-host` codegens `convex/_generated/` and prints the dev URL; (3) run the client with `VITE_CONVEX_URL=<url>` (env or `.env.local`) — `apps/dev/src/main.tsx` reads it and forces `resolveConvexMultiplayer`. No Convex Cloud account is required: `examples/convex-host/docker-compose.yml` runs the open-source backend (FSL-1.1-Apache-2.0) locally or on any Docker host (Fly.io/Railway templates upstream; Vercel can host only the game client, never the backend) — set `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY` in `.env.local` and the same `bunx convex dev` deploys there with full parity (crons, scheduling, file storage). The ws path is the same shape: `multiplayer: wsPresence({ topology })`, a `@jgengine/node` host (`createGameHost` + `createGameWsServer`), and `VITE_JG_WS_URL` forcing `resolveShellMultiplayer`.
|