@jgengine/convex 0.11.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/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@ Agents building on the published SDK can also read this programmatically:
12
12
  same data as typed values, so an updater can diff its installed version against
13
13
  the latest and surface the migration steps.
14
14
 
15
+ ## 0.12.0
16
+
17
+ ### Migrate
18
+
19
+ - **Author games through `defineGame` from `@jgengine/shell`** — it is the single public authoring entry (and carries the define-game capability tag). Core's constructor was renamed `defineGame` → `defineGameDefinition` (`@jgengine/core/game/defineGame`); only hosts and tooling call it. If you imported `defineGame` from core, switch the import to `@jgengine/shell`. `GamePlayer`/`GamePlayerShell` are `@internal` now — `GameHost` is the documented mount, and it owns the editor summon (`?mode=editor`, F2+E, dev save endpoint), so delete any per-game editor bootstrap.
20
+ - **Declare your world with `world()`** (`@jgengine/core/world/place`): `world({ id, ground: { mode, size, surface? }, physics? })` with `flat` / `round` / `voxel` / `board` ground. New scaffolds emit a thin infinite flat place instead of the old 96 m meadow. `environment()` still works but is demoted to the legacy consumption path for editor-written scene data — **nothing required** for existing games; new worlds should use `world()` and author all dressing in the editor.
21
+ - Everything else is additive — stat pools, minimap marker sources, auction house, city/path generation, editor workspaces require no changes to existing games.
22
+
23
+ ### Added
24
+
25
+ - **World = place: `world()` API** (#1178) — a world is substrate + laws, not a coded diorama. `@jgengine/core/world/place` ships `world({ id, ground: { mode, size, surface?, generator? }, physics? })`; size is discriminated by mode (flat `{x,z}` with `Infinity` axes, round `{radius}`, voxel generator domain, board `{x,y}` cells — TS and runtime both reject mismatches). `ground.surface` carries matter/feel laws (metal vs slime vs felt) read by the same rule systems; per-place `physics` resolves over the game default; multiple worlds per game are first-class. The shell renders place substrates (`PlaceScene`) under the engine default sky; seeds derive from world id + save via `seedForPlace`.
26
+ - **`@jgengine/shell/gameKit`** — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng in one import.
27
+ - **Portable generic stat pools** — `@jgengine/core/stats/statPool`: bounded named pools (health, energy, heat, ammo…) with the entity-stats integration rebuilt on them; recipe in skill `jgengine-combat`.
28
+ - **Auction-house economy primitives** (#1086) — `@jgengine/core/economy/auctionBook` (timed bid auctions, escrowed bids, outbid refunds, optional buyout, anti-snipe close extension, seller settlement minus house cut) plus market price history.
29
+ - **Portable minimap marker sources** — core world seam feeding game minimaps; recipe in skill `jgengine-ui`.
30
+ - **Unified seed-driven path network + city generation v2** (#1103, #1106) — streets/blocks/buildings with grid-ness, curviness, branching and block-size dials; zone bands, weighted building-class mixes, massing pieces, balance presets, junction-centered clustering, road-derived block polygons and parcels. Shell renders realistic roads: real intersections, curbs, lane lines, tunnels, and roads riding bridge decks.
31
+ - **Physical fidelity** — mesh-accurate hitboxes (shots pass through holes in concave models, #1075), auto-fit entity/object colliders from measured model bounds (#1072), movement obstruction reading fitted boxes + compound-box openings (#1077).
32
+ - **Editor overhaul** (#1110) — materials / lighting / scripting / animation-timeline / multiplayer workspaces, command palette, minimap bake wired end-to-end (#1036), real offscreen GLB thumbnails in the content browser, hierarchy drag-drop + keyboard + context menus + per-object visibility/lock, multi-select inspector, console RPC, asset drag-place, ortho projection, pivot modes, play chrome.
33
+ - **Editor authoring pipeline** — author entity definitions and starter catalogs (#1012), author catalogs/schema fields in the Data tab (#1043), promote an authored scene folder into a runnable game (#1011), authored triggers promoted to a shared primitive with goal/win rules (#1013), asset import rewrites the game's `game/assets.ts` (#1042).
34
+ - **Skills** — research-backed `game-design` and `level-design` skills (#962); every-game-owns-custom-UI invariant codified (#1148).
35
+
36
+ ### Changed
37
+
38
+ - `npx jgengine create` scaffolds a thin infinite flat `world()` place; the editor `blankWorld` no longer pre-bakes seeded grass. Dressing belongs in `editor.scene.json`.
39
+ - Environment studio visual-quality pass — turf tufts, depth-aware water, relief soil, weathered poles; grass fields no longer rebuild geometry per frame (#1090).
40
+
15
41
  ## 0.11.0
16
42
 
17
43
  ### Migrate
@@ -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`,
@@ -19,6 +19,7 @@ export function invokeHostedWorld(invocation) {
19
19
  content: game.content,
20
20
  store: { load: () => loaded, save: () => { } },
21
21
  ...(now === undefined ? {} : { now }),
22
+ ...(game.models === undefined ? {} : { models: game.models }),
22
23
  });
23
24
  for (const userId of members)
24
25
  session.runner().resume(userId);
package/dist/server.d.ts CHANGED
@@ -4,7 +4,9 @@ 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, SessionVisibility } from "@jgengine/core/runtime/hostPersistence";
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";
8
10
  /** @internal */
9
11
  export declare function jgengineTables(): {
10
12
  jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -368,18 +370,6 @@ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
368
370
  export declare function resolveActor(ctx: {
369
371
  auth: Auth;
370
372
  }, 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
373
  /** @internal */
384
374
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
385
375
  export declare const JG_RUNTIME_TICK_MS = 1000;
@@ -576,4 +566,3 @@ export type JgCronSpec = {
576
566
  };
577
567
  /** @internal */
578
568
  export declare function jgengineCronSpecs(): readonly JgCronSpec[];
579
- export {};
package/dist/server.js CHANGED
@@ -1,12 +1,14 @@
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";
5
- import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
4
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
6
5
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
7
6
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
7
+ import { isAutoJoinCandidate, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
8
8
  import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
9
9
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
10
+ /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
11
+ export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
10
12
  const saveConfigValidator = v.union(v.literal("none"), v.object({
11
13
  auto: v.string(),
12
14
  scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
@@ -119,13 +121,7 @@ const schemaForTypes = defineSchema(jgengineTables());
119
121
  const query = queryGeneric;
120
122
  const mutation = mutationGeneric;
121
123
  const internalMutation = internalMutationGeneric;
122
- export const DEFAULT_CONVEX_POSE_RULES = {
123
- maxSpeed: 12,
124
- maxVerticalOffset: 3,
125
- minElapsedSec: 0.05,
126
- maxElapsedSec: 0.5,
127
- keepAliveRefreshMs: 10_000,
128
- };
124
+ export const DEFAULT_CONVEX_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
129
125
  /** 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
126
  * @internal
131
127
  */
@@ -144,30 +140,38 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
144
140
  }
145
141
  return null;
146
142
  }
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
- }
163
143
  async function requireServerMember(ctx, serverId, actorUserId) {
164
144
  const server = await ctx.db.get("jgGameServers", serverId);
165
145
  if (!server)
166
146
  return null;
167
- if (!server.memberUserIds.includes(actorUserId))
147
+ if (!isServerMember(server.memberUserIds, actorUserId))
168
148
  return null;
169
149
  return server;
170
150
  }
151
+ /** Resolve the acting user for a mutation, throwing the standard error when unauthenticated.
152
+ * @internal
153
+ */
154
+ async function requireActor(ctx, externalId, mode) {
155
+ const actorUserId = await resolveActor(ctx, externalId, mode);
156
+ if (!actorUserId)
157
+ throw new ConvexError("Not authenticated");
158
+ return actorUserId;
159
+ }
160
+ /**
161
+ * Resolve the actor and confirm server membership, invoking `fn` only when both hold and yielding
162
+ * `deniedResult` for every denial (unauthenticated or non-member). The uniform-denial path shared by
163
+ * the read handlers whose not-authenticated and not-member cases return the same value.
164
+ * @internal
165
+ */
166
+ async function withMember(ctx, mode, args, deniedResult, fn) {
167
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
168
+ if (!actorUserId)
169
+ return deniedResult;
170
+ const server = await requireServerMember(ctx, args.serverId, actorUserId);
171
+ if (server === null)
172
+ return deniedResult;
173
+ return fn(server, actorUserId);
174
+ }
171
175
  const builtinRuntimeCommands = {
172
176
  "engine.ping": {
173
177
  validate: (_snapshot) => null,
@@ -372,10 +376,7 @@ export function createGameServerFunctions(options) {
372
376
  isNew: v.boolean(),
373
377
  }),
374
378
  handler: async (ctx, args) => {
375
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
376
- if (!actorUserId) {
377
- throw new ConvexError("Not authenticated");
378
- }
379
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
379
380
  const now = Date.now();
380
381
  const runtime = resolveRuntime(registry, args.gameId);
381
382
  const save = runtime.save;
@@ -397,13 +398,12 @@ export function createGameServerFunctions(options) {
397
398
  joinable.push(...rows);
398
399
  }
399
400
  server =
400
- joinable.find((row) => {
401
- if (row.memberUserIds.includes(actorUserId))
402
- return true;
403
- if (row.memberUserIds.length >= row.slotsPerServer)
404
- return false;
405
- return (row.visibility ?? "public") !== "private";
406
- }) ?? null;
401
+ joinable.find((row) => isAutoJoinCandidate({
402
+ memberUserIds: row.memberUserIds,
403
+ slotsPerServer: row.slotsPerServer,
404
+ visibility: row.visibility,
405
+ userId: actorUserId,
406
+ })) ?? null;
407
407
  }
408
408
  if (!server) {
409
409
  const serverId = await ctx.db.insert("jgGameServers", {
@@ -427,16 +427,16 @@ export function createGameServerFunctions(options) {
427
427
  if (!server)
428
428
  throw new ConvexError("Failed to create server");
429
429
  }
430
- if (server.memberUserIds.length >= server.slotsPerServer &&
431
- !server.memberUserIds.includes(actorUserId)) {
430
+ if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
432
431
  throw new ConvexError("Server is full");
433
432
  }
434
- if ((server.visibility ?? "public") === "private" &&
435
- !canJoinPrivateServer({
436
- isMember: server.memberUserIds.includes(actorUserId),
437
- joinCode: server.joinCode,
438
- suppliedCode: args.joinCode,
439
- })) {
433
+ if (isPrivateJoinBlocked({
434
+ visibility: server.visibility,
435
+ memberUserIds: server.memberUserIds,
436
+ userId: actorUserId,
437
+ joinCode: server.joinCode,
438
+ suppliedCode: args.joinCode,
439
+ })) {
440
440
  throw new ConvexError("Server is private");
441
441
  }
442
442
  const profile = await ctx.db
@@ -444,9 +444,7 @@ export function createGameServerFunctions(options) {
444
444
  .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
445
445
  .unique();
446
446
  const isNew = profile === null;
447
- const memberUserIds = server.memberUserIds.includes(actorUserId)
448
- ? server.memberUserIds
449
- : [...server.memberUserIds, actorUserId];
447
+ const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
450
448
  await ctx.db.patch(server._id, {
451
449
  memberUserIds,
452
450
  status: "running",
@@ -469,20 +467,15 @@ export function createGameServerFunctions(options) {
469
467
  },
470
468
  returns: v.null(),
471
469
  handler: async (ctx, args) => {
472
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
473
- if (!actorUserId) {
474
- throw new ConvexError("Not authenticated");
475
- }
476
- const server = await ctx.db.get("jgGameServers", args.serverId);
477
- if (!server)
478
- return null;
479
- if (!server.memberUserIds.includes(actorUserId))
470
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
471
+ const server = await requireServerMember(ctx, args.serverId, actorUserId);
472
+ if (server === null)
480
473
  return null;
481
474
  const now = Date.now();
482
475
  const runtime = resolveRuntime(registry, server.gameId);
483
476
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
484
477
  await persistServerSnapshot(ctx, server, snapshot, server.save);
485
- const memberUserIds = server.memberUserIds.filter((id) => id !== actorUserId);
478
+ const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
486
479
  const sessionPlayers = { ...server.sessionPlayers };
487
480
  delete sessionPlayers[actorUserId];
488
481
  await ctx.db.patch(server._id, {
@@ -490,7 +483,7 @@ export function createGameServerFunctions(options) {
490
483
  sessionPlayers,
491
484
  updatedAt: now,
492
485
  dirtyAt: now,
493
- status: memberUserIds.length === 0 ? "open" : server.status,
486
+ status: statusAfterLeave(memberUserIds.length, server.status),
494
487
  });
495
488
  const pose = await ctx.db
496
489
  .query("jgPoses")
@@ -510,15 +503,12 @@ export function createGameServerFunctions(options) {
510
503
  },
511
504
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
512
505
  handler: async (ctx, args) => {
513
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
514
- if (!actorUserId) {
515
- throw new ConvexError("Not authenticated");
516
- }
506
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
517
507
  const server = await ctx.db.get("jgGameServers", args.serverId);
518
508
  if (!server) {
519
509
  return { ok: false, reason: "Server not found" };
520
510
  }
521
- if (!server.memberUserIds.includes(actorUserId)) {
511
+ if (!isServerMember(server.memberUserIds, actorUserId)) {
522
512
  return { ok: false, reason: "Not a member of this server" };
523
513
  }
524
514
  const loadedRevision = server.revision;
@@ -555,14 +545,9 @@ export function createGameServerFunctions(options) {
555
545
  },
556
546
  returns: v.boolean(),
557
547
  handler: async (ctx, args) => {
558
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
559
- if (!actorUserId) {
560
- throw new ConvexError("Not authenticated");
561
- }
562
- const server = await ctx.db.get("jgGameServers", args.serverId);
563
- if (!server)
564
- return false;
565
- if (!server.memberUserIds.includes(actorUserId))
548
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
549
+ const server = await requireServerMember(ctx, args.serverId, actorUserId);
550
+ if (server === null)
566
551
  return false;
567
552
  const runtime = resolveRuntime(registry, server.gameId);
568
553
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
@@ -588,15 +573,7 @@ export function createGameServerFunctions(options) {
588
573
  sessionPlayers: v.any(),
589
574
  updatedAt: v.number(),
590
575
  }), v.null()),
591
- handler: async (ctx, args) => {
592
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
593
- if (!actorUserId)
594
- return null;
595
- const server = await ctx.db.get("jgGameServers", args.serverId);
596
- if (!server)
597
- return null;
598
- if (!server.memberUserIds.includes(actorUserId))
599
- return null;
576
+ handler: (ctx, args) => withMember(ctx, mode, args, null, (server, actorUserId) => {
600
577
  const sessionPlayers = server.sessionPlayers;
601
578
  const ownSession = sessionPlayers[actorUserId];
602
579
  return {
@@ -612,7 +589,7 @@ export function createGameServerFunctions(options) {
612
589
  sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
613
590
  updatedAt: server.updatedAt,
614
591
  };
615
- },
592
+ }),
616
593
  });
617
594
  const getPlayerProfile = query({
618
595
  args: {
@@ -653,17 +630,13 @@ export function createGameServerFunctions(options) {
653
630
  },
654
631
  returns: v.array(v.any()),
655
632
  handler: async (ctx, args) => {
656
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
657
- if (!actorUserId)
658
- return [];
659
- const server = await ctx.db.get("jgGameServers", args.serverId);
660
- if (!server || !server.memberUserIds.includes(actorUserId))
661
- return [];
662
- const row = await ctx.db
663
- .query("jgFeedBuffers")
664
- .withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
665
- .unique();
666
- return row?.entries ?? [];
633
+ return withMember(ctx, mode, args, [], async () => {
634
+ const row = await ctx.db
635
+ .query("jgFeedBuffers")
636
+ .withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
637
+ .unique();
638
+ return row?.entries ?? [];
639
+ });
667
640
  },
668
641
  });
669
642
  const pushFeedEntry = mutation({
@@ -675,16 +648,13 @@ export function createGameServerFunctions(options) {
675
648
  },
676
649
  returns: v.null(),
677
650
  handler: async (ctx, args) => {
678
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
679
- if (!actorUserId) {
680
- throw new ConvexError("Not authenticated");
681
- }
651
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
682
652
  const allowed = validateFeedWrite(feedWriteGate, args.action);
683
653
  if (!allowed.ok) {
684
654
  throw new ConvexError(allowed.reason);
685
655
  }
686
656
  const server = await ctx.db.get("jgGameServers", args.serverId);
687
- if (!server || !server.memberUserIds.includes(actorUserId)) {
657
+ if (!server || !isServerMember(server.memberUserIds, actorUserId)) {
688
658
  throw new ConvexError("Not a member of this server");
689
659
  }
690
660
  const now = Date.now();
@@ -887,25 +857,22 @@ export function createPresenceFunctions(options) {
887
857
  lastSeenAt: v.number(),
888
858
  })),
889
859
  handler: async (ctx, args) => {
890
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
891
- if (!actorUserId)
892
- return [];
893
- if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
894
- return [];
895
- const threshold = Date.now() - freshWindowMs;
896
- const rows = await ctx.db
897
- .query("jgPoses")
898
- .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
899
- .collect();
900
- return rows
901
- .filter((row) => row.updatedAt >= threshold)
902
- .map((row) => ({
903
- userId: row.userId,
904
- position: { x: row.x, y: row.y, z: row.z },
905
- rotationY: row.rotationY,
906
- rotationPitch: row.rotationPitch,
907
- lastSeenAt: row.updatedAt,
908
- }));
860
+ return withMember(ctx, mode, args, [], async () => {
861
+ const threshold = Date.now() - freshWindowMs;
862
+ const rows = await ctx.db
863
+ .query("jgPoses")
864
+ .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
865
+ .collect();
866
+ return rows
867
+ .filter((row) => row.updatedAt >= threshold)
868
+ .map((row) => ({
869
+ userId: row.userId,
870
+ position: { x: row.x, y: row.y, z: row.z },
871
+ rotationY: row.rotationY,
872
+ rotationPitch: row.rotationPitch,
873
+ lastSeenAt: row.updatedAt,
874
+ }));
875
+ });
909
876
  },
910
877
  });
911
878
  const sync = mutation({
@@ -921,12 +888,7 @@ export function createPresenceFunctions(options) {
921
888
  externalId: v.optional(v.string()),
922
889
  },
923
890
  returns: v.null(),
924
- handler: async (ctx, args) => {
925
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
926
- if (!actorUserId)
927
- return null;
928
- if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
929
- return null;
891
+ handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
930
892
  const now = Date.now();
931
893
  const existing = await ctx.db
932
894
  .query("jgPoses")
@@ -962,7 +924,7 @@ export function createPresenceFunctions(options) {
962
924
  await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
963
925
  }
964
926
  return null;
965
- },
927
+ }),
966
928
  });
967
929
  const leave = mutation({
968
930
  args: {
@@ -1004,12 +966,7 @@ export function createChatFunctions(options) {
1004
966
  body: v.string(),
1005
967
  at: v.number(),
1006
968
  })),
1007
- handler: async (ctx, args) => {
1008
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
1009
- if (!actorUserId)
1010
- return [];
1011
- if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
1012
- return [];
969
+ handler: (ctx, args) => withMember(ctx, mode, args, [], async () => {
1013
970
  const rows = await ctx.db
1014
971
  .query("jgChatMessages")
1015
972
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -1025,7 +982,7 @@ export function createChatFunctions(options) {
1025
982
  body: row.body,
1026
983
  at: row.at,
1027
984
  }));
1028
- },
985
+ }),
1029
986
  });
1030
987
  const sendMessage = mutation({
1031
988
  args: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": "tsgo --noEmit -p tsconfig.json"
31
31
  },
32
32
  "dependencies": {
33
- "@jgengine/core": "^0.11.0"
33
+ "@jgengine/core": "^0.12.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -9,16 +9,16 @@ Read the repository [README](../../../README.md) for package truth, commands, la
9
9
 
10
10
  ## 1. Establish the target
11
11
 
12
- Capture only decisions that change architecture:
12
+ 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
13
 
14
14
  - player point of view and controls
15
15
  - world shape and authored content
16
16
  - core loop, interaction, combat, and progression
17
17
  - solo, local, hosted, or persistent multiplayer
18
- - HUD/menu states and art direction
18
+ - HUD/menu states and **custom** UI art direction (every game owns its presentation — no stock drop-in face)
19
19
  - one observable completion scenario
20
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/*`.
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/*`.
22
22
 
23
23
  ## 2. Select domains
24
24
 
@@ -26,6 +26,8 @@ Load only rows the target needs. Do not preload every domain.
26
26
 
27
27
  | Need | Skill | Owner |
28
28
  | --- | --- | --- |
29
+ | player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
30
+ | metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
29
31
  | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
30
32
  | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
31
33
  | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
@@ -34,7 +36,7 @@ Load only rows the target needs. Do not preload every domain.
34
36
  | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
35
37
  | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
36
38
 
37
- Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
39
+ World content routes editor-first: any request that adds, moves, restyles, or removes something visible in the world — however phrased ("design this world", "place the enemies", "make it look better") — starts in `jgengine-editor`; the other domains consume the authored document. Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
38
40
 
39
41
  ## 3. Discover before designing
40
42
 
@@ -43,6 +45,7 @@ Within each selected domain:
43
45
  1. Search `capabilities.md` by intent; it maps needs to imports.
44
46
  2. Open `api.md` only when exact signatures or export inventory are needed.
45
47
  3. Open the linked reference only for a deeper recipe or trap.
48
+ 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.
46
49
 
47
50
  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
51