feedback2: merge WE — setup downloads the battle emotes and 玩法说明 pages from the public mirror, accurate deployment docs (GitHub #42); English title Stronghold Protocol: Alliance; doctor comment after PR #27

This commit is contained in:
sganggs committed 2026-10-03 23:51:56 +08:00
commit 7ab852ef57
27 files changed
+441 -88

No files matched your search

+62 -1
View File
@@ -18,9 +18,10 @@ import { pngSize, isCompletePng, isMp3, validate } from '../tools/assets/formats
import { encodeWoff2, decodeWoff2Tables, readSfnt, uintBase128 } from '../tools/assets/woff2.mjs';
import { assetToPath, pickUnitSfx, indexAudio } from '../tools/assets/audio.mjs';
import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs';
import { collectEnemyIds, skillIndicesByChar } from '../tools/assets/plan.mjs';
import { collectEnemyIds, skillIndicesByChar, buildPlan, GUIDE_PAGES, UI_EXTRAS } from '../tools/assets/plan.mjs';
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import { spineEntry } from '../public/js/assets.js';
import { EMOTE_CATALOG, emoteArtGroup } from '../shared/constants.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC = join(ROOT, 'public');
@@ -468,6 +469,66 @@ describe('downloader (fake network)', () => {
});
});
// ---------------------------------------------------------------------------
// GitHub issue #42: a source deploy on a server without the Arknights client had no public/assets/local, so the battle
// emotes showed default icons and 玩法说明 showed text tips. The public mirror has both (checked 2026-10-03: all 55
// URLs answer 200 on raw.githubusercontent.com and on jsDelivr, same pictures and sizes as the local extraction), so the
// plan downloads them like any other UI sprite, keyed by the data/local-assets.json group and name.
describe('emotes and 玩法说明 pages from the public mirror (GitHub issue #42)', () => {
const AA2 = 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/';
const plan = () => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {} }).template;
test('the plan fetches the 36 battle emotes and the 19 guide pages from ArknightsAssets2 cn, keyed like the local art', () => {
const ui = plan().ui;
assert.equal(EMOTE_CATALOG.length, 36);
for (const e of EMOTE_CATALOG) {
const key = `${emoteArtGroup(e.id)}/${e.picId}`; // the local manifest's group + name (emoticon/<dir>/<picId>)
assert.deepEqual(ui[key]?.alts, [{ rel: `ui/${key}.png`, urls: [`${AA2}ui/emoticon/theme/%5Buc%5D${e.themeId}/icon/${e.picId}.png`], kind: 'png' }], key);
}
assert.ok(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'], 'keyed by picId, not by the emote id (fooldoctor_06 → pic 08)');
assert.notEqual(ui['emoticon/slug/pic_thanks_battle'].alts[0].rel, ui['emoticon/basic/pic_thanks_battle'].alts[0].rel, 'same picId, two themes');
assert.deepEqual(GUIDE_PAGES.slice(0, 2), ['autochess_home_1', 'autochess_home_2']);
assert.equal(GUIDE_PAGES.length, 19);
for (const k of GUIDE_PAGES) {
assert.deepEqual(ui[`guide/${k}`]?.alts, [{ rel: `ui/guide/${k}.png`, urls: [`${AA2}arts/guidebookpages/%5Bpack%5Dautochess/${k}.png`], kind: 'png' }], k);
}
const rels = Object.values(ui).map((l) => l.alts[0].rel);
assert.equal(new Set(rels).size, rels.length, 'no two UI entries share a file');
assert.ok(rels.every((r) => !r.startsWith('local/')), 'public/assets/local belongs to tools/local-extract');
assert.equal(mirrorUrl(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'].alts[0].urls[0]),
'https://cdn.jsdelivr.net/gh/ArknightsAssets/ArknightsAssets2@cn/assets/dyn/ui/emoticon/theme/%5Buc%5Demoticon_foolsday_doctor/icon/pic_fooldoctor_08_battle.png',
'the jsDelivr fallback (downloader) serves the same path');
assert.ok(Object.isFrozen(UI_EXTRAS) && UI_EXTRAS.every(Object.isFrozen));
});
test('a rebuilt manifest lists the copies that are on disk under /assets/ui/…; a missing one is left out', async () => {
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const root = await mkdtemp(join(tmpdir(), 'sp-mirror-art-'));
const ui = plan().ui;
const keys = ['emoticon/basic/pic_happy_battle', 'guide/autochess_home_1', 'emoticon/slug/pic_bye_battle'];
for (const k of keys.slice(0, 2)) {
await mkdir(join(root, dirname(ui[k].alts[0].rel)), { recursive: true });
await writeFile(join(root, ui[k].alts[0].rel), 'x');
}
const r = resolveTemplate({ ui: Object.fromEntries(keys.map((k) => [k, ui[k]])) }, { root, spine: new Map() });
assert.deepEqual(r.value.ui, {
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png',
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
});
assert.deepEqual(r.misses, ['ui.emoticon/slug/pic_bye_battle']);
});
test('the committed data/assets.json lists all 55, so setup on an install made before them downloads them', () => {
// tools/setup.mjs runs fetch-assets when data/assets.json lists a file that is not on disk (checkAssets)
const m = readJson('data/assets.json');
for (const e of EMOTE_CATALOG) assert.equal(m.ui[`emoticon/${e.dir}/${e.picId}`], `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
for (const k of GUIDE_PAGES) assert.equal(m.ui[`guide/${k}`], `/assets/ui/guide/${k}.png`, k);
assert.equal(m.stats.ui, Object.keys(m.ui).length);
assert.ok(!JSON.stringify(m).includes('/assets/local/'), 'never a local-client path');
});
});
// ---------------------------------------------------------------------------
describe('generated manifest data/assets.json', () => {
const haveManifest = existsSync(MANIFEST);
+16
View File
@@ -71,6 +71,22 @@ test('fetch-assets shrink guard: the committed manifest minus some audio entries
assert.equal(statSync(MANIFEST).mtimeMs, before, 'importing the tool runs nothing');
});
test('a server where the mirror emotes / 玩法说明 pages failed to download keeps the committed manifest, so the next setup retries them (#42)', async () => {
// setup re-runs fetch-assets only while data/assets.json lists a file that is not on disk: a manifest rewritten without
// these entries would stop the retries, and the emotes would stay default icons on that server
const { shrinkGuard, parseArgs } = await import('../tools/fetch-assets.mjs');
const prev = JSON.parse(readFileSync(MANIFEST, 'utf8'));
const next = JSON.parse(JSON.stringify(prev));
for (const k of Object.keys(next.ui)) if (/^(emoticon|guide)\//.test(k)) delete next.ui[k];
next.stats = { ...next.stats, ui: Object.keys(next.ui).length };
const g = shrinkGuard(prev, next, parseArgs([]));
assert.equal(g.write, false);
assert.equal(g.dropped.length, 36 + 19);
for (const k of ['ui.emoticon/basic/pic_happy_battle', 'ui.emoticon/fooldoctor/pic_fooldoctor_08_battle', 'ui.guide/autochess_home_1', 'ui.guide/autochess_handbook_4']) {
assert.ok(g.dropped.includes(k), k);
}
});
test('fetch-assets still runs as a script: --help lists --allow-shrink', () => {
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'fetch-assets.mjs'), '--help'], { encoding: 'utf8', timeout: 30000 });
assert.equal(r.status, 0, r.stderr);
+25 -1
View File
@@ -1,5 +1,6 @@
// The optional local-client art manifest (data/local-assets.json, DESIGN §13): an install without it must get an
// empty manifest (200) instead of a 404, and an install with it must get the real file.
// empty manifest (200) instead of a 404, and an install with it must get the real file. The docs and the setup /
// doctor messages say what falls back without it (GitHub issue #42, DESIGN §22.5).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -69,3 +70,26 @@ test('present data/local-assets.json is served as-is', async () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('docs and messages say what falls back without the local art and how a server without the client gets it (GitHub issue #42)', async () => {
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
const { LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT } = await import('../tools/setup.mjs');
for (const f of ['README.md', 'docs/DEPLOY.md', 'tools/setup.mjs', 'tools/doctor.mjs']) {
assert.ok(!/不影响游戏|其他功能不受影响|游戏不受影响/.test(read(f)), `${f}: never "the local art does not matter"`);
}
for (const re of [/3D 棋盘/, /界面图标/, /源石虫/]) assert.match(LOCAL_ART_FALLBACK, re);
assert.ok(!/表情|玩法说明/.test(LOCAL_ART_FALLBACK), 'the emotes and the 玩法说明 pages are downloaded, not local-only');
assert.match(LOCAL_ART_COPY_HINT, /同一版本的整合包/);
// setup's row is printed on every start (scripts/launch.mjs): it names the fallbacks and points to DEPLOY §6; doctor adds the hint
const noClientRow = read('tools/setup.mjs').split('\n').find((l) => l.includes("'未检测到本机明日方舟客户端'"));
assert.ok(noClientRow && noClientRow.includes('LOCAL_ART_FALLBACK') && noClientRow.includes('DEPLOY.md 第 6 节') && !noClientRow.includes('LOCAL_ART_COPY_HINT'), noClientRow);
assert.match(read('tools/doctor.mjs'), /未提取:\$\{LOCAL_ART_FALLBACK\}(\$\{LOCAL_ART_COPY_HINT\})/);
const deploy = read('docs/DEPLOY.md');
const s6 = deploy.slice(deploy.indexOf('## 6. 本地客户端素材'));
assert.ok(deploy.includes('## 6. 本地客户端素材') && s6.length > 200, 'DEPLOY §6');
for (const re of [/同一版本/, /public\/assets\/local\//, /data\/local-assets\.json/, /3D 棋盘/, /源石虫/, /表情/, /玩法说明/]) assert.match(s6, re);
const readme = read('README.md');
assert.match(readme, /表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载/);
assert.match(readme, /\*\*同一版本\*\*的整合包/);
assert.ok(!/需本地提取/.test(read('docs/PLAYING.md')), 'PLAYING: the 玩法说明 pages come with the download');
});
+1 -1
View File
@@ -248,7 +248,7 @@ describe('public/js/ui/emotes.js helpers', () => {
} };
const realFetch = globalThis.fetch;
test('art URLs come from the manifest only (keyed by picId); unknown / unlisted → null', async () => {
test('art URLs come only from what the manifests list (here the local one, keyed by picId; data/assets.json absent); unknown / unlisted → null', async () => {
globalThis.fetch = async (url) => (String(url).endsWith('/local-assets.json')
? { ok: true, status: 200, json: async () => manifest } : { ok: false, status: 404, json: async () => ({}) });
try {
+104 -6
View File
@@ -1,12 +1,16 @@
// Local-client art is optional (DESIGN §13): the emote art, the 玩法说明 pages and the official tier chips resolve
// from data/local-assets.json when present and fall back (text / tips / CSS) when the manifest or an entry is missing.
// The emotes and the 玩法说明 pages are also on the public mirror (GitHub issue #42): data/assets.json lists the copies
// tools/fetch-assets.mjs downloads under the same group and name (ui['<group>/<name>']); the client takes the local
// file first, then the mirror copy, then its fallback (data.js artUrls / nextArtUrl).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { EMOTES, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
import { EMOTES, EMOTE_CATALOG, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
import { GUIDE_PAGES } from '../../tools/assets/plan.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -19,13 +23,28 @@ const manifest = {
'ui/battle': { img_chess_level_3: { path: '/assets/local/ui/battle/img_chess_level_3.png', w: 45, h: 45 } },
},
};
// what server/index.js serves when data/local-assets.json is absent
const EMPTY_LOCAL = { version: 1, source: 'none', count: 0, groups: {} };
let mode = 'ok';
let mode = 'ok'; // data/local-assets.json: 'ok' (the manifest above) | 'empty' (the server's stand-in) | 'missing' (404)
let assetsDoc = null; // data/assets.json, or null (404)
globalThis.fetch = async (url) => {
if (String(url).endsWith('/local-assets.json') && mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
const u = String(url);
if (u.endsWith('/local-assets.json')) {
if (mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
if (mode === 'empty') return { ok: true, status: 200, json: async () => EMPTY_LOCAL };
} else if (u.endsWith('/assets.json') && assetsDoc) return { ok: true, status: 200, json: async () => assetsDoc };
return { ok: false, status: 404, json: async () => ({}) };
};
/** Reload both manifests after changing `mode` / `assetsDoc`. */
async function reload(data) {
const orig = console.warn; console.warn = () => {};
try {
for (const n of ['local', 'assets']) { await data.invalidate(n); await data.load(n); }
} finally { console.warn = orig; }
}
describe('local-client art fallbacks', () => {
test('manifest present: listed entries resolve, unlisted ones stay null', async () => {
const { data, localAsset } = await import('../../public/js/data.js');
@@ -34,7 +53,7 @@ describe('local-client art fallbacks', () => {
const { guidePages, GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
const { tierChipUrl } = await import('../../public/js/ui/components.js');
assert.equal(emoteArtUrl('autochess_battle_happy'), emoteArtPath('autochess_battle_happy'));
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'unlisted emote art is never requested');
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'listed by neither manifest (no data/assets.json here): no URL, never requested');
assert.equal(emoteArtUrl('nope'), null);
const pages = guidePages();
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, missing pages skipped');
@@ -48,9 +67,9 @@ describe('local-client art fallbacks', () => {
test('manifest missing: everything falls back', async () => {
mode = 'missing';
assetsDoc = null;
const { data } = await import('../../public/js/data.js');
const orig = console.warn; console.warn = () => {};
try { await data.invalidate('local'); } finally { console.warn = orig; }
await reload(data);
assert.equal(data.status('local'), 'missing');
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
const { guidePages } = await import('../../public/js/ui/guide.js');
@@ -71,6 +90,85 @@ describe('local-client art fallbacks', () => {
});
});
describe('emotes and 玩法说明 pages: local art first, then the mirror copy (GitHub issue #42)', () => {
test('lookup order: the local file, then data/assets.json ui[\'<group>/<name>\'], then nothing', async () => {
mode = 'ok';
assetsDoc = { ui: {
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png', // both: the local one wins
'emoticon/basic_2/pic_sad_battle': '/assets/ui/emoticon/basic_2/pic_sad_battle.png', // the mirror copy only
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
'guide/autochess_home_9': '/assets/ui/guide/autochess_home_9.png',
'ui/battle/img_chess_level_4': '/assets/ui/x.png', // not one of the mirrored groups' names
} };
const { data, artUrls, nextArtUrl, localAsset } = await import('../../public/js/data.js');
await reload(data);
const { emoteArtUrl, emoteArtUrls } = await import('../../public/js/ui/emotes.js');
const { guidePages } = await import('../../public/js/ui/guide.js');
assert.deepEqual(emoteArtUrls('autochess_battle_happy'), ['/assets/local/emoticon/basic/pic_happy_battle.png', '/assets/ui/emoticon/basic/pic_happy_battle.png']);
assert.equal(emoteArtUrl('autochess_battle_happy'), '/assets/local/emoticon/basic/pic_happy_battle.png', 'local first');
assert.equal(emoteArtUrl('autochess_battle_sad'), '/assets/ui/emoticon/basic_2/pic_sad_battle.png', 'no local file: the mirror copy');
assert.deepEqual(emoteArtUrls('autochess_battle_dying'), [], 'neither: the glyph');
assert.equal(emoteArtUrl('autochess_battle_dying'), null);
assert.deepEqual(emoteArtUrls('__proto__'), []);
const pages = guidePages();
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_home_9', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, either source');
assert.deepEqual(pages[0].urls, ['/assets/local/guide/autochess_home_1.png', '/assets/ui/guide/autochess_home_1.png']);
assert.equal(pages[0].url, '/assets/local/guide/autochess_home_1.png');
assert.deepEqual(pages[2].urls, ['/assets/ui/guide/autochess_home_9.png']);
assert.deepEqual(artUrls('guide', 'autochess_home_2'), ['/assets/local/guide/autochess_home_2.png']);
assert.deepEqual(artUrls('ui/battle', 'img_chess_level_3'), ['/assets/local/ui/battle/img_chess_level_3.png']);
assert.deepEqual(artUrls('guide', '__proto__'), []);
assert.deepEqual(artUrls('constructor', 'x'), []);
assert.equal(localAsset('guide', 'autochess_home_9'), null, 'localAsset stays local-only');
// an image that fails to load (a copied local-assets.json without its files) falls through to the next URL
const urls = emoteArtUrls('autochess_battle_happy');
assert.equal(nextArtUrl(urls, new Set()), urls[0]);
assert.equal(nextArtUrl(urls, new Set([urls[0]])), urls[1]);
assert.equal(nextArtUrl(urls, new Set(urls)), null, 'both failed: the fallback');
assert.equal(nextArtUrl(urls), urls[0]);
assert.equal(nextArtUrl(null), null);
});
test('no local client (a source deploy on a server, issue #42): every emote and all 19 pages come from the committed data/assets.json', async () => {
mode = 'empty';
assetsDoc = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const { data } = await import('../../public/js/data.js');
await reload(data);
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
const { guidePages } = await import('../../public/js/ui/guide.js');
for (const e of EMOTE_CATALOG) assert.equal(emoteArtUrl(e.id), `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
const pages = guidePages();
assert.deepEqual(pages.map((p) => p.key), GUIDE_PAGES);
assert.deepEqual(pages.map((p) => p.url), GUIDE_PAGES.map((k) => `/assets/ui/guide/${k}.png`));
assert.ok(pages.every((p) => p.urls.length === 1));
});
test('the guide stage: the local page, else the downloaded copy, else the official tips text (0.1.1\'s fallback)', async () => {
const { guideStage } = await import('../../public/js/ui/guide.js');
const local = '/assets/local/guide/autochess_home_1.png';
const mirror = '/assets/ui/guide/autochess_home_1.png';
const page = { key: 'autochess_home_1', urls: [local, mirror] };
assert.deepEqual(guideStage(page, new Set()), { kind: 'image', src: local }, 'local ok → local');
assert.deepEqual(guideStage(page), { kind: 'image', src: local }, 'nothing failed yet');
assert.deepEqual(guideStage(page, new Set([local])), { kind: 'image', src: mirror }, 'local fails → the downloaded copy');
assert.deepEqual(guideStage(page, new Set([local, mirror])), { kind: 'tips' }, 'both fail → tips');
// data/assets.json lists the downloaded page but the file is not on disk (a git pull and restart without setup)
assert.deepEqual(guideStage({ urls: [mirror] }, new Set([mirror])), { kind: 'tips' }, 'listed, not on disk → tips, not a blank notice');
assert.deepEqual(guideStage({ urls: [] }), { kind: 'tips' }, 'no copy');
assert.deepEqual(guideStage(null), { kind: 'tips' }, 'no page');
// the viewer shows exactly that: the image stage for a URL, the tips stage otherwise (never the old blank notice)
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
assert.match(src, /const stage = guideStage\(cur, failed\)/);
assert.match(src, /: html`<div class="guide__stage guide__stage--text"><\$\{TipsFallback\} \/><\/div>`\}/);
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
});
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
});
});
function safeRead() {
try { return readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'); } catch { return null; }
}
+16 -1
View File
@@ -37,10 +37,25 @@ test('the release version is what players see', () => {
assert.match(read('public/js/screens/title.js'), /v\$\{APP_VERSION\}/, 'title screen footer');
assert.ok(!/PROTOCOL v1/.test(read('public/js/screens/title.js')), 'no protocol number posing as a version');
const server = read('server/index.js');
assert.match(server, /Stronghold Protocol: Covenant v\$\{APP_VERSION\}/, 'boot banner');
assert.match(server, /Stronghold Protocol: Alliance v\$\{APP_VERSION\}/, 'boot banner');
assert.match(server, /app: APP_VERSION/, '/healthz');
});
test('the English title is the official one: Stronghold Protocol: Alliance (as in the reply to GitHub issue #38, which stays open)', () => {
// EN client data, activity_table basicInfo.act2autochess.name = "Stronghold Protocol: Alliance" (CN 卫戍协议:盟约);
// the project used to call it "Covenant". The Chinese title stays 卫戍协议:盟约; the repository keeps its name.
const readme = read('README.md');
assert.match(readme.split('\n')[0], /^# 卫戍协议:盟约 · Stronghold Protocol: Alliance$/, 'README title');
assert.match(readme, /mode \*Stronghold Protocol: Alliance\*/, 'README English summary');
assert.match(read('server/index.js'), /卫戍协议:盟约 · Stronghold Protocol: Alliance v/, 'boot banner');
assert.equal(pkg.name, 'stronghold-protocol-alliance');
assert.equal(lock.name, pkg.name);
assert.equal(lock.packages[''].name, pkg.name);
for (const f of ['README.md', 'server/index.js', 'package.json', 'package-lock.json', 'NOTICE.md', 'public/index.html', 'docs/DEPLOY.md']) {
assert.ok(!/covenant/i.test(read(f)), `${f}: no "Covenant" title left`);
}
});
test('GPL-3.0-or-later: LICENSE, package metadata and notices', () => {
const license = read('LICENSE');
assert.match(license.slice(0, 200), /GNU GENERAL PUBLIC LICENSE\s+Version 3, 29 June 2007/);