feedback1d WN: the local-client slug models are an optional overlay; the manifest never depends on the extraction

data/assets.json pointed 灼热源石虫 / 炽焰源石虫 at /assets/local/ files that only exist
where tools/local-extract ran, with no fallback: without the extraction the
asset tests failed, setup re-ran fetch-assets (rewriting the tracked manifest)
and the slugs drew as icon diamonds.

- The web model stays the 源石虫 alias (spine + spineAliasOf). The official
  model is enemies[id].spineLocal: file names in the data/local-assets.json
  group spine/enemy/<id> plus the parsed metadata, from the committed
  tools/assets/local-enemy-spines.json (fetch-assets --local-spines refreshes
  it from the extracted files, read only). The manifest is byte-identical with
  or without the extraction and has no /assets/local/ URL.
- Client: assets.js spineEntry uses spineLocal only when the local manifest
  lists every file (createFieldView awaits assets.local() with the manifest);
  UnitView loads the web model when the local one fails.
- extract.py writes the atlas with size: / pma: true itself (the same text as
  atlas.mjs normalizeAtlas), so the extraction is usable without a fetch pass.
- setup notes when an older extraction lacks the enemy models.
This commit is contained in:
sganggs committed 2026-10-03 05:18:31 +08:00
1 parent f705797a86
commit 04634f5bf7
14 files changed
+633 -119

No files matched your search

+14 -3
View File
@@ -1,7 +1,8 @@
// Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects
// the single-file download alternatives, and resolves the template against the
// files that actually exist on disk into data/assets.json. Entries whose files
// are missing are dropped (never emitted as broken URLs).
// are missing are dropped (never emitted as broken URLs); a `literal(value)` node
// is emitted as it is (no files behind it).
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
@@ -13,6 +14,15 @@ export const MANIFEST_VERSION = 1;
const isLeaf = (n) => !!n && typeof n === 'object' && Array.isArray(n.alts);
const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'string' && Object.keys(n).length === 1;
const LITERAL = Symbol('literal');
const isLiteral = (n) => !!n && typeof n === 'object' && Object.hasOwn(n, LITERAL);
/**
* A template node emitted as it is (a JSON copy; null fields kept): data that is no file to download or resolve, e.g.
* the metadata of a local-client Spine model (plan.mjs enemies[id].spineLocal).
* @param {any} value JSON value
*/
export function literal(value) { return { [LITERAL]: JSON.parse(JSON.stringify(value)) }; }
/**
* Collect every leaf of the template with its dotted path.
@@ -23,7 +33,7 @@ const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'st
*/
export function collectLeaves(node, path = '', out = []) {
if (isLeaf(node)) { out.push({ path, leaf: node }); return out; }
if (isModelRef(node) || !node || typeof node !== 'object') return out;
if (isModelRef(node) || isLiteral(node) || !node || typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
return out;
}
@@ -78,6 +88,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
const files = new Set();
const walk = (node, path) => {
if (node === null || node === undefined) return undefined;
if (isLiteral(node)) return JSON.parse(JSON.stringify(node[LITERAL]));
if (isLeaf(node)) {
for (let i = 0; i < node.alts.length; i++) {
const a = node.alts[i];
@@ -110,7 +121,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
}
return out;
};
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v);
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v) && !isLiteral(v);
const value = walk(template, '');
return { value, misses, fallbacks, files };
}