卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
// Official module (uniequip) TYPE icons from the local-client art (DESIGN §13 / §16): `data/local-assets.json`
// groups.module is keyed by the client's mixed-case file names ('PRI-X', 'mar-x', …) and matched case-insensitively
// against a module's typeName; the art is optional — without the manifest or an entry every module UI keeps its
// lettered tile / type text.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { moduleTypeIconUrl } from '../../public/js/ui/assetUrls.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const LOCAL = path.join(ROOT, 'data/local-assets.json');
const entry = (k) => ({ path: `/assets/local/module/${k}.png`, w: 50, h: 50, kind: 'Sprite' });
const manifest = {
version: 1,
groups: { module: Object.fromEntries(['PRI-X', 'pri-y', 'mar-x', 'isw-a', 'TRP-D'].map((k) => [k, entry(k)])) },
};
let mode = 'ok';
globalThis.fetch = async (url) => {
if (String(url).endsWith('/local-assets.json') && mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
return { ok: false, status: 404, json: async () => ({}) };
};
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
if (v.props?.fallback) yield* walk(v.props.fallback);
}
const textOf = (v) => [...walk(v)].map((n) => n.props?.children).flat().filter((c) => typeof c === 'string' || typeof c === 'number').join('');
describe('module type icons (local-client art, optional)', () => {
test('lookup is case-insensitive both ways and maps the Greek type letters; exact keys win', () => {
assert.equal(moduleTypeIconUrl(manifest, 'MAR-X'), '/assets/local/module/mar-x.png', 'upper-case type → lower-case file');
assert.equal(moduleTypeIconUrl(manifest, 'pri-x'), '/assets/local/module/PRI-X.png', 'lower-case type → upper-case file');
assert.equal(moduleTypeIconUrl(manifest, 'Pri-Y'), '/assets/local/module/pri-y.png');
assert.equal(moduleTypeIconUrl(manifest, 'PRI-X'), '/assets/local/module/PRI-X.png');
assert.equal(moduleTypeIconUrl(manifest, ' TRP-D '), '/assets/local/module/TRP-D.png', 'trimmed');
assert.equal(moduleTypeIconUrl(manifest, 'ISW-A'), '/assets/local/module/isw-a.png');
assert.equal(moduleTypeIconUrl(manifest, 'ISW-α'), '/assets/local/module/isw-a.png', 'α → a');
assert.equal(moduleTypeIconUrl(manifest, 'TRP-Δ'), '/assets/local/module/TRP-D.png', 'Δ → d');
});
test('missing manifest / group / entry / junk ⇒ null (the caller draws the lettered tile), never throws', () => {
for (const local of [null, undefined, {}, { groups: null }, { groups: {} }, { groups: { module: 'x' } }, 42, 'str']) {
assert.equal(moduleTypeIconUrl(local, 'MAR-X'), null, JSON.stringify(local));
}
assert.equal(moduleTypeIconUrl(manifest, 'MAR-Y'), null, 'entry missing');
for (const t of [null, undefined, '', ' ', 42, {}]) assert.equal(moduleTypeIconUrl(manifest, t), null, String(t));
const broken = { groups: { module: { 'mar-x': { w: 1 }, 'MAR-Y': { path: '' }, 'mar-y': entry('mar-y') } } };
assert.equal(moduleTypeIconUrl(broken, 'MAR-X'), null, 'entry without a path');
assert.equal(moduleTypeIconUrl(broken, 'MAR-Y'), '/assets/local/module/mar-y.png', 'an empty exact entry falls through to a valid one');
});
test('the real manifest (when extracted on this machine) covers every module type of chess.json, with on-disk files', { skip: !existsSync(LOCAL) && 'no data/local-assets.json (optional)' }, () => {
const local = JSON.parse(readFileSync(LOCAL, 'utf8'));
if (!local?.groups?.module) return; // an older extraction without the module group: the UI falls back
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const recs = Array.isArray(chess) ? chess : Object.values(chess.chess || chess);
const types = new Set(recs.flatMap((c) => (Array.isArray(c?.modules) ? c.modules : []).map((m) => m?.typeName).filter(Boolean)));
assert.ok(types.size > 50, `module types in chess.json: ${types.size}`);
const missing = [];
for (const t of types) {
const url = moduleTypeIconUrl(local, t);
if (!url) { missing.push(t); continue; }
assert.ok(existsSync(path.join(ROOT, 'public', url)), `${t} → ${url} exists`);
}
assert.deepEqual(missing, [], 'every module type has an official icon');
});
test('干员调配 module tile: official icon when listed, lettered tile when the manifest or the entry is missing', async () => {
const { data } = await import('../../public/js/data.js');
const { Img } = await import('../../public/js/ui/gameComponents.js');
const { ModuleGlyph } = await import('../../public/js/screens/loadout.js');
const imgOf = (vnode) => [...walk(vnode)].find((n) => n.type === Img);
await data.load('local');
let img = imgOf(ModuleGlyph({ m: null, rec: { uniEquipId: 'uniequip_002_x', typeName: 'MAR-X' }, id: 'uniequip_002_x' }));
assert.equal(img.props.src, '/assets/local/module/mar-x.png');
assert.equal(textOf(img.props.fallback), 'X', 'the letter stays as the image-error fallback');
img = imgOf(ModuleGlyph({ m: null, rec: { uniEquipId: 'uniequip_003_x', typeName: 'MAR-Y' }, id: 'uniequip_003_x' }));
assert.equal(img.props.src, null, 'unlisted type');
assert.equal(textOf(img.props.fallback), 'Y');
// manifest missing
mode = 'missing';
const orig = console.warn; console.warn = () => {};
try { await data.invalidate('local'); } finally { console.warn = orig; }
assert.equal(data.status('local'), 'missing');
img = imgOf(ModuleGlyph({ m: null, rec: { uniEquipId: 'uniequip_002_x', typeName: 'MAR-X' }, id: 'uniequip_002_x' }));
assert.equal(img.props.src, null);
assert.equal(textOf(img.props.fallback), 'X');
assert.equal(moduleTypeIconUrl(data.get('local'), 'MAR-X'), null);
});
test('the in-match detail panel and shop card resolve the icon from the local manifest and keep their text fallback', () => {
const detail = readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8');
assert.match(detail, /<\$\{Img\} src=\$\{moduleTypeIconUrl\(data\.get\('local'\), lo\.module\.typeName\)\} fallback=\$\{html`<b class="num">\$\{moduleBadge\(lo\.module\)\}<\/b>`\}/);
assert.match(detail, /<span class="dmodule__type">\$\{lo\.module\.typeName\}<\/span>/, 'the type text stays');
const shop = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8');
assert.match(shop, /<\$\{Img\} src=\$\{moduleTypeIconUrl\(data\.get\('local'\), mod\.typeName\)\} class="scard__modicon" \/>\$\{mod\.typeName\}/,
'icon beside the type text (Img renders nothing without it)');
});
});