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

+298
View File
@@ -0,0 +1,298 @@
// waves.js: per-match setup (official type schedule + picks), per-action replacement, scaling, bounties, 联防 routing,
// boss templates, preview. The official-number fixtures live in waves-official.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import {
setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, buildUniteWave, previewOf, roundPick, gateOf,
isFlyKey,
} from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
const TS = DATA.factions.templateSlots;
const PLACEHOLDERS = new Set([TS.N, TS.E, TS.S, TS.NF, TS.EF, TS.SF]);
const fly = (k) => DATA.enemies[k].isFlyEnemy;
test('match setup: stage allowed by the mode, 3 distinct random factions, weighted boss, hidden boss only with a hidden round', () => {
for (const modeId of ['mode_single_funny', 'mode_multi_funny', 'mode_multi_normal', 'mode_single_hard', 'mode_multi_abyss']) {
const gd = new GameData(DATA, modeId);
const bosses = new Set();
for (let seed = 1; seed <= 30; seed++) {
const s = setupMatchWaves(gd, createRng(seed));
assert.ok(gd.mode.stages.includes(s.stageId), `${modeId} stage ${s.stageId}`);
assert.ok(DATA.stages[s.stageId].active && DATA.stages[s.stageId].weight > 0);
assert.equal(new Set(s.factions).size, 3);
for (const f of s.factions) assert.ok(DATA.factions.types[f].involveRandom, f);
assert.ok(gd.mode.bossWeights[s.bossId] > 0);
bosses.add(s.bossId);
if (gd.hiddenRound) assert.ok(['boss_8', 'boss_9', 'boss_10'].includes(s.hiddenBossId));
else assert.equal(s.hiddenBossId, null);
}
assert.ok(bosses.size >= 3, 'bosses vary with the seed');
}
const a = setupMatchWaves(new GameData(DATA, 'mode_multi_hard'), createRng(5));
const b = setupMatchWaves(new GameData(DATA, 'mode_multi_hard'), createRng(5));
assert.deepEqual(a, b, 'deterministic');
});
test('type schedule: each chosen type owns exactly 3 of the 15 round slots, SPECIAL the other 6, shuffled; the order stays hidden', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const firstSlot = new Map();
for (let seed = 1; seed <= 60; seed++) {
const s = setupMatchWaves(gd, createRng(seed));
assert.equal(s.typeSlots.length, 15);
const count = (t) => s.typeSlots.filter((x) => x === t).length;
for (const f of s.factions) assert.equal(count(f), 3, `seed ${seed}: ${f}`);
assert.equal(count('SPECIAL'), 6);
firstSlot.set(s.typeSlots[0], (firstSlot.get(s.typeSlots[0]) || 0) + 1);
// the schedule rides on the factions array without leaking into the public view
assert.deepEqual(s.factions.schedule.typeSlots, s.typeSlots);
assert.equal(JSON.stringify(s.factions), JSON.stringify([...s.factions]));
assert.deepEqual(Object.keys(s.factions), ['0', '1', '2']);
// picks: one per round, of the round's type and half; the special key is allowed in the mode
for (let r = 1; r <= 15; r++) {
const p = s.picks[r];
assert.ok(p, `seed ${seed} R${r}`);
assert.equal(p.type, s.typeSlots[r - 1]);
assert.equal(p.firstHalf, r <= 7);
const e = DATA.factions.entries[p.key];
assert.equal(e.type, p.type);
assert.equal(e.firstHalf, r <= 7);
assert.ok(!gd.inactiveEnemies.has(p.key), `R${r}: banned special ${p.key}`);
assert.ok(e.N.some((x) => x.key === p.normal) && e.E.some((x) => x.key === p.elite));
assert.equal(p.fly, e.fly);
assert.equal(roundPick(gd, createRng(1), s.factions, r), p, 'the factions array resolves the same pick');
assert.equal(roundPick(gd, createRng(1), s, r), p, 'so does the setup object');
}
}
assert.ok(firstSlot.size >= 3, 'R1 type varies (shuffled slots)');
// the extra schedule draws do not shift what the caller draws next from the setup rng (bans stay seed-stable)
const r1 = createRng(9); setupMatchWaves(gd, r1);
const r2 = createRng(9);
const stageIds = gd.mode.stages.filter((id) => gd.stage(id) && gd.stage(id).active !== false && gd.stage(id).weight > 0);
r2(); // stage
r2.shuffle(Object.values(DATA.factions.types).filter((t) => t.involveRandom).map((t) => t.type));
r2(); // boss
if (gd.hiddenRound) r2();
assert.ok(stageIds.length > 0);
assert.equal(r1(), r2());
});
test('normal waves: one pick per round, per-action counts 1–5, the other movement class skipped, literal keys kept, official scaling', () => {
for (const modeId of ['mode_multi_normal', 'mode_multi_funny', 'mode_single_abyss']) {
const gd = new GameData(DATA, modeId);
for (let seed = 1; seed <= 8; seed++) {
const setup = setupMatchWaves(gd, createRng(seed));
for (let r = 1; r < gd.bossRound; r++) {
const w = buildNormalWave(gd, createRng(seed + 100), setup.factions, r);
const tpl = DATA.waves[w.templateId];
const pick = setup.picks[r];
assert.equal(w.pick, pick);
assert.equal(w.templateId, gd.roundCfg(r).template);
assert.equal(w.timeLimit, gd.combatTimeLimit(r));
assert.deepEqual(gd.enemyScale(r), gd.baseEnemyScale(r), 'no custom multiplier');
const scale = gd.enemyScale(r);
const allowed = new Set([pick.key, pick.normal, pick.elite]);
for (const s of w.spawns) {
const a = tpl.spawns[s.actionIndex];
assert.ok(a && !a.action, `R${r}: action ${s.actionIndex}`);
assert.equal(s.time, a.time);
assert.equal(s.routeIndex, a.routeIndex);
if (PLACEHOLDERS.has(a.key)) {
assert.ok(allowed.has(s.enemyKey), `${modeId} R${r}: ${s.enemyKey} not from the pick`);
assert.equal(fly(s.enemyKey), pick.fly, 'one movement class per round');
assert.ok(s.count >= 1 && s.count <= 5, 'clamp 1–5 per action');
if (s.count > 1) assert.ok(Math.abs(s.interval - Math.max(a.count * a.interval / s.count, 0.05 * a.count * a.interval)) < 1e-9, 'same window');
} else {
assert.equal(s.enemyKey, a.key, 'literal key kept');
assert.equal(s.count, a.count);
}
assert.equal(s.mods.hpMul, scale.hpMul);
assert.equal(s.mods.atkMul, scale.atkMul);
assert.equal(s.mods.speedMul, scale.speedMul);
assert.ok(['N', 'E', 'S', 'NF', 'EF', 'SF', 'T', 'TF'].includes(s.mods.slot));
assert.equal(s.preview.gate, gateOf(tpl.routes[s.routeIndex].start));
}
// every placeholder action of the pick's class is present, every other one skipped
for (const [i, a] of tpl.spawns.entries()) {
if (a.action || !PLACEHOLDERS.has(a.key)) continue;
const sameClass = DATA.factions.generation.placeholders[a.key].fly === pick.fly;
assert.equal(w.spawns.some((s) => s.actionIndex === i), sameClass, `R${r} action ${i} (${a.slot})`);
assert.equal(w.actions.find((x) => x.index === i).valid, sameClass);
}
assert.equal(w.entries[pick.fly ? 'fly' : 'ground'].key, pick.key);
assert.equal(w.entries[pick.fly ? 'ground' : 'fly'], null);
}
}
}
});
test('plain factions array (tools): the round type is drawn from the schedule marginal; ABYSS speed; ACLOON never spawns', () => {
const gd = new GameData(DATA, 'mode_multi_abyss');
const seen = new Set();
for (let seed = 1; seed <= 80; seed++) seen.add(buildNormalWave(gd, createRng(seed), ['FLY', 'DOT', 'TIMES'], 5).pick.type);
assert.deepEqual([...seen].sort(), ['DOT', 'FLY', 'SPECIAL', 'TIMES']);
assert.equal(gd.enemyScale(2).speedMul, 1);
assert.ok(Math.abs(gd.enemyScale(3).speedMul - 1.15) < 1e-9);
for (const id of Object.keys(DATA.waves)) {
const t = DATA.waves[id];
if (t.kind !== 'normal') continue;
const r = t.usedBy[0]?.round;
if (!r) continue;
const w = buildNormalWave(gd, createRng(2), ['FLY', 'DOT', 'TIMES'], r);
assert.ok(!w.spawns.some((s) => s.enemyKey === 'enemy_9012_acloon'));
}
});
test('boss templates: leaders never scaled, pairs use the multi template, singles _s; escorts follow the round pick (E or EF only)', () => {
const gd = new GameData(DATA, 'mode_multi_abyss');
for (let seed = 1; seed <= 6; seed++) {
const setup = setupMatchWaves(gd, createRng(seed));
const pick = setup.picks[14];
for (const bossId of Object.keys(gd.mode.bossWeights)) {
const pair = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo: false });
const single = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo: true });
assert.ok(!/_s$/.test(pair.templateId) && /_s$/.test(single.templateId), `${bossId}: ${pair.templateId} / ${single.templateId}`);
for (const w of [pair, single]) {
assert.equal(w.pick, pick);
const boss = w.spawns.filter((s) => s.tag === 'boss');
assert.ok(boss.length >= 1);
for (const s of boss) { assert.equal(s.mods.hpMul, undefined); assert.equal(s.preview.boss, true); }
assert.equal(w.timeLimit, Infinity);
const tpl = DATA.waves[w.templateId];
for (const s of w.spawns) {
assert.ok(DATA.enemies[s.enemyKey], s.enemyKey);
const a = tpl.spawns[s.actionIndex];
if (a.slot === 'E' || a.slot === 'EF') {
assert.equal(s.enemyKey, pick.elite);
assert.equal(a.slot === 'EF', pick.fly, 'only the escorts of the pick\'s class');
}
}
}
}
}
const sgd = new GameData(DATA, 'mode_single_funny');
const setup = setupMatchWaves(sgd, createRng(3));
const w = buildBossWave(sgd, createRng(3), setup.factions, 9, { bossId: 'boss_5', solo: true });
assert.equal(w.templateId, 'act2autochess_h07_05_s');
assert.equal(w.pick, setup.picks[9], 'solo 标准: the leader round uses slot 9');
});
test('bounty spawns: joined to the template\'s first normal action of the enemy\'s class, spread over its window; no solo ×0.7', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const picks = [];
picks[5] = { round: 5, type: 'DOT', key: 'enemy_1270_nhstlk', normal: 'enemy_1271_nhsbr', elite: 'enemy_1271_nhsbr_2', fly: false, firstHalf: true };
const pickKeys = DATA.factions.entries.enemy_1270_nhstlk;
picks[5].normal = pickKeys.N[0].key; picks[5].elite = pickKeys.E[0].key;
const w = buildNormalWave(gd, createRng(1), { picks }, 5);
const kill = DATA.choices.cards.bounty.find((c) => c.payout === 'kill' && DATA.enemies[c.enemyKey] && !isFlyKey(gd, c.enemyKey));
const perf = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && DATA.enemies[c.enemyKey] && !isFlyKey(gd, c.enemyKey));
const specs = bountySpawns(gd, 5, w, [{ id: 'b1', card: kill }, { id: 'b2', card: perf }], 'p1');
assert.equal(specs.length, 2);
assert.deepEqual(specs[0].bounty, { coins: kill.coin, ownerPlayerId: 'p1' });
assert.equal(specs[0].count, kill.count);
assert.equal(specs[0].tag, 'bounty');
assert.equal(specs[0].mods.bountyId, 'b1');
assert.equal(specs[1].bounty, undefined);
// host = the first lrsldr action of act1autochess_05; the 2 bounty units are INSERTED among its own units
// (client _InsertSpActionToNormal: unit i at floor((i+1)·len/(n+1)), len growing) and the list spread over the window
const tpl = DATA.waves[w.templateId];
const hostIdx = tpl.spawns.findIndex((s) => s.key === TS.N);
const host = tpl.spawns[hostIdx];
const own = w.spawns.find((s) => s.actionIndex === hostIdx).count;
const total = own + kill.count + perf.count;
const step = Math.max(host.count * host.interval / total, 0.05 * host.count * host.interval);
const list = new Array(own).fill('own');
list.splice(Math.floor((1 * list.length) / 3), 0, 'kill');
list.splice(Math.floor((2 * list.length) / 3), 0, 'perf');
assert.equal(specs[0].routeIndex, host.routeIndex);
assert.ok(Math.abs(specs[0].time - (host.time + list.indexOf('kill') * step)) < 1e-9, 'kill bounty inserted among the own units');
assert.ok(Math.abs(specs[1].time - (host.time + list.indexOf('perf') * step)) < 1e-9);
assert.ok(specs[0].time < host.time + (total - 1) * step, 'not appended after the own units');
assert.equal(specs[0].preview.gate, 'lower');
// withBounties re-times the host action's own units around the inserted bounty units (one spec per run)
const all = withBounties(gd, 5, w, [{ id: 'b1', card: kill }, { id: 'b2', card: perf }], 'p1');
const hostSpecs = all.filter((s) => s.actionIndex === hostIdx && s.tag !== 'bounty');
assert.equal(hostSpecs.reduce((n, s) => n + s.count, 0), own);
const ownTimes = [];
for (const s of hostSpecs) for (let i = 0; i < s.count; i++) ownTimes.push(s.time + i * s.interval);
const wantOwn = list.map((v, j) => (v === 'own' ? host.time + j * step : null)).filter((t) => t !== null);
assert.deepEqual(ownTimes.map((t) => t.toFixed(6)), wantOwn.map((t) => t.toFixed(6)));
assert.equal(all.reduce((n, s) => n + s.count, 0), w.spawns.reduce((n, s) => n + s.count, 0) + 2, 'Σ units = wave + bounties');
assert.notEqual(w.spawns.find((s) => s.actionIndex === hostIdx).interval, hostSpecs[0].interval, 'the shared wave is not mutated');
// no bounty-specific multiplier in solo (the 70 % base is already in enemyScale)
const sgd = new GameData(DATA, 'mode_single_hard');
const solo = bountySpawns(sgd, 5, w, [{ id: 'b2', card: perf }], 'p1', { solo: true });
assert.equal(solo[0].mods.hpMul, sgd.enemyScale(5).hpMul);
assert.equal(solo[0].mods.defMul, undefined);
// a flyer joins the first yokai action
const flyCard = { ...kill, enemyKey: 'enemy_1005_yokai_2', count: 2 };
const fs = bountySpawns(gd, 5, w, [{ id: 'b3', card: flyCard }], 'p1');
assert.equal(fs[0].routeIndex, tpl.spawns.find((s) => s.key === TS.NF).routeIndex);
assert.equal(w.routes[fs[0].routeIndex].motion, 'FLY');
});
test('联防 wave: walkers on the escaped lrsldr action, flyers on yokai, tokens on gopro_2/lazerd; per-owner offsets, window spread', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const leaked = [];
for (let i = 0; i < 12; i++) leaked.push({ enemyKey: 'enemy_1422_lrsldr', mods: { hpMul: 1.2, slot: 'N' }, sourcePlayerId: 'a' });
leaked.push({ enemyKey: 'enemy_1427_lrnazg', mods: { slot: 'E' }, sourcePlayerId: 'c' });
for (let i = 0; i < 3; i++) leaked.push({ enemyKey: 'enemy_1005_yokai', mods: { slot: 'NF' }, sourcePlayerId: 'b', bounty: { coins: 2, ownerPlayerId: 'b' } });
leaked.push({ enemyKey: 'enemy_1007_slime', mods: null, sourcePlayerId: 'b' });
leaked.push({ enemyKey: 'enemy_1269_nhfly', mods: null, sourcePlayerId: 'b' }); // 枯朽之种: summoned only ⇒ token
const one = buildUniteWave(gd, leaked, 1, 60);
const two = buildUniteWave(gd, leaked, 2, 60);
assert.equal(one.templateId, 'act1autochess_escaped_single');
assert.equal(two.templateId, 'act1autochess_escaped_multi');
for (const w of [one, two]) {
const tpl = DATA.waves[w.templateId];
const act = (k) => tpl.spawns.find((s) => s.key === k);
assert.equal(w.spawns.length, 18);
assert.equal(w.spawns.filter((s) => s.sourcePlayerId === 'a').length, 12);
for (const s of w.spawns) {
const tokenOnly = DATA.enemies[s.enemyKey].tokenOnly;
const f = DATA.enemies[s.enemyKey].isFlyEnemy;
const want = act(tokenOnly ? (f ? TS.TF : TS.T) : (f ? TS.NF : TS.N));
assert.equal(s.routeIndex, want.routeIndex, `${s.enemyKey}: on the ${want.key} action`);
assert.equal(w.routes[s.routeIndex].motion === 'FLY', f);
}
// the elite 灵幛 walks with the normal walkers (the class is ignored)
const n = act(TS.N);
const walkers = w.spawns.filter((s) => s.routeIndex === n.routeIndex);
assert.equal(walkers.length, 12 + 1 + 1);
const a = walkers.filter((s) => s.sourcePlayerId === 'a');
const step = Math.max(n.count * n.interval / 12, 0.05 * n.count * n.interval);
assert.equal(a[0].time, n.time);
assert.ok(Math.abs(a[11].time - (n.time + 11 * step)) < 1e-9, 'owner a spread over the action window');
const c = walkers.find((s) => s.sourcePlayerId === 'c');
assert.equal(c.time, n.time + 0.5, 'second owner offset 0.5 s');
assert.ok(w.spawns.some((s) => s.bounty && s.bounty.coins === 2), 'kill bounties keep paying');
}
});
test('preview: one entry per action with gate / time / class flags, bounty and boss entries flagged, parts omitted; Σ count kept', () => {
const p = previewOf([
{ enemyKey: 'a', count: 2, time: 5, preview: { gate: 'upper', fly: false, elite: false } },
{ enemyKey: 'a', count: 1, time: 3, preview: { gate: 'lower', fly: false, elite: false } },
{ enemyKey: 'b', count: 1, time: 4, tag: 'bounty', preview: { gate: 'lower', fly: true, elite: true } },
{ enemyKey: 'c', count: 1, time: 0, tag: 'boss', preview: { gate: 'lower', boss: true } },
{ enemyKey: 'd', count: 1, tag: 'part' },
]);
assert.deepEqual(p, [
{ enemyKey: 'c', count: 1, gate: 'lower', t: 0, fly: false, elite: true, boss: true, source: 'wave', tag: 'boss' },
{ enemyKey: 'a', count: 1, gate: 'lower', t: 3, fly: false, elite: false, boss: false, source: 'wave', tag: null },
{ enemyKey: 'b', count: 1, gate: 'lower', t: 4, fly: true, elite: true, boss: false, source: 'bounty', tag: 'bounty' },
{ enemyKey: 'a', count: 2, gate: 'upper', t: 5, fly: false, elite: false, boss: false, source: 'wave', tag: null },
]);
// a real round: the preview Σ equals the spawned enemies, gates follow the action routes
const gd = new GameData(DATA, 'mode_multi_normal');
const setup = setupMatchWaves(gd, createRng(11));
const w = buildNormalWave(gd, createRng(11), setup.factions, 12);
const pv = previewOf(w.spawns);
assert.equal(pv.length, w.spawns.length);
assert.equal(pv.reduce((n, e) => n + e.count, 0), w.spawns.reduce((n, s) => n + s.count, 0));
assert.ok(pv.some((e) => e.gate === 'upper') && pv.some((e) => e.gate === 'lower'), 'R12 uses both gates');
for (let i = 1; i < pv.length; i++) assert.ok(pv[i].t >= pv[i - 1].t, 'sorted by spawn time');
for (const r of [1, 2, 3]) assert.ok(previewOf(buildNormalWave(gd, createRng(1), setup.factions, r).spawns).every((e) => e.gate === 'lower'), `R${r}: lower gate only`);
});