@jgengine/convex 0.18.0 → 0.19.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.
@@ -0,0 +1,20 @@
1
+ import { type GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
2
+ import type { JGMutationCtx, JGDataModel } from "./server.js";
3
+ import type { GenericQueryCtx } from "convex/server";
4
+ type JGQueryCtx = GenericQueryCtx<JGDataModel>;
5
+ /**
6
+ * Trusted host hydration for claim/bootstrap work; the caller checks access before invoking it.
7
+ * @capability territory-host-hydration Load an actor profile and bounded claim chunks for a host transaction.
8
+ */
9
+ export declare function loadTerritoryState(ctx: JGQueryCtx | JGMutationCtx, args: {
10
+ serverId: string;
11
+ gameId: string;
12
+ userId: string;
13
+ chunkKeys: readonly string[];
14
+ }): Promise<GameRuntimeSnapshot>;
15
+ /**
16
+ * Persist dirty territory chunks and profiles in the caller's mutation, without a shared-server write.
17
+ * @capability territory-host-persistence Persist dirty ownership chunks and profiles without rewriting the shared server.
18
+ */
19
+ export declare function persistTerritoryState(ctx: JGMutationCtx, snapshot: GameRuntimeSnapshot, nowMs?: number): Promise<void>;
20
+ export {};
@@ -0,0 +1,47 @@
1
+ import { createEmptyPlayerRow, createRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
2
+ /**
3
+ * Trusted host hydration for claim/bootstrap work; the caller checks access before invoking it.
4
+ * @capability territory-host-hydration Load an actor profile and bounded claim chunks for a host transaction.
5
+ */
6
+ export async function loadTerritoryState(ctx, args) {
7
+ const keys = [...new Set(args.chunkKeys)];
8
+ if (keys.length > 64)
9
+ throw new RangeError("Territory read exceeds 64 chunks");
10
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", args.userId).eq("gameId", args.gameId)).unique();
11
+ const chunks = {};
12
+ for (const chunkKey of keys) {
13
+ const row = await ctx.db.query("jgWorldChunks").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId).eq("chunkKey", chunkKey)).unique();
14
+ if (row)
15
+ chunks[chunkKey] = row.snapshot;
16
+ }
17
+ return createRuntimeSnapshot({ serverId: args.serverId, gameId: args.gameId, players: { [args.userId]: profile?.playerState ?? createEmptyPlayerRow(args.userId) }, chunks, revision: profile?.revision ?? 0 });
18
+ }
19
+ /**
20
+ * Persist dirty territory chunks and profiles in the caller's mutation, without a shared-server write.
21
+ * @capability territory-host-persistence Persist dirty ownership chunks and profiles without rewriting the shared server.
22
+ */
23
+ export async function persistTerritoryState(ctx, snapshot, nowMs = Date.now()) {
24
+ for (const userId of snapshot.dirty.players) {
25
+ const playerState = snapshot.players[userId];
26
+ if (!playerState)
27
+ throw new Error("Dirty territory player was not hydrated");
28
+ const row = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", snapshot.gameId)).unique();
29
+ if (row)
30
+ await ctx.db.patch(row._id, { playerState, revision: row.revision + 1, updatedAt: nowMs });
31
+ else
32
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: snapshot.gameId, playerState, revision: 1, createdAt: nowMs, updatedAt: nowMs });
33
+ }
34
+ for (const chunkKey of snapshot.dirty.chunks) {
35
+ const chunk = snapshot.chunks[chunkKey];
36
+ const row = await ctx.db.query("jgWorldChunks").withIndex("by_server_and_chunk", q => q.eq("serverId", snapshot.serverId).eq("chunkKey", chunkKey)).unique();
37
+ if (!chunk) {
38
+ if (row)
39
+ await ctx.db.delete(row._id);
40
+ continue;
41
+ }
42
+ if (row)
43
+ await ctx.db.patch(row._id, { snapshot: chunk, updatedAt: nowMs });
44
+ else
45
+ await ctx.db.insert("jgWorldChunks", { serverId: snapshot.serverId, chunkKey, snapshot: chunk, updatedAt: nowMs });
46
+ }
47
+ }
@@ -0,0 +1,68 @@
1
+ import type { DocumentByName, GenericQueryCtx } from "convex/server";
2
+ import type { JGDataModel, JGMutationCtx } from "./server.js";
3
+ import { type IncomingPose, type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
4
+ import type { PresencePosition, PresenceResidentRow } from "@jgengine/core/multiplayer/presenceContract";
5
+ type ReadContext = {
6
+ db: Pick<GenericQueryCtx<JGDataModel>["db"], "get" | "query">;
7
+ };
8
+ type PoseRow = DocumentByName<JGDataModel, "jgPoses">;
9
+ /** Normalized actor identity and pose read from an indexed jgPoses row. */
10
+ export interface WorldPresenceRecord extends PresenceResidentRow {
11
+ _id: PoseRow["_id"];
12
+ serverId: string;
13
+ presenceId: string;
14
+ homeGameId: string;
15
+ kind: string;
16
+ label: string | null;
17
+ position: PresencePosition;
18
+ rotationY: number;
19
+ rotationPitch: number;
20
+ lastSeenAt: number;
21
+ lastWorldSnapshotAt?: number;
22
+ }
23
+ /** World chunk size and host-owned pose, checkpoint and retention policies. */
24
+ export interface WorldPresenceOptions {
25
+ chunkSize?: number;
26
+ rules?: PoseSyncRules;
27
+ idleTimeoutMs?: number;
28
+ revokedTtlMs?: number;
29
+ snapshotIntervalMs?: number;
30
+ }
31
+ /** Persist game-owned last-position state when the host store requests a checkpoint. */
32
+ export type PresenceSnapshotWriter = (presence: WorldPresenceRecord, nowMs: number) => Promise<void>;
33
+ /** Indexed presence storage for hosts that own authentication, bot ownership and spawn policy.
34
+ * @capability world-presence-store indexed neighborhood presence with pose throttling and physical expiry
35
+ * Call only after resolving the actor and their world access; browser arguments are not trusted identities.
36
+ */
37
+ export declare function createWorldPresenceStore(options?: WorldPresenceOptions): {
38
+ active: (ctx: ReadContext, actorExternalId: string, serverId?: string) => Promise<WorldPresenceRecord | null>;
39
+ ensure(ctx: JGMutationCtx, args: {
40
+ serverId: string;
41
+ actorExternalId: string;
42
+ homeGameId: string;
43
+ kind: string;
44
+ position: PresencePosition;
45
+ presenceId?: string;
46
+ label?: string;
47
+ ownerActorId?: string;
48
+ }, nowMs?: number): Promise<WorldPresenceRecord>;
49
+ sync(ctx: JGMutationCtx, presence: WorldPresenceRecord, incoming?: IncomingPose, args?: {
50
+ nowMs?: number;
51
+ rules?: PoseSyncRules;
52
+ onSnapshot?: PresenceSnapshotWriter;
53
+ }): Promise<WorldPresenceRecord>;
54
+ revoke(ctx: JGMutationCtx, presence: WorldPresenceRecord, onSnapshot?: PresenceSnapshotWriter, nowMs?: number): Promise<void>;
55
+ nearby(ctx: ReadContext, serverId: string, viewerChunkKey?: string, args?: {
56
+ rings?: number;
57
+ limit?: number;
58
+ }): Promise<WorldPresenceRecord[]>;
59
+ reap(ctx: JGMutationCtx, args?: {
60
+ nowMs?: number;
61
+ batchSize?: number;
62
+ onSnapshot?: PresenceSnapshotWriter;
63
+ }): Promise<{
64
+ reaped: number;
65
+ deleted: number;
66
+ }>;
67
+ };
68
+ export {};
@@ -0,0 +1,128 @@
1
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, shouldPersistWorldSnapshot } from "@jgengine/core/multiplayer/presenceModel";
2
+ import { chunkKeyAt, chunkKeysAround, parseChunkKey, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks";
3
+ function record(row) {
4
+ return { _id: row._id, serverId: row.serverId, presenceId: row.sessionId ?? row.userId,
5
+ actorExternalId: row.userId, ownerActorId: row.ownerActorId,
6
+ homeGameId: row.homeGameId ?? "", kind: row.kind ?? "player", label: row.label ?? null,
7
+ position: { x: row.x, y: row.y, z: row.z }, rotationY: row.rotationY,
8
+ rotationPitch: row.rotationPitch, lastSeenAt: row.updatedAt, lastWorldSnapshotAt: row.lastWorldSnapshotAt };
9
+ }
10
+ function finitePose(position, rotationY, rotationPitch) {
11
+ for (const value of [position.x, position.z, position.y ?? 0, rotationY ?? 0, rotationPitch ?? 0]) {
12
+ if (!Number.isFinite(value))
13
+ throw new RangeError("Presence pose must be finite");
14
+ }
15
+ }
16
+ /** Indexed presence storage for hosts that own authentication, bot ownership and spawn policy.
17
+ * @capability world-presence-store indexed neighborhood presence with pose throttling and physical expiry
18
+ * Call only after resolving the actor and their world access; browser arguments are not trusted identities.
19
+ */
20
+ export function createWorldPresenceStore(options = {}) {
21
+ const size = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
22
+ const rules = options.rules ?? DEFAULT_POSE_SYNC_RULES;
23
+ const idle = options.idleTimeoutMs ?? 240_000;
24
+ const revokedTtl = options.revokedTtlMs ?? 600_000;
25
+ const snapshotInterval = options.snapshotIntervalMs ?? 30_000;
26
+ if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(idle) || idle < 1 ||
27
+ !Number.isFinite(revokedTtl) || revokedTtl < 0 || !Number.isFinite(snapshotInterval) || snapshotInterval < 0)
28
+ throw new RangeError("Invalid world presence policy");
29
+ async function active(ctx, actorExternalId, serverId) {
30
+ const rows = serverId === undefined
31
+ ? await ctx.db.query("jgPoses").withIndex("by_user", q => q.eq("userId", actorExternalId)).take(32)
32
+ : await ctx.db.query("jgPoses").withIndex("by_server_and_user", q => q.eq("serverId", serverId).eq("userId", actorExternalId)).take(32);
33
+ const live = rows.filter(row => row.revokedAt === undefined).sort((a, b) => b.updatedAt - a.updatedAt)[0];
34
+ return live ? record(live) : null;
35
+ }
36
+ return {
37
+ active,
38
+ async ensure(ctx, args, nowMs = Date.now()) {
39
+ finitePose(args.position);
40
+ const rows = await ctx.db.query("jgPoses").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", args.actorExternalId)).take(32);
41
+ const existing = rows.filter(row => row.revokedAt === undefined).sort((a, b) => b.updatedAt - a.updatedAt)[0];
42
+ for (const row of rows)
43
+ if (row._id !== existing?._id)
44
+ await ctx.db.delete(row._id);
45
+ const patch = { serverId: args.serverId, userId: args.actorExternalId, sessionId: args.presenceId ?? args.actorExternalId,
46
+ homeGameId: args.homeGameId, kind: args.kind, ownerActorId: args.ownerActorId,
47
+ label: args.label ?? existing?.label, x: args.position.x, y: args.position.y, z: args.position.z,
48
+ rotationY: 0, rotationPitch: 0, chunkKey: chunkKeyAt([args.position.x, args.position.y, args.position.z], size),
49
+ updatedAt: nowMs, revokedAt: undefined };
50
+ const id = existing ? existing._id : await ctx.db.insert("jgPoses", patch);
51
+ if (existing)
52
+ await ctx.db.patch(id, patch);
53
+ return record((await ctx.db.get("jgPoses", id)));
54
+ },
55
+ async sync(ctx, presence, incoming, args = {}) {
56
+ const nowMs = args.nowMs ?? Date.now();
57
+ if (incoming)
58
+ finitePose(incoming.position, incoming.rotationY, incoming.rotationPitch);
59
+ const current = await ctx.db.get("jgPoses", presence._id);
60
+ if (!current || current.revokedAt !== undefined)
61
+ return presence;
62
+ const currentRecord = record(current);
63
+ const decision = decidePoseSync({ position: currentRecord.position, rotationY: current.rotationY,
64
+ rotationPitch: current.rotationPitch, lastSeenAtMs: current.updatedAt }, incoming ?? { position: currentRecord.position }, args.rules ?? rules, nowMs);
65
+ const next = { ...currentRecord, position: decision.position, rotationY: decision.rotationY, rotationPitch: decision.rotationPitch };
66
+ const patch = {};
67
+ if (decision.changed)
68
+ Object.assign(patch, { x: next.position.x, y: next.position.y, z: next.position.z,
69
+ chunkKey: chunkKeyAt([next.position.x, next.position.y, next.position.z], size), rotationY: next.rotationY, rotationPitch: next.rotationPitch });
70
+ if (decision.changed || decision.refreshKeepAlive) {
71
+ patch.updatedAt = nowMs;
72
+ next.lastSeenAt = nowMs;
73
+ }
74
+ if (args.onSnapshot && shouldPersistWorldSnapshot(current.lastWorldSnapshotAt, nowMs, snapshotInterval)) {
75
+ await args.onSnapshot(next, nowMs);
76
+ patch.lastWorldSnapshotAt = nowMs;
77
+ next.lastWorldSnapshotAt = nowMs;
78
+ }
79
+ if (Object.keys(patch).length)
80
+ await ctx.db.patch(current._id, patch);
81
+ return next;
82
+ },
83
+ async revoke(ctx, presence, onSnapshot, nowMs = Date.now()) {
84
+ const row = await ctx.db.get("jgPoses", presence._id);
85
+ if (!row || row.revokedAt !== undefined)
86
+ return;
87
+ if (onSnapshot)
88
+ await onSnapshot(record(row), nowMs);
89
+ await ctx.db.patch(row._id, { revokedAt: nowMs, updatedAt: nowMs });
90
+ },
91
+ async nearby(ctx, serverId, viewerChunkKey = "0,0", args = {}) {
92
+ const coord = parseChunkKey(viewerChunkKey);
93
+ if (!coord)
94
+ return [];
95
+ const requestedLimit = args.limit ?? 500;
96
+ if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1)
97
+ throw new RangeError("Invalid presence limit");
98
+ const limit = Math.min(1000, requestedLimit);
99
+ const rows = [];
100
+ for (const chunkKey of chunkKeysAround([coord.cx * size, 0, coord.cz * size], args.rings ?? 1, size)) {
101
+ const chunk = await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", serverId).eq("chunkKey", chunkKey)).take(limit - rows.length);
102
+ rows.push(...chunk.filter(row => row.revokedAt === undefined).map(record));
103
+ if (rows.length >= limit)
104
+ break;
105
+ }
106
+ return rows;
107
+ },
108
+ async reap(ctx, args = {}) {
109
+ const nowMs = args.nowMs ?? Date.now();
110
+ const requestedBatch = args.batchSize ?? 200;
111
+ if (!Number.isFinite(nowMs) || !Number.isSafeInteger(requestedBatch) || requestedBatch < 1)
112
+ throw new RangeError("Invalid presence reaper batch");
113
+ const batchSize = Math.min(500, requestedBatch);
114
+ const rows = await ctx.db.query("jgPoses").withIndex("by_revoked_updated", q => q.eq("revokedAt", undefined).lt("updatedAt", nowMs - idle)).take(batchSize);
115
+ let reaped = 0;
116
+ for (const row of rows) {
117
+ if (args.onSnapshot)
118
+ await args.onSnapshot(record(row), nowMs);
119
+ await ctx.db.delete(row._id);
120
+ reaped++;
121
+ }
122
+ const revoked = await ctx.db.query("jgPoses").withIndex("by_revoked", q => q.gte("revokedAt", 0).lte("revokedAt", nowMs - revokedTtl)).take(batchSize - reaped);
123
+ for (const row of revoked)
124
+ await ctx.db.delete(row._id);
125
+ return { reaped, deleted: reaped + revoked.length };
126
+ },
127
+ };
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.18.0",
3
+ "version": "0.19.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.18.0"
33
+ "@jgengine/core": "^0.19.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -143,7 +143,7 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
143
143
 
144
144
  ## JGengine notes
145
145
 
146
- - Multi-session engine work often spans `docs/plans/`, GitHub issues, and stacked PRs — point at those; do not paste full plan bodies.
146
+ - Multi-session engine work spans GitHub issues and PRs — point at those; do not paste full plan bodies.
147
147
  - Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
148
148
  - Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
149
- - Papercuts and durable process changes belong in `PAPERCUTS.md` / skills, not only the handoff.
149
+ - Durable process changes belong in skills, not only the handoff.
@@ -5,10 +5,14 @@ description: Route game work to the smallest useful JGengine guidance set.
5
5
 
6
6
  # JGengine intake and router
7
7
 
8
- Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
8
+ This skill decides what to load; it is not an engine manual. Package map, commands, layering, and license live in the JGengine README (repo-root [`README.md`](../../../README.md) in the monorepo, [jgengine.com](https://jgengine.com) from a created project); open it when you need one of those facts.
9
+
10
+ A created project installs only `jgengine`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
9
11
 
10
12
  ## 1. Establish the target
11
13
 
14
+ For greenfield builds, write `src/art-direction.md` before any code.
15
+
12
16
  Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. Capture only decisions that change architecture:
13
17
 
14
18
  - player point of view and controls
@@ -18,7 +22,7 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
18
22
  - HUD/menu states and **custom** UI art direction (compose shipped building blocks + HudTheme; no unskinned stock face)
19
23
  - one observable completion scenario
20
24
 
21
- For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
25
+ Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player model, editor scene); a grid, card, tile, or other board game with no 3D scene takes `create --2d` (a `board` world drawn by GameUI over `@jgengine/core/puzzle/cellGrid`). For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
26
 
23
27
  For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
24
28
 
@@ -44,9 +48,9 @@ World content routes editor-first: any request that adds, moves, restyles, or re
44
48
 
45
49
  Within each selected domain:
46
50
 
47
- 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
48
- 1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
49
- 2. Open `api.md` only when exact signatures or export inventory are needed.
51
+ 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
52
+ 1. Search capabilities by intent; do not read `capabilities.md` top to bottom. `npx jgengine find <intent>` searches every domain's index and every recipe at once, installed or not, and prints the primitive and its import (or the recipe to open); in the monorepo, `grep -i "<intent>" .claude/skills/*/capabilities.md` does the same. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
53
+ 2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
50
54
  3. Open the linked reference only for a deeper recipe or trap.
51
55
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
52
56