@jgengine/assets 0.7.0 → 0.8.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,53 @@ 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.8.0
15
+
16
+ Transport-agnostic multiplayer (socket.io, WebRTC p2p, LAN/Fly adapters) plus grid/voxel
17
+ and puzzle primitives, HUD-only presentation, cumulative leveling, and a round of
18
+ controller/camera/sensor additions. **Breaking:** the `gameui` component kit moved out of
19
+ `@jgengine/react` onto the shadcn registry.
20
+
21
+ ### Migrate
22
+
23
+ - Bump every `@jgengine/*` dependency to `^0.8.0` (the eight packages version in lockstep).
24
+ - Additive only, except for `gameui` — every other 0.7.0 API is unchanged; opt into any of the below by importing it directly.
25
+ - **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`.
26
+ - `leveling({ thresholdMode: 'cumulative' })` is opt-in; the default `perLevel` behavior is unchanged.
27
+ - `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.
28
+
29
+ ### Added
30
+
31
+ - 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).
32
+ - 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).
33
+ - 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).
34
+ - 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).
35
+ - `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).
36
+ - Declared-action intent board — `turn/intent` `createIntentBoard` for one-turn-ahead intents (Slay-the-Spire style): declare/peek/all/consume/clear (#168).
37
+ - `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).
38
+ - `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).
39
+ - 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).
40
+ - 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).
41
+ - 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).
42
+ - `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).
43
+ - 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).
44
+ - 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).
45
+ - 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).
46
+ - **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.
47
+ - **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.
48
+ - **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`.
49
+ - **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"`).
50
+ - **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? })`.
51
+ - `ws()` (`@jgengine/core/runtime/adapter`) gained an optional `url` field, carried through by `resolveShellMultiplayer` (`args.url ?? adapter.url ?? default`).
52
+
53
+ ### Removed
54
+
55
+ - **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`.
56
+
57
+ ### Docs
58
+
59
+ - 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.
60
+
14
61
  ## 0.7.0
15
62
 
16
63
  The engine-gaps release — 22 system-level additions across turn/tactics, cards & boards,
package/README.md CHANGED
@@ -23,7 +23,8 @@ Everything collapses into the core `AssetCatalog` via `buildCatalog({ basePath }
23
23
  ```
24
24
  assets list [--category <c>] [--source <s>] # browse the generated index
25
25
  assets search <term> # grep the index
26
- assets pull <source-id> [--dir public] # download + extract GLBs into <dir>/models/<source>/
26
+ assets pull <source-id> [--dir public] [--mirror <baseUrl>] [--offline]
27
+ # download + extract GLBs into <dir>/models/<source>/
27
28
  assets add <path|url> --category <c> --license <l> [--author <a>]
28
29
  assets reindex [public/models] # regenerate generated/*.json from pulled packs
29
30
  assets verify # license + alias-integrity gate
@@ -31,6 +32,23 @@ assets verify # license + alias-integrity gate
31
32
 
32
33
  Run in-repo with `bun run --cwd packages/assets src/cli/pull.ts <verb> …`.
33
34
 
35
+ ### Mirror fallback and offline pulls
36
+
37
+ `pull` never has just one path to the bytes. For a given `source-id` it tries, in order, until one succeeds:
38
+
39
+ 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.
42
+
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.
44
+
45
+ `--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
+
47
+ **Network-restricted environments** — pull once on a machine that can reach the providers, then either:
48
+
49
+ - 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
+ - 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.
51
+
34
52
  ## Adding assets
35
53
 
36
54
  **A whole new pack** — add one entry to the matching `src/sources/*.ts` (this is the layer contributors PR). No filenames are hand-typed; `reindex` reads the real `.glb` names out of the extracted pack, so entries can't silently 404.
@@ -46,7 +64,7 @@ bun src/cli/pull.ts reindex ../../apps/dev/public/models # regenerate
46
64
  bun src/cli/pull.ts verify # license + alias gate
47
65
  ```
48
66
 
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> }`.
67
+ 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
68
 
51
69
  **A single one-off** — no code edit, zero bytes stored (URL) or copied into `local/` (path):
52
70
 
@@ -78,7 +96,7 @@ export const game: PlayableGame = {
78
96
  };
79
97
  ```
80
98
 
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.
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.
82
100
 
83
101
  ### Serving the bytes
84
102
 
@@ -94,3 +112,4 @@ bun src/cli/pull.ts pull kenney-nature --dir ../../apps/dev/public # → apps/
94
112
 
95
113
  - Kenney rotates download URLs, so its sources `scrape` the asset page at pull time.
96
114
  - 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`.
115
+ - `pull`'s mirror fallback and `--offline` guard exist for network-restricted environments (CI, sandboxes without provider access); see "Mirror fallback and offline pulls" above.
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare function flag(argv: string[], name: string): string | undefined;
3
+ export declare function isPopulated(dir: string): boolean;
4
+ export declare function cmdPull(argv: string[]): Promise<void>;
package/dist/cli/pull.js CHANGED
@@ -1,8 +1,8 @@
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
6
  import { generatedIndex } from "../generated/index.js";
7
7
  import { reindex } from "../indexGen.js";
8
8
  import { isScrapeDownload } from "../manifest.js";
@@ -15,7 +15,7 @@ const generatedDir = join(srcDir, "generated");
15
15
  const singlesJson = join(srcDir, "singles.json");
16
16
  const localDir = join(pkgRoot, "local");
17
17
  const CDN_BASE = "https://cdn.jsdelivr.net/gh/Noisemaker111/jgengine@main/packages/assets/local";
18
- function flag(argv, name) {
18
+ export function flag(argv, name) {
19
19
  const index = argv.indexOf(`--${name}`);
20
20
  return index >= 0 ? argv[index + 1] : undefined;
21
21
  }
@@ -46,25 +46,33 @@ function cmdSearch(argv) {
46
46
  }
47
47
  console.log(`— ${Math.min(rows.length, limit)} of ${rows.length} matches for "${term}"`);
48
48
  }
49
- async function cmdPull(argv) {
49
+ export function isPopulated(dir) {
50
+ return existsSync(dir) && readdirSync(dir).length > 0;
51
+ }
52
+ export async function cmdPull(argv) {
50
53
  const sourceId = argv[0];
51
- if (sourceId === undefined)
52
- fail("usage: pull <source-id> [--dir <dir>]");
54
+ if (sourceId === undefined) {
55
+ fail("usage: pull <source-id> [--dir <dir>] [--mirror <baseUrl>] [--offline]");
56
+ }
53
57
  const source = sourceById.get(sourceId);
54
58
  if (source === undefined)
55
59
  fail(`unknown source: ${sourceId}`);
56
60
  const outRoot = resolve(flag(argv, "dir") ?? "public");
57
61
  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}`);
62
+ const offline = argv.includes("--offline");
63
+ const mirrorBase = flag(argv, "mirror") ?? process.env.JGENGINE_ASSETS_MIRROR;
64
+ if (offline) {
65
+ if (!isPopulated(outDir)) {
66
+ fail(`--offline set but ${outDir} is empty; pull it once on a connected machine (or via ` +
67
+ `--mirror/JGENGINE_ASSETS_MIRROR) and commit/host that directory before running offline`);
66
68
  }
69
+ console.log(`offline: ${outDir} already populated, skipping network`);
70
+ return;
67
71
  }
72
+ console.log(`resolving ${sourceId}${isScrapeDownload(source.download) ? " (scrape)" : ""}` +
73
+ `${mirrorBase !== undefined ? ` [mirror override: ${mirrorBase}]` : ""}…`);
74
+ const { archive, url, attempted } = await downloadPackArchive(source, { mirrorBase });
75
+ console.log(`downloaded ${url}${attempted.length > 1 ? ` (after ${attempted.length - 1} failed attempt(s))` : ""}`);
68
76
  const glbs = extractGlbs(archive);
69
77
  if (glbs.length === 0)
70
78
  fail(`no .glb files found in ${sourceId} archive`);
@@ -143,28 +151,30 @@ function cmdVerify() {
143
151
  console.error(` ✗ ${error}`);
144
152
  fail(`verify failed with ${result.errors.length} problem(s)`);
145
153
  }
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);
154
+ if (import.meta.main) {
155
+ const [command, ...rest] = process.argv.slice(2);
156
+ switch (command) {
157
+ case "list":
158
+ cmdList(rest);
159
+ break;
160
+ case "search":
161
+ cmdSearch(rest);
162
+ break;
163
+ case "pull":
164
+ await cmdPull(rest);
165
+ break;
166
+ case "add":
167
+ cmdAdd(rest);
168
+ break;
169
+ case "reindex":
170
+ cmdReindex(rest);
171
+ break;
172
+ case "verify":
173
+ cmdVerify();
174
+ break;
175
+ default:
176
+ console.log("usage: assets <list|search|pull|add|reindex|verify> [...args]");
177
+ if (command !== undefined && command !== "help")
178
+ process.exit(1);
179
+ }
170
180
  }
@@ -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,35 @@ 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
+ export interface DownloadPackOptions {
22
+ /** Explicit mirror base override (CLI `--mirror` / `JGENGINE_ASSETS_MIRROR`), tried before the primary provider path. */
23
+ mirrorBase?: string;
24
+ fetchImpl?: FetchLike;
25
+ }
26
+ export interface DownloadPackResult {
27
+ archive: Uint8Array;
28
+ url: string;
29
+ /** Every URL attempted, in order, ending with the one that succeeded. */
30
+ attempted: readonly string[];
31
+ }
32
+ /**
33
+ * Resolves and downloads a pack's archive, trying sources in order until one
34
+ * 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
37
+ * source supplied the bytes; a mismatch is treated as a failed attempt so the
38
+ * next source in the chain is tried. Throws with every attempted URL and its
39
+ * failure reason when all sources fail.
40
+ */
41
+ export declare function downloadPackArchive(source: AssetSource, options?: DownloadPackOptions): Promise<DownloadPackResult>;
14
42
  export declare function extractGlbs(archive: Uint8Array): ExtractedGlb[];
15
43
  export declare function extractTextures(archive: Uint8Array): ExtractedTexture[];
16
44
  export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
package/dist/download.js CHANGED
@@ -26,11 +26,11 @@ function score(candidate) {
26
26
  value += 1;
27
27
  return value;
28
28
  }
29
- export async function resolveArchiveUrl(source) {
29
+ export async function resolveArchiveUrl(source, fetchImpl = fetch) {
30
30
  const download = source.download;
31
31
  if (!isScrapeDownload(download))
32
32
  return download.url;
33
- const response = await fetch(download.scrape, { redirect: "follow" });
33
+ const response = await fetchImpl(download.scrape, { redirect: "follow" });
34
34
  if (!response.ok)
35
35
  throw new Error(`scrape ${download.scrape} -> HTTP ${response.status}`);
36
36
  const html = await response.text();
@@ -40,12 +40,79 @@ export async function resolveArchiveUrl(source) {
40
40
  }
41
41
  return url;
42
42
  }
43
- export async function downloadArchive(url) {
44
- const response = await fetch(url, { redirect: "follow" });
43
+ export async function downloadArchive(url, fetchImpl = fetch) {
44
+ const response = await fetchImpl(url, { redirect: "follow" });
45
45
  if (!response.ok)
46
46
  throw new Error(`download ${url} -> HTTP ${response.status}`);
47
47
  return new Uint8Array(await response.arrayBuffer());
48
48
  }
49
+ /**
50
+ * Layout for the `--mirror` / `JGENGINE_ASSETS_MIRROR` base URL override: the
51
+ * archive for a pack is expected at `<baseUrl>/<provider>/<packId>.zip`, e.g.
52
+ * `https://my-mirror.example.com/kenney/kenney-nature.zip`.
53
+ */
54
+ export function mirrorOverrideUrl(baseUrl, source) {
55
+ const base = baseUrl.replace(/\/+$/, "");
56
+ return `${base}/${source.provider}/${source.id}.zip`;
57
+ }
58
+ async function verifyPinnedSha(source, archive) {
59
+ const download = source.download;
60
+ if (isScrapeDownload(download) || download.sha256 === undefined)
61
+ return;
62
+ const actual = await sha256Hex(archive);
63
+ if (actual !== download.sha256) {
64
+ throw new Error(`sha256 mismatch: expected ${download.sha256}, got ${actual}`);
65
+ }
66
+ }
67
+ /**
68
+ * Resolves and downloads a pack's archive, trying sources in order until one
69
+ * 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
72
+ * source supplied the bytes; a mismatch is treated as a failed attempt so the
73
+ * next source in the chain is tried. Throws with every attempted URL and its
74
+ * failure reason when all sources fail.
75
+ */
76
+ export async function downloadPackArchive(source, options = {}) {
77
+ const fetchImpl = options.fetchImpl ?? fetch;
78
+ const attempted = [];
79
+ const errors = [];
80
+ const tryUrl = async (url) => {
81
+ attempted.push(url);
82
+ try {
83
+ const archive = await downloadArchive(url, fetchImpl);
84
+ await verifyPinnedSha(source, archive);
85
+ return { archive, url, attempted };
86
+ }
87
+ catch (error) {
88
+ errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
89
+ return undefined;
90
+ }
91
+ };
92
+ if (options.mirrorBase !== undefined) {
93
+ const result = await tryUrl(mirrorOverrideUrl(options.mirrorBase, source));
94
+ if (result !== undefined)
95
+ return result;
96
+ }
97
+ try {
98
+ const primaryUrl = await resolveArchiveUrl(source, fetchImpl);
99
+ const result = await tryUrl(primaryUrl);
100
+ if (result !== undefined)
101
+ return result;
102
+ }
103
+ catch (error) {
104
+ const label = isScrapeDownload(source.download) ? source.download.scrape : source.download.url;
105
+ errors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
106
+ attempted.push(label);
107
+ }
108
+ if (source.mirror !== undefined) {
109
+ const result = await tryUrl(source.mirror);
110
+ if (result !== undefined)
111
+ return result;
112
+ }
113
+ throw new Error(`failed to download ${source.id} from all ${attempted.length} source(s):\n` +
114
+ errors.map((line) => ` - ${line}`).join("\n"));
115
+ }
49
116
  export function extractGlbs(archive) {
50
117
  const entries = unzipSync(archive, {
51
118
  filter: (file) => /\.glb$/i.test(file.name),
@@ -19,6 +19,8 @@ export interface AssetSource {
19
19
  categories: readonly string[];
20
20
  download: AssetDownload;
21
21
  homepage?: string;
22
+ /** Direct archive URL tried as a last resort when the primary provider path fails; see `downloadPackArchive`. */
23
+ mirror?: string;
22
24
  }
23
25
  export interface IndexEntry {
24
26
  id: string;