Files
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

155 lines
9.6 KiB
JavaScript

// A teammate's operator shows ITS owner's skill / module in the detail card (DESIGN §16), not the viewer's loadout nor
// the defaults: prep scouting (前往查看 → g.watch 'n:<pid>' → m.field prep: true, Match.prepFieldMeta) and the sim's
// UnitInfo in shared fields (联防 / 最终攻势 / an observed battle) carry `skillIndex` / `moduleId` per unit, and the
// game screen's detail card composes them (gameLogic unitLoadout → chessLoadout). Regression: the card showed the
// defaults for every teammate unit (m.field prep units had no loadout; the sim UnitInfo had no module).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { PHASE } from '../../shared/constants.js';
import { checkLoadout, loadoutOptions, MODULE_NONE } from '../../shared/protocol.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { unitLoadout, chessLoadout } from '../../public/js/ui/gameLogic.js';
import { DATA, makeMatch } from './harness.js';
const REAL = { skip: !hasGeneratedData() };
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), elite module MAR-X (uniequip_002_inside) or none
const SWIRE = 'chess_char_3_04_a'; // 琳琅诗怀雅: several elite modules
const wire = (msg) => JSON.parse(JSON.stringify(msg)); // what the scout's browser receives (undefined dropped)
function place(ps, chessId, row, col) {
const p = ps.newPiece('chess', chessId);
ps.board.set(`${row},${col}`, p);
return p;
}
function clearBoard(ps) {
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
}
test('prep scouting: m.field units carry the scouted player\'s skill / module (the viewer\'s loadout never leaks)', REAL, () => {
const sg = chess(chess(SWIRE).goldenId);
const alt = sg.modules.find((x) => !x.isDefault).uniEquipId;
const lo0 = checkLoadout({ [INSIDE]: { skill: 0, module: MODULE_NONE }, [SWIRE]: { skill: 0, module: alt } }, chess).loadout;
const lo1 = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout; // p_1: S1 for 隐现, modules on the defaults
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: lo0 },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: lo1 },
];
const h = makeMatch({ mode: 'coop', seats, seed: 5 }).start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
const p1 = h.ps('p_1');
clearBoard(p0);
clearBoard(p1);
const n = place(p0, INSIDE, 9, 3); // normal: S1, no module
const g = place(p0, chess(INSIDE).goldenId, 10, 3); // elite: S1 + 不装备
const s = place(p0, sg.chessId, 11, 3); // elite: S1 + the alternative module
const plain = Object.values(DATA.chess).find((c) => !c.isGolden && c.visible && !c.isHidden && !c.isDiy && c.chessId !== INSIDE && c.chessId !== SWIRE);
const pl = place(p0, plain.goldenId, 12, 3); // not in the loadout: defaults
const t = place(p1, chess(INSIDE).goldenId, 10, 4); // p_1's own elite 隐现: S1 + its default module
// p_1 scouts p_0 (前往查看 in prep)
assert.equal(m.phase, PHASE.PREP);
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
const meta = wire(h.lastTo('p_1', 'm.field'));
assert.equal(meta.prep, true);
const by = new Map(meta.units.map((u) => [u.uid, u]));
assert.equal(by.get(n.uid).skillIndex, 0);
assert.ok(!('moduleId' in by.get(n.uid)), 'normal chess have no module');
assert.deepEqual([by.get(g.uid).skillIndex, by.get(g.uid).moduleId], [0, MODULE_NONE]);
assert.deepEqual([by.get(s.uid).skillIndex, by.get(s.uid).moduleId], [0, alt]);
const pg = chess(plain.goldenId);
const pOpt = loadoutOptions(plain, pg);
assert.deepEqual([by.get(pl.uid).skillIndex, by.get(pl.uid).moduleId], [pOpt.defaultSkill, pOpt.defaultModule]);
// exactly what the scouted board fights with (PlayerBattleInput units)
for (const u of p0.battleInput().units) {
const v = by.get(u.uid);
assert.equal(v.skillIndex, u.skillIndex, `${u.chessId}: skill`);
assert.equal(v.moduleId ?? null, u.moduleId ?? null, `${u.chessId}: module`);
}
// the scout's detail card (game.js detailLoadout → unitLoadout → DetailPanel chessLoadout) shows p_0's choice
const card = (u) => chessLoadout(chess(u.defId), unitLoadout(chess(u.defId), u), chess);
assert.deepEqual([card(by.get(g.uid)).skillIndex, card(by.get(g.uid)).module.id], [0, MODULE_NONE]);
assert.equal(card(by.get(g.uid)).changed, true, 'shown as 已调配');
assert.deepEqual([card(by.get(s.uid)).skillIndex, card(by.get(s.uid)).module.id], [0, alt]);
assert.equal(card(by.get(n.uid)).skillIndex, 0);
assert.equal(card(by.get(pl.uid)).changed, false, 'defaults stay defaults');
// …and p_1's own loadout (隐现 elite on S1 with its default module) does not bleed into p_0's 隐现 cards
const own = chessLoadout(chess(g.id), p1.loadout, chess);
assert.equal(own.module.id, 'uniequip_002_inside');
assert.notEqual(card(by.get(g.uid)).module.id, own.module.id);
// the other way round: p_0 scouts p_1 → p_1's loadout, not p_0's 不装备
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const u1 = wire(h.lastTo('p_0', 'm.field')).units.find((u) => u.uid === t.uid);
assert.deepEqual([u1.skillIndex, u1.moduleId], [0, 'uniequip_002_inside']);
assert.deepEqual([card(u1).skillIndex, card(u1).module.id], [0, 'uniequip_002_inside']);
m.dispose();
});
test('sim UnitInfo (shared fields): each helper\'s elite carries its own module; normal chess / enemies none', REAL, () => {
const G = chess(INSIDE).goldenId;
const h = makeBattle({
kind: 'unite',
players: [
{ playerId: 'A', seat: 0, side: 'L', colOffset: 0, units: [
{ uid: 11, kind: 'chess', chessId: G, row: 10, col: 4, skillIndex: 0, moduleId: MODULE_NONE },
{ uid: 12, kind: 'chess', chessId: INSIDE, row: 11, col: 4, skillIndex: 0 },
] },
{ playerId: 'B', seat: 1, side: 'L', colOffset: 8, units: [{ uid: 21, kind: 'chess', chessId: G, row: 10, col: 4 }] },
],
enemies: [], autoFinish: false, timeLimit: 10,
});
h.step();
const spawns = new Map(h.eventsOf('spawn').map((e) => [e[1].uid, wire(e[1])]));
const meta = new Map(wire(h.b.fieldMeta()).units.map((u) => [u.uid, u]));
for (const src of [spawns, meta]) {
assert.deepEqual([src.get(11).skillIndex, src.get(11).moduleId, src.get(11).ownerId], [0, MODULE_NONE, 'A']);
assert.deepEqual([src.get(21).skillIndex, src.get(21).moduleId, src.get(21).ownerId], [1, 'uniequip_002_inside', 'B'], 'B on the defaults');
assert.equal(src.get(12).skillIndex, 0);
assert.ok(!('moduleId' in src.get(12)), 'normal chess: no module');
}
// A viewer (B) opening A's elite sees S1 + 不装备
const lo = chessLoadout(chess(G), unitLoadout(chess(G), meta.get(11)), chess);
assert.deepEqual([lo.skillIndex, lo.module.id, lo.module.none], [0, MODULE_NONE, true]);
// enemies never carry a loadout
const en = h.spawn('enemy_1007_slime');
assert.ok(en);
h.step();
const ei = wire(h.eventsOf('spawn').map((e) => e[1]).find((u) => u.side === 'enemy'));
assert.ok(ei && !('moduleId' in ei) && !('skillIndex' in ei));
});
test('gameLogic unitLoadout: the unit\'s own skill / module keyed by the base chess id; nothing carried → defaults', () => {
const sk = (index, isDefault = false) => ({ index, skillId: `sk_${index}`, iconId: `sk_${index}`, name: `技能${index}`, isDefault });
const base = { chessId: 'c_a', baseId: 'c_a', goldenId: 'c_b', isGolden: false, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)] };
const gold = { chessId: 'c_b', baseId: 'c_a', isGolden: true, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)],
module: { id: 'uniequip_x', name: '模组X', type: 'XYZ-X', active: true },
modules: [{ uniEquipId: 'uniequip_x', name: '模组X', typeName: 'XYZ-X', isDefault: true }, { uniEquipId: 'uniequip_y', name: '模组Y', typeName: 'XYZ-Y' }] };
const get = (id) => ({ c_a: base, c_b: gold }[id] || null);
assert.deepEqual(unitLoadout(gold, { defId: 'c_b', skillIndex: 0, moduleId: 'uniequip_y' }), { c_a: { skill: 0, module: 'uniequip_y' } });
assert.deepEqual(unitLoadout(base, { defId: 'c_a', skillIndex: 1, moduleId: 'uniequip_y' }), { c_a: { skill: 1 } }, 'a normal chess has no module');
assert.equal(unitLoadout(base, { defId: 'c_a' }), null, 'nothing carried');
assert.equal(unitLoadout(base, { skillIndex: -1, moduleId: '' }), null, 'junk ignored');
assert.equal(unitLoadout(null, { skillIndex: 0 }), null);
assert.equal(unitLoadout(base, null), null);
const r = chessLoadout(gold, unitLoadout(gold, { skillIndex: 0, moduleId: 'uniequip_y' }), get);
assert.deepEqual([r.skill.skillId, r.module.id, r.changed], ['sk_0', 'uniequip_y', true]);
const d = chessLoadout(gold, unitLoadout(gold, { skillIndex: 2 }), get);
assert.deepEqual([d.skill.skillId, d.module.id, d.changed], ['sk_2', 'uniequip_x', false], 'no module carried → the default one');
});
test('game.js: a teammate unit\'s detail card uses the unit\'s own loadout (never the viewer\'s, never forced defaults)', () => {
const src = readFileSync(new URL('../../public/js/screens/game.js', import.meta.url), 'utf8');
const i = src.indexOf('const detailLoadout = (() => {');
assert.ok(i > 0, 'detailLoadout present');
const block = src.slice(i, src.indexOf('})();', i));
assert.match(block, /unitLoadout\(resolved\.chess, u\)/, 'teammate units → unitLoadout');
assert.match(block, /owner === myId\)\) return priv\?\.loadout/, 'own units → m.private.loadout');
});