feedback2 WE: setup downloads the 36 battle emotes and the 19 玩法说明 pages from the public mirror (ArknightsAssets2 cn); the client shows the local-client art first, then the mirror copy, then its fallback (#42)

This commit is contained in:
sganggs committed 2026-10-03 23:05:42 +08:00
1 parent 380af1ba80
commit db7b2b1404
11 files changed
+280 -44

No files matched your search

+83 -5
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');
@@ -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,65 @@ 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('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; }
}