@jgengine/convex 0.10.0 → 0.11.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
+ }
@@ -32,7 +32,8 @@ export interface HostedWorldOutcome<T> {
32
32
  * The reconstruct → op → save core of the stateless hosted path, Convex-agnostic so it unit-tests against
33
33
  * `memoryWorldStore`: build a fresh {@link createHostedWorldSession} from `store.load()`, resume persisted
34
34
  * members and replay held inputs, run the op, then persist the snapshot at `revision + 1` iff the world changed.
35
- */
35
+ * @internal
36
+ */
36
37
  export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): HostedWorldOutcome<T>;
37
38
  /** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
38
39
  export declare const JG_HOSTED_TICK_MS = 1000;
@@ -41,7 +42,8 @@ export declare const JG_HOSTED_TICK_MS = 1000;
41
42
  * reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
42
43
  * {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
43
44
  * drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
44
- */
45
+ * @internal
46
+ */
45
47
  export declare function createHostedGameServerFunctions(options: {
46
48
  games: Record<string, HostedGameConfig>;
47
49
  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();
@@ -94,7 +95,8 @@ async function persistWorldRow(ctx, args) {
94
95
  * reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
95
96
  * {@link invokeHostedWorld}. Destructure + re-export the returned functions from a `convex/` module and
96
97
  * drive `tickHostedWorlds` from a cron. Requires {@link jgengineHostedTables} in the deployment schema.
97
- */
98
+ * @internal
99
+ */
98
100
  export function createHostedGameServerFunctions(options) {
99
101
  const mode = options.auth ?? "anonymous";
100
102
  const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
@@ -302,7 +304,10 @@ export function createHostedGameServerFunctions(options) {
302
304
  args: {},
303
305
  handler: async (ctx) => {
304
306
  const nowMs = Date.now();
305
- const rows = await ctx.db.query("jgHostedWorlds").collect();
307
+ const rows = await ctx.db
308
+ .query("jgHostedWorlds")
309
+ .withIndex("by_tick_anchor", (q) => q.lte("tickAnchorMs", nowMs - tickMs))
310
+ .collect();
306
311
  let ticked = 0;
307
312
  let saved = 0;
308
313
  for (const row of rows) {
@@ -312,8 +317,6 @@ export function createHostedGameServerFunctions(options) {
312
317
  if (row.memberUserIds.length === 0)
313
318
  continue;
314
319
  const elapsedMs = nowMs - row.tickAnchorMs;
315
- if (elapsedMs < tickMs)
316
- continue;
317
320
  const { store, captured } = worldStoreForRow(row);
318
321
  const outcome = invokeHostedWorld({
319
322
  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
@@ -4,7 +4,8 @@ import type { ChatMessage } from "@jgengine/core/game/chat";
4
4
  import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
5
5
  import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
6
6
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
7
- import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
7
+ import type { LeaderboardIncrement, SessionVisibility } from "@jgengine/core/runtime/hostPersistence";
8
+ /** @internal */
8
9
  export declare function jgengineTables(): {
9
10
  jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
10
11
  createdAt: number;
@@ -55,7 +56,9 @@ export declare function jgengineTables(): {
55
56
  createdAt: import("convex/values").VFloat64<number, "required">;
56
57
  updatedAt: import("convex/values").VFloat64<number, "required">;
57
58
  }, "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}`>, {
59
+ by_dirty: ["dirtyAt", "_creationTime"];
58
60
  by_game_and_status: ["gameId", "status", "_creationTime"];
61
+ by_status: ["status", "_creationTime"];
59
62
  }, {}, {}>;
60
63
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
61
64
  createdAt: number;
@@ -167,7 +170,8 @@ export declare function jgengineTables(): {
167
170
  * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
168
171
  * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
169
172
  * {@link jgengineTables} — spread either (or both) into `defineSchema`.
170
- */
173
+ * @internal
174
+ */
171
175
  export declare function jgengineHostedTables(): {
172
176
  jgHostedWorlds: import("convex/server").TableDefinition<import("convex/values").VObject<{
173
177
  createdAt: number;
@@ -191,6 +195,7 @@ export declare function jgengineHostedTables(): {
191
195
  updatedAt: import("convex/values").VFloat64<number, "required">;
192
196
  }, "required", "createdAt" | "gameId" | "inputs" | "memberUserIds" | "revision" | "serverId" | "snapshot" | "tickAnchorMs" | "updatedAt" | `inputs.${string}` | `snapshot.${string}`>, {
193
197
  by_game_and_server: ["gameId", "serverId", "_creationTime"];
198
+ by_tick_anchor: ["tickAnchorMs", "_creationTime"];
194
199
  }, {}, {}>;
195
200
  };
196
201
  declare const schemaForTypes: import("convex/server").SchemaDefinition<{
@@ -243,7 +248,9 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
243
248
  createdAt: import("convex/values").VFloat64<number, "required">;
244
249
  updatedAt: import("convex/values").VFloat64<number, "required">;
245
250
  }, "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}`>, {
251
+ by_dirty: ["dirtyAt", "_creationTime"];
246
252
  by_game_and_status: ["gameId", "status", "_creationTime"];
253
+ by_status: ["status", "_creationTime"];
247
254
  }, {}, {}>;
248
255
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
249
256
  createdAt: number;
@@ -355,12 +362,28 @@ type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
355
362
  type JGMutationCtx = GenericMutationCtx<JGDataModel>;
356
363
  export type JgAuthMode = "anonymous" | "required";
357
364
  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`. */
365
+ /** 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`.
366
+ * @internal
367
+ */
359
368
  export declare function resolveActor(ctx: {
360
369
  auth: Auth;
361
370
  }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
371
+ /** True when a server's `visibility` should surface in public listings/browse results.
372
+ * @internal
373
+ */
374
+ export declare function isListablePublicly(visibility: SessionVisibility | undefined): boolean;
375
+ /** A non-member may enter a private server only by presenting its `joinCode`; members always pass.
376
+ * @internal
377
+ */
378
+ export declare function canJoinPrivateServer(args: {
379
+ isMember: boolean;
380
+ joinCode: string | undefined;
381
+ suppliedCode: string | undefined;
382
+ }): boolean;
383
+ /** @internal */
362
384
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
363
385
  export declare const JG_RUNTIME_TICK_MS = 1000;
386
+ /** @internal */
364
387
  export declare function createGameServerFunctions(options?: {
365
388
  runtimes?: GameRuntime[];
366
389
  auth?: JgAuthMode;
@@ -460,6 +483,7 @@ export declare function createGameServerFunctions(options?: {
460
483
  saved: number;
461
484
  }>>;
462
485
  };
486
+ /** @internal */
463
487
  export declare function createLeaderboardFunctions(options?: {
464
488
  auth?: JgAuthMode;
465
489
  }): {
@@ -490,6 +514,7 @@ export declare function createLeaderboardFunctions(options?: {
490
514
  gameId: string;
491
515
  }, Promise<null>>;
492
516
  };
517
+ /** @internal */
493
518
  export declare function createPresenceFunctions(options?: {
494
519
  auth?: JgAuthMode;
495
520
  freshWindowMs?: number;
@@ -525,6 +550,7 @@ export declare function createPresenceFunctions(options?: {
525
550
  serverId: string;
526
551
  }, Promise<null>>;
527
552
  };
553
+ /** @internal */
528
554
  export declare function createChatFunctions(options?: {
529
555
  auth?: JgAuthMode;
530
556
  historyLimit?: number;
@@ -548,5 +574,6 @@ export type JgCronSpec = {
548
574
  intervalSeconds: number;
549
575
  functionKey: "tickActiveServers" | "flushDirtyServers";
550
576
  };
577
+ /** @internal */
551
578
  export declare function jgengineCronSpecs(): readonly JgCronSpec[];
552
579
  export {};
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
3
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
+ import { normalizeJoinCode } from "@jgengine/core/multiplayer/matchmaking";
4
5
  import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
6
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
7
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
@@ -10,6 +11,7 @@ const saveConfigValidator = v.union(v.literal("none"), v.object({
10
11
  auto: v.string(),
11
12
  scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
12
13
  }));
14
+ /** @internal */
13
15
  export function jgengineTables() {
14
16
  return {
15
17
  jgGameServers: defineTable({
@@ -30,7 +32,10 @@ export function jgengineTables() {
30
32
  dirtyAt: v.optional(v.number()),
31
33
  createdAt: v.number(),
32
34
  updatedAt: v.number(),
33
- }).index("by_game_and_status", ["gameId", "status"]),
35
+ })
36
+ .index("by_game_and_status", ["gameId", "status"])
37
+ .index("by_status", ["status"])
38
+ .index("by_dirty", ["dirtyAt"]),
34
39
  jgPlayerProfiles: defineTable({
35
40
  userId: v.string(),
36
41
  gameId: v.string(),
@@ -91,7 +96,8 @@ export function jgengineTables() {
91
96
  * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
92
97
  * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
93
98
  * {@link jgengineTables} — spread either (or both) into `defineSchema`.
94
- */
99
+ * @internal
100
+ */
95
101
  export function jgengineHostedTables() {
96
102
  return {
97
103
  jgHostedWorlds: defineTable({
@@ -104,7 +110,9 @@ export function jgengineHostedTables() {
104
110
  tickAnchorMs: v.number(),
105
111
  createdAt: v.number(),
106
112
  updatedAt: v.number(),
107
- }).index("by_game_and_server", ["gameId", "serverId"]),
113
+ })
114
+ .index("by_game_and_server", ["gameId", "serverId"])
115
+ .index("by_tick_anchor", ["tickAnchorMs"]),
108
116
  };
109
117
  }
110
118
  const schemaForTypes = defineSchema(jgengineTables());
@@ -118,7 +126,9 @@ export const DEFAULT_CONVEX_POSE_RULES = {
118
126
  maxElapsedSec: 0.5,
119
127
  keepAliveRefreshMs: 10_000,
120
128
  };
121
- /** 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`. */
129
+ /** 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`.
130
+ * @internal
131
+ */
122
132
  export async function resolveActor(ctx, claimedExternalId, mode) {
123
133
  const identity = await ctx.auth.getUserIdentity();
124
134
  if (identity !== null) {
@@ -134,6 +144,22 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
134
144
  }
135
145
  return null;
136
146
  }
147
+ /** True when a server's `visibility` should surface in public listings/browse results.
148
+ * @internal
149
+ */
150
+ export function isListablePublicly(visibility) {
151
+ return (visibility ?? "public") !== "private";
152
+ }
153
+ /** A non-member may enter a private server only by presenting its `joinCode`; members always pass.
154
+ * @internal
155
+ */
156
+ export function canJoinPrivateServer(args) {
157
+ if (args.isMember)
158
+ return true;
159
+ if (args.joinCode === undefined || args.suppliedCode === undefined)
160
+ return false;
161
+ return normalizeJoinCode(args.suppliedCode) === normalizeJoinCode(args.joinCode);
162
+ }
137
163
  async function requireServerMember(ctx, serverId, actorUserId) {
138
164
  const server = await ctx.db.get("jgGameServers", serverId);
139
165
  if (!server)
@@ -223,6 +249,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
223
249
  revision: server.revision,
224
250
  });
225
251
  }
252
+ /** @internal */
226
253
  export async function applyLeaderboardIncrements(ctx, gameId, entries) {
227
254
  const now = Date.now();
228
255
  for (const entry of entries) {
@@ -324,6 +351,7 @@ async function flushServerIfDue(ctx, server, now, runtime) {
324
351
  return true;
325
352
  }
326
353
  export const JG_RUNTIME_TICK_MS = 1_000;
354
+ /** @internal */
327
355
  export function createGameServerFunctions(options) {
328
356
  const mode = options?.auth ?? "required";
329
357
  const hostSlotsPerServer = options?.slotsPerServer ?? 16;
@@ -404,8 +432,11 @@ export function createGameServerFunctions(options) {
404
432
  throw new ConvexError("Server is full");
405
433
  }
406
434
  if ((server.visibility ?? "public") === "private" &&
407
- !server.memberUserIds.includes(actorUserId) &&
408
- args.serverId === undefined) {
435
+ !canJoinPrivateServer({
436
+ isMember: server.memberUserIds.includes(actorUserId),
437
+ joinCode: server.joinCode,
438
+ suppliedCode: args.joinCode,
439
+ })) {
409
440
  throw new ConvexError("Server is private");
410
441
  }
411
442
  const profile = await ctx.db
@@ -691,11 +722,15 @@ export function createGameServerFunctions(options) {
691
722
  updatedAt: v.number(),
692
723
  })),
693
724
  handler: async (ctx, args) => {
725
+ const limit = args.limit ?? 20;
694
726
  const rows = await ctx.db
695
727
  .query("jgGameServers")
696
728
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
697
- .take(args.limit ?? 20);
698
- return rows.map((row) => ({
729
+ .take(Math.min(limit * 4, 200));
730
+ return rows
731
+ .filter((row) => isListablePublicly(row.visibility))
732
+ .slice(0, limit)
733
+ .map((row) => ({
699
734
  _id: row._id,
700
735
  status: row.status,
701
736
  memberCount: row.memberUserIds.length,
@@ -709,12 +744,13 @@ export function createGameServerFunctions(options) {
709
744
  args: {},
710
745
  handler: async (ctx) => {
711
746
  const now = Date.now();
712
- const servers = await ctx.db.query("jgGameServers").collect();
747
+ const servers = await ctx.db
748
+ .query("jgGameServers")
749
+ .withIndex("by_status", (q) => q.eq("status", "running"))
750
+ .collect();
713
751
  let ticked = 0;
714
752
  let saved = 0;
715
753
  for (const server of servers) {
716
- if (server.status !== "running")
717
- continue;
718
754
  if (server.memberUserIds.length === 0)
719
755
  continue;
720
756
  const elapsedMs = now - server.tickAnchorMs;
@@ -738,11 +774,12 @@ export function createGameServerFunctions(options) {
738
774
  args: {},
739
775
  handler: async (ctx) => {
740
776
  const now = Date.now();
741
- const servers = await ctx.db.query("jgGameServers").collect();
777
+ const servers = await ctx.db
778
+ .query("jgGameServers")
779
+ .withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
780
+ .collect();
742
781
  let saved = 0;
743
782
  for (const server of servers) {
744
- if (server.dirtyAt === undefined)
745
- continue;
746
783
  const runtime = resolveRuntime(registry, server.gameId);
747
784
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
748
785
  await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
@@ -768,6 +805,7 @@ export function createGameServerFunctions(options) {
768
805
  }
769
806
  const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
770
807
  const MAX_LEADERBOARD_TOP_LIMIT = 100;
808
+ /** @internal */
771
809
  export function createLeaderboardFunctions(options) {
772
810
  void options;
773
811
  const getTop = query({
@@ -831,6 +869,7 @@ export function createLeaderboardFunctions(options) {
831
869
  });
832
870
  return { getTop, getProfile, incrementMany };
833
871
  }
872
+ /** @internal */
834
873
  export function createPresenceFunctions(options) {
835
874
  const mode = options?.auth ?? "required";
836
875
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
@@ -946,6 +985,7 @@ export function createPresenceFunctions(options) {
946
985
  });
947
986
  return { list, sync, leave };
948
987
  }
988
+ /** @internal */
949
989
  export function createChatFunctions(options) {
950
990
  const mode = options?.auth ?? "required";
951
991
  const historyLimit = options?.historyLimit ?? 100;
@@ -1041,6 +1081,7 @@ const JG_CRON_SPECS = [
1041
1081
  { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
1042
1082
  { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
1043
1083
  ];
1084
+ /** @internal */
1044
1085
  export function jgengineCronSpecs() {
1045
1086
  return JG_CRON_SPECS;
1046
1087
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
5
- "license": "AGPL-3.0-only",
5
+ "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "repository": {
@@ -13,7 +13,7 @@
13
13
  "files": [
14
14
  "dist",
15
15
  "CHANGELOG.md",
16
- "llms.txt"
16
+ "skills"
17
17
  ],
18
18
  "exports": {
19
19
  ".": {
@@ -30,7 +30,7 @@
30
30
  "check-types": "tsgo --noEmit -p tsconfig.json"
31
31
  },
32
32
  "dependencies": {
33
- "@jgengine/core": "^0.10.0"
33
+ "@jgengine/core": "^0.11.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: jgengine
3
+ description: Route game work to the smallest useful JGengine guidance set.
4
+ ---
5
+
6
+ # JGengine intake and router
7
+
8
+ Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
9
+
10
+ ## 1. Establish the target
11
+
12
+ Capture only decisions that change architecture:
13
+
14
+ - player point of view and controls
15
+ - world shape and authored content
16
+ - core loop, interaction, combat, and progression
17
+ - solo, local, hosted, or persistent multiplayer
18
+ - HUD/menu states and art direction
19
+ - one observable completion scenario
20
+
21
+ For a new in-repo game, scaffold with `bun run new:game <id> --name "Title"`. Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
+
23
+ ## 2. Select domains
24
+
25
+ Load only rows the target needs. Do not preload every domain.
26
+
27
+ | Need | Skill | Owner |
28
+ | --- | --- | --- |
29
+ | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
30
+ | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
31
+ | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
32
+ | targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
33
+ | HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
34
+ | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
35
+ | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
36
+
37
+ Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
38
+
39
+ ## 3. Discover before designing
40
+
41
+ Within each selected domain:
42
+
43
+ 1. Search `capabilities.md` by intent; it maps needs to imports.
44
+ 2. Open `api.md` only when exact signatures or export inventory are needed.
45
+ 3. Open the linked reference only for a deeper recipe or trap.
46
+
47
+ If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter; if editor-owned content cannot be expressed, file the editor gap first.
48
+
49
+ ## 4. Build and finish
50
+
51
+ Keep scene coordinates in the editor document, state serializable, randomness injected, and hot-path work bounded. Use `jgengine-verify` for evidence appropriate to the change. Inside this monorepo, `workflow` owns issue/PR completion. Visual polish work also uses the `jgengine-ui` quality reference.