@jgengine/assets 0.7.0 → 0.9.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,69 @@ 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
+ ## Unreleased
15
+
16
+ ## 0.9.0
17
+
18
+ ### Added
19
+
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - 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).
24
+ - 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.
25
+
26
+ ### Changed
27
+
28
+ - 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"`).
29
+
30
+ ## 0.8.0
31
+
32
+ Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
33
+ and puzzle primitives, HUD-only presentation, cumulative leveling, and a round of
34
+ controller/camera/sensor additions. **Breaking:** the `gameui` component kit moved out of
35
+ `@jgengine/react` onto the shadcn registry.
36
+
37
+ ### Migrate
38
+
39
+ - Bump every `@jgengine/*` dependency to `^0.8.0` (the eight packages version in lockstep).
40
+ - Additive only, except for `gameui` — every other 0.7.0 API is unchanged; opt into any of the below by importing it directly.
41
+ - **Breaking:** replace any `@jgengine/react/gameui` import with the equivalent registry component (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), and swap `GameUiThemeProvider` for `--jg-*` CSS variables on a wrapper element. `GameIcon` and friends moved to `@jgengine/react/gameIcons`.
42
+ - `leveling({ thresholdMode: 'cumulative' })` is opt-in; the default `perLevel` behavior is unchanged.
43
+ - `defineGame.physics.gravity`/`jumpVelocity` are now read by the built-in kinematics controller every frame — if a game already set them expecting a no-op, jump/fall now actually reflects them; omit both to keep the previous defaults.
44
+
45
+ ### Added
46
+
47
+ - Cumulative leveling — `leveling({ thresholdMode: 'cumulative' })` tracks xp as a lifetime total that resolves upward across levels and clamps at the max-level threshold once capped (#12).
48
+ - Direction-aware pool depletion — `EffectSystem.canReceive(instanceId, effect, magnitude?)` takes an optional signed magnitude; negative checks the opposite direction and returns `pools-depleted` only when every stat in the receive order is already at max, so heals reach fully-depleted targets (#168).
49
+ - Puzzle primitives — `puzzle/cellGrid` (uniform-cell boards: line-clear, match-3 cascade, run detection) and `puzzle/fallingPiece` (rotation-state shapes, ghost drop, lock delay, classic gravity/level/score curves) for Tetris/match-3 games (#166).
50
+ - Voxel field — `world/voxelField` (`createVoxelField`, chunked block lattice, neighbors/exposedFaces, 3D DDA raycast, dirty-tracked `chunkVersion`) for voxel games and instanced renderers; assert on `field.summary()` the way environment worlds assert on `summarizeEnvironment` (#166).
51
+ - `defineGame` games may omit `assets` (an empty catalog is injected); `PlayableGame.presentation: 'hud'` mounts no 3D canvas/camera/pointer for board/card/menu games; an `environment()` world auto-renders as the shell's backdrop when `PlayableGame.environment` is unset (#166).
52
+ - Declared-action intent board — `turn/intent` `createIntentBoard` for one-turn-ahead intents (Slay-the-Spire style): declare/peek/all/consume/clear (#168).
53
+ - `turnLoop` lifecycle hooks — `config.onTurnStart`/`onTurnEnd` fire on every `advanceTurn()`; `ctx.game.turn.loop(id, config)` lazily creates/returns a notify-wrapped `TurnLoop` so every mutation (`advanceTurn`/`advancePhase`/`advanceRound`/`spend`/`gain`/`refill`/`setOrder`/...) auto-bumps `ctx.version()` with no manual wiring (#163/#168).
54
+ - `ctx.game.store` — a reactive per-game keyed store (`set`/`delete`/`get`/`has`/`subscribe`/`mapSnapshot`/`arraySnapshot`) plus `@jgengine/react`'s `useGameStore` selector hook, replacing hand-rolled module-level stores for ad-hoc reactive game state; `ctx.game.cards.pile(id, config?)` lazily creates/returns a notify-wrapped `CardPile` the same way; `createCardPile` gained an `onChange` hook for headless use; `CommandDefinition.apply` may return void for side-effect-only commands (#163).
55
+ - Camera — `sideScroll` rig (fixed lateral follow for 2.5D platformers/beat-'em-ups), a `none` rig (no camera mounted; pairs with `PlayableGame.presentation: 'hud'`), `rts.pan: false` (static backdrop camera: no pan/edge-scroll/rotate/zoom, still re-centers on the follow target), and the `observer` rig now defaults to the local player when `bind` is unset (#167).
56
+ - Sensors + session — `sensor/concealment` (`colorDistance`/`concealmentScore`/`createConcealmentSensor`), `sensor/freezeMonitor` (`createFreezeMonitor`), `session/roles` (`assignRoles`); `createRoundState`'s `RoundConfig.teams` accepts per-team roles and an optional `winCondition` that `evaluate()` checks each tick, and takes an optional `phaseOrder` for arbitrary named phase cycles (`concludeRound`/`evaluate` settle only while the current phase is neither the first nor the last entry) (#151).
57
+ - Appearance replication — presence rows carry an optional per-slot appearance channel (cosmetic ids, hex tints, model keys) alongside pose, riding the existing pose message with no protocol bump; wire `ctx.player.cosmetics.get(userId)` into the outgoing pose (#151).
58
+ - `ctx.input` — a per-frame held-action snapshot (`publish(held)`/`isDown(action)`/`held()`) without bumping `ctx.version()`; action bindings gained `repeatMs` (repeat-fire while held); every command resolved from a bound action now carries `aim`; `pointer.secondaryCommand` runs a command on right-click off the same raycast as move/ping (#164).
59
+ - Object spatial queries + entity patching — `ctx.scene.object.at`/`inBox`/`raycast`/`raycastAll` over unit-box objects; `ctx.scene.entity.update(id, patch)` for name/position/rotation/role/movement/behaviors/meta; per-instance `renderObject`/`objectStyles` overrides; `pointerService.worldHitCenter()` + pointer-lock center-ray aiming (#165).
60
+ - Controller movement config — `PlayerMovementConfig` (`mode`: free/axis/grid, `axis`, `cellSize`, `collideObjects`, `beforeCommit` pre-commit hook) for the shell-driven walk controller; `defineGame.physics.gravity`/`jumpVelocity` are honored by the built-in walk controller (distinct from the standalone `physics/physicsWorld` rigid-body sim); `ctx.player.motion.impulse`/`setVerticalVelocity`/`setY`/`takePending` (`MotionIntents`); `entity.spawnPoseOf`/`resetToSpawn`/`resetAllToSpawn` (#162).
61
+ - Model material/animation + paint — `ModelConfig.tint`/`metalness`/`roughness`/`animation` (GLTF clip playback, paused pose holds); `PointerHit.uv` + `pointerService.sampleSurface()` for material-aware picking; `ctx.scene.entity.paint` runtime paint layer, auto-rendered via a per-instance canvas texture with no per-game wiring; remote-player appearance tint recolors the shell's default capsule (#151).
62
+ - **Transport pipe seam** (`@jgengine/ws/pipe`) — `createWsBackend` runs over any bidirectional string channel, not just a raw `WebSocket`: `TransportPipe`/`TransportPipeHandlers`/`TransportPipeFactory`, with `webSocketPipe(url, webSocketFactory?)` as the default. `createWsBackend({ userId, url?, pipe? })` — `url` stays the common case, `pipe` opens the seam to socket.io, WebRTC, and in-process loopback below.
63
+ - **Browser-safe authoritative host** — `createGameHost` and `memoryPersistence` moved from `@jgengine/node` to `@jgengine/ws/host` (zero Node dependencies; `@jgengine/node` re-exports both unchanged from `@jgengine/node/host` / `@jgengine/node/persistence`, so existing imports keep working). `@jgengine/ws/hostRouter`'s `createHostRouter({ host, authenticate?, poseRules?, positionHistoryMs?, chatRateLimit?, chatHistoryLimit?, chatMaxBodyLength?, now? })` extracts the ws wire-protocol session logic out of `createGameWsServer` into a transport-agnostic `HostRouter` (`connect(transport) → { handleRaw, close }`, `rewind`, `close`); `@jgengine/node`'s `createGameWsServer` is now a thin binding of this router onto the `ws` npm package, same public API. `loopbackPipe(router): TransportPipeFactory` connects a `createWsBackend` straight into an in-process router.
64
+ - **Socket.IO transport** — `@jgengine/ws/socketIoPipe` (`SocketIoLikeSocket` structural type, `socketIoPipe(socket)`, `createSocketIoBackend({ socket, userId, … })`) and `@jgengine/node/socketIoServer` (`attachGameSocketIoServer({ io, host, …router options }): { rewind, close }`, structural `SocketIoLikeServer`/`SocketIoLikeServerSocket`) ride the existing ws JSON protocol over socket.io's `send`/`message` frames — no socket.io dependency in either package's types. New `socketIo({ topology?, url? })` adapter in `@jgengine/core/runtime/adapter`.
65
+ - **WebRTC peer-to-peer** (`@jgengine/ws/peer`) — one browser tab hosts, authoritatively, with no server process. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? })` runs a `GameHost` + `HostRouter` in the host tab (`backend` is the host player's own loopback connection, `accept(offerCode) → Promise<answerCode>` per guest); `createPeerGuest({ userId, token?, rtc? })` offers/connects from the joining side. `encodePeerSignal`/`decodePeerSignal` turn SDP into copy-pasteable base64url codes for manual cross-machine signaling; `broadcastChannelSignaling(room)` automates it for same-origin multi-tab play, with `announcePeerHost`/`joinPeerSession` wiring host/guest to a `PeerSignaling` in one call. New `p2p({ topology?, room? })` adapter (topology defaults `"private"`).
66
+ - **LAN adapter + Fly sugar** — `lan({ topology?, port?, path? })` in `@jgengine/core/runtime/adapter` resolves through `@jgengine/shell/multiplayer`'s `resolveShellMultiplayer` to `ws(s)://<page hostname>:<port ?? 8080><path ?? /ws>` derived from `window.location`, so any browser on the LAN auto-connects to whichever machine served the page — no URL configuration. `fly({ app, topology?, path? })` is `ws` sugar for a Fly.io deploy: resolves to `wss://<app>.fly.dev<path ?? "/ws">`. `apps/dev`'s Vite server now listens on the network (`server: { host: true }`) and exposes `?p2p=host` / `?p2p=join` query params wired through the new `resolvePeerShellMultiplayer({ gameId, role, room?, userId?, feedActions? })`.
67
+ - `ws()` (`@jgengine/core/runtime/adapter`) gained an optional `url` field, carried through by `resolveShellMultiplayer` (`args.url ?? adapter.url ?? default`).
68
+
69
+ ### Removed
70
+
71
+ - **The `gameui` component kit** (`@jgengine/react/gameui`) — the themed HUD kit (bars, slots, feedback, meters, panels, screens, reticles, icons) and its `GameUiThemeProvider`/`useGameUiTheme` theming have been removed. **Breaking** for anyone importing `@jgengine/react/gameui` (or its subpaths/barrel). The components now ship as installable shadcn registry items at `https://jgengine.com/r/<name>.json` (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), styled with Tailwind + `--jg-*` CSS variables instead of a theme object. The icon catalog (`GameIcon`, `iconForAction`, `iconForItemId`, `isGameIconName`, `GameIconName`) moved to `@jgengine/react/gameIcons`. To theme, set the `--jg-*` variables on a wrapper element (the registry's `jg-theme` presets mirror the old `ember`/`synthwave`/`fieldkit` palettes) instead of wrapping in `GameUiThemeProvider`.
72
+
73
+ ### Docs
74
+
75
+ - Added [CREDITS.md](CREDITS.md) crediting [achrefelouafi](https://github.com/achrefelouafi) for the MIT Three.js reference projects behind the procedural building, water, rain, and snow renderers, with links from the root, `@jgengine/core`, and `@jgengine/shell` READMEs.
76
+
14
77
  ## 0.7.0
15
78
 
16
79
  The engine-gaps release — 22 system-level additions across turn/tactics, cards & boards,
package/README.md CHANGED
@@ -18,18 +18,57 @@ 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
- assets pull <source-id> [--dir public] # download + extract GLBs into <dir>/models/<source>/
27
- assets add <path|url> --category <c> --license <l> [--author <a>]
45
+ assets pull <source-id> [--dir public] [--mirror <baseUrl>] [--offline]
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
28
49
  assets reindex [public/models] # regenerate generated/*.json from pulled packs
29
50
  assets verify # license + alias-integrity gate
30
51
  ```
31
52
 
32
- 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`.)
54
+
55
+ ### Mirror fallback and offline pulls
56
+
57
+ `pull` never has just one path to the bytes. For a given `source-id` it tries, in order, until one succeeds:
58
+
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`.
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.
63
+
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.
65
+
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.
67
+
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:
69
+
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
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.
33
72
 
34
73
  ## Adding assets
35
74
 
@@ -46,7 +85,7 @@ bun src/cli/pull.ts reindex ../../apps/dev/public/models # regenerate
46
85
  bun src/cli/pull.ts verify # license + alias gate
47
86
  ```
48
87
 
49
- A **new provider** is just a new `src/sources/<provider>.ts` added to the `sources` array in `src/sources/index.ts`. Pinned providers use `download: { url, sha256? }`; providers that rotate URLs (Kenney) use `download: { scrape: <page> }`.
88
+ A **new provider** is just a new `src/sources/<provider>.ts` added to the `sources` array in `src/sources/index.ts`. Pinned providers use `download: { url, sha256? }`; providers that rotate URLs (Kenney) use `download: { scrape: <page> }`. Any entry can also carry an optional top-level `mirror: <archiveUrl>` — a direct URL `pull` falls back to if both a `--mirror`/`JGENGINE_ASSETS_MIRROR` override and the primary path fail (see "Mirror fallback and offline pulls" above).
50
89
 
51
90
  **A single one-off** — no code edit, zero bytes stored (URL) or copied into `local/` (path):
52
91
 
@@ -78,7 +117,7 @@ export const game: PlayableGame = {
78
117
  };
79
118
  ```
80
119
 
81
- `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 `packages/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.
82
121
 
83
122
  ### Serving the bytes
84
123
 
@@ -94,3 +133,4 @@ bun src/cli/pull.ts pull kenney-nature --dir ../../apps/dev/public # → apps/
94
133
 
95
134
  - Kenney rotates download URLs, so its sources `scrape` the asset page at pull time.
96
135
  - Quaternius / KayKit pages gate downloads behind JS; automated `pull` falls back to a clear error when no archive link is found — download those manually into the staging dir, then `reindex`.
136
+ - `pull`'s mirror fallback and `--offline` guard exist for network-restricted environments (CI, sandboxes without provider access); see "Mirror fallback and offline pulls" above.
@@ -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,2 +1,8 @@
1
1
  #!/usr/bin/env node
2
- export {};
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";
5
+ export declare function flag(argv: string[], name: string): string | undefined;
6
+ export declare function describeNetworkFailure(error: unknown): string;
7
+ export declare function isPopulated(dir: string): boolean;
8
+ export declare function cmdPull(argv: string[]): Promise<void>;
package/dist/cli/pull.js CHANGED
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { downloadArchive, extractGlbs, extractTextures, resolveArchiveUrl, sha256Hex } from "../download.js";
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
- function flag(argv, name) {
24
+ export function flag(argv, name) {
19
25
  const index = argv.indexOf(`--${name}`);
20
26
  return index >= 0 ? argv[index + 1] : undefined;
21
27
  }
@@ -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");
@@ -46,28 +65,42 @@ function cmdSearch(argv) {
46
65
  }
47
66
  console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} matches for "${term}"`);
48
67
  }
49
- async function cmdPull(argv) {
68
+ export function isPopulated(dir) {
69
+ return existsSync(dir) && readdirSync(dir).length > 0;
70
+ }
71
+ export async function cmdPull(argv) {
50
72
  const sourceId = argv[0];
51
- if (sourceId === undefined)
52
- fail("usage: pull <source-id> [--dir <dir>]");
73
+ if (sourceId === undefined) {
74
+ fail("usage: pull <source-id> [--dir <dir>] [--mirror <baseUrl>] [--offline]");
75
+ }
53
76
  const source = sourceById.get(sourceId);
54
77
  if (source === undefined)
55
78
  fail(`unknown source: ${sourceId}`);
56
79
  const outRoot = resolve(flag(argv, "dir") ?? "public");
57
80
  const outDir = join(outRoot, "models", sourceId);
58
- console.log(`resolving ${sourceId}${isScrapeDownload(source.download) ? " (scrape)" : ""}…`);
59
- const url = await resolveArchiveUrl(source);
60
- console.log(`downloading ${url}`);
61
- const archive = await downloadArchive(url);
62
- if (!isScrapeDownload(source.download) && source.download.sha256 !== undefined) {
63
- const actual = await sha256Hex(archive);
64
- if (actual !== source.download.sha256) {
65
- fail(`sha256 mismatch for ${sourceId}: expected ${source.download.sha256}, got ${actual}`);
81
+ const offline = argv.includes("--offline");
82
+ const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
83
+ if (offline) {
84
+ if (!isPopulated(outDir)) {
85
+ fail(`--offline set but ${outDir} is empty; pull it once on a connected machine (or via ` +
86
+ `--mirror/JGENGINE_ASSETS_MIRROR) and commit/host that directory before running offline`);
66
87
  }
88
+ console.log(`offline: ${outDir} already populated, skipping network`);
89
+ return;
67
90
  }
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 });
100
+ console.log(`downloaded ${url}${attempted.length > 1 ? ` (after ${attempted.length - 1} failed attempt(s))` : ""}`);
68
101
  const glbs = extractGlbs(archive);
69
102
  if (glbs.length === 0)
70
- fail(`no .glb files found in ${sourceId} archive`);
103
+ fail(`no .glb files found in ${source.id} archive`);
71
104
  mkdirSync(outDir, { recursive: true });
72
105
  for (const glb of glbs)
73
106
  writeFileSync(join(outDir, glb.file), glb.bytes);
@@ -77,8 +110,7 @@ async function cmdPull(argv) {
77
110
  mkdirSync(dirname(dest), { recursive: true });
78
111
  writeFileSync(dest, texture.bytes);
79
112
  }
80
- const textureNote = textures.length > 0 ? ` + ${textures.length} texture(s)` : "";
81
- console.log(`pulled ${glbs.length} models${textureNote} -> ${outDir}`);
113
+ return { outDir, models: glbs.length, textures: textures.length, url };
82
114
  }
83
115
  function readSingles() {
84
116
  if (!existsSync(singlesJson))
@@ -88,10 +120,11 @@ function readSingles() {
88
120
  function writeSingles(list) {
89
121
  writeFileSync(singlesJson, `${JSON.stringify(list, null, 2)}\n`);
90
122
  }
91
- function cmdAdd(argv) {
123
+ function cmdRegisterSingle(argv) {
92
124
  const target = argv[0];
93
- if (target === undefined)
94
- 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
+ }
95
128
  const license = flag(argv, "license");
96
129
  if (license === undefined)
97
130
  fail("add requires --license");
@@ -124,6 +157,95 @@ function cmdAdd(argv) {
124
157
  writeSingles(singles);
125
158
  console.log(`added single ${id} -> ${url}${isUrl ? " (0 bytes stored)" : " (copied to local/)"}`);
126
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
+ }
127
249
  function cmdReindex(argv) {
128
250
  const modelsDir = resolve(argv[0] ?? join("public", "models"));
129
251
  if (!existsSync(modelsDir))
@@ -143,28 +265,33 @@ function cmdVerify() {
143
265
  console.error(` ✗ ${error}`);
144
266
  fail(`verify failed with ${result.errors.length} problem(s)`);
145
267
  }
146
- const [command, ...rest] = process.argv.slice(2);
147
- switch (command) {
148
- case "list":
149
- cmdList(rest);
150
- break;
151
- case "search":
152
- cmdSearch(rest);
153
- break;
154
- case "pull":
155
- await cmdPull(rest);
156
- break;
157
- case "add":
158
- cmdAdd(rest);
159
- break;
160
- case "reindex":
161
- cmdReindex(rest);
162
- break;
163
- case "verify":
164
- cmdVerify();
165
- break;
166
- default:
167
- console.log("usage: assets <list|search|pull|add|reindex|verify> [...args]");
168
- if (command !== undefined && command !== "help")
169
- process.exit(1);
268
+ if (import.meta.main) {
269
+ const [command, ...rest] = process.argv.slice(2);
270
+ switch (command) {
271
+ case "list":
272
+ cmdList(rest);
273
+ break;
274
+ case "search":
275
+ cmdSearch(rest);
276
+ break;
277
+ case "pull":
278
+ await cmdPull(rest).catch((error) => fail(describeNetworkFailure(error)));
279
+ break;
280
+ case "add":
281
+ await cmdAdd(rest).catch((error) => fail(describeNetworkFailure(error)));
282
+ break;
283
+ case "register":
284
+ cmdRegisterSingle(rest);
285
+ break;
286
+ case "reindex":
287
+ cmdReindex(rest);
288
+ break;
289
+ case "verify":
290
+ cmdVerify();
291
+ break;
292
+ default:
293
+ console.log("usage: assets <add|list|search|pull|register|reindex|verify> [...args]");
294
+ if (command !== undefined && command !== "help")
295
+ process.exit(1);
296
+ }
170
297
  }
@@ -1,4 +1,5 @@
1
1
  import { type AssetSource } from "./manifest.js";
2
+ export type FetchLike = typeof fetch;
2
3
  export interface ExtractedGlb {
3
4
  file: string;
4
5
  bytes: Uint8Array;
@@ -9,8 +10,50 @@ export interface ExtractedTexture {
9
10
  bytes: Uint8Array;
10
11
  }
11
12
  export declare function findArchiveUrl(html: string, pageUrl: string): string | null;
12
- export declare function resolveArchiveUrl(source: AssetSource): Promise<string>;
13
- export declare function downloadArchive(url: string): Promise<Uint8Array>;
13
+ export declare function resolveArchiveUrl(source: AssetSource, fetchImpl?: FetchLike): Promise<string>;
14
+ export declare function downloadArchive(url: string, fetchImpl?: FetchLike): Promise<Uint8Array>;
15
+ /**
16
+ * Layout for the `--mirror` / `JGENGINE_ASSETS_MIRROR` base URL override: the
17
+ * archive for a pack is expected at `<baseUrl>/<provider>/<packId>.zip`, e.g.
18
+ * `https://my-mirror.example.com/kenney/kenney-nature.zip`.
19
+ */
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;
34
+ export interface DownloadPackOptions {
35
+ /** Explicit mirror base override (CLI `--mirror` / `JGENGINE_ASSETS_MIRROR`), tried before the primary provider path. */
36
+ mirrorBase?: string;
37
+ fetchImpl?: FetchLike;
38
+ }
39
+ export interface DownloadPackResult {
40
+ archive: Uint8Array;
41
+ url: string;
42
+ /** Every URL attempted, in order, ending with the one that succeeded. */
43
+ attempted: readonly string[];
44
+ }
45
+ /**
46
+ * Resolves and downloads a pack's archive, trying sources in order until one
47
+ * succeeds: (1) the mirror base override at `mirrorOverrideUrl`, (2) the
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
52
+ * source supplied the bytes; a mismatch is treated as a failed attempt so the
53
+ * next source in the chain is tried. Throws with every attempted URL and its
54
+ * failure reason when all sources fail.
55
+ */
56
+ export declare function downloadPackArchive(source: AssetSource, options?: DownloadPackOptions): Promise<DownloadPackResult>;
14
57
  export declare function extractGlbs(archive: Uint8Array): ExtractedGlb[];
15
58
  export declare function extractTextures(archive: Uint8Array): ExtractedTexture[];
16
59
  export declare function sha256Hex(bytes: Uint8Array): Promise<string>;