Files
ark-stronghold-protocol/tools/assets/downloader.mjs
T
sganggs 9db99aad00 feedback5 WH: 自选 art — fetch-assets plans every data/backups.json unit research 07 lacks (the 72 owned 6★: avatar / portrait with E2, battle Spine Front / Back, every skill icon and skill sound, sub-profession icon, from 07's URL patterns), the 自选 summons as tokens, and a type icon per module of chess.json / backups.json (manifest modules, the loadout tiles' fallback); --add-only downloads only missing files and never rewrites, re-downloads or deletes an existing one (worktrees sharing public/assets)
Downloaded 1,295 files, 127.2 MB (133,426,089 bytes) into the shared public/assets; no existing file changed or removed. data/assets.json only gains entries: 72 chars (all with avatar, portrait, Front and Back Spine), 38 tokens (avatars; no Spine — upstream has skin variants only), 208 skill icons, 10 sub-profession icons, 102 SFX units, 131 module type icons.
2026-10-05 20:13:40 +08:00

227 lines
9.4 KiB
JavaScript

// Concurrent, idempotent, verifying downloader for the asset pipeline.
//
// - Bounded concurrency (default 16) over a job list; each job writes exactly one
// file under the output root, atomically (temp file + rename), so a partial
// download never appears at the final path.
// - Per source: up to `retries` attempts with exponential backoff on network
// errors, timeouts, 403/429/5xx and payloads that fail format validation;
// a 404/410 moves on immediately. Sources: each candidate URL, then its
// jsDelivr mirror (sources.mirrorUrl).
// - Idempotent: an existing file is kept when its size matches the ledger entry
// of a previous download or the expected byte count from research, or (when
// neither is known) when it passes format validation. The ledger lives in
// .cache/assets-ledger.json. `keepExisting` (fetch-assets --add-only) keeps
// every existing file as it is; `written` = the files this run wrote.
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { mirrorUrl } from './sources.mjs';
import { validate } from './formats.mjs';
/**
* @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job
* @typedef {{ status: 'ok'|'skip'|'miss'|'error', bytes: number, url?: string, error?: string, sizeChanged?: boolean }} JobResult
*/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class HttpError extends Error {
constructor(status, url) { super(`HTTP ${status} ${url}`); this.status = status; }
}
function fmtMB(n) { return (n / 1048576).toFixed(1) + ' MB'; }
export class Downloader {
/**
* @param {object} o
* @param {string} o.root absolute output directory (public/assets)
* @param {string} o.ledgerPath absolute path of the ledger JSON
* @param {number} [o.concurrency]
* @param {number} [o.retries]
* @param {number} [o.timeoutMs]
* @param {boolean} [o.force] re-download even when files exist
* @param {boolean} [o.keepExisting] never re-download or rewrite an existing file (a checkout sharing public/assets)
* @param {(msg:string)=>void} [o.log]
* @param {typeof fetch} [o.fetchImpl]
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
*/
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
this.root = root;
this.ledgerPath = ledgerPath;
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
this.retries = Math.max(1, Number(retries) || 3);
this.timeoutMs = timeoutMs;
this.force = force && !keepExisting;
this.keepExisting = !!keepExisting;
/** @type {Set<string>} the files (paths under root) this run wrote */
this.written = new Set();
this.log = log;
this.fetch = fetchImpl;
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
this.ledger = { files: {} };
this.totals = { ok: 0, skip: 0, miss: 0, error: 0, bytesDownloaded: 0, sizeChanged: 0 };
this.dirty = 0;
this.saving = false;
}
/** Load the ledger (missing or corrupt ledger → empty). */
async loadLedger() {
try {
const j = JSON.parse(await readFile(this.ledgerPath, 'utf8'));
if (j && typeof j === 'object' && j.files && typeof j.files === 'object') this.ledger = j;
} catch { /* first run */ }
}
/** Persist the ledger. */
async saveLedger() {
await mkdir(dirname(this.ledgerPath), { recursive: true });
const tmp = this.ledgerPath + '.tmp';
await writeFile(tmp, JSON.stringify(this.ledger));
await rename(tmp, this.ledgerPath);
this.dirty = 0;
}
/**
* Decide whether an existing file can be kept.
* @param {Job} job
* @returns {Promise<number>} size of the kept file, or -1 to (re)download
*/
async existingSize(job) {
if (this.force) return -1;
const abs = join(this.root, job.rel);
let st;
try { st = await stat(abs); } catch { return -1; }
if (!st.isFile() || st.size <= 0) return -1;
if (this.keepExisting) return st.size;
const led = this.ledger.files[job.rel];
if (job.mutable) {
// Post-processed files (atlases) change size; validate content instead.
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
}
if (led && led.bytes === st.size) return st.size;
if (job.bytes && job.bytes === st.size) return st.size;
if (led && led.bytes !== st.size) return -1; // changed since we wrote it
// Unknown provenance (e.g. ledger deleted): keep it only if it validates.
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
}
/**
* Fetch one URL with retries.
* @returns {Promise<{buf:Buffer}|{notFound:true}|{error:string}>}
*/
async fetchWithRetries(url, kind) {
let lastErr = 'unknown error';
for (let attempt = 1; attempt <= this.retries; attempt++) {
try {
const res = await this.fetch(url, {
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
signal: AbortSignal.timeout(this.timeoutMs),
redirect: 'follow',
});
if (res.status === 404 || res.status === 410) {
try { await res.body?.cancel(); } catch { /* ignore */ }
return { notFound: true };
}
if (!res.ok) {
try { await res.body?.cancel(); } catch { /* ignore */ }
throw new HttpError(res.status, url);
}
const buf = Buffer.from(await res.arrayBuffer());
const len = Number(res.headers.get('content-length'));
const enc = res.headers.get('content-encoding');
if (!enc && Number.isFinite(len) && len > 0 && len !== buf.length) {
throw new Error(`truncated body ${buf.length}/${len} ${url}`);
}
if (!validate(kind, buf)) throw new Error(`invalid ${kind} payload (${buf.length} B) ${url}`);
return { buf };
} catch (e) {
lastErr = e?.message || String(e);
if (attempt < this.retries && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
}
}
return { error: lastErr };
}
/**
* Download one job (all candidate URLs and mirrors).
* @param {Job} job
* @returns {Promise<JobResult>}
*/
async runJob(job) {
const kept = await this.existingSize(job);
if (kept >= 0) return { status: 'skip', bytes: kept };
let lastError = null;
for (const url of job.urls) {
const sources = [url, mirrorUrl(url)].filter(Boolean);
for (const src of sources) {
const r = await this.fetchWithRetries(src, job.kind);
if (r.notFound) continue;
if (r.error) { lastError = r.error; continue; }
const abs = join(this.root, job.rel);
await mkdir(dirname(abs), { recursive: true });
const tmp = `${abs}.part${process.pid}`;
try {
await writeFile(tmp, r.buf);
await rename(tmp, abs);
} catch (e) {
try { await unlink(tmp); } catch { /* ignore */ }
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
}
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
this.written.add(job.rel);
this.dirty++;
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
}
}
return lastError ? { status: 'error', bytes: 0, error: lastError } : { status: 'miss', bytes: 0, error: 'not found (404) on all sources' };
}
/**
* Run a list of jobs with bounded concurrency. Duplicate `rel`s are run once.
* @param {Job[]} jobs
* @param {string} label progress label
* @returns {Promise<Map<string, JobResult>>}
*/
async run(jobs, label = 'download') {
const unique = [];
const seen = new Set();
for (const j of jobs) { if (!seen.has(j.rel)) { seen.add(j.rel); unique.push(j); } }
const results = new Map();
const t0 = Date.now();
let done = 0; let bytes = 0; let lastLog = 0;
const counts = { ok: 0, skip: 0, miss: 0, error: 0 };
const progress = (force) => {
const now = Date.now();
if (!force && now - lastLog < 2000) return;
lastLog = now;
const secs = Math.max(0.001, (now - t0) / 1000);
this.log(`[${label}] ${done}/${unique.length} ok=${counts.ok} skip=${counts.skip} miss=${counts.miss} err=${counts.error} ` +
`${fmtMB(bytes)} ${(bytes / 1048576 / secs).toFixed(2)} MB/s`);
};
let next = 0;
const worker = async () => {
while (next < unique.length) {
const job = unique[next++];
let r;
try { r = await this.runJob(job); } catch (e) { r = { status: 'error', bytes: 0, error: e?.message || String(e) }; }
results.set(job.rel, r);
counts[r.status]++;
this.totals[r.status]++;
if (r.status === 'ok') { bytes += r.bytes; this.totals.bytesDownloaded += r.bytes; }
if (r.sizeChanged) this.totals.sizeChanged++;
done++;
if (this.dirty >= 200 && !this.saving) {
this.saving = true;
try { await this.saveLedger(); } catch { /* retried at the end */ } finally { this.saving = false; }
}
progress(false);
}
};
await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker));
if (unique.length) progress(true);
await this.saveLedger();
return results;
}
}