Files
ark-stronghold-protocol/tools/assets/downloader.mjs
T
sganggs 7059528c47 feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged
Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new
  public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into
  ui/gameLogic/shortcuts.js.
- i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json
  (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands
  them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole
  msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN
  terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone,
  Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is
  byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages.
- public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo /
  fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is
  played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick
  handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only,
  so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name).
- public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers.
- public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear
  button's aria-label / title are t('设置') (en.json already has "Settings").
- tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's
  --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the
  voice plan unchanged.
- data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files
  6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash
  (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields.
- docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md,
  DEPLOY.md, PLAYING.md auto-merged.
- Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js,
  textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests.

Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15,
board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313,
docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283
scenarios match, nothing regenerated.
2026-10-06 14:41:40 +08:00

237 lines
10 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. Each candidate uses its opt-in prefix proxy
// first (when enabled), then the original URL and jsDelivr fallback. The proxy gets one short attempt
// per file and shares a run-wide circuit breaker with the index/font fetchers.
// - 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 { MirrorPolicy } from './network.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)
* @param {'direct'|'mirror'} [o.source] preferred download source
* @param {string} [o.proxyPrefix] HTTPS prefix for GitHub downloads
* @param {MirrorPolicy} [o.mirrorPolicy] invocation-wide policy and mirror circuit breaker
*/
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400, source = 'direct', proxyPrefix, mirrorPolicy }) {
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.network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log });
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}|{skipped:true}|{error:string}>}
*/
async fetchWithRetries(url, kind) {
if (this.network.skip(url)) return { skipped: true };
let lastErr = 'unknown error';
const attempts = this.network.isProxy(url) ? 1 : this.retries;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const res = await this.network.request(url, this.fetch, {
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
redirect: 'follow',
}, this.timeoutMs);
if (res.status === 404 || res.status === 410) {
try { await res.body?.cancel(); } catch { /* ignore */ }
this.network.succeeded(url); // a missing asset is not a proxy outage
return { notFound: true };
}
if (!res.ok) {
try { await res.body?.cancel(); } catch { /* ignore */ }
throw new HttpError(res.status, url);
}
const buf = await this.network.readBody(url, res);
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}`);
this.network.succeeded(url);
return { buf };
} catch (e) {
lastErr = e?.message || String(e);
this.network.failed(url);
if (attempt < attempts && 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 = this.network.urls(url);
for (const src of sources) {
const r = await this.fetchWithRetries(src, job.kind);
if (r.notFound || r.skipped) 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 };
}
}
if (lastError) this.network.directFailureHint();
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;
}
}