卫戍协议:盟约 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

+417
View File
@@ -0,0 +1,417 @@
// test/ui/loadout.test.js — 干员调配 client logic (DESIGN §16): the loadout model (storage parsing, options, choices,
// sanitising a stale browser loadout so the server never refuses it, roster filters, display helpers), the server
// sync (room.loadout after welcome / edits, debounce, lock / rate handling, never on missing data) and the overlay's
// auto-close rule.
import { test } 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 { checkLoadout, MODULE_NONE } from '../../shared/protocol.js';
import { PHASE } from '../../shared/constants.js';
import {
parseStored, toStored, chessOptions, effectiveChoice, setChoice, resetChoice, sanitizeEntries, rosterOf, filterRoster,
changedCount, moduleBadge, attrRows, skillTags, skillLabel, selectedSkill, selectedModule, recordsOf,
} from '../../public/js/ui/loadoutModel.js';
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
import { createStore } from '../../public/js/store.js';
import { shouldAutoClose } from '../../public/js/screens/loadout.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const BONDS = JSON.parse(readFileSync(path.join(ROOT, 'data/bonds.json'), 'utf8'));
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
const getBond = (id) => (Object.hasOwn(BONDS, id) ? BONDS[id] : null);
const INSIDE = 'chess_char_1_01_a';
const SWIRE = 'chess_char_3_04_a';
const { base: IB, golden: IG } = recordsOf(INSIDE, get);
const { base: SB, golden: SG } = recordsOf(SWIRE, get);
const SWIRE_ALT = SG.modules.find((m) => !m.isDefault).uniEquipId;
test('parseStored: tolerant of junk, keeps structurally valid entries; toStored round trip', () => {
assert.deepEqual(parseStored(null), {});
assert.deepEqual(parseStored('x'), {});
assert.deepEqual(parseStored({ v: 1, entries: { [INSIDE]: { skill: 0, module: 'none', junk: 1 }, 'bad id': { skill: 1 }, [SWIRE]: { skill: -1 } } }),
{ [INSIDE]: { skill: 0, module: 'none' } });
assert.deepEqual(parseStored({ [INSIDE]: { skill: 1 } }), { [INSIDE]: { skill: 1 } }, 'bare map (older build)');
const e = { [INSIDE]: { skill: 0 } };
assert.deepEqual(parseStored(JSON.parse(JSON.stringify(toStored(e)))), e);
});
test('options: skill records at Lv4 (normal) and Lv7 (elite); modules + 不装备 with defaults flagged', () => {
const o = chessOptions(IB, IG);
assert.deepEqual(o.skillOptions.map((s) => s.index), [0, 1]);
assert.equal(o.skillOptions.find((s) => s.isDefault).index, 1);
for (const s of o.skillOptions) {
assert.equal(s.normal.level, IB.status.skillLevel, 'normal record at the normal skill level');
assert.equal(s.elite.level, IG.status.skillLevel, 'elite record at the elite skill level');
assert.equal(s.normal.skillId, s.elite.skillId);
}
assert.deepEqual(o.moduleOptions.map((m) => m.id), ['uniequip_002_inside', MODULE_NONE]);
assert.ok(o.moduleOptions[0].isDefault && o.moduleOptions[0].rec.typeName === 'MAR-X');
assert.equal(o.moduleOptions[1].rec, null);
const s = chessOptions(SB, SG);
assert.equal(s.moduleOptions.length, SG.modules.length + 1);
});
test('choices: set / reset keep only non-default entries; effectiveChoice falls back to defaults', () => {
let e = {};
assert.deepEqual(effectiveChoice(e, IB, IG), { skill: 1, module: 'uniequip_002_inside', changed: false });
e = setChoice(e, IB, IG, { skill: 0 });
assert.deepEqual(e, { [INSIDE]: { skill: 0 } });
e = setChoice(e, IB, IG, { module: MODULE_NONE });
assert.deepEqual(e, { [INSIDE]: { skill: 0, module: MODULE_NONE } });
assert.deepEqual(effectiveChoice(e, IB, IG), { skill: 0, module: MODULE_NONE, changed: true });
e = setChoice(e, IB, IG, { skill: 1, module: 'uniequip_002_inside' });
assert.deepEqual(e, {}, 'back to the defaults ⇒ no entry');
e = setChoice(e, IB, IG, { skill: 5 }); // not selectable: ignored
assert.deepEqual(e, {});
e = setChoice(setChoice({}, SB, SG, { module: SWIRE_ALT }), IB, IG, { skill: 0 });
assert.equal(changedCount(e, get), 2);
e = resetChoice(e, SWIRE);
assert.deepEqual(e, { [INSIDE]: { skill: 0 } });
// stale entries resolve to the defaults
assert.deepEqual(effectiveChoice({ [INSIDE]: { skill: 9, module: 'gone' } }, IB, IG), { skill: 1, module: 'uniequip_002_inside', changed: false });
});
test('sanitizeEntries: drops unknown chess / illegal parts one by one, and the result always passes checkLoadout', () => {
const stale = {
[INSIDE]: { skill: 0, module: SWIRE_ALT }, // the module belongs to another character: the skill part survives
[SWIRE]: { module: SWIRE_ALT },
chess_char_9_99_a: { skill: 0 }, // removed from the data
[IG.chessId]: { skill: 0 }, // an elite id
[SB.chessId + 'x']: { skill: 1 },
};
const out = sanitizeEntries(stale, get);
assert.deepEqual(out, { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } });
assert.ok(checkLoadout(out, get).ok);
// every visible chess × every choice sanitises to an accepted loadout
const all = {};
for (const c of rosterOf(Object.values(CHESS))) {
const o = chessOptions(c, get(c.goldenId));
all[c.chessId] = { skill: o.skills.find((i) => i !== o.defaultSkill), module: o.modules[o.modules.length - 1] };
}
const s = sanitizeEntries(all, get);
assert.equal(Object.keys(s).length, 112);
assert.ok(checkLoadout(s, get).ok);
});
test('selectedSkill / selectedModule for the in-match UI (shop cards, detail panel)', () => {
const lo = checkLoadout({ [INSIDE]: { skill: 0, module: MODULE_NONE } }, get).loadout;
assert.equal(selectedSkill(lo, IB, get).index, 0);
assert.equal(selectedSkill(lo, IG, get).level, IG.status.skillLevel);
assert.equal(selectedModule(lo, IG, get), null);
assert.equal(selectedModule({}, IG, get).uniEquipId, 'uniequip_002_inside');
assert.equal(selectedModule(lo, IB, get), null, 'normal chess have no module');
assert.equal(selectedSkill(null, SB, get).index, SB.skill.index);
});
test('roster and filters: 112 visible chess in shop order; tier / class / bond / search / changed-only', () => {
const roster = rosterOf(Object.values(CHESS));
assert.equal(roster.length, 112);
assert.ok(roster.every((c) => !c.isGolden && c.visible));
for (let i = 1; i < roster.length; i++) assert.ok(roster[i - 1].tier <= roster[i].tier);
const t3 = filterRoster(roster, { tier: 3 }, {}, get, getBond);
assert.ok(t3.length > 0 && t3.every((c) => c.tier === 3));
const snipers = filterRoster(roster, { prof: 'SNIPER' }, {}, get, getBond);
assert.ok(snipers.length > 0 && snipers.every((c) => c.profession === 'SNIPER'));
const bond = IB.bonds[0];
assert.ok(filterRoster(roster, { bond }, {}, get, getBond).every((c) => c.bonds.includes(bond)));
assert.deepEqual(filterRoster(roster, { query: '隐现' }, {}, get, getBond).map((c) => c.chessId), [INSIDE]);
const bondName = getBond(bond).name;
assert.ok(filterRoster(roster, { query: bondName }, {}, get, getBond).some((c) => c.chessId === INSIDE), 'search matches bond names');
assert.deepEqual(filterRoster(roster, { changedOnly: true }, { [INSIDE]: { skill: 0 }, [SWIRE]: { skill: SB.skill.index } }, get, getBond).map((c) => c.chessId), [INSIDE]);
});
test('display helpers: module badge, stat rows, skill tags', () => {
assert.equal(moduleBadge({ typeName: 'MAR-X' }), 'X');
assert.equal(moduleBadge({ typeName: 'ISW-α' }), 'α');
assert.equal(moduleBadge(null, MODULE_NONE), '—');
assert.deepEqual(attrRows({ maxHp: 80, atk: 22, def: 0 }).map((r) => [r.label, r.text, r.positive]), [['生命上限', '+80', true], ['攻击力', '+22', true]]);
assert.deepEqual(attrRows({ cost: -1, respawnTime: -4 }).map((r) => [r.label, r.text, r.positive]), [['部署费用', '-1', true], ['再部署时间', '-4秒', true]]);
assert.deepEqual(attrRows(null), []);
const t = skillTags(IB.skills[1]);
assert.equal(t.sp, '自动回复');
assert.equal(t.cost, IB.skills[1].spCost);
assert.equal(t.duration, '弹药');
assert.equal(skillLabel(2), 'S3');
const passive = skillTags({ skillType: 'PASSIVE', spType: 8 });
assert.equal(passive.passive, true);
assert.equal(passive.cost, null);
});
// ---- sync ---------------------------------------------------------------------------------------------------------------
function fakeNet() {
const listeners = new Map();
const net = {
status: 'online',
sent: [],
replies: [],
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
request(t, fields) {
net.sent.push({ t, ...fields });
const r = net.replies.shift();
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
},
};
return net;
}
function fakeTimers() {
let now = 0;
let seq = 0;
const q = new Map();
return {
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
clearTimeout: (id) => q.delete(id),
async advance(ms) {
const end = now + ms;
for (;;) {
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
q.delete(next[0]);
now = next[1].at;
next[1].fn();
for (let i = 0; i < 5; i++) await Promise.resolve();
}
now = end;
for (let i = 0; i < 5; i++) await Promise.resolve();
},
};
}
const syncStore = (entries = {}) => createStore({ entries, open: false, from: null, sel: null, filters: {}, sync: 'idle' });
test('sync: welcome sends the sanitised loadout; edits are debounced; identical content is not resent', async () => {
const net = fakeNet();
const T = fakeTimers();
const target = syncStore({ [INSIDE]: { skill: 0 }, chess_char_9_99_a: { skill: 1 } });
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
net.emit('welcome', {});
await T.advance(100);
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }], 'stale entry dropped');
assert.equal(target.get().sync, 'synced');
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
await T.advance(SYNC_DEBOUNCE_MS - 10);
assert.equal(net.sent.length, 1, 'debounced');
await T.advance(20);
assert.equal(net.sent.length, 2);
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } });
target.set({ entries: { ...target.get().entries } }); // same content, new object
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 2, 'unchanged content is not resent');
net.emit('welcome', {}); // a new / resumed session always gets it again
await T.advance(100);
assert.equal(net.sent.length, 3);
s.dispose();
});
test('sync: locked match (WRONG_PHASE) is not an error; RATE retries; offline waits for the next welcome; missing data never sends', async () => {
const net = fakeNet();
const T = fakeTimers();
const target = syncStore({ [INSIDE]: { skill: 0 } });
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
net.replies.push({ error: 'WRONG_PHASE' });
net.emit('welcome', {});
await T.advance(100);
assert.equal(target.get().sync, 'locked');
// back in the lobby (room.state without a match): sent again
net.emit('room.state', { inMatch: false });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 2);
assert.equal(target.get().sync, 'synced');
// RATE → retried after RETRY_MS
net.replies.push({ error: 'RATE' });
target.set({ entries: {} });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 3);
assert.equal(target.get().sync, 'pending');
await T.advance(RETRY_MS + 10);
assert.equal(net.sent.length, 4);
assert.deepEqual(net.sent[3].entries, {});
assert.equal(target.get().sync, 'synced');
// offline: nothing sent until the next welcome
net.status = 'reconnecting';
target.set({ entries: { [INSIDE]: { skill: 0 } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 4);
net.status = 'online';
net.emit('welcome', {});
await T.advance(100);
assert.equal(net.sent.length, 5);
s.dispose();
// chess data missing: never sanitise everything away (that would clear the server's copy)
const net2 = fakeNet();
const t2 = syncStore({ [INSIDE]: { skill: 0 } });
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => null, lookupChess: () => null });
net2.emit('welcome', {});
await T.advance(100);
assert.equal(net2.sent.length, 0);
assert.equal(t2.get().sync, 'error');
s2.dispose();
});
test('sync: closing the overlay sends a pending edit at once (the next click — 准备就绪 / 开始模拟 — must not overtake it)', async () => {
const net = fakeNet();
const T = fakeTimers();
const target = syncStore({});
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
net.emit('welcome', {});
await T.advance(100);
assert.equal(net.sent.length, 1);
target.set({ open: true, from: 'briefing' });
target.set({ entries: { [INSIDE]: { skill: 0 } } });
await T.advance(50); // well inside the debounce
assert.equal(net.sent.length, 1, 'still debounced while the overlay is open');
target.set({ open: false });
for (let i = 0; i < 5; i++) await Promise.resolve(); // microtasks only: no timer ran
assert.equal(net.sent.length, 2, 'sent on close, before any later task (e.g. the g.infoReady click)');
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 2, 'the cancelled debounce does not send it twice');
// closing without a pending edit sends nothing
target.set({ open: true });
target.set({ open: false });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 2);
s.dispose();
});
test('overlay auto-close: briefing entry closes when INFO_CHECK ends; lobby / room entry closes when a match starts', () => {
const open = (from) => ({ open: true, from });
assert.equal(shouldAutoClose(open('briefing'), PHASE.INFO_CHECK, true, true), false);
assert.equal(shouldAutoClose(open('briefing'), PHASE.BAND_DRAFT, true, true), true);
assert.equal(shouldAutoClose(open('room'), null, false, false), false);
assert.equal(shouldAutoClose(open('room'), null, true, false), true);
assert.equal(shouldAutoClose(open('lobby'), PHASE.INFO_CHECK, true, true), false, 'reopened during the briefing from elsewhere');
assert.equal(shouldAutoClose({ open: false, from: 'briefing' }, PHASE.PREP, true, true), false);
});
test('untimed phases show no countdown: the "无倒计时" placeholder of Countdown is hidden (user playtest #11)', () => {
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
assert.match(css, /\.countdown\[aria-label="无倒计时"\]\s*\{\s*display:\s*none;/);
const html = readFileSync(path.join(ROOT, 'public/index.html'), 'utf8');
assert.match(html, /<link rel="stylesheet" href="\/css\/screens\/loadout\.css" \/>/, 'loaded on every page');
// contract with ui/components.js: a missing deadline renders the Countdown with exactly that aria-label (or nothing)
const comp = readFileSync(path.join(ROOT, 'public/js/ui/components.js'), 'utf8');
const fn = comp.slice(comp.indexOf('export function Countdown'), comp.indexOf('export function Countdown') + 2500);
assert.ok(/'无倒计时'/.test(fn) || /return null/.test(fn), 'Countdown marks (or skips) the untimed state');
});
// ---- review fixes (adversarial review of the loadout workstream) -------------------------------------------------------
/** A net whose replies are released by hand (the socket is ordered: replies come back in request order). */
function deferredNet() {
const listeners = new Map();
const net = {
status: 'online',
sent: [],
pending: [],
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
request(t, fields) {
net.sent.push({ t, ...fields });
return new Promise((resolve, reject) => net.pending.push({ resolve, reject }));
},
async reply(error = null) {
const p = net.pending.shift();
if (error) p.reject(Object.assign(new Error(error), { code: error })); else p.resolve({ t: 'ok' });
for (let i = 0; i < 5; i++) await Promise.resolve();
},
};
return net;
}
const ticks = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); };
test('sync: an edit closed while an earlier room.loadout is still in flight is sent at once, not after that reply', async () => {
const net = deferredNet();
const T = fakeTimers();
const target = syncStore({ [INSIDE]: { skill: 0 } });
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get, notify: () => {} });
net.emit('welcome', {});
await T.advance(100);
assert.equal(net.sent.length, 1, 'welcome send in flight (no reply yet)');
target.set({ open: true, from: 'briefing' });
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
target.set({ open: false });
await ticks(); // microtasks only: the player's next click (准备就绪) is a later task
assert.equal(net.sent.length, 2, 'the edit went out before the first reply (the same ordered socket)');
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0, module: MODULE_NONE } });
// the older reply must not mark the newer content as synced / the newer reply wins
await net.reply();
assert.equal(target.get().sync, 'sending', 'the older reply does not settle the state');
await net.reply();
assert.equal(target.get().sync, 'synced');
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 2, 'nothing resent');
// reverting to the content of a request that is still in flight is not sent twice; reverting to the last accepted
// content while a different one is in flight is sent (the server applies the newest frame)
target.set({ entries: { [INSIDE]: { skill: 0 } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 3);
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 4, 'back to the accepted content while another is in flight: sent');
target.set({ entries: { [INSIDE]: { skill: 0, module: MODULE_NONE } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(net.sent.length, 4, 'identical to the one in flight: not duplicated');
await net.reply();
await net.reply();
assert.equal(target.get().sync, 'synced');
s.dispose();
});
test('sync: an edit refused because the match locked its loadout is told to the player once; a resync is not', async () => {
const net = deferredNet();
const T = fakeTimers();
const told = [];
const target = syncStore({ [INSIDE]: { skill: 0 } });
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get, notify: (t) => told.push(t) });
net.emit('welcome', {}); // reconnect during a running match: the resync is refused, nothing to tell
await T.advance(100);
await net.reply('WRONG_PHASE');
assert.equal(target.get().sync, 'locked');
assert.deepEqual(told, []);
// an edit (the briefing overlay closed by INFO_CHECK ending) arrives too late
target.set({ open: true, from: 'briefing' });
target.set({ entries: { [INSIDE]: { skill: 1 } } });
target.set({ open: false });
await ticks();
assert.equal(net.sent.length, 2);
await net.reply('WRONG_PHASE');
assert.equal(target.get().sync, 'locked');
assert.equal(told.length, 1);
assert.match(told[0], /下一局生效/);
s.dispose();
});
test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB download in the lobby for it)', async () => {
const net = deferredNet();
const T = fakeTimers();
let loads = 0;
const target = syncStore({});
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
net.emit('welcome', {});
await T.advance(100);
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {} }]);
assert.equal(loads, 0);
await net.reply();
target.set({ entries: { [INSIDE]: { skill: 0 } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(loads, 1, 'a real loadout is sanitised against the data');
assert.deepEqual(net.sent[1].entries, { [INSIDE]: { skill: 0 } });
s.dispose();
});
test('entry badge (review fix): counts like the screen once chess.json is loaded (stale entries of retired chess do not count)', async () => {
const { badgeCount } = await import('../../public/js/screens/loadout.js');
// unknown id, a retired (hidden) chess on a non-default skill (红豆 S1), an elite id
const entries = { [INSIDE]: { skill: 0 }, chess_char_9_99_a: { skill: 1 }, chess_char_1_05_a: { skill: 0 }, [IB.goldenId]: { skill: 0 } };
assert.equal(get('chess_char_1_05_a').isHidden, true, 'fixture: 红豆 is retired');
assert.equal(badgeCount(entries, null), 4, 'before the data: the stored entries');
assert.equal(badgeCount(entries, get), 1, 'with the data: only chess the screen shows as 已调整');
assert.equal(badgeCount(entries, get), changedCount(entries, get));
assert.equal(badgeCount({}, get), 0);
});