@jgengine/assets 0.8.0 → 0.10.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
@@ -11,6 +11,42 @@ Agents building on the published SDK can also read this programmatically:
11
11
  same data as typed values, so an updater can diff its installed version against
12
12
  the latest and surface the migration steps.
13
13
 
14
+ ## 0.10.0
15
+
16
+ ### Migrate
17
+
18
+ - **Nothing to change for existing games** — the shared mobile-composition system (viewport allocation, region collision, `--jg-*` viewport vars) is wired automatically by the shell for every game; `HudPanel`s inside a `HudCanvas` register themselves. Legacy `orientation: "landscape" | "portrait"` keeps its advisory dismissible-hint behavior unchanged.
19
+ - **Opt a driving/landscape game into the strict gate**: replace the advisory `orientation` with the contract form — `orientation: { mobile: "landscape-required" }` (rules: `any` · `portrait`/`landscape` advisory · `portrait-required`/`landscape-required` · `unsupported`). In portrait the engine now shows a polished `RotateDeviceScreen`, suppresses input, freezes the sim, and unmounts the HUD/controls until the device is landscape.
20
+ - **Declare HUD intent** where useful: `HudPanel` gains `priority` (`critical`/`secondary`/`tertiary`), `mobileBehavior` (`hidden` unmounts on phones, `transient` softens collision, …), `allowOverlapWith`, and `collisionGroup`. Optional — omitting them keeps prior behavior.
21
+ - **Mobile validation now also fails on overlap**: `bun run shoot <game> --device mobile|mobile-landscape|both` exits non-zero on forbidden inter-element collisions (not just viewport overflow), naming both regions.
22
+
23
+ ### Added
24
+
25
+ - Shared mobile layout composition — `@jgengine/core/ui/gameLayout` (pure geometry: `resolveLayoutMode`, `intersects`, `overlapArea`, `detectLayoutCollisions`, `computeGameplayRect`, `LayoutRegion`, `GameViewportLayout`) and `@jgengine/core/ui/orientation` (`resolveOrientationRequirement`, `orientationGateActive`, `MobileOrientationRule`). `@jgengine/react` adds `GameViewportProvider` (mounted by the shell), hooks (`useGameViewportLayout`, `useGameLayoutMode`, `useGameOrientation`, `useReservedControlZones`, `useLayoutCollisions`, `useRegisterLayoutRegion`), and a headless `RotateDeviceScreen`. The provider tracks `window.visualViewport`, publishes `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS vars, resolves the explicit layout mode, and detects forbidden region overlaps (dev `data-jg-layout-collision` + console diagnostic).
26
+ - Mandatory orientation contract — `PlayableGame.orientation` accepts `{ mobile: <rule> }`; `landscape-required`/`portrait-required` render an engine-owned rotate gate that blocks gameplay (input suppressed, simulation frozen, HUD/controls unmounted), self-correcting when the device is turned. Reduced-motion and safe-area respected; `visualViewport`-sized for mobile Safari/PWA.
27
+ - Reserved touch-control zones — the touch dock registers its joystick / action-cluster / utility rectangles (runtime-measured) as `control` regions, so HUD placement and collision validation know exactly where controls sit.
28
+ - HUD placement metadata — `HudPanel` gains `priority`, `mobileBehavior`, `allowOverlapWith`, `collisionGroup`, and registers as a collision-tracked layout region.
29
+ - Mobile-landscape shoot device — `bun run shoot --device mobile-landscape` (844×390) plus collision-gate reads that fail mobile validation on forbidden overlaps.
30
+ - Canyon Chase migrated as the reference landscape-required game — declares `orientation: { mobile: "landscape-required" }`, composes its HUD through coordinated `HudPanel` regions (pursuit distance critical, border secondary, survey map hidden on mobile, radio transient) instead of independent fixed corners.
31
+
32
+ ### Changed
33
+
34
+ - First-person projectile tracers now originate from the weapon muzzle instead of the camera/eye centerline. Hit detection is unchanged (still crosshair-accurate) — only the drawn tracer's start point moved to the viewmodel muzzle, which tracks full aim yaw and pitch. Non-viewmodel games and enemy tracers are unaffected.
35
+
36
+ ## 0.9.0
37
+
38
+ ### Added
39
+
40
+ - Mobile HUD fit — design-resolution UI scaling now applies to every game by default: `HudCanvas` measures the live viewport and scales the whole HUD from `PlayableGame.hudFit.designSize` (default 1600×900, clamps `minScale`/`maxScale` 0.4–1) so desktop-authored layouts shrink to fit a phone instead of overflowing it; `hudFit.mobile` overrides tune the phone fit separately. Pure math lives in `@jgengine/core/ui/hudScale` (`hudScaleForViewport`, `resolveHudFit`, `rectOverflow`, `overflowingPanels`); the shell mounts `@jgengine/react`'s `HudViewportProvider` around `GameUI` so games need no wiring.
41
+ - Graphics → UI scale — a player-facing `graphics.uiScale` slider (0.5–1.5, default 1) multiplies the computed HUD scale on every platform; the same resolution system drives desktop preference and mobile shrink.
42
+ - HUD overflow gate — `HudCanvas` measures every `HudPanel` against the viewport at runtime and reports offenders on a `data-hud-overflow` attribute (plus a console warning); `bun run shoot <game> --device mobile|both` now exits non-zero naming the panels that escape the viewport.
43
+ - Automatic visibility & streaming defaults — an engine-level `VisibilitySystem` (exported from `@jgengine/core`) gives every scene renderer-agnostic culling and asset-streaming policy with no per-object wiring: distance culling, preload margins, hysteresis, delayed unloading, multi-camera awareness (including cameras excluded from streaming), and per-object overrides (always-visible, never-unload, disabled culling/streaming, custom render distance and preload margin).
44
+ - Self-hosted asset mirror — `DEFAULT_RELEASE_BASE` now points at this repo's rolling `packs` release instead of the upstream host; a catalog-driven `mirror-assets` workflow (weekly cron + manual dispatch) keeps the mirror in sync with the asset catalog.
45
+
46
+ ### Changed
47
+
48
+ - Mobile HUD fit is on by default — design-resolution HUD scaling now applies to every game with no config change (previously opt-in via `platforms: ["web", "mobile"]`). A desktop-only game keeps the legacy fixed 0.85 compact zoom by declaring `platforms: ["web"]` (without `"mobile"`).
49
+
14
50
  ## 0.8.0
15
51
 
16
52
  Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
package/README.md CHANGED
@@ -18,33 +18,54 @@ A self-generating, license-verified index of thousands of CC0 3D models — host
18
18
 
19
19
  Everything collapses into the core `AssetCatalog` via `buildCatalog({ basePath })`.
20
20
 
21
+ ## `add` — one command for anything
22
+
23
+ `assets add <query>` is the front door. It fuzzy-searches **every** catalog at once — 3D models, whole packs, HUD components (the shadcn registry), and `game-icon` glyphs — then does the fetch and prints the exact copy-paste wiring. One mental model instead of four.
24
+
25
+ ```bash
26
+ assets add astronaut --dir ../../apps/dev/public # model → pull + reindex + print the assets.ts snippet
27
+ assets add nature --dir ../../apps/dev/public # whole pack → pull + reindex + how to wire an id
28
+ assets add "mana bar" # HUD component → the `npx shadcn add` cmd + <VitalBar/> usage
29
+ assets add sword # icon → the game-icon name to drop in a slot
30
+ ```
31
+
32
+ - A **model / pack** match is fully automated: if the pack isn't already in `<dir>/models/`, it's pulled and extracted, then `reindex` runs so the id is addressable, then the `buildCatalog` + model-seam snippet is printed.
33
+ - A **component / icon** match prints the one-liner to run and the import + usage — no bytes to pull.
34
+ - Ambiguous query? `add` lists the top matches across kinds; narrow with `--kind model|pack|component|icon` or a more specific term. `--json` emits the ranked matches for scripting.
35
+
36
+ The same ranking is available programmatically: `import { findAssets } from "@jgengine/assets"`.
37
+
21
38
  ## CLI
22
39
 
23
40
  ```
41
+ assets add <query> [--kind <k>] [--dir <dir>] [--mirror <baseUrl>] [--json]
42
+ # unified import: models, packs, components, icons
24
43
  assets list [--category <c>] [--source <s>] # browse the generated index
25
44
  assets search <term> # grep the index
26
45
  assets pull <source-id> [--dir public] [--mirror <baseUrl>] [--offline]
27
- # download + extract GLBs into <dir>/models/<source>/
28
- assets add <path|url> --category <c> --license <l> [--author <a>]
46
+ # download + extract a whole pack's GLBs into <dir>/models/<source>/
47
+ assets register <path|url> --category <c> --license <l> [--author <a>]
48
+ # register a one-off single into the shipped index
29
49
  assets reindex [public/models] # regenerate generated/*.json from pulled packs
30
50
  assets verify # license + alias-integrity gate
31
51
  ```
32
52
 
33
- Run in-repo with `bun run --cwd packages/assets src/cli/pull.ts <verb> …`.
53
+ Run in-repo with `bun run --cwd packages/assets src/cli/pull.ts <verb> …`. (`add <path|url> --license …` still works as an alias for `register`.)
34
54
 
35
55
  ### Mirror fallback and offline pulls
36
56
 
37
57
  `pull` never has just one path to the bytes. For a given `source-id` it tries, in order, until one succeeds:
38
58
 
39
59
  1. **Mirror base override** — `--mirror <baseUrl>` (or the `JGENGINE_ASSETS_MIRROR` env var if `--mirror` is not passed) — the archive is expected at `<baseUrl>/<provider>/<source-id>.zip`, e.g. `https://my-mirror.example.com/kenney/kenney-nature.zip`.
40
- 2. **The primary provider path** — the source's pinned `{ url, sha256? }` or, for providers that rotate URLs (Kenney), a scrape of the asset page.
41
- 3. **The pack's own `mirror`**an optional direct archive URL set on the `AssetSource` entry itself (`src/sources/*.ts`), tried as a last resort.
60
+ 2. **The default GitHub-release mirror** — `https://github.com/Noisemaker111/jgengine/releases/download/packs/<provider>-<source-id>.zip`, on this repo's own rolling `packs` release (no separate assets repo). github.com is reachable from every cloud sandbox with no network-policy change, so zero-setup sessions still pull. `.github/workflows/mirror-assets.yml` (weekly cron + manual dispatch) keeps the release in sync with `src/sources/*.ts` automatically — adding a catalog entry is the whole publishing step, no manual upload. Skip this hop with `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
61
+ 3. **The primary provider path**the source's pinned `{ url, sha256? }` or, for providers that rotate URLs (Kenney), a scrape of the asset page.
62
+ 4. **The pack's own `mirror`** — an optional direct archive URL set on the `AssetSource` entry itself (`src/sources/*.ts`), tried as a last resort.
42
63
 
43
- If every attempt fails, `pull` throws one aggregated error naming every URL it tried and why each one failed. Whenever the source's `download` is pinned with a `sha256`, the downloaded bytes are hashed and checked against it **no matter which of the three paths supplied them** — a mirror serving stale or tampered bytes is rejected and the next source in the chain is tried instead.
64
+ If every attempt fails, `pull` throws one aggregated error naming every URL it tried and why each one failed. Whenever the source's `download` is pinned with a `sha256`, the downloaded bytes are hashed and checked against it **no matter which path supplied them** — a mirror serving stale or tampered bytes is rejected and the next source in the chain is tried instead.
44
65
 
45
66
  `--offline` skips the network entirely: it succeeds immediately if `<dir>/models/<source-id>/` already has files in it, and otherwise fails fast with a message telling you to pull once on a connected machine or point `--mirror`/`JGENGINE_ASSETS_MIRROR` at a reachable archive — useful for CI scripts that should error in seconds instead of hanging on a blocked fetch.
46
67
 
47
- **Network-restricted environments** — pull once on a machine that can reach the providers, then either:
68
+ **Network-restricted environments** — a `403` on `CONNECT` from an outbound proxy means the environment's network policy blocks that provider host (common in sandboxed cloud sessions); it is a policy decision, not a transient failure, so retrying never helps. Either allowlist the host in the environment settings, or pull once on a machine that can reach the providers, then:
48
69
 
49
70
  - commit (or otherwise host) the resulting `public/models/<source-id>/` directory so restricted environments read it straight off disk and use `assets pull --offline` as a fast, honest no-op check, or
50
71
  - host your own mirror of the zip archives at `<baseUrl>/<provider>/<source-id>.zip` and set `JGENGINE_ASSETS_MIRROR=<baseUrl>` (or pass `--mirror <baseUrl>`) so `pull` fetches from it instead of the original provider.
@@ -96,7 +117,7 @@ export const game: PlayableGame = {
96
117
  };
97
118
  ```
98
119
 
99
- `buildCatalog({ sources: ["kenney-nature"] })` restricts to chosen packs; `includeAliases` / `includeSingles` default true. Discover ids with `assets search <term>` / `assets list --category <c>` instead of memorizing them. See `Games/asset-showcase` for a full working example.
120
+ `buildCatalog({ sources: ["kenney-nature"] })` restricts to chosen packs; `includeAliases` / `includeSingles` default true. Discover ids with `assets add <query>` (or `assets search <term>` / `assets list --category <c>`) instead of memorizing them.
100
121
 
101
122
  ### Serving the bytes
102
123
 
@@ -0,0 +1,4 @@
1
+ /** Sibling of `cli/` under `src/` (dev) or `dist/` (published) so reindex writes the tree consumers import. */
2
+ export declare function resolveGeneratedDir(cliDir: string): string;
3
+ export declare function resolvePackageTreeRoot(cliDir: string): string;
4
+ export declare function resolvePackageRoot(cliDir: string): string;
@@ -0,0 +1,11 @@
1
+ import { join, resolve } from "node:path";
2
+ /** Sibling of `cli/` under `src/` (dev) or `dist/` (published) so reindex writes the tree consumers import. */
3
+ export function resolveGeneratedDir(cliDir) {
4
+ return join(resolve(cliDir, ".."), "generated");
5
+ }
6
+ export function resolvePackageTreeRoot(cliDir) {
7
+ return resolve(cliDir, "..");
8
+ }
9
+ export function resolvePackageRoot(cliDir) {
10
+ return resolve(cliDir, "..", "..");
11
+ }
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ /** Sibling of `cli/` under `src/` (dev) or `dist/` (published) so reindex writes the tree consumers import. */
3
+ export declare const generatedDir: string;
4
+ export { resolveGeneratedDir } from "./paths.js";
2
5
  export declare function flag(argv: string[], name: string): string | undefined;
6
+ export declare function describeNetworkFailure(error: unknown): string;
3
7
  export declare function isPopulated(dir: string): boolean;
4
8
  export declare function cmdPull(argv: string[]): Promise<void>;
package/dist/cli/pull.js CHANGED
@@ -3,16 +3,22 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFi
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { downloadPackArchive, extractGlbs, extractTextures } from "../download.js";
6
+ import { rankAssets } from "../find.js";
6
7
  import { generatedIndex } from "../generated/index.js";
7
8
  import { reindex } from "../indexGen.js";
8
9
  import { isScrapeDownload } from "../manifest.js";
10
+ import { registryCatalog } from "../registry.js";
11
+ import { componentWiringSnippet, iconWiringSnippet, modelWiringSnippet } from "../snippet.js";
9
12
  import { sourceById } from "../sources/index.js";
10
13
  import { verifyManifest } from "../verify.js";
14
+ import { resolveGeneratedDir, resolvePackageRoot, resolvePackageTreeRoot } from "./paths.js";
11
15
  const here = dirname(fileURLToPath(import.meta.url));
12
- const pkgRoot = resolve(here, "..", "..");
13
- const srcDir = join(pkgRoot, "src");
14
- const generatedDir = join(srcDir, "generated");
15
- const singlesJson = join(srcDir, "singles.json");
16
+ const packageTreeRoot = resolvePackageTreeRoot(here);
17
+ const pkgRoot = resolvePackageRoot(here);
18
+ /** Sibling of `cli/` under `src/` (dev) or `dist/` (published) so reindex writes the tree consumers import. */
19
+ export const generatedDir = resolveGeneratedDir(here);
20
+ export { resolveGeneratedDir } from "./paths.js";
21
+ const singlesJson = join(packageTreeRoot, "singles.json");
16
22
  const localDir = join(pkgRoot, "local");
17
23
  const CDN_BASE = "https://cdn.jsdelivr.net/gh/Noisemaker111/jgengine@main/packages/assets/local";
18
24
  export function flag(argv, name) {
@@ -23,6 +29,19 @@ function fail(message) {
23
29
  console.error(`error: ${message}`);
24
30
  process.exit(1);
25
31
  }
32
+ export function describeNetworkFailure(error) {
33
+ const chain = [];
34
+ let current = error;
35
+ while (current instanceof Error) {
36
+ chain.push(current.message);
37
+ current = current.cause;
38
+ }
39
+ const text = chain.length > 0 ? chain.join(" — ") : String(error);
40
+ const looksPolicyBlocked = /\b403\b|CONNECT|proxy|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|fetch failed/i.test(text);
41
+ return looksPolicyBlocked
42
+ ? `${text}\nhint: a 403/CONNECT failure here usually means the sandbox network policy blocks this host — not a transient error, retrying won't help. Allowlist the host in the environment settings, point JGENGINE_ASSETS_MIRROR at a reachable mirror, or build with procedural geometry.`
43
+ : text;
44
+ }
26
45
  function cmdList(argv) {
27
46
  const category = flag(argv, "category");
28
47
  const source = flag(argv, "source");
@@ -69,13 +88,19 @@ export async function cmdPull(argv) {
69
88
  console.log(`offline: ${outDir} already populated, skipping network`);
70
89
  return;
71
90
  }
72
- console.log(`resolving ${sourceId}${isScrapeDownload(source.download) ? " (scrape)" : ""}` +
73
- `${mirrorBase !== undefined ? ` [mirror override: ${mirrorBase}]` : ""}…`);
74
- const { archive, url, attempted } = await downloadPackArchive(source, { mirrorBase });
91
+ const result = await fetchPackInto(source, outRoot, { mirrorBase });
92
+ const textureNote = result.textures > 0 ? ` + ${result.textures} texture(s)` : "";
93
+ console.log(`pulled ${result.models} models${textureNote} -> ${result.outDir}`);
94
+ }
95
+ async function fetchPackInto(source, outRoot, options) {
96
+ const outDir = join(outRoot, "models", source.id);
97
+ console.log(`resolving ${source.id}${isScrapeDownload(source.download) ? " (scrape)" : ""}` +
98
+ `${options.mirrorBase !== undefined ? ` [mirror override: ${options.mirrorBase}]` : ""}…`);
99
+ const { archive, url, attempted } = await downloadPackArchive(source, { mirrorBase: options.mirrorBase });
75
100
  console.log(`downloaded ${url}${attempted.length > 1 ? ` (after ${attempted.length - 1} failed attempt(s))` : ""}`);
76
101
  const glbs = extractGlbs(archive);
77
102
  if (glbs.length === 0)
78
- fail(`no .glb files found in ${sourceId} archive`);
103
+ fail(`no .glb files found in ${source.id} archive`);
79
104
  mkdirSync(outDir, { recursive: true });
80
105
  for (const glb of glbs)
81
106
  writeFileSync(join(outDir, glb.file), glb.bytes);
@@ -85,8 +110,7 @@ export async function cmdPull(argv) {
85
110
  mkdirSync(dirname(dest), { recursive: true });
86
111
  writeFileSync(dest, texture.bytes);
87
112
  }
88
- const textureNote = textures.length > 0 ? ` + ${textures.length} texture(s)` : "";
89
- console.log(`pulled ${glbs.length} models${textureNote} -> ${outDir}`);
113
+ return { outDir, models: glbs.length, textures: textures.length, url };
90
114
  }
91
115
  function readSingles() {
92
116
  if (!existsSync(singlesJson))
@@ -96,10 +120,11 @@ function readSingles() {
96
120
  function writeSingles(list) {
97
121
  writeFileSync(singlesJson, `${JSON.stringify(list, null, 2)}\n`);
98
122
  }
99
- function cmdAdd(argv) {
123
+ function cmdRegisterSingle(argv) {
100
124
  const target = argv[0];
101
- if (target === undefined)
102
- fail("usage: add <path|url> --category <c> --license <l> [--author <a>] [--id <id>]");
125
+ if (target === undefined) {
126
+ fail("usage: register <path|url> --category <c> --license <l> [--author <a>] [--id <id>]");
127
+ }
103
128
  const license = flag(argv, "license");
104
129
  if (license === undefined)
105
130
  fail("add requires --license");
@@ -132,6 +157,95 @@ function cmdAdd(argv) {
132
157
  writeSingles(singles);
133
158
  console.log(`added single ${id} -> ${url}${isUrl ? " (0 bytes stored)" : " (copied to local/)"}`);
134
159
  }
160
+ const KINDS = ["model", "pack", "component", "icon"];
161
+ function describeMatch(match) {
162
+ switch (match.kind) {
163
+ case "model":
164
+ return `[model] ${match.id}${match.via === "alias" ? " (alias)" : match.via === "single" ? " (single)" : ""}`;
165
+ case "pack":
166
+ return `[pack] ${match.source} — ${match.title}`;
167
+ case "component":
168
+ return `[component] ${match.name} — ${match.title}`;
169
+ case "icon":
170
+ return `[icon] ${match.name}`;
171
+ }
172
+ }
173
+ async function performAdd(match, argv) {
174
+ if (match.kind === "component") {
175
+ const component = registryCatalog.components.find((entry) => entry.name === match.name);
176
+ if (component === undefined)
177
+ fail(`component vanished from catalog: ${match.name}`);
178
+ console.log(`component: ${component.title} — ${component.description}\n`);
179
+ console.log(componentWiringSnippet(component));
180
+ return;
181
+ }
182
+ if (match.kind === "icon") {
183
+ console.log(`icon: ${match.name}\n`);
184
+ console.log(iconWiringSnippet(match.name));
185
+ return;
186
+ }
187
+ const source = sourceById.get(match.source);
188
+ if (source === undefined)
189
+ fail(`match resolves to unknown source: ${match.source}`);
190
+ const outRoot = resolve(flag(argv, "dir") ?? "public");
191
+ const outDir = join(outRoot, "models", match.source);
192
+ const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
193
+ if (isPopulated(outDir)) {
194
+ console.log(`${match.source} already present in ${outDir}`);
195
+ }
196
+ else {
197
+ const result = await fetchPackInto(source, outRoot, { mirrorBase });
198
+ const textureNote = result.textures > 0 ? ` + ${result.textures} texture(s)` : "";
199
+ console.log(`pulled ${result.models} models${textureNote} -> ${result.outDir}`);
200
+ }
201
+ const result = reindex(join(outRoot, "models"), generatedDir);
202
+ console.log(`reindexed ${result.total} entries -> ${generatedDir}`);
203
+ if (match.kind === "model") {
204
+ console.log(`\nwire it in (characters/enemies go in entityModels instead):\n`);
205
+ console.log(modelWiringSnippet(match.id));
206
+ }
207
+ else {
208
+ console.log(`\n${match.title}: browse ids with \`assets list --source ${match.source}\`, then wire like:\n`);
209
+ console.log(modelWiringSnippet(`${match.source}/<model>`));
210
+ }
211
+ }
212
+ async function cmdAdd(argv) {
213
+ const query = argv[0];
214
+ if (query === undefined) {
215
+ fail("usage: add <query> [--kind model|pack|component|icon] [--dir <dir>] [--mirror <baseUrl>] [--json]\n" +
216
+ " add <path|url> --license <l> [--category <c>] (register a one-off single into the shipped index)");
217
+ }
218
+ // Back-compat: the old `add` registered a one-off single, always with --license.
219
+ if (flag(argv, "license") !== undefined) {
220
+ cmdRegisterSingle(argv);
221
+ return;
222
+ }
223
+ const kindFlag = flag(argv, "kind");
224
+ if (kindFlag !== undefined && !KINDS.includes(kindFlag)) {
225
+ fail(`--kind must be one of ${KINDS.join(", ")}`);
226
+ }
227
+ const kind = kindFlag;
228
+ const ranked = rankAssets(query, { kind, limit: Number(flag(argv, "limit") ?? "12") });
229
+ if (ranked.length === 0) {
230
+ fail(`no asset matches "${query}" — try a broader term or \`assets search <term>\``);
231
+ }
232
+ if (argv.includes("--json")) {
233
+ console.log(JSON.stringify(ranked, null, 2));
234
+ return;
235
+ }
236
+ const top = ranked[0];
237
+ const runnerUp = ranked[1]?.score ?? 0;
238
+ const decisive = ranked.length === 1 || kind !== undefined || top.score - runnerUp >= 20;
239
+ if (!decisive) {
240
+ console.log(`several matches for "${query}" — narrow with --kind or a more specific term:\n`);
241
+ for (const entry of ranked.slice(0, 8))
242
+ console.log(` ${describeMatch(entry.match)}`);
243
+ console.log(`\nthen re-run, e.g. \`assets add "${query}" --kind ${top.match.kind}\``);
244
+ return;
245
+ }
246
+ console.log(`→ ${describeMatch(top.match)}\n`);
247
+ await performAdd(top.match, argv);
248
+ }
135
249
  function cmdReindex(argv) {
136
250
  const modelsDir = resolve(argv[0] ?? join("public", "models"));
137
251
  if (!existsSync(modelsDir))
@@ -161,10 +275,13 @@ if (import.meta.main) {
161
275
  cmdSearch(rest);
162
276
  break;
163
277
  case "pull":
164
- await cmdPull(rest);
278
+ await cmdPull(rest).catch((error) => fail(describeNetworkFailure(error)));
165
279
  break;
166
280
  case "add":
167
- cmdAdd(rest);
281
+ await cmdAdd(rest).catch((error) => fail(describeNetworkFailure(error)));
282
+ break;
283
+ case "register":
284
+ cmdRegisterSingle(rest);
168
285
  break;
169
286
  case "reindex":
170
287
  cmdReindex(rest);
@@ -173,7 +290,7 @@ if (import.meta.main) {
173
290
  cmdVerify();
174
291
  break;
175
292
  default:
176
- console.log("usage: assets <list|search|pull|add|reindex|verify> [...args]");
293
+ console.log("usage: assets <add|list|search|pull|register|reindex|verify> [...args]");
177
294
  if (command !== undefined && command !== "help")
178
295
  process.exit(1);
179
296
  }
@@ -18,6 +18,19 @@ export declare function downloadArchive(url: string, fetchImpl?: FetchLike): Pro
18
18
  * `https://my-mirror.example.com/kenney/kenney-nature.zip`.
19
19
  */
20
20
  export declare function mirrorOverrideUrl(baseUrl: string, source: AssetSource): string;
21
+ /**
22
+ * Default asset mirror: this repo's own GitHub Releases, reachable from every
23
+ * cloud sandbox without network-policy changes (github.com is on the default
24
+ * allowlist). Assets live on the rolling `packs` release, one flat zip per
25
+ * pack named `<provider>-<packId>.zip`, kept in sync with the source catalog
26
+ * by `.github/workflows/mirror-assets.yml` — adding a catalog entry is the
27
+ * whole publishing step. Override the chain with `--mirror` /
28
+ * `JGENGINE_ASSETS_MIRROR`, or disable this hop with
29
+ * `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
30
+ */
31
+ export declare const DEFAULT_RELEASE_BASE = "https://github.com/Noisemaker111/jgengine/releases/download/packs";
32
+ /** URL of `source`'s archive on the default GitHub-release mirror. */
33
+ export declare function defaultReleaseUrl(source: AssetSource): string;
21
34
  export interface DownloadPackOptions {
22
35
  /** Explicit mirror base override (CLI `--mirror` / `JGENGINE_ASSETS_MIRROR`), tried before the primary provider path. */
23
36
  mirrorBase?: string;
@@ -32,8 +45,10 @@ export interface DownloadPackResult {
32
45
  /**
33
46
  * Resolves and downloads a pack's archive, trying sources in order until one
34
47
  * succeeds: (1) the mirror base override at `mirrorOverrideUrl`, (2) the
35
- * primary provider path (`resolveArchiveUrl`: scrape or pinned URL), (3) the
36
- * pack's own `mirror` URL. A pinned `sha256` is verified against whichever
48
+ * default GitHub-release mirror at `defaultReleaseUrl` (skipped when
49
+ * `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`), (3) the primary provider path
50
+ * (`resolveArchiveUrl`: scrape or pinned URL), (4) the pack's own `mirror`
51
+ * URL. A pinned `sha256` is verified against whichever
37
52
  * source supplied the bytes; a mismatch is treated as a failed attempt so the
38
53
  * next source in the chain is tried. Throws with every attempted URL and its
39
54
  * failure reason when all sources fail.
package/dist/download.js CHANGED
@@ -55,6 +55,21 @@ export function mirrorOverrideUrl(baseUrl, source) {
55
55
  const base = baseUrl.replace(/\/+$/, "");
56
56
  return `${base}/${source.provider}/${source.id}.zip`;
57
57
  }
58
+ /**
59
+ * Default asset mirror: this repo's own GitHub Releases, reachable from every
60
+ * cloud sandbox without network-policy changes (github.com is on the default
61
+ * allowlist). Assets live on the rolling `packs` release, one flat zip per
62
+ * pack named `<provider>-<packId>.zip`, kept in sync with the source catalog
63
+ * by `.github/workflows/mirror-assets.yml` — adding a catalog entry is the
64
+ * whole publishing step. Override the chain with `--mirror` /
65
+ * `JGENGINE_ASSETS_MIRROR`, or disable this hop with
66
+ * `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`.
67
+ */
68
+ export const DEFAULT_RELEASE_BASE = "https://github.com/Noisemaker111/jgengine/releases/download/packs";
69
+ /** URL of `source`'s archive on the default GitHub-release mirror. */
70
+ export function defaultReleaseUrl(source) {
71
+ return `${DEFAULT_RELEASE_BASE}/${source.provider}-${source.id}.zip`;
72
+ }
58
73
  async function verifyPinnedSha(source, archive) {
59
74
  const download = source.download;
60
75
  if (isScrapeDownload(download) || download.sha256 === undefined)
@@ -67,8 +82,10 @@ async function verifyPinnedSha(source, archive) {
67
82
  /**
68
83
  * Resolves and downloads a pack's archive, trying sources in order until one
69
84
  * succeeds: (1) the mirror base override at `mirrorOverrideUrl`, (2) the
70
- * primary provider path (`resolveArchiveUrl`: scrape or pinned URL), (3) the
71
- * pack's own `mirror` URL. A pinned `sha256` is verified against whichever
85
+ * default GitHub-release mirror at `defaultReleaseUrl` (skipped when
86
+ * `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`), (3) the primary provider path
87
+ * (`resolveArchiveUrl`: scrape or pinned URL), (4) the pack's own `mirror`
88
+ * URL. A pinned `sha256` is verified against whichever
72
89
  * source supplied the bytes; a mismatch is treated as a failed attempt so the
73
90
  * next source in the chain is tried. Throws with every attempted URL and its
74
91
  * failure reason when all sources fail.
@@ -94,6 +111,11 @@ export async function downloadPackArchive(source, options = {}) {
94
111
  if (result !== undefined)
95
112
  return result;
96
113
  }
114
+ if (process.env.JGENGINE_ASSETS_NO_DEFAULT_MIRROR !== "1") {
115
+ const result = await tryUrl(defaultReleaseUrl(source));
116
+ if (result !== undefined)
117
+ return result;
118
+ }
97
119
  try {
98
120
  const primaryUrl = await resolveArchiveUrl(source, fetchImpl);
99
121
  const result = await tryUrl(primaryUrl);
package/dist/find.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type AssetKind = "model" | "pack" | "component" | "icon";
2
+ export type AssetMatch = {
3
+ kind: "model";
4
+ id: string;
5
+ source: string;
6
+ file?: string;
7
+ via: "index" | "alias" | "single";
8
+ } | {
9
+ kind: "pack";
10
+ source: string;
11
+ title: string;
12
+ categories: readonly string[];
13
+ } | {
14
+ kind: "component";
15
+ name: string;
16
+ title: string;
17
+ description: string;
18
+ } | {
19
+ kind: "icon";
20
+ name: string;
21
+ };
22
+ export interface FindOptions {
23
+ /** Restrict results to one kind. */
24
+ kind?: AssetKind;
25
+ /** Cap the number of matches returned (default 12). */
26
+ limit?: number;
27
+ }
28
+ export interface RankedMatch {
29
+ match: AssetMatch;
30
+ score: number;
31
+ }
32
+ /** Rank every catalog entry — models, packs, HUD components, icons — against one query. */
33
+ export declare function rankAssets(query: string, options?: FindOptions): RankedMatch[];
34
+ /** The ranked matches for a query — models, packs, HUD components, and icons in one list. */
35
+ export declare function findAssets(query: string, options?: FindOptions): AssetMatch[];
package/dist/find.js ADDED
@@ -0,0 +1,147 @@
1
+ import { aliases } from "./aliases.js";
2
+ import { generatedIndex } from "./generated/index.js";
3
+ import { keyFromFile } from "./indexGen.js";
4
+ import { registryCatalog } from "./registry.js";
5
+ import { singles } from "./singles.js";
6
+ import { sources } from "./sources/index.js";
7
+ function normalize(value) {
8
+ return value.toLowerCase().replace(/[\s_/-]+/g, " ").trim();
9
+ }
10
+ function tokenScore(token, hay, words) {
11
+ if (words.includes(token))
12
+ return 70;
13
+ if (hay.startsWith(token))
14
+ return 55;
15
+ if (words.some((word) => word.startsWith(token)))
16
+ return 45;
17
+ if (hay.includes(token))
18
+ return 30;
19
+ return 0;
20
+ }
21
+ /**
22
+ * 0 (no match) … 100 (exact). Single-word queries reward whole-word and prefix
23
+ * hits over loose substrings; a multi-word query ("mana bar") scores by the mean
24
+ * of its tokens and only counts when *every* token lands in the field.
25
+ */
26
+ function scoreText(needle, hay) {
27
+ const n = normalize(needle);
28
+ const h = normalize(hay);
29
+ if (n.length === 0 || h.length === 0)
30
+ return 0;
31
+ if (h === n)
32
+ return 100;
33
+ const words = h.split(" ");
34
+ const tokens = n.split(" ").filter((token) => token.length > 0);
35
+ if (tokens.length <= 1)
36
+ return tokenScore(n, h, words);
37
+ let sum = 0;
38
+ for (const token of tokens) {
39
+ const score = tokenScore(token, h, words);
40
+ if (score === 0)
41
+ return 0;
42
+ sum += score;
43
+ }
44
+ return Math.min(90, Math.round(sum / tokens.length));
45
+ }
46
+ function best(needle, ...hays) {
47
+ let top = 0;
48
+ for (const hay of hays) {
49
+ if (hay === undefined)
50
+ continue;
51
+ top = Math.max(top, scoreText(needle, hay));
52
+ }
53
+ return top;
54
+ }
55
+ function sourceOf(id) {
56
+ const slash = id.indexOf("/");
57
+ return slash === -1 ? id : id.slice(0, slash);
58
+ }
59
+ /** Rank every catalog entry — models, packs, HUD components, icons — against one query. */
60
+ export function rankAssets(query, options = {}) {
61
+ const q = query.trim();
62
+ const ranked = [];
63
+ const push = (score, match) => {
64
+ if (score > 0)
65
+ ranked.push({ score, match });
66
+ };
67
+ if (options.kind === undefined || options.kind === "model") {
68
+ for (const entry of generatedIndex) {
69
+ push(best(q, entry.id, keyFromFile(entry.file), ...entry.categories), {
70
+ kind: "model",
71
+ id: entry.id,
72
+ source: entry.source,
73
+ file: entry.file,
74
+ via: "index",
75
+ });
76
+ }
77
+ for (const alias of aliases) {
78
+ push(best(q, alias.key, alias.target), {
79
+ kind: "model",
80
+ id: alias.target,
81
+ source: sourceOf(alias.target),
82
+ via: "alias",
83
+ });
84
+ }
85
+ for (const single of singles) {
86
+ push(best(q, single.id, ...single.categories), {
87
+ kind: "model",
88
+ id: single.id,
89
+ source: sourceOf(single.id),
90
+ via: "single",
91
+ });
92
+ }
93
+ }
94
+ if (options.kind === undefined || options.kind === "pack") {
95
+ for (const source of sources) {
96
+ push(best(q, source.id, source.title, source.provider, ...source.categories), {
97
+ kind: "pack",
98
+ source: source.id,
99
+ title: source.title,
100
+ categories: source.categories,
101
+ });
102
+ }
103
+ }
104
+ if (options.kind === undefined || options.kind === "component") {
105
+ for (const component of registryCatalog.components) {
106
+ push(best(q, component.name, component.title, component.description), {
107
+ kind: "component",
108
+ name: component.name,
109
+ title: component.title,
110
+ description: component.description,
111
+ });
112
+ }
113
+ }
114
+ if (options.kind === undefined || options.kind === "icon") {
115
+ for (const icon of registryCatalog.icons) {
116
+ push(best(q, icon), { kind: "icon", name: icon });
117
+ }
118
+ }
119
+ ranked.sort((a, b) => b.score - a.score || matchKey(a.match).localeCompare(matchKey(b.match)));
120
+ return dedupe(ranked).slice(0, options.limit ?? 12);
121
+ }
122
+ function matchKey(match) {
123
+ switch (match.kind) {
124
+ case "model":
125
+ return `model:${match.id}`;
126
+ case "pack":
127
+ return `pack:${match.source}`;
128
+ default:
129
+ return `${match.kind}:${match.name}`;
130
+ }
131
+ }
132
+ function dedupe(ranked) {
133
+ const seen = new Set();
134
+ const out = [];
135
+ for (const entry of ranked) {
136
+ const key = matchKey(entry.match);
137
+ if (seen.has(key))
138
+ continue;
139
+ seen.add(key);
140
+ out.push(entry);
141
+ }
142
+ return out;
143
+ }
144
+ /** The ranked matches for a query — models, packs, HUD components, and icons in one list. */
145
+ export function findAssets(query, options = {}) {
146
+ return rankAssets(query, options).map((entry) => entry.match);
147
+ }
package/dist/index.d.ts CHANGED
@@ -6,4 +6,7 @@ export { generatedIndex, generatedBySource } from "./generated/index.js";
6
6
  export { readGlbDims } from "./dims.js";
7
7
  export { buildCatalog, entryUrl, type BuildCatalogOptions } from "./catalogs/build.js";
8
8
  export { createStarterCatalog } from "./catalogs/starter.js";
9
+ export { findAssets, rankAssets, type AssetKind, type AssetMatch, type FindOptions, type RankedMatch, } from "./find.js";
10
+ export { registryCatalog, componentInstallUrl, type RegistryCatalog, type RegistryComponent } from "./registry.js";
11
+ export { modelWiringSnippet, componentWiringSnippet, iconWiringSnippet, type ModelSnippetOptions } from "./snippet.js";
9
12
  export { verifyManifest, type VerifyResult } from "./verify.js";
package/dist/index.js CHANGED
@@ -6,4 +6,7 @@ export { generatedIndex, generatedBySource } from "./generated/index.js";
6
6
  export { readGlbDims } from "./dims.js";
7
7
  export { buildCatalog, entryUrl } from "./catalogs/build.js";
8
8
  export { createStarterCatalog } from "./catalogs/starter.js";
9
+ export { findAssets, rankAssets, } from "./find.js";
10
+ export { registryCatalog, componentInstallUrl } from "./registry.js";
11
+ export { modelWiringSnippet, componentWiringSnippet, iconWiringSnippet } from "./snippet.js";
9
12
  export { verifyManifest } from "./verify.js";