@jgengine/node 0.11.0 → 0.13.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,58 @@ 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.13.0
16
+
17
+ ### Migrate
18
+
19
+ - **Nothing required** — this release is additive fixes, defaults, and new drop-in primitives. Bump `@jgengine/*` pins to `^0.13.0` (CLI `jgengine` is 0.10.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
20
+ - Two defaults changed in the player's favor, both overridable: **offline games now persist to localStorage automatically** (#1207) and **rigged catalog assets animate by default** via clip metadata + semantic clip roles (#1209). If a game relied on rigged models staying frozen or on saves being memory-only, opt out explicitly; otherwise you get better behavior for free.
21
+
22
+ ### Added
23
+
24
+ - **Drop-in inventory grid, window manager, and character sheet** (#1220) — `@jgengine/react` ships `InventoryGrid` (real drag/stack/split), a toggleable-window system (`panels`: bag, character sheet, spellbook-style windows with `@jgengine/core/ui/panelModel` state), and a `CharacterSheet` paperdoll, all HudTheme-skinnable — wire in a line or two instead of re-deriving from raw divs.
25
+ - **Serializable runtime snapshots** (#1179) — capture/restore of live runtime state, including magazine state (`@jgengine/core/combat/magazine`) and stat modifiers, for saves and replication.
26
+ - **Portable weapon plumbing** (#1211) — `@jgengine/core/combat/weaponFire` composes trigger → magazine → spread → projectile/hitscan as data, no game-local glue.
27
+ - **Portable leveling** (#1206) and **portable damage-pool access** (#1208) — progression and damage pools exposed as genre-agnostic seams on the game context.
28
+ - **Named combat VFX presets** (#1204) — `@jgengine/core/combat/vfxPresets`: a visible attack is one line.
29
+ - **Runtime-measured render-bounds hitboxes** (#1227) — colliders wrap what the shell actually mounts, not the raw asset bounds.
30
+ - **Character motion feel** — procedural part motion for rig-less part-composed characters (#1222) plus squash & stretch and death splat (#1229).
31
+ - **City fabric** (#1191) — `streetGenerator` + `cityGenerator`, editor bake verb, street-aware lots, road junctions, grounding, and a web playground.
32
+ - **`jgengine recipe` CLI** (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK, listed and copied from the CLI.
33
+ - **Editor/debug modes mirror to the URL** with a proper editor exit (#1203); game template ships a dependency-free WebGL screenshot script (#1202).
34
+
35
+ ### Changed
36
+
37
+ - Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).
38
+ - RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind `@source` renders its theme (#1195).
39
+ - Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223); new-game briefs direct engine bugs/gaps upstream (#1200).
40
+
41
+ ## 0.12.0
42
+
43
+ ### Migrate
44
+
45
+ - **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.
46
+ - **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.
47
+ - Everything else is additive — stat pools, minimap marker sources, auction house, city/path generation, editor workspaces require no changes to existing games.
48
+
49
+ ### Added
50
+
51
+ - **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`.
52
+ - **`@jgengine/shell/gameKit`** — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng in one import.
53
+ - **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`.
54
+ - **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.
55
+ - **Portable minimap marker sources** — core world seam feeding game minimaps; recipe in skill `jgengine-ui`.
56
+ - **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.
57
+ - **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).
58
+ - **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.
59
+ - **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).
60
+ - **Skills** — research-backed `game-design` and `level-design` skills (#962); every-game-owns-custom-UI invariant codified (#1148).
61
+
62
+ ### Changed
63
+
64
+ - `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`.
65
+ - Environment studio visual-quality pass — turf tufts, depth-aware water, relief soil, weathered poles; grass fields no longer rebuild geometry per frame (#1090).
66
+
15
67
  ## 0.11.0
16
68
 
17
69
  ### Migrate
@@ -0,0 +1,16 @@
1
+ /** File extensions the editor treats as placeable models. */
2
+ export declare const MODEL_EXT: RegExp;
3
+ /**
4
+ * Derives the stable catalog id a model's workspace-relative path resolves under — lowercased,
5
+ * extension-stripped, non-alphanumerics collapsed to single hyphens. The folder scan and every
6
+ * durable import share this so a placement referencing the id resolves regardless of how the
7
+ * asset entered the catalog.
8
+ * @internal
9
+ */
10
+ export declare function assetIdFromRel(rel: string): string;
11
+ /**
12
+ * Strips path components and unsafe characters from an uploaded model filename, keeping its
13
+ * `.glb`/`.gltf` extension — so an untrusted upload name can never escape its target folder.
14
+ * @internal
15
+ */
16
+ export declare function safeAssetFilename(filename: string): string;
@@ -0,0 +1,32 @@
1
+ import { extname } from "node:path";
2
+ /** File extensions the editor treats as placeable models. */
3
+ export const MODEL_EXT = /\.(glb|gltf)$/i;
4
+ /**
5
+ * Derives the stable catalog id a model's workspace-relative path resolves under — lowercased,
6
+ * extension-stripped, non-alphanumerics collapsed to single hyphens. The folder scan and every
7
+ * durable import share this so a placement referencing the id resolves regardless of how the
8
+ * asset entered the catalog.
9
+ * @internal
10
+ */
11
+ export function assetIdFromRel(rel) {
12
+ const cleaned = rel
13
+ .replace(MODEL_EXT, "")
14
+ .replace(/[^a-z0-9]+/gi, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .toLowerCase();
17
+ return cleaned.length > 0 ? cleaned : "asset";
18
+ }
19
+ /**
20
+ * Strips path components and unsafe characters from an uploaded model filename, keeping its
21
+ * `.glb`/`.gltf` extension — so an untrusted upload name can never escape its target folder.
22
+ * @internal
23
+ */
24
+ export function safeAssetFilename(filename) {
25
+ const base = (filename.split(/[\\/]/).pop() ?? "").trim();
26
+ const ext = extname(base).toLowerCase();
27
+ const stem = base
28
+ .slice(0, base.length - ext.length)
29
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
30
+ .replace(/^[-.]+|[-.]+$/g, "");
31
+ return (stem.length > 0 ? stem : "asset") + ext;
32
+ }
@@ -15,6 +15,13 @@ export interface EditorManifest {
15
15
  * @internal
16
16
  */
17
17
  export declare function buildEditorManifest(sceneDir: string, assetsDir: string): EditorManifest;
18
+ /**
19
+ * Persists an uploaded model into the asset folder so it survives reload and the manifest scan re-lists it
20
+ * under the same id — the durable counterpart to the standalone editor's ephemeral blob imports. Returns the
21
+ * manifest entry (id/url/label) the scan would produce for the written file. The pure core `/__jgengine/import-asset` serves.
22
+ * @internal
23
+ */
24
+ export declare function importEditorAsset(assetsDir: string, filename: string, bytes: Uint8Array): EditorManifestAsset;
18
25
  interface DevServerLike {
19
26
  middlewares: {
20
27
  use(route: string, handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void;
@@ -35,8 +42,9 @@ export interface EditorHostOptions {
35
42
  /**
36
43
  * Dev-server plugin that turns any folder into a standalone-editor workspace: it serves the folder's
37
44
  * `editor.scene.json` and every model under it at `/__jgengine/manifest`, streams the model bytes at
38
- * `/__jgengine/assets/*`, and writes Save back to `editor.scene.json` — the server half of
39
- * `jgengine editor`, drop-in for a Vite dev config.
45
+ * `/__jgengine/assets/*`, persists uploaded models into the asset folder at `POST /__jgengine/import-asset`
46
+ * (so a dropped GLB survives reload as a durable catalog asset), and writes Save back to `editor.scene.json`
47
+ * — the server half of `jgengine editor`, drop-in for a Vite dev config.
40
48
  * @internal
41
49
  */
42
50
  export declare function editorHostPlugin(options?: EditorHostOptions): EditorHostPluginShape;
@@ -1,17 +1,12 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { extname, join, relative, resolve, sep } from "node:path";
3
+ import { MODEL_EXT, assetIdFromRel, safeAssetFilename } from "./assetNaming.js";
3
4
  import { EDITOR_SCENE_FILENAME, devSavePlugin } from "./devSavePlugin.js";
4
- const MODEL_EXT = /\.(glb|gltf)$/i;
5
+ import { importPromotedAsset, isPromotedProject } from "./promotedAssetCatalog.js";
5
6
  const ASSET_ROUTE = "/__jgengine/assets/";
6
7
  const MANIFEST_ROUTE = "/__jgengine/manifest";
7
- function assetIdFromRel(rel) {
8
- const cleaned = rel
9
- .replace(MODEL_EXT, "")
10
- .replace(/[^a-z0-9]+/gi, "-")
11
- .replace(/^-+|-+$/g, "")
12
- .toLowerCase();
13
- return cleaned.length > 0 ? cleaned : "asset";
14
- }
8
+ const IMPORT_ROUTE = "/__jgengine/import-asset";
9
+ const MAX_ASSET_BYTES = 128 * 1024 * 1024;
15
10
  function walkModels(root, dir = root) {
16
11
  if (!existsSync(dir))
17
12
  return [];
@@ -46,11 +41,44 @@ export function buildEditorManifest(sceneDir, assetsDir) {
46
41
  });
47
42
  return { scene, assets };
48
43
  }
44
+ /**
45
+ * Persists an uploaded model into the asset folder so it survives reload and the manifest scan re-lists it
46
+ * under the same id — the durable counterpart to the standalone editor's ephemeral blob imports. Returns the
47
+ * manifest entry (id/url/label) the scan would produce for the written file. The pure core `/__jgengine/import-asset` serves.
48
+ * @internal
49
+ */
50
+ export function importEditorAsset(assetsDir, filename, bytes) {
51
+ if (!MODEL_EXT.test(filename)) {
52
+ throw new Error(`unsupported asset file (expected .glb/.gltf): ${filename}`);
53
+ }
54
+ const safe = safeAssetFilename(filename);
55
+ mkdirSync(assetsDir, { recursive: true });
56
+ writeFileSync(join(assetsDir, safe), bytes);
57
+ return { id: assetIdFromRel(safe), url: ASSET_ROUTE + safe, label: safe };
58
+ }
59
+ function readBodyBuffer(req) {
60
+ return new Promise((resolvePromise, reject) => {
61
+ let size = 0;
62
+ const chunks = [];
63
+ req.on("data", (chunk) => {
64
+ size += chunk.length;
65
+ if (size > MAX_ASSET_BYTES) {
66
+ reject(new Error("asset upload too large"));
67
+ req.destroy();
68
+ return;
69
+ }
70
+ chunks.push(chunk);
71
+ });
72
+ req.on("end", () => resolvePromise(Buffer.concat(chunks)));
73
+ req.on("error", reject);
74
+ });
75
+ }
49
76
  /**
50
77
  * Dev-server plugin that turns any folder into a standalone-editor workspace: it serves the folder's
51
78
  * `editor.scene.json` and every model under it at `/__jgengine/manifest`, streams the model bytes at
52
- * `/__jgengine/assets/*`, and writes Save back to `editor.scene.json` — the server half of
53
- * `jgengine editor`, drop-in for a Vite dev config.
79
+ * `/__jgengine/assets/*`, persists uploaded models into the asset folder at `POST /__jgengine/import-asset`
80
+ * (so a dropped GLB survives reload as a durable catalog asset), and writes Save back to `editor.scene.json`
81
+ * — the server half of `jgengine editor`, drop-in for a Vite dev config.
54
82
  * @internal
55
83
  */
56
84
  export function editorHostPlugin(options = {}) {
@@ -70,6 +98,46 @@ export function editorHostPlugin(options = {}) {
70
98
  res.setHeader("content-type", "application/json");
71
99
  res.end(JSON.stringify(buildEditorManifest(dir, assetsDir)));
72
100
  });
101
+ server.middlewares.use(IMPORT_ROUTE, (req, res, next) => {
102
+ if (req.method !== "POST") {
103
+ next();
104
+ return;
105
+ }
106
+ const header = req.headers["x-jg-filename"];
107
+ const rawName = Array.isArray(header) ? header[0] : header;
108
+ void readBodyBuffer(req)
109
+ .then((bytes) => {
110
+ let filename;
111
+ try {
112
+ filename = decodeURIComponent(rawName ?? "");
113
+ }
114
+ catch {
115
+ filename = rawName ?? "";
116
+ }
117
+ let asset;
118
+ if (isPromotedProject(dir)) {
119
+ // Promoted projects resolve assets through a typed `src/game/assets.ts` catalog, so
120
+ // persist a durable `extras` entry there. A thrown transform error (unparseable or
121
+ // multi-call source) falls back to the folder-scan import, preserving the contract.
122
+ try {
123
+ asset = importPromotedAsset(dir, filename, bytes);
124
+ }
125
+ catch {
126
+ asset = importEditorAsset(assetsDir, filename, bytes);
127
+ }
128
+ }
129
+ else {
130
+ asset = importEditorAsset(assetsDir, filename, bytes);
131
+ }
132
+ res.setHeader("content-type", "application/json");
133
+ res.end(JSON.stringify(asset));
134
+ })
135
+ .catch((error) => {
136
+ res.statusCode = 400;
137
+ res.setHeader("content-type", "application/json");
138
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
139
+ });
140
+ });
73
141
  server.middlewares.use(ASSET_ROUTE, (req, res, next) => {
74
142
  const rel = decodeURIComponent((req.url ?? "").split("?")[0]).replace(/^\/+/, "");
75
143
  const full = resolve(assetsDir, rel);
@@ -0,0 +1,40 @@
1
+ import type { EditorManifestAsset } from "./editorHostPlugin.js";
2
+ /**
3
+ * A durable catalog entry the editor writes into a promoted game's `src/game/assets.ts` — the
4
+ * `extras` literal `buildCatalog` consumes. Mirrors `@jgengine/assets`' `CatalogExtra`, but `label`
5
+ * is required here because the source literal always carries a human-facing name.
6
+ * @internal
7
+ */
8
+ export interface PromotedExtra {
9
+ id: string;
10
+ url: string;
11
+ label: string;
12
+ }
13
+ /**
14
+ * True when `dir` is an already-promoted game project: one whose assets resolve through a typed
15
+ * `src/game/assets.ts` catalog rather than a scanned model folder.
16
+ * @internal
17
+ */
18
+ export declare function isPromotedProject(dir: string): boolean;
19
+ /**
20
+ * Adds or replaces one `extras` entry in a promoted game's `src/game/assets.ts` and returns the
21
+ * rewritten text. PURE and deterministic — it does not touch the filesystem.
22
+ *
23
+ * A bounded, non-AST transform: it locates the single `buildCatalog({ ... })` call by brace-depth
24
+ * scan, then upserts an id-keyed entry into that call's `extras: [ ... ]` array (creating the array
25
+ * when absent, collapsing it back to no `extras` when it would become empty). Re-import of the same
26
+ * id replaces its entry in place, so the transform is idempotent and never duplicates an id. If the
27
+ * source has zero or more than one `buildCatalog(` call it THROWS, so the caller falls back to the
28
+ * legacy folder-scan import rather than risk corrupting the file.
29
+ * @internal
30
+ */
31
+ export declare function upsertCatalogExtra(source: string, extra: PromotedExtra): string;
32
+ /**
33
+ * Persists an imported model into a promoted game's `public/` and registers a durable `extras`
34
+ * entry in its `src/game/assets.ts`, so the shipped game resolves the asset through its typed
35
+ * catalog. Copies the bytes under `public/<basePath>/imported/<safe>` and rewrites the catalog
36
+ * source via {@link upsertCatalogExtra}. Returns the same `{ id, url, label }` shape the folder-scan
37
+ * `importEditorAsset` returns — but the url is the shipped `public/` path, not the dev asset route.
38
+ * @internal
39
+ */
40
+ export declare function importPromotedAsset(dir: string, filename: string, bytes: Uint8Array): EditorManifestAsset;
@@ -0,0 +1,176 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { MODEL_EXT, assetIdFromRel, safeAssetFilename } from "./assetNaming.js";
4
+ /**
5
+ * True when `dir` is an already-promoted game project: one whose assets resolve through a typed
6
+ * `src/game/assets.ts` catalog rather than a scanned model folder.
7
+ * @internal
8
+ */
9
+ export function isPromotedProject(dir) {
10
+ return existsSync(join(dir, "src", "game", "assets.ts"));
11
+ }
12
+ /** Scans forward from `open` (index of `{` or `[`) to its matching close, respecting string literals. */
13
+ function matchBracketSpan(source, open) {
14
+ const openCh = source[open];
15
+ const closeCh = openCh === "{" ? "}" : "]";
16
+ let depth = 0;
17
+ let quote = null;
18
+ for (let i = open; i < source.length; i++) {
19
+ const ch = source[i];
20
+ if (quote !== null) {
21
+ if (ch === "\\") {
22
+ i++;
23
+ continue;
24
+ }
25
+ if (ch === quote)
26
+ quote = null;
27
+ continue;
28
+ }
29
+ if (ch === '"' || ch === "'" || ch === "`") {
30
+ quote = ch;
31
+ continue;
32
+ }
33
+ if (ch === openCh)
34
+ depth++;
35
+ else if (ch === closeCh) {
36
+ depth--;
37
+ if (depth === 0)
38
+ return i;
39
+ }
40
+ }
41
+ throw new Error("promotedAssetCatalog: unbalanced brackets in assets.ts");
42
+ }
43
+ /** Serializes one extras entry as a `{ id, url, label }` object literal with stable key order. */
44
+ function serializeEntry(extra) {
45
+ return `{ id: ${JSON.stringify(extra.id)}, url: ${JSON.stringify(extra.url)}, label: ${JSON.stringify(extra.label)} }`;
46
+ }
47
+ /**
48
+ * Adds or replaces one `extras` entry in a promoted game's `src/game/assets.ts` and returns the
49
+ * rewritten text. PURE and deterministic — it does not touch the filesystem.
50
+ *
51
+ * A bounded, non-AST transform: it locates the single `buildCatalog({ ... })` call by brace-depth
52
+ * scan, then upserts an id-keyed entry into that call's `extras: [ ... ]` array (creating the array
53
+ * when absent, collapsing it back to no `extras` when it would become empty). Re-import of the same
54
+ * id replaces its entry in place, so the transform is idempotent and never duplicates an id. If the
55
+ * source has zero or more than one `buildCatalog(` call it THROWS, so the caller falls back to the
56
+ * legacy folder-scan import rather than risk corrupting the file.
57
+ * @internal
58
+ */
59
+ export function upsertCatalogExtra(source, extra) {
60
+ const callRe = /\bbuildCatalog\(\s*\{/g;
61
+ const matches = [...source.matchAll(callRe)];
62
+ if (matches.length !== 1) {
63
+ throw new Error(`promotedAssetCatalog: expected exactly one buildCatalog({...}) call, found ${matches.length}`);
64
+ }
65
+ const match = matches[0];
66
+ // Brace of the options object literal is the last char of the matched `buildCatalog(\s*{`.
67
+ const objOpen = match.index + match[0].length - 1;
68
+ const objClose = matchBracketSpan(source, objOpen);
69
+ const objBody = source.slice(objOpen, objClose + 1);
70
+ // Detect indentation of the object's first inner line to keep emitted code consistent.
71
+ const indentMatch = /\{\s*\n(\s+)\S/.exec(objBody);
72
+ const indent = indentMatch ? indentMatch[1] : " ";
73
+ const extrasRe = /\bextras\s*:\s*\[/;
74
+ const extrasMatch = extrasRe.exec(objBody);
75
+ const entries = new Map();
76
+ if (extrasMatch) {
77
+ const arrOpenInObj = objBody.indexOf("[", extrasMatch.index);
78
+ const arrCloseInObj = matchBracketSpan(objBody, arrOpenInObj);
79
+ const arrInner = objBody.slice(arrOpenInObj + 1, arrCloseInObj);
80
+ // Parse each `{ ... }` object literal in the array via brace-depth scan + JSON.parse.
81
+ let cursor = 0;
82
+ while (cursor < arrInner.length) {
83
+ const braceOpen = arrInner.indexOf("{", cursor);
84
+ if (braceOpen === -1)
85
+ break;
86
+ const braceClose = matchBracketSpan(arrInner, braceOpen);
87
+ const literal = arrInner.slice(braceOpen, braceClose + 1);
88
+ const parsed = parseEntryLiteral(literal);
89
+ entries.set(parsed.id, parsed);
90
+ cursor = braceClose + 1;
91
+ }
92
+ entries.set(extra.id, extra);
93
+ const arrOpenAbs = objOpen + arrOpenInObj;
94
+ const arrCloseAbs = objOpen + arrCloseInObj;
95
+ // Collapse an emptied array back to no `extras` for clean reversibility. Unreachable via a plain
96
+ // import (we just added an entry), but keeps the transform's remove-symmetry intact.
97
+ if (entries.size === 0) {
98
+ return spliceOutExtras(source, objOpen + extrasMatch.index, arrCloseAbs);
99
+ }
100
+ const serialized = serializeArray([...entries.values()], indent);
101
+ return source.slice(0, arrOpenAbs) + serialized + source.slice(arrCloseAbs + 1);
102
+ }
103
+ // No extras yet: splice a fresh `extras: [ <entry> ],` right after the opening `{`.
104
+ entries.set(extra.id, extra);
105
+ const serialized = serializeArray([...entries.values()], indent);
106
+ const insertAt = objOpen + 1;
107
+ const insertion = `\n${indent}extras: ${serialized},`;
108
+ return source.slice(0, insertAt) + insertion + source.slice(insertAt);
109
+ }
110
+ /** Removes an `extras: [...]` declaration (and a trailing comma) so the object reverts cleanly. */
111
+ function spliceOutExtras(source, declStartAbs, arrCloseAbs) {
112
+ let declEndAbs = arrCloseAbs + 1;
113
+ // Swallow a trailing comma after `]`.
114
+ if (source[declEndAbs] === ",")
115
+ declEndAbs++;
116
+ // Swallow leading whitespace/newline before the declaration.
117
+ let start = declStartAbs;
118
+ while (start > 0 && /[ \t]/.test(source[start - 1]))
119
+ start--;
120
+ if (source[start - 1] === "\n")
121
+ start--;
122
+ return source.slice(0, start) + source.slice(declEndAbs);
123
+ }
124
+ /** Serializes an extras array literal with one entry per indented line. */
125
+ function serializeArray(entries, indent) {
126
+ if (entries.length === 0)
127
+ return "[]";
128
+ const inner = entries.map((e) => `${indent} ${serializeEntry(e)},`).join("\n");
129
+ return `[\n${inner}\n${indent}]`;
130
+ }
131
+ /** Parses a `{ id, url, label }` object literal (unquoted keys) into a PromotedExtra via JSON. */
132
+ function parseEntryLiteral(literal) {
133
+ // Quote bare identifier keys so JSON.parse accepts the source-literal form.
134
+ const jsonish = literal.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
135
+ const obj = JSON.parse(jsonish);
136
+ if (typeof obj.id !== "string" || typeof obj.url !== "string") {
137
+ throw new Error("promotedAssetCatalog: malformed extras entry");
138
+ }
139
+ return {
140
+ id: obj.id,
141
+ url: obj.url,
142
+ label: typeof obj.label === "string" ? obj.label : obj.id,
143
+ };
144
+ }
145
+ /** Reads the `basePath` string literal from a promoted `assets.ts`, defaulting to `/models`. */
146
+ function readBasePath(source) {
147
+ const m = /\bbasePath\s*:\s*["'`]([^"'`]+)["'`]/.exec(source);
148
+ return m ? m[1] : "/models";
149
+ }
150
+ /**
151
+ * Persists an imported model into a promoted game's `public/` and registers a durable `extras`
152
+ * entry in its `src/game/assets.ts`, so the shipped game resolves the asset through its typed
153
+ * catalog. Copies the bytes under `public/<basePath>/imported/<safe>` and rewrites the catalog
154
+ * source via {@link upsertCatalogExtra}. Returns the same `{ id, url, label }` shape the folder-scan
155
+ * `importEditorAsset` returns — but the url is the shipped `public/` path, not the dev asset route.
156
+ * @internal
157
+ */
158
+ export function importPromotedAsset(dir, filename, bytes) {
159
+ if (!MODEL_EXT.test(filename)) {
160
+ throw new Error(`unsupported asset file (expected .glb/.gltf): ${filename}`);
161
+ }
162
+ const assetsTsPath = join(dir, "src", "game", "assets.ts");
163
+ const source = readFileSync(assetsTsPath, "utf8");
164
+ const basePath = readBasePath(source);
165
+ const safe = safeAssetFilename(filename);
166
+ const publicSub = basePath.replace(/^\/+/, "");
167
+ const targetDir = join(dir, "public", publicSub, "imported");
168
+ mkdirSync(targetDir, { recursive: true });
169
+ writeFileSync(join(targetDir, safe), bytes);
170
+ const url = `${basePath.replace(/\/+$/, "")}/imported/${safe}`;
171
+ const id = assetIdFromRel(safe);
172
+ const label = safe;
173
+ const rewritten = upsertCatalogExtra(source, { id, url, label });
174
+ writeFileSync(assetsTsPath, rewritten);
175
+ return { id, url, label };
176
+ }
@@ -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 { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
4
4
  import { type WorldGameHost } from "@jgengine/ws/worldHost";
5
5
  import { type WorldPersistence } from "./persistence.js";
@@ -8,6 +8,8 @@ import { type GameWsServer, type GameWsServerOptions } from "./wsServer.js";
8
8
  export interface HostedGameDefinition {
9
9
  game: 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
  /** Config for {@link createWorldGameServer}: how to resolve a game by id, the tick cadence, and the underlying ws-server/router options (minus `host`, which the server builds). */
13
15
  export interface WorldGameServerOptions extends Omit<GameWsServerOptions, "host"> {
@@ -19,6 +19,7 @@ export function createWorldGameServer(options) {
19
19
  content: resolved.content,
20
20
  now: clock,
21
21
  store: persistence.store({ gameId, serverId }),
22
+ ...(resolved.models === undefined ? {} : { models: resolved.models }),
22
23
  });
23
24
  liveSessions.set(serverId, session);
24
25
  return session;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/node",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Standalone authoritative game host for JGengine: in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,11 +31,12 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.11.0",
35
- "@jgengine/ws": "^0.11.0",
34
+ "@jgengine/core": "^0.13.0",
35
+ "@jgengine/ws": "^0.13.0",
36
36
  "ws": "^8.18.0"
37
37
  },
38
38
  "devDependencies": {
39
+ "@jgengine/assets": "workspace:*",
39
40
  "@jgengine/sql": "workspace:*",
40
41
  "@types/node": "^24",
41
42
  "@types/ws": "^8",
@@ -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,15 +36,17 @@ 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
 
41
43
  Within each selected domain:
42
44
 
45
+ 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
43
46
  1. Search `capabilities.md` by intent; it maps needs to imports.
44
47
  2. Open `api.md` only when exact signatures or export inventory are needed.
45
48
  3. Open the linked reference only for a deeper recipe or trap.
49
+ 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
50
 
47
51
  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
52