Files
ark-stronghold-protocol/tools/assets/manifest.mjs
T
sganggs 9bfac54c8f feedback5 WU: 焰狐龙梓兰 (MH05) leaves data/assets.json; a voice slot with no line on disk is dropped, not []
She left the 自选 pool in 0.2.0 (data/backups.json diy.excluded, a collab operator) but her art stayed in the
manifest because fetch-assets refuses to shrink it. Regenerated offline in this worktree, whose public/assets and
public/fonts are the shared ones: node tools/fetch-assets.mjs --offline --add-only --allow-shrink (--add-only so no
file on disk is rewritten and the fonts entry is kept). The run dropped exactly her 163 entries.

JSON diff against the previous manifest: removed chars.char_1048_orchd2, skills.skchr_orchd2_1..3,
skillsById.skchr_orchd2_1..3, audio.sfx.units.char_1048_orchd2; changed hash and stats (files 6994 -> 6979,
bytes 462620448 -> 458334505, chars 210 -> 209, charsWithBack 207 -> 206, spineModels 673 -> 671,
skills 525 -> 522, sfxUnits 484 -> 483); nothing added, nothing else changed.

The first regeneration also added 71 entries like audio.voice.char_134_ifrit = {select: [], place: []}: the 自选
operators' voice lines are planned but not downloaded on this machine, and resolveTemplate kept an array whose lines
were all missing as []. tools/assets/manifest.mjs now drops such an array like a missing leaf (and the entry it
empties); an array that is empty in the template stays.

Tests: test/assets.test.js (the array rule), test/assets-diy.test.js (no diy.excluded operator keeps art in the
manifest). docs/ASSETS.md: 71 自选 picks, 209 operators (206 with Back), 522 skill icons, the empty-slot rule.
2026-10-06 17:51:41 +08:00

188 lines
8.0 KiB
JavaScript

// 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); a `literal(value)` node
// is emitted as it is (no files behind it).
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { assetUrl, githubSourceUrl, mirrorUrl } from './sources.mjs';
/** Manifest schema version (bump on breaking shape changes). */
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.
* @param {any} node
* @param {string} [path]
* @param {{path:string, leaf:any}[]} [out]
* @returns {{path:string, leaf:any}[]}
*/
export function collectLeaves(node, path = '', out = []) {
if (isLeaf(node)) { out.push({ path, leaf: node }); 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;
}
/**
* Download leaves round by round: round r tries each unsatisfied leaf's r-th alternative.
* A leaf only moves on to its next alternative after a definitive miss (404 on every source);
* a transient failure (network error, 5xx, invalid payload after all retries) stops that leaf, so
* a fallback file (e.g. the base enemy's icon) never lands on the primary's path by accident and
* the next run retries the primary.
* @param {{path:string, leaf:any}[]} leaves
* @param {import('./downloader.mjs').Downloader} dl
* @param {string} root public/assets
* @param {string} [label]
* @returns {Promise<string[]>} dotted paths of leaves that stopped on a transient error
*/
export async function downloadLeaves(leaves, dl, root, label = 'files') {
const tried = new Map(leaves.map((l) => [l, 0]));
const blocked = new Set();
const satisfied = (l) => l.leaf.alts.slice(0, tried.get(l)).some((a) => existsSync(join(root, a.rel)));
let last = new Map();
for (let round = 0; round < 8; round++) {
const jobs = [];
for (const l of leaves) {
const t = tried.get(l);
if (blocked.has(l)) continue;
if (t > 0 && satisfied(l)) continue;
if (t > 0 && last.get(l.leaf.alts[t - 1].rel)?.status === 'error') { blocked.add(l); continue; }
if (t >= l.leaf.alts.length) continue;
jobs.push(l.leaf.alts[t]);
tried.set(l, t + 1);
}
if (!jobs.length) break;
last = await dl.run(jobs, round === 0 ? label : `${label} fallback#${round}`);
}
return [...blocked].map((l) => l.path);
}
/**
* Resolve the template against the disk.
* @param {any} template
* @param {object} o
* @param {string} o.root public/assets
* @param {Map<string, any>} o.spine resolved Spine entries by model key
* @param {(rel:string)=>string|undefined} [o.sourceOf] URL a file was downloaded from (ledger), to report
* fallbacks that share the primary's path (e.g. an enemy icon taken from its base id)
* @returns {{ value: any, misses: string[], fallbacks: string[], files: Set<string> }}
*/
export function resolveTemplate(template, { root, spine, sourceOf = () => undefined }) {
const misses = [];
const fallbacks = [];
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];
if (existsSync(join(root, a.rel))) {
const src = sourceOf(a.rel);
// Provenance only: disabling/changing a proxy does not change the
// identity of a previously downloaded asset.
const primary = node.alts[0].urls.flatMap((u) => [u, mirrorUrl(u)]);
if (i > 0) fallbacks.push(`${path} ← ${src || a.urls[0]}`);
else if (src && !primary.includes(githubSourceUrl(src))) fallbacks.push(`${path} ← ${src}`);
files.add(a.rel);
return assetUrl(a.rel);
}
}
misses.push(path);
return undefined;
}
if (isModelRef(node)) {
const e = spine.get(node.model);
if (!e) { misses.push(`${path} (spine ${node.model})`); return undefined; }
for (const u of [e.skel, e.atlas, ...e.textures]) files.add(u.replace(/^\/assets\//, ''));
return e;
}
if (Array.isArray(node)) {
const lines = node.map((x, i) => walk(x, `${path}[${i}]`)).filter((x) => x !== undefined);
// An array whose every line is missing on disk is a missing entry, like a missing leaf (e.g. a voice slot whose
// lines were never downloaded here): dropped, never an empty [] that reads as a slot with no lines.
return lines.length || !node.length ? lines : undefined;
}
if (typeof node !== 'object') return node;
const out = {};
for (const [k, v] of Object.entries(node)) {
const r = walk(v, path ? `${path}.${k}` : k);
if (r === undefined) continue;
if (r && typeof r === 'object' && !Array.isArray(r) && !Object.keys(r).length && isContainer(v)) continue;
out[k] = r;
}
return out;
};
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v) && !isLiteral(v);
const value = walk(template, '');
return { value, misses, fallbacks, files };
}
/**
* Total size in bytes of files under root.
* @param {string} root
* @param {Iterable<string>} rels
* @returns {number}
*/
export function totalBytes(root, rels) {
let n = 0;
for (const r of rels) { try { n += statSync(join(root, r)).size; } catch { /* missing */ } }
return n;
}
/**
* Deterministic short content hash of a JSON value (for cache busting).
* @param {any} value
* @returns {string}
*/
export function contentHash(value) {
return createHash('sha1').update(JSON.stringify(value)).digest('hex').slice(0, 12);
}
/** Top-level manifest fields that describe the build, not assets: never counted as dropped entries. */
const BUILD_FIELDS = new Set(['version', 'hash', 'generator', 'stats']);
/**
* Entries of the manifest `prev` that `next` no longer has, as sorted dotted paths. An entry is a leaf value (a URL
* string, a number, a flag, null or an array — an array is one entry); an object entry missing from `next` contributes
* all its leaves. A path that changed shape (a leaf became an object or the reverse) is still there, not dropped.
* The build fields (version, hash, generator, stats) are left out.
* @param {object} prev the current data/assets.json
* @param {object} next the manifest about to replace it
* @returns {string[]}
*/
export function droppedEntries(prev, next) {
const out = [];
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const leaves = (v, path) => {
if (!isObj(v)) { out.push(path); return; }
for (const [k, x] of Object.entries(v)) leaves(x, `${path}.${k}`);
};
const walk = (p, n, path) => {
if (n === undefined) { leaves(p, path); return; }
if (!isObj(p) || !isObj(n)) return;
for (const [k, v] of Object.entries(p)) walk(v, Object.hasOwn(n, k) ? n[k] : undefined, `${path}.${k}`);
};
if (!isObj(prev)) return out;
const nx = isObj(next) ? next : {};
for (const [k, v] of Object.entries(prev)) {
if (!BUILD_FIELDS.has(k)) walk(v, Object.hasOwn(nx, k) ? nx[k] : undefined, k);
}
return out.sort();
}