Report missing asset leaves and add opt-in strict mode (#421)

This commit is contained in:
sganggs committed 2026-10-09 17:13:13 +08:00
1 parent 174ccdf94d
commit 3e3919c917
6 files changed
+211 -14

No files matched your search

+85 -9
View File
@@ -30,6 +30,16 @@
// the entries it would drop and exits 1; --allow-shrink (or --prune) writes the
// smaller manifest.
//
// Every leaf the plan had resolved but no alternative of which is on disk is also
// reported on its own line — `[assets] N leaves dropped: no alternative on disk
// (…, audio.sfx.units.some_char.attack, …)` — and kept as `droppedLeaves` in the
// report, whether or not it shrinks the committed manifest: such a leaf is an
// entry the client loses (a sound that never plays), not a URL it falls back
// from. --strict (or SP_ASSETS_STRICT=1) turns those drops into exit code 1 for
// CI and packaging runs; by default the run only warns. No file is downloaded,
// dropped or kept differently either way, and a run whose files are all present
// produces the same manifest byte for byte.
//
// Beyond research 07's ids the plan covers what data/*.json adds: spawnable
// enemies / tokens (data/enemies.json, data/tokens.json), the 自选 owned-6★ picks
// of data/backups.json `units` (art from 07's URL patterns) and their summons
@@ -44,7 +54,8 @@
//
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
// [--dry-run] [--refresh-index] [--prune]
// [--allow-shrink] [--add-only] [--local-spines] [--help]
// [--allow-shrink] [--add-only] [--strict]
// [--local-spines] [--help]
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync, realpathSync } from 'node:fs';
@@ -103,6 +114,9 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
(public/assets/local/** of tools/local-extract is never deleted); implies --allow-shrink
--allow-shrink write data/assets.json even when it loses entries the current one has
(without it such a run keeps the current manifest, lists the entries and exits 1)
--strict exit 1 when a leaf was dropped for having no file on disk (the report's
droppedLeaves: an entry the plan resolved and the client would lose, e.g. a
sound that never plays); by default such a drop is only reported
--add-only download only files missing on disk; never re-download, rewrite or delete an existing
file, no font rebuild (a worktree sharing public/assets and public/fonts)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} and ${LOCAL_TOKEN_SPINES_FILE} from the
@@ -110,17 +124,28 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
(public/assets/local/spine/enemy/, public/assets/local/spine/token/)
--help this text
Environment: SP_ASSET_SOURCE sets the default source; SP_GITHUB_PROXY sets the
HTTPS mirror prefix (default https://gh-proxy.com/; empty disables the proxy).
HTTPS mirror prefix (default https://gh-proxy.com/; empty disables the proxy);
SP_ASSETS_STRICT=1 is the same as --strict (CI / packaging builds).
Mirror attempts have an 8 s response header timeout; response body has a separate idle timeout. Stops for this run after 3 consecutive
failures. Only explicitly enabled GitHub downloads use the third-party proxy.`;
/**
* True for the usual truthy spellings of a boolean environment variable (`1`, `true`, `yes`, `on`); empty, `0`,
* `false`, `no` and `off` are false, and so is anything absent.
* @param {string|undefined} value
* @returns {boolean}
*/
function envFlag(value) {
return typeof value === 'string' && value !== '' && !/^(0|false|no|off)$/i.test(value);
}
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, addOnly:boolean, localSpines:boolean, voiceLang:string, voiceAll:boolean, help:boolean, source:string}}
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, strict:boolean, addOnly:boolean, localSpines:boolean, voiceLang:string, voiceAll:boolean, help:boolean, source:string}}
*/
export function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, addOnly: false, localSpines: false, voiceLang: 'cn', voiceAll: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, strict: envFlag(process.env.SP_ASSETS_STRICT), addOnly: false, localSpines: false, voiceLang: 'cn', voiceAll: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -131,6 +156,7 @@ export function parseArgs(argv) {
else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = true;
else if (k === '--allow-shrink') o.allowShrink = true;
else if (k === '--strict') o.strict = true;
else if (k === '--add-only') o.addOnly = true;
else if (k === '--local-spines') o.localSpines = true;
else if (k === '--voice-lang') { if (!VOICE_DIRS[v]) throw new Error(`unknown --voice-lang ${v} (cn | jp | en | kr)`); o.voiceLang = v; }
@@ -184,6 +210,50 @@ export function shrinkGuard(prev, next, { allowShrink = false, prune = false } =
return { dropped, write: dropped.length === 0 || !!allowShrink || !!prune };
}
/**
* The one summary line for the leaves the plan had resolved but whose every alternative file is missing on disk
* (resolveTemplate `droppedLeaves`). Each of them disappears from data/assets.json, so the client has no entry at all —
* for a sound, silence that no "the client uses fallbacks" wording explains. Printed on every run, empty or not: a
* green line is the check that nothing was dropped.
* @param {string[]} droppedLeaves dotted template paths
* @param {number} [max] how many paths the line names before "…"
* @returns {string}
*/
export function leafDropSummary(droppedLeaves, max = 8) {
const n = droppedLeaves.length;
if (!n) return '[assets] no leaf dropped: every planned leaf has a file on disk';
return `[assets] ${n} ${n === 1 ? 'leaf' : 'leaves'} dropped: no alternative on disk ` +
`(${droppedLeaves.slice(0, max).join(', ')}${n > max ? ', …' : ''})`;
}
/**
* The error line of a --strict / SP_ASSETS_STRICT run that dropped at least one leaf for having no alternative on
* disk. CI and packaging runs pass --strict so a manifest with such holes cannot ship (the entry is gone from the
* client's manifest; nothing retries it later, the next plain run only warns again).
* @param {string[]} droppedLeaves dotted template paths
* @param {number} [max] how many paths the line names before pointing at the report
* @returns {string}
*/
export function strictLeafDropError(droppedLeaves, max = 10) {
const n = droppedLeaves.length;
return `ERROR: ${n} ${n === 1 ? 'leaf' : 'leaves'} dropped for having no alternative on disk ` +
`(--strict / SP_ASSETS_STRICT=1): ${droppedLeaves.slice(0, max).join(', ')}` +
`${n > max ? `, … (all ${n} in ${relative(ROOT, REPORT)})` : ''}`;
}
/**
* Exit code of a finished run: 0 only when nothing failed — every required asset is present, no leaf was dropped
* while --strict / SP_ASSETS_STRICT is on, and the manifest was written (the shrink guard keeps the current one when
* the rebuild would lose entries).
* @param {{requiredMisses?:number, droppedLeaves?:number, strict?:boolean, manifestWritten?:boolean}} o
* @returns {number} 0 or 1
*/
export function runExitCode({ requiredMisses = 0, droppedLeaves = 0, strict = false, manifestWritten = true } = {}) {
if (requiredMisses) return 1;
if (strict && droppedLeaves) return 1;
return manifestWritten ? 0 : 1;
}
/**
* Files under public/assets the manifest does not reference (`--prune` deletes them). public/assets/local/** belongs to
* tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete all of it. Compared without
@@ -411,11 +481,15 @@ async function main() {
const charIds = Object.keys(assets07.operators || {});
const required = requiredMisses(manifest, charIds);
// Leaves the plan had resolved and the manifest loses here (no alternative of theirs on disk): reported on their own
// line below and kept in the report, whether or not they shrink the committed manifest.
const droppedLeaves = resolved.droppedLeaves;
const report = {
downloadedBytes: dl.totals.bytesDownloaded,
totals: dl.totals,
stats: manifest.stats,
requiredMisses: required,
droppedLeaves,
misses: resolved.misses,
downloadErrors, // leaves whose primary failed transiently (fallbacks not tried; re-run to retry)
fallbacks: resolved.fallbacks,
@@ -444,8 +518,9 @@ async function main() {
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
if (downloadErrors.length) log(`download errors (${downloadErrors.length}, re-run to retry): ${downloadErrors.slice(0, 10).join(', ')}`);
log(leafDropSummary(droppedLeaves));
if (resolved.misses.length) {
log(`missing (${resolved.misses.length}, omitted from manifest; client uses fallbacks):`);
log(`missing (${resolved.misses.length}, omitted from manifest; the client falls back only where the entry has another alternative):`);
for (const m of resolved.misses.slice(0, 40)) log(` ${m}`);
if (resolved.misses.length > 40) log(` … see ${REPORT}`);
}
@@ -460,11 +535,12 @@ async function main() {
if (!guard.write) log(' re-run to retry the downloads (--refresh-index for the audio/model indexes), or pass --allow-shrink (or --prune) to write the smaller manifest');
}
log(`manifest: ${MANIFEST}${guard.write ? '' : ' (kept)'} · report: ${REPORT} · ${((Date.now() - t0) / 1000).toFixed(0)} s`);
if (required.length) {
log(`ERROR: ${required.length} required assets missing: ${required.slice(0, 10).join(', ')}`);
return 1;
if (required.length) log(`ERROR: ${required.length} required assets missing: ${required.slice(0, 10).join(', ')}`);
if (opts.strict && droppedLeaves.length) {
log(strictLeafDropError(droppedLeaves));
log(' a plain run downloads them again (--refresh-index if the upstream index lost them); without --strict / SP_ASSETS_STRICT this is only a warning');
}
return guard.write ? 0 : 1;
return runExitCode({ requiredMisses: required.length, droppedLeaves: droppedLeaves.length, strict: opts.strict, manifestWritten: guard.write });
}
// run only as a script (tests import parseArgs / shrinkGuard)