@jgengine/convex 0.10.0 → 0.12.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/README.md CHANGED
@@ -11,7 +11,7 @@ import { resolveConvexMultiplayer } from "@jgengine/convex/resolveConvexMultipla
11
11
 
12
12
  const session = resolveConvexMultiplayer({
13
13
  game: myGame,
14
- gameId: "voxel-mine",
14
+ gameId: "my-game",
15
15
  url: import.meta.env.VITE_CONVEX_URL,
16
16
  force: import.meta.env.VITE_CONVEX_URL !== undefined,
17
17
  });
@@ -44,6 +44,6 @@ No game-specific code lives there — any JGengine game can point at the same de
44
44
 
45
45
  Every factory defaults to `auth: "anonymous"` — 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.
46
46
 
47
- See `examples/convex-host` for the reference thin consumer (`bunx convex dev` codegens `convex/_generated/` and prints the dev URL) and `Games/voxel-mine` for a game that declares `multiplayer: convex({ topology: "shared" })`. No Convex Cloud account is needed — `examples/convex-host/docker-compose.yml` runs the open-source backend anywhere Docker runs, with `CONVEX_SELF_HOSTED_URL`/`CONVEX_SELF_HOSTED_ADMIN_KEY` pointing the same CLI at it.
47
+ See `examples/convex-host` for the reference thin consumer (`bunx convex dev` codegens `convex/_generated/` and prints the dev URL). Point any game that declares `multiplayer: convexPresence({ topology: "shared" })` (or `convex({ topology: "shared", authority: "server" })` for a shared sim) at the same deployment. No Convex Cloud account is needed — `examples/convex-host/docker-compose.yml` runs the open-source backend anywhere Docker runs, with `CONVEX_SELF_HOSTED_URL`/`CONVEX_SELF_HOSTED_ADMIN_KEY` pointing the same CLI at it.
48
48
 
49
- Part of [JGengine](https://github.com/Noisemaker111/jgengine). AGPL-3.0-only.
49
+ Part of [JGengine](https://github.com/Noisemaker111/jgengine). Apache-2.0.
@@ -0,0 +1,35 @@
1
+ import type { ConvexReactClient } from "convex/react";
2
+ import type { FunctionReference } from "convex/server";
3
+ import { type SaveBackend } from "@jgengine/core/game/saveStore";
4
+ /** 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. */
5
+ export interface ConvexSaveFunctions {
6
+ read: FunctionReference<"query", "public", {
7
+ key: string;
8
+ }, string | null>;
9
+ write: FunctionReference<"mutation", "public", {
10
+ key: string;
11
+ value: string;
12
+ }, unknown>;
13
+ remove: FunctionReference<"mutation", "public", {
14
+ key: string;
15
+ }, unknown>;
16
+ }
17
+ /** Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override. */
18
+ export declare function defaultConvexSaveFunctions(): ConvexSaveFunctions;
19
+ /** Config for {@link createConvexSaveBackend} — the Convex client, optional function refs (defaults to the `saves.*` convention), and an optional key `namespace`. */
20
+ export interface ConvexSaveBackendOptions {
21
+ client: ConvexReactClient;
22
+ /** The read/write/remove function refs; defaults to the `saves.*` convention. */
23
+ functions?: ConvexSaveFunctions;
24
+ /** Prefix every key so multiple games or users can share one `saves` table. */
25
+ namespace?: string;
26
+ }
27
+ /**
28
+ * A cloud {@link SaveBackend} backed by Convex — reads through a query and
29
+ * writes through mutations, so a {@link createSaveStore} configured with it
30
+ * saves to the server instead of `localStorage`. The only change from an offline
31
+ * game is swapping the backend; autosave, slots, and migration behave the same.
32
+ *
33
+ * @capability convex-save save a game to Convex (cloud) through the SaveBackend seam
34
+ */
35
+ export declare function createConvexSaveBackend(options: ConvexSaveBackendOptions): SaveBackend;
@@ -0,0 +1,24 @@
1
+ import { anyApi } from "convex/server";
2
+ import { remoteSaveBackend } from "@jgengine/core/game/saveStore";
3
+ /** Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override. */
4
+ export function defaultConvexSaveFunctions() {
5
+ return anyApi.saves;
6
+ }
7
+ /**
8
+ * A cloud {@link SaveBackend} backed by Convex — reads through a query and
9
+ * writes through mutations, so a {@link createSaveStore} configured with it
10
+ * saves to the server instead of `localStorage`. The only change from an offline
11
+ * game is swapping the backend; autosave, slots, and migration behave the same.
12
+ *
13
+ * @capability convex-save save a game to Convex (cloud) through the SaveBackend seam
14
+ */
15
+ export function createConvexSaveBackend(options) {
16
+ const functions = options.functions ?? defaultConvexSaveFunctions();
17
+ const prefix = options.namespace === undefined ? "" : `${options.namespace}:`;
18
+ const scope = (key) => `${prefix}${key}`;
19
+ return remoteSaveBackend({
20
+ read: (key) => options.client.query(functions.read, { key: scope(key) }),
21
+ write: (key, value) => options.client.mutation(functions.write, { key: scope(key), value }).then(() => undefined),
22
+ remove: (key) => options.client.mutation(functions.remove, { key: scope(key) }).then(() => undefined),
23
+ });
24
+ }
@@ -1,5 +1,5 @@
1
1
  import type { GameDefinition } from "@jgengine/core/game/defineGame";
2
- import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
2
+ import type { GameContextContent, GameContextModels } from "@jgengine/core/runtime/gameContext";
3
3
  import { type InputFrame } from "@jgengine/core/runtime/hostedGameRunner";
4
4
  import { type HostedWorldSession, type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
5
5
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
@@ -8,6 +8,8 @@ import { type JgAuthMode } from "./server.js";
8
8
  export interface HostedGameConfig {
9
9
  definition: GameDefinition<ModelAssetRef, unknown>;
10
10
  content: GameContextContent;
11
+ /** Render-model lookup for collider auto-fit — pass the same lookup the shell derives (`contextModels(playable)`) so the authoritative host resolves the hitboxes clients see. */
12
+ models?: GameContextModels;
11
13
  }
12
14
  /**
13
15
  * Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`,
@@ -32,7 +34,8 @@ export interface HostedWorldOutcome<T> {
32
34
  * The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
33
35
  * `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
34
36
  * members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
35
- */
37
+ * @internal
38
+ */
36
39
  export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T>;
37
40
  /** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
38
41
  export declare const JG_HOSTED_TICK_MS = 1000;
@@ -41,7 +44,8 @@ export declare const JG_HOSTED_TICK_MS = 1000;
41
44
  * reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
42
45
  * {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
43
46
  * drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
44
- */
47
+ * @internal
48
+ */
45
49
  export declare function createHostedGameServerFunctions(options: {
46
50
  games: Record<string, HostedGameConfig>;
47
51
  auth?: JgAuthMode;
@@ -8,7 +8,8 @@ import { jgengineHostedTables, resolveActor } from "./server.js";
8
8
  * The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
9
9
  * `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
10
10
  * members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
11
- */
11
+ * @internal
12
+ */
12
13
  export function invokeHostedWorld(invocation) {
13
14
  const { game, store, members = [], inputs = {}, now, op } = invocation;
14
15
  const loaded = store.load();
@@ -18,6 +19,7 @@ export function invokeHostedWorld(invocation) {
18
19
  content: game.content,
19
20
  store: { load: () => loaded, save: () => { } },
20
21
  ...(now === undefined ? {} : { now }),
22
+ ...(game.models === undefined ? {} : { models: game.models }),
21
23
  });
22
24
  for (const userId of members)
23
25
  session.runner().resume(userId);
@@ -94,7 +96,8 @@ async function persistWorldRow(ctx, args) {
94
96
  * reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
95
97
  * {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
96
98
  * drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
97
- */
99
+ * @internal
100
+ */
98
101
  export function createHostedGameServerFunctions(options) {
99
102
  const mode = options.auth ?? "anonymous";
100
103
  const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
@@ -302,7 +305,10 @@ export function createHostedGameServerFunctions(options) {
302
305
  args: {},
303
306
  handler: async (ctx) => {
304
307
  const nowMs = Date.now();
305
- const rows = await ctx.db.query("jgHostedWorlds").collect();
308
+ const rows = await ctx.db
309
+ .query("jgHostedWorlds")
310
+ .withIndex("by_tick_anchor", (q) => q.lte("tickAnchorMs", nowMs - tickMs))
311
+ .collect();
306
312
  let ticked = 0;
307
313
  let saved = 0;
308
314
  for (const row of rows) {
@@ -312,8 +318,6 @@ export function createHostedGameServerFunctions(options) {
312
318
  if (row.memberUserIds.length === 0)
313
319
  continue;
314
320
  const elapsedMs = nowMs - row.tickAnchorMs;
315
- if (elapsedMs < tickMs)
316
- continue;
317
321
  const { store, captured } = worldStoreForRow(row);
318
322
  const outcome = invokeHostedWorld({
319
323
  game,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./convexChatTransport.js";
2
2
  export * from "./convexPresenceTransport.js";
3
3
  export * from "./createConvexBackend.js";
4
+ export * from "./convexSaveBackend.js";
4
5
  export * from "./createConvexGameTransport.js";
5
6
  export * from "./hostedServer.js";
6
7
  export * from "./occ.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./convexChatTransport.js";
2
2
  export * from "./convexPresenceTransport.js";
3
3
  export * from "./createConvexBackend.js";
4
+ export * from "./convexSaveBackend.js";
4
5
  export * from "./createConvexGameTransport.js";
5
6
  export * from "./hostedServer.js";
6
7
  export * from "./occ.js";
package/dist/occ.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
3
3
  export declare const REVISION_CONFLICT_REASON = "Revision conflict";
4
+ /** @internal */
4
5
  export declare function commitIfRevisionMatch(loadedRevision: number, currentRevision: number): {
5
6
  ok: true;
6
7
  } | {
7
8
  ok: false;
8
9
  reason: string;
9
10
  };
11
+ /** @internal */
10
12
  export declare function applyCommandWithOcc(args: {
11
13
  loadedRevision: number;
12
14
  currentRevision: number;
package/dist/occ.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export const REVISION_CONFLICT_REASON = "Revision conflict";
2
+ /** @internal */
2
3
  export function commitIfRevisionMatch(loadedRevision, currentRevision) {
3
4
  if (currentRevision !== loadedRevision) {
4
5
  return { ok: false, reason: REVISION_CONFLICT_REASON };
5
6
  }
6
7
  return { ok: true };
7
8
  }
9
+ /** @internal */
8
10
  export function applyCommandWithOcc(args) {
9
11
  const gate = commitIfRevisionMatch(args.loadedRevision, args.currentRevision);
10
12
  if (!gate.ok)
package/dist/server.d.ts CHANGED
@@ -5,6 +5,9 @@ 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
+ /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
9
+ export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
10
+ /** @internal */
8
11
  export declare function jgengineTables(): {
9
12
  jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
10
13
  createdAt: number;
@@ -55,7 +58,9 @@ export declare function jgengineTables(): {
55
58
  createdAt: import("convex/values").VFloat64<number, "required">;
56
59
  updatedAt: import("convex/values").VFloat64<number, "required">;
57
60
  }, "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}`>, {
61
+ by_dirty: ["dirtyAt", "_creationTime"];
58
62
  by_game_and_status: ["gameId", "status", "_creationTime"];
63
+ by_status: ["status", "_creationTime"];
59
64
  }, {}, {}>;
60
65
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
61
66
  createdAt: number;
@@ -167,7 +172,8 @@ export declare function jgengineTables(): {
167
172
  * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
168
173
  * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
169
174
  * {@link jgengineTables} — spread either (or both) into `defineSchema`.
170
- */
175
+ * @internal
176
+ */
171
177
  export declare function jgengineHostedTables(): {
172
178
  jgHostedWorlds: import("convex/server").TableDefinition<import("convex/values").VObject<{
173
179
  createdAt: number;
@@ -191,6 +197,7 @@ export declare function jgengineHostedTables(): {
191
197
  updatedAt: import("convex/values").VFloat64<number, "required">;
192
198
  }, "required", "createdAt" | "gameId" | "inputs" | "memberUserIds" | "revision" | "serverId" | "snapshot" | "tickAnchorMs" | "updatedAt" | `inputs.${string}` | `snapshot.${string}`>, {
193
199
  by_game_and_server: ["gameId", "serverId", "_creationTime"];
200
+ by_tick_anchor: ["tickAnchorMs", "_creationTime"];
194
201
  }, {}, {}>;
195
202
  };
196
203
  declare const schemaForTypes: import("convex/server").SchemaDefinition<{
@@ -243,7 +250,9 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
243
250
  createdAt: import("convex/values").VFloat64<number, "required">;
244
251
  updatedAt: import("convex/values").VFloat64<number, "required">;
245
252
  }, "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}`>, {
253
+ by_dirty: ["dirtyAt", "_creationTime"];
246
254
  by_game_and_status: ["gameId", "status", "_creationTime"];
255
+ by_status: ["status", "_creationTime"];
247
256
  }, {}, {}>;
248
257
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
249
258
  createdAt: number;
@@ -355,12 +364,16 @@ type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
355
364
  type JGMutationCtx = GenericMutationCtx<JGDataModel>;
356
365
  export type JgAuthMode = "anonymous" | "required";
357
366
  export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
358
- /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`. */
367
+ /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
368
+ * @internal
369
+ */
359
370
  export declare function resolveActor(ctx: {
360
371
  auth: Auth;
361
372
  }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
373
+ /** @internal */
362
374
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
363
375
  export declare const JG_RUNTIME_TICK_MS = 1000;
376
+ /** @internal */
364
377
  export declare function createGameServerFunctions(options?: {
365
378
  runtimes?: GameRuntime[];
366
379
  auth?: JgAuthMode;
@@ -460,6 +473,7 @@ export declare function createGameServerFunctions(options?: {
460
473
  saved: number;
461
474
  }>>;
462
475
  };
476
+ /** @internal */
463
477
  export declare function createLeaderboardFunctions(options?: {
464
478
  auth?: JgAuthMode;
465
479
  }): {
@@ -490,6 +504,7 @@ export declare function createLeaderboardFunctions(options?: {
490
504
  gameId: string;
491
505
  }, Promise<null>>;
492
506
  };
507
+ /** @internal */
493
508
  export declare function createPresenceFunctions(options?: {
494
509
  auth?: JgAuthMode;
495
510
  freshWindowMs?: number;
@@ -525,6 +540,7 @@ export declare function createPresenceFunctions(options?: {
525
540
  serverId: string;
526
541
  }, Promise<null>>;
527
542
  };
543
+ /** @internal */
528
544
  export declare function createChatFunctions(options?: {
529
545
  auth?: JgAuthMode;
530
546
  historyLimit?: number;
@@ -548,5 +564,5 @@ export type JgCronSpec = {
548
564
  intervalSeconds: number;
549
565
  functionKey: "tickActiveServers" | "flushDirtyServers";
550
566
  };
567
+ /** @internal */
551
568
  export declare function jgengineCronSpecs(): readonly JgCronSpec[];
552
- export {};