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

96 lines
5.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Official enemy counts (research 08 §2 / §7-1): the client's RandomEnemyGenerater reproduced exactly — fixtures from
// the official PRTS screenshots and from the research script over every special entry × round.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildNormalWave, replacedCount, roundHalfEven, previewOf } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-waves.json', import.meta.url), 'utf8')).entries;
const name = (k) => DATA.enemies[k].name;
/** The round's wave with a forced pick (entry key, round). */
function waveOf(gd, entryKey, round) {
const e = DATA.factions.entries[entryKey];
const picks = [];
picks[round] = { round, type: e.type, key: e.key, normal: e.N[0].key, elite: e.E[0].key, fly: e.fly, firstHalf: e.firstHalf };
return buildNormalWave(gd, createRng(1), { picks }, round);
}
const byName = (w) => {
const m = {};
for (const s of w.spawns) m[name(s.enemyKey)] = (m[name(s.enemyKey)] || 0) + s.count;
return m;
};
test('roundHalfEven is C# Math.Round (banker\'s)', () => {
assert.deepEqual([0.5, 1.5, 2.5, 3.5, 4.5, 4.49, 4.51, 4.33, 9.1].map(roundHalfEven), [0, 2, 2, 4, 4, 4, 5, 4, 9]);
});
test('R1 fixtures of the official screenshots (research 08 §2.6): one kind per round, no drones in a ground round', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.deepEqual(byName(waveOf(gd, 'enemy_1325_cbgpro_2', 1)), { 源石虫: 2, 猎狗pro: 4 }, '战场02/07: 高级军用猎狗');
assert.deepEqual(byName(waveOf(gd, 'enemy_1305_mhslim_2', 1)), { 源石虫: 2, 灼热源石虫: 5 }, '战场04: 炽焰源石虫 (9.1 → cap 5)');
assert.deepEqual(byName(waveOf(gd, 'enemy_1010_demon_2', 1)), { 源石虫: 2, 萨卡兹刀兵: 3 }, '萨卡兹大剑组长 (2.84 → 3)');
// a FLY round: the walking N action is dropped, the fly placeholders replaced
const r3 = waveOf(gd, 'enemy_1355_mrfly', 3);
assert.deepEqual(byName(r3), { 妖怪: 5, 妖怪MKII: 4 }, 'R3 FLY 护障');
assert.ok(r3.spawns.every((s) => DATA.enemies[s.enemyKey].isFlyEnemy), 'no walkers in a FLY round');
const r1fly = waveOf(gd, 'enemy_1355_mrfly', 1);
assert.ok(r1fly.spawns.some((s) => s.enemyKey === 'enemy_1007_slime') && !r1fly.spawns.some((s) => s.enemyKey === 'enemy_1422_lrsldr'), '战场01: 源石虫 + drones, no ground N');
});
test('the .5 tie: an elite action of 2 × “灵幛” becomes 隐形弩手组长 ×4 (4.5 → 4, banker\'s rounding)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.equal(replacedCount(gd, 'enemy_1427_lrnazg', 2, 'enemy_1019_jshoot_2'), 4);
assert.equal(replacedCount(gd, 'enemy_1427_lrnazg', 1, 'enemy_1019_jshoot_2'), 2);
assert.equal(replacedCount(gd, 'enemy_1422_lrsldr', 10, 'enemy_1005_yokai'), 5, 'cap 5 per action');
assert.equal(replacedCount(gd, 'enemy_1425_lrcmra', 1, 'enemy_1439_dslntf'), 1, 'floor 1');
});
test('every special entry × round matches the official per-action composition (429 fixtures: kind, count, gate)', () => {
const gd = new GameData(DATA, 'mode_multi_abyss');
let n = 0;
for (const [short, rounds] of Object.entries(OFFICIAL)) {
for (const [r, want] of Object.entries(rounds)) {
const w = waveOf(gd, `enemy_${short}`, Number(r));
const got = w.spawns.map((s) => `${s.actionIndex}:${s.enemyKey.replace('enemy_', '')}:${s.count}:${s.preview.gate === 'upper' ? 'U' : 'L'}`).join(' ');
assert.equal(got, want, `${short} R${r}`);
n++;
}
}
assert.equal(n, 429);
});
test('同盟 险境 R3 never exceeds 10 enemies per board (official 6–10; ours used to reach 68)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
for (const e of Object.values(DATA.factions.entries)) {
if (!e.firstHalf || gd.inactiveEnemies.has(e.key)) continue;
const total = waveOf(gd, e.key, 3).spawns.reduce((s, x) => s + x.count, 0);
assert.ok(total >= 6 && total <= 10, `${e.key}: R3 ${total}`);
}
});
test('distribution over matches ≈ the official one (research 08 §2.7, 4 000 simulated official matches)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const official = [0, 4.7, 7.7, 7.7, 10.3, 20.0, 23.2, 26.2, 18.2, 20.3, 22.2, 23.6, 35.0, 37.5];
const sums = new Array(14).fill(0);
const N = 300;
let flyRounds = 0;
for (let seed = 1; seed <= N; seed++) {
const setup = setupMatchWaves(gd, createRng(seed * 7919));
for (let r = 1; r <= 13; r++) {
const w = buildNormalWave(gd, createRng(seed), setup.factions, r);
sums[r] += previewOf(w.spawns).reduce((s, e) => s + e.count, 0);
if (w.pick.fly) flyRounds++;
}
}
for (let r = 1; r <= 13; r++) {
const avg = sums[r] / N;
assert.ok(Math.abs(avg - official[r]) <= official[r] * 0.12, `R${r}: avg ${avg.toFixed(1)} vs official ${official[r]}`);
}
const share = flyRounds / (N * 13);
assert.ok(share > 0.06 && share < 0.14, `FLY rounds ${share.toFixed(3)} ≈ 10 %`);
});