@jgengine/convex 0.9.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/dist/server.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
1
+ import type { Auth, DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
2
2
  import type { GenericId } from "convex/values";
3
3
  import type { ChatMessage } from "@jgengine/core/game/chat";
4
4
  import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
5
5
  import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
6
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;
@@ -163,6 +166,38 @@ export declare function jgengineTables(): {
163
166
  by_server_channel: ["serverId", "channelId", "_creationTime"];
164
167
  }, {}, {}>;
165
168
  };
169
+ /**
170
+ * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
171
+ * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
172
+ * {@link jgengineTables} — spread either (or both) into `defineSchema`.
173
+ * @internal
174
+ */
175
+ export declare function jgengineHostedTables(): {
176
+ jgHostedWorlds: import("convex/server").TableDefinition<import("convex/values").VObject<{
177
+ createdAt: number;
178
+ gameId: string;
179
+ inputs: any;
180
+ memberUserIds: string[];
181
+ revision: number;
182
+ serverId: string;
183
+ snapshot: any;
184
+ tickAnchorMs: number;
185
+ updatedAt: number;
186
+ }, {
187
+ gameId: import("convex/values").VString<string, "required">;
188
+ serverId: import("convex/values").VString<string, "required">;
189
+ snapshot: import("convex/values").VAny<any, "required", string>;
190
+ revision: import("convex/values").VFloat64<number, "required">;
191
+ memberUserIds: import("convex/values").VArray<string[], import("convex/values").VString<string, "required">, "required">;
192
+ inputs: import("convex/values").VAny<any, "required", string>;
193
+ tickAnchorMs: import("convex/values").VFloat64<number, "required">;
194
+ createdAt: import("convex/values").VFloat64<number, "required">;
195
+ updatedAt: import("convex/values").VFloat64<number, "required">;
196
+ }, "required", "createdAt" | "gameId" | "inputs" | "memberUserIds" | "revision" | "serverId" | "snapshot" | "tickAnchorMs" | "updatedAt" | `inputs.${string}` | `snapshot.${string}`>, {
197
+ by_game_and_server: ["gameId", "serverId", "_creationTime"];
198
+ by_tick_anchor: ["tickAnchorMs", "_creationTime"];
199
+ }, {}, {}>;
200
+ };
166
201
  declare const schemaForTypes: import("convex/server").SchemaDefinition<{
167
202
  jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
168
203
  createdAt: number;
@@ -213,7 +248,9 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
213
248
  createdAt: import("convex/values").VFloat64<number, "required">;
214
249
  updatedAt: import("convex/values").VFloat64<number, "required">;
215
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"];
216
252
  by_game_and_status: ["gameId", "status", "_creationTime"];
253
+ by_status: ["status", "_creationTime"];
217
254
  }, {}, {}>;
218
255
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
219
256
  createdAt: number;
@@ -325,8 +362,28 @@ type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
325
362
  type JGMutationCtx = GenericMutationCtx<JGDataModel>;
326
363
  export type JgAuthMode = "anonymous" | "required";
327
364
  export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
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
+ */
368
+ export declare function resolveActor(ctx: {
369
+ auth: Auth;
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 */
328
384
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
329
385
  export declare const JG_RUNTIME_TICK_MS = 1000;
386
+ /** @internal */
330
387
  export declare function createGameServerFunctions(options?: {
331
388
  runtimes?: GameRuntime[];
332
389
  auth?: JgAuthMode;
@@ -426,6 +483,7 @@ export declare function createGameServerFunctions(options?: {
426
483
  saved: number;
427
484
  }>>;
428
485
  };
486
+ /** @internal */
429
487
  export declare function createLeaderboardFunctions(options?: {
430
488
  auth?: JgAuthMode;
431
489
  }): {
@@ -456,6 +514,7 @@ export declare function createLeaderboardFunctions(options?: {
456
514
  gameId: string;
457
515
  }, Promise<null>>;
458
516
  };
517
+ /** @internal */
459
518
  export declare function createPresenceFunctions(options?: {
460
519
  auth?: JgAuthMode;
461
520
  freshWindowMs?: number;
@@ -491,6 +550,7 @@ export declare function createPresenceFunctions(options?: {
491
550
  serverId: string;
492
551
  }, Promise<null>>;
493
552
  };
553
+ /** @internal */
494
554
  export declare function createChatFunctions(options?: {
495
555
  auth?: JgAuthMode;
496
556
  historyLimit?: number;
@@ -514,5 +574,6 @@ export type JgCronSpec = {
514
574
  intervalSeconds: number;
515
575
  functionKey: "tickActiveServers" | "flushDirtyServers";
516
576
  };
577
+ /** @internal */
517
578
  export declare function jgengineCronSpecs(): readonly JgCronSpec[];
518
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(),
@@ -87,6 +92,29 @@ export function jgengineTables() {
87
92
  }).index("by_server_channel", ["serverId", "channelId"]),
88
93
  };
89
94
  }
95
+ /**
96
+ * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
97
+ * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
98
+ * {@link jgengineTables} — spread either (or both) into `defineSchema`.
99
+ * @internal
100
+ */
101
+ export function jgengineHostedTables() {
102
+ return {
103
+ jgHostedWorlds: defineTable({
104
+ gameId: v.string(),
105
+ serverId: v.string(),
106
+ snapshot: v.any(),
107
+ revision: v.number(),
108
+ memberUserIds: v.array(v.string()),
109
+ inputs: v.any(),
110
+ tickAnchorMs: v.number(),
111
+ createdAt: v.number(),
112
+ updatedAt: v.number(),
113
+ })
114
+ .index("by_game_and_server", ["gameId", "serverId"])
115
+ .index("by_tick_anchor", ["tickAnchorMs"]),
116
+ };
117
+ }
90
118
  const schemaForTypes = defineSchema(jgengineTables());
91
119
  const query = queryGeneric;
92
120
  const mutation = mutationGeneric;
@@ -98,7 +126,10 @@ export const DEFAULT_CONVEX_POSE_RULES = {
98
126
  maxElapsedSec: 0.5,
99
127
  keepAliveRefreshMs: 10_000,
100
128
  };
101
- async function resolveActor(ctx, claimedExternalId, mode) {
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
+ */
132
+ export async function resolveActor(ctx, claimedExternalId, mode) {
102
133
  const identity = await ctx.auth.getUserIdentity();
103
134
  if (identity !== null) {
104
135
  if (claimedExternalId !== undefined &&
@@ -113,6 +144,22 @@ async function resolveActor(ctx, claimedExternalId, mode) {
113
144
  }
114
145
  return null;
115
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
+ }
116
163
  async function requireServerMember(ctx, serverId, actorUserId) {
117
164
  const server = await ctx.db.get("jgGameServers", serverId);
118
165
  if (!server)
@@ -202,6 +249,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
202
249
  revision: server.revision,
203
250
  });
204
251
  }
252
+ /** @internal */
205
253
  export async function applyLeaderboardIncrements(ctx, gameId, entries) {
206
254
  const now = Date.now();
207
255
  for (const entry of entries) {
@@ -303,6 +351,7 @@ async function flushServerIfDue(ctx, server, now, runtime) {
303
351
  return true;
304
352
  }
305
353
  export const JG_RUNTIME_TICK_MS = 1_000;
354
+ /** @internal */
306
355
  export function createGameServerFunctions(options) {
307
356
  const mode = options?.auth ?? "required";
308
357
  const hostSlotsPerServer = options?.slotsPerServer ?? 16;
@@ -383,8 +432,11 @@ export function createGameServerFunctions(options) {
383
432
  throw new ConvexError("Server is full");
384
433
  }
385
434
  if ((server.visibility ?? "public") === "private" &&
386
- !server.memberUserIds.includes(actorUserId) &&
387
- args.serverId === undefined) {
435
+ !canJoinPrivateServer({
436
+ isMember: server.memberUserIds.includes(actorUserId),
437
+ joinCode: server.joinCode,
438
+ suppliedCode: args.joinCode,
439
+ })) {
388
440
  throw new ConvexError("Server is private");
389
441
  }
390
442
  const profile = await ctx.db
@@ -670,11 +722,15 @@ export function createGameServerFunctions(options) {
670
722
  updatedAt: v.number(),
671
723
  })),
672
724
  handler: async (ctx, args) => {
725
+ const limit = args.limit ?? 20;
673
726
  const rows = await ctx.db
674
727
  .query("jgGameServers")
675
728
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
676
- .take(args.limit ?? 20);
677
- 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) => ({
678
734
  _id: row._id,
679
735
  status: row.status,
680
736
  memberCount: row.memberUserIds.length,
@@ -688,12 +744,13 @@ export function createGameServerFunctions(options) {
688
744
  args: {},
689
745
  handler: async (ctx) => {
690
746
  const now = Date.now();
691
- 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();
692
751
  let ticked = 0;
693
752
  let saved = 0;
694
753
  for (const server of servers) {
695
- if (server.status !== "running")
696
- continue;
697
754
  if (server.memberUserIds.length === 0)
698
755
  continue;
699
756
  const elapsedMs = now - server.tickAnchorMs;
@@ -717,11 +774,12 @@ export function createGameServerFunctions(options) {
717
774
  args: {},
718
775
  handler: async (ctx) => {
719
776
  const now = Date.now();
720
- 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();
721
781
  let saved = 0;
722
782
  for (const server of servers) {
723
- if (server.dirtyAt === undefined)
724
- continue;
725
783
  const runtime = resolveRuntime(registry, server.gameId);
726
784
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
727
785
  await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
@@ -747,6 +805,7 @@ export function createGameServerFunctions(options) {
747
805
  }
748
806
  const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
749
807
  const MAX_LEADERBOARD_TOP_LIMIT = 100;
808
+ /** @internal */
750
809
  export function createLeaderboardFunctions(options) {
751
810
  void options;
752
811
  const getTop = query({
@@ -810,6 +869,7 @@ export function createLeaderboardFunctions(options) {
810
869
  });
811
870
  return { getTop, getProfile, incrementMany };
812
871
  }
872
+ /** @internal */
813
873
  export function createPresenceFunctions(options) {
814
874
  const mode = options?.auth ?? "required";
815
875
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
@@ -925,6 +985,7 @@ export function createPresenceFunctions(options) {
925
985
  });
926
986
  return { list, sync, leave };
927
987
  }
988
+ /** @internal */
928
989
  export function createChatFunctions(options) {
929
990
  const mode = options?.auth ?? "required";
930
991
  const historyLimit = options?.historyLimit ?? 100;
@@ -1020,6 +1081,7 @@ const JG_CRON_SPECS = [
1020
1081
  { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
1021
1082
  { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
1022
1083
  ];
1084
+ /** @internal */
1023
1085
  export function jgengineCronSpecs() {
1024
1086
  return JG_CRON_SPECS;
1025
1087
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.9.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.9.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.