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).
288 lines
18 KiB
JavaScript
288 lines
18 KiB
JavaScript
// Adversarial cross-checks of waves.js against the official client algorithms (research 08 §2–§5):
|
||
// * an independent re-implementation of RandomEnemyGenerater (_DoReplaceActionDataClient / BE count rule) and of
|
||
// AutoChessEnemyPreviewManager's zone choice, run straight on the raw official files in .cache/gamedata, compared
|
||
// with buildNormalWave / buildBossWave for every mode × round × template (leader and hidden rounds included) ×
|
||
// allowed special entry — key, count, spawn time, unit step and preview zone of every action (skipped when the
|
||
// cache is absent);
|
||
// * preview zones of leader rounds (row offset 13: the leaders standing at (3,x)/(4,x) are in the LOWER zone);
|
||
// * 联防 timing decoded from the client (`_CalculateActionPredelayConsiderUid`): owner k at preDelay + 0.5·k (no cap),
|
||
// unit step min(max(W/M, 0.05·W), 5) with M = the largest owner group; host action = the first placeholder action of
|
||
// the class, else action 0 (`_GetSpEnemyActionData`).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { GameData } from '../../server/match/gamedata.js';
|
||
import { buildNormalWave, buildBossWave, buildUniteWave, gateOf, previewOf, bountySpawns, withBounties } from '../../server/match/waves.js';
|
||
import { createRng } from '../../server/sim/rng.js';
|
||
import { DATA } from './harness.js';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||
const CACHE = join(ROOT, '.cache', 'gamedata');
|
||
const HAS_CACHE = ['excel/activity_table.json', 'levels/enemydata/enemy_database.json', 'levels/activities/act1autochess/level_act1autochess_01.json']
|
||
.every((rel) => existsSync(join(CACHE, rel)));
|
||
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
|
||
const TS = DATA.factions.templateSlots;
|
||
|
||
/** Forced pick of a special entry for round r. */
|
||
function pickOf(e, round) {
|
||
return { round, type: e.type, key: e.key, normal: e.N[0].key, elite: e.E[0].key, fly: e.fly, firstHalf: e.firstHalf };
|
||
}
|
||
|
||
test('independent official generator on the raw client data: every mode × round × template × entry (keys, counts, times, steps, preview zones)', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||
const act = raw('excel/activity_table.json');
|
||
const ac = act.autoChessData;
|
||
const season = act.activity.AUTOCHESS_SEASON.act2autochess;
|
||
const cd = ac.constData;
|
||
const attr = ac.randomEnemyAttributeDict;
|
||
const db = new Map(raw('levels/enemydata/enemy_database.json').enemies.map((e) => [e.Key, e.Value]));
|
||
const f32 = Math.fround;
|
||
const stat = (key, level) => {
|
||
const lv = new Map(db.get(key).map((v) => [v.level, v.enemyData]));
|
||
const base = lv.get(0).attributes;
|
||
const ov = level ? (lv.get(level)?.attributes || {}) : {};
|
||
const g = (n) => (ov[n] && ov[n].m_defined ? ov[n].m_value : base[n].m_value);
|
||
return [g('maxHp'), g('atk'), g('def'), g('magicResistance')];
|
||
};
|
||
const power = (k) => {
|
||
const [hp, atk, df, res] = stat(k, attr[k].level);
|
||
const p = f32(f32(f32(atk * cd.enemyAtkFactor) + f32(hp * cd.enemyMaxHpFactor)) + f32(df * cd.enemyDefFactor));
|
||
return f32(p + f32(cd.enemyMagicResistanceFactor * res));
|
||
};
|
||
const bankers = (x) => { const fl = Math.floor(x); return x - fl === 0.5 ? (fl % 2 === 0 ? fl : fl + 1) : Math.round(x); };
|
||
const newCount = (n, t, k) => {
|
||
const origin = f32(f32(n * power(t)) / attr[t].enemyBattleEffectivenessFactor);
|
||
const ratio = f32(origin / f32(power(k) / attr[k].enemyBattleEffectivenessFactor));
|
||
return Math.max(cd.minReplacedEnemyCount, Math.min(cd.maxReplacedEnemyCount, bankers(ratio)));
|
||
};
|
||
const TPL = {
|
||
[cd.templateEnemyNormal]: ['N', false], [cd.templateEnemyElite]: ['E', false], [cd.templateEnemySpecial]: ['S', false],
|
||
[cd.templateEnemyNormalFly]: ['N', true], [cd.templateEnemyEliteFly]: ['E', true], [cd.templateEnemySpecialFly]: ['S', true],
|
||
};
|
||
// preview pen: enemy_place_rect anchors (15,7) / (18,7); eligible anchors have row ≤ target row; nearest wins
|
||
const zone = (start, leader) => {
|
||
const t = start.row + (leader ? 13 : 6);
|
||
const c = [[15, 'lower'], [18, 'upper']].filter(([r]) => r <= t).sort((a, b) => (Math.abs(a[0] - t) + Math.abs(7 - start.col)) - (Math.abs(b[0] - t) + Math.abs(7 - start.col)));
|
||
return c.length ? c[0][1] : 'lower';
|
||
};
|
||
const levels = new Map();
|
||
const level = (levelId) => {
|
||
const p = levelId.replace(/^Activities\//i, '').toLowerCase();
|
||
if (!levels.has(p)) levels.set(p, raw(`levels/activities/${p}.json`));
|
||
return levels.get(p);
|
||
};
|
||
const compose = (lv, e, leader) => {
|
||
const out = [];
|
||
lv.waves[0].fragments[0].actions.forEach((a, ai) => {
|
||
if (a.actionType !== 'SPAWN') return;
|
||
let k = a.key, n = a.count;
|
||
const win = a.count * a.interval;
|
||
if (TPL[k]) {
|
||
const [cls, fly] = TPL[k];
|
||
const nk = { N: e.attachedNormalEnemyKeys[0], E: e.attachedEliteEnemyKeys[0], S: e.specialEnemyKey }[cls];
|
||
if (attr[nk].isFlyEnemy !== fly) return; // isValid = false
|
||
n = newCount(a.count, k, nk);
|
||
k = nk;
|
||
}
|
||
const step = n > 1 ? Math.max(win / n, 0.05 * win) : 0;
|
||
out.push(`${ai}:${k}x${n}@${a.preDelay}/${step.toFixed(6)}:${zone(lv.routes[a.routeIndex].startPosition, leader)}`);
|
||
});
|
||
return out.join(' ');
|
||
};
|
||
let n = 0;
|
||
let leaderChecks = 0;
|
||
for (const [modeId, rounds] of Object.entries(season.battleDataDict)) {
|
||
if (modeId === 'mode_training_1') continue;
|
||
const gd = new GameData(DATA, modeId);
|
||
const inactive = new Set(season.modeDataDict[modeId].inactiveEnemyKey || []);
|
||
for (const [r, list] of Object.entries(rounds)) {
|
||
const round = Number(r);
|
||
const half = round <= Math.floor(cd.maxLevelCnt / 2);
|
||
for (const entry of list) {
|
||
const lv = level(entry.levelId);
|
||
const templateId = entry.levelId.replace(/^.*level_/i, '').toLowerCase();
|
||
for (const e of Object.values(season.specialEnemyInfoDict)) {
|
||
if (e.isInFirstHalf !== half || inactive.has(e.specialEnemyKey)) continue;
|
||
const picks = [];
|
||
picks[round] = pickOf(DATA.factions.entries[e.specialEnemyKey], round);
|
||
const w = entry.bossId
|
||
? buildBossWave(gd, createRng(1), { picks }, round, { bossId: entry.bossId, solo: /_s$/.test(templateId) })
|
||
: buildNormalWave(gd, createRng(1), { picks }, round);
|
||
assert.equal(w.templateId, templateId, `${modeId} R${r} ${entry.bossId ?? ''}`);
|
||
const got = w.spawns.map((s) => `${s.actionIndex}:${s.enemyKey}x${s.count}@${s.time}/${(+s.interval).toFixed(6)}:${s.preview.gate}`).join(' ');
|
||
assert.equal(got, compose(lv, e, !!entry.bossId), `${modeId} R${r} ${templateId} ${e.specialEnemyKey}`);
|
||
n++;
|
||
if (entry.bossId) leaderChecks++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert.ok(n > 5000 && leaderChecks > 1000, `${n} compositions (${leaderChecks} leader rounds)`);
|
||
});
|
||
|
||
test('preview zones: target row = start row + 6 (leader rounds + 13), upper zone only from target row 18', () => {
|
||
// normal fields: only the (12,10) gate reaches the upper zone (a row-11 start would target row 17: lower)
|
||
for (const [row, want] of [[9, 'lower'], [10, 'lower'], [11, 'lower'], [12, 'upper']]) assert.equal(gateOf([row, 10]), want, `normal row ${row}`);
|
||
// leader / hidden rounds: (5,10) upper; the (2,10) gate and the leaders at (3,x) / (4,x) lower
|
||
for (const [row, want] of [[2, 'lower'], [3, 'lower'], [4, 'lower'], [5, 'upper']]) {
|
||
assert.equal(gateOf([row, 10], true), want, `leader row ${row}`);
|
||
assert.equal(gateOf([row, 10]), want, `leader row ${row} (inferred from the boss-field row)`);
|
||
}
|
||
assert.equal(gateOf(null), 'lower');
|
||
// real leader templates: 猎手 (h08_02, hidden, route (4,10)) and the h07_02 leader (4,10) sit in the lower zone,
|
||
// the (5,10) escorts in the upper one
|
||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||
const picks = [];
|
||
const e = DATA.factions.entries.enemy_1005_yokai_3;
|
||
picks[14] = pickOf(e, 14);
|
||
picks[15] = pickOf(e, 15);
|
||
const hidden = buildBossWave(gd, createRng(1), { picks }, 15, { bossId: 'boss_9', solo: false });
|
||
assert.equal(hidden.templateId, 'act1autochess_h08_02');
|
||
const boss = hidden.spawns.find((s) => s.tag === 'boss');
|
||
assert.deepEqual(DATA.waves.act1autochess_h08_02.routes[boss.routeIndex].start, [4, 10]);
|
||
assert.equal(boss.preview.gate, 'lower');
|
||
assert.ok(hidden.spawns.filter((s) => s.tag !== 'boss' && s.tag !== 'part').every((s) => s.preview.gate === 'upper'), 'escorts from (5,10): upper');
|
||
const pv = previewOf(hidden.spawns);
|
||
assert.equal(pv.find((x) => x.boss).gate, 'lower');
|
||
// a bounty in a leader round follows the same leader offset
|
||
const card = { enemyKey: 'enemy_1007_slime', count: 1, coin: 1, payout: 'kill' };
|
||
const b = bountySpawns(gd, 15, hidden, [{ id: 'x', card }], 'p', { side: 'L' });
|
||
const bRoute = hidden.routes[b[0].routeIndex];
|
||
assert.equal(b[0].preview.gate, gateOf(bRoute.start, true));
|
||
});
|
||
|
||
const WALK = 'enemy_1422_lrsldr';
|
||
const leak = (owner, key = WALK, extra = {}) => ({ enemyKey: key, mods: null, sourcePlayerId: owner, ...extra });
|
||
|
||
test('联防 timing (decoded _CalculateActionPredelayConsiderUid): step = min(max(W/M, 0.05·W), 5 s), owner k at +0.5·k', () => {
|
||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||
const tpl = DATA.waves.act1autochess_escaped_single;
|
||
const host = tpl.spawns.find((s) => s.key === TS.N); // lrsldr 5 @ 3 s / 8 s ⇒ W = 40
|
||
const W = host.count * host.interval;
|
||
const times = (w, owner) => w.spawns.filter((s) => s.sourcePlayerId === owner).map((s) => s.time);
|
||
// M = 2 ⇒ 40/2 = 20 s, capped at 5 s (the old code spread a lone owner's 2 leaks 20 s apart)
|
||
let w = buildUniteWave(gd, [leak('a'), leak('b'), leak('a')], 1, 60);
|
||
assert.deepEqual(times(w, 'a'), [host.time, host.time + 5]);
|
||
assert.deepEqual(times(w, 'b'), [host.time + 0.5]);
|
||
// the step uses the LARGEST owner group for every owner of the action: a 12, b 2 ⇒ 40/12 for both
|
||
const many = [];
|
||
for (let i = 0; i < 12; i++) many.push(leak('a'));
|
||
many.push(leak('b'), leak('b'));
|
||
w = buildUniteWave(gd, many, 1, 60);
|
||
const step = Math.min(Math.max(W / 12, 0.05 * W), 5);
|
||
assert.ok(times(w, 'a').every((t, i) => Math.abs(t - (host.time + i * step)) < 1e-9));
|
||
assert.ok(times(w, 'b').every((t, i) => Math.abs(t - (host.time + 0.5 + i * step)) < 1e-9), 'b steps by 40/12, not 40/2 capped');
|
||
// 25 leaks of one owner: the 0.05·W floor (2 s)
|
||
w = buildUniteWave(gd, Array.from({ length: 25 }, () => leak('a')), 1, 60);
|
||
assert.equal(times(w, 'a')[24], host.time + 24 * 2);
|
||
// owner offsets are not capped: the 12th owner starts 5.5 s after the action
|
||
w = buildUniteWave(gd, Array.from({ length: 12 }, (_, i) => leak(`o${i}`)), 1, 60);
|
||
assert.equal(times(w, 'o11')[0], host.time + 5.5);
|
||
assert.ok(w.spawns.every((s) => s.actionIndex === tpl.spawns.indexOf(host) && s.routeIndex === host.routeIndex));
|
||
// flyers and walkers are separate actions (each with its own M and owner order)
|
||
w = buildUniteWave(gd, [leak('a', 'enemy_1005_yokai'), leak('b'), leak('b')], 1, 60);
|
||
const fh = tpl.spawns.find((s) => s.key === TS.NF);
|
||
assert.deepEqual(times(w, 'a'), [fh.time], 'owner a is the first owner of the flyer action');
|
||
assert.deepEqual(times(w, 'b'), [host.time, host.time + 5], 'owner b is the first owner of the walker action');
|
||
});
|
||
|
||
test('联防 host action: the first action of the class placeholder, else action 0 (client _GetSpEnemyActionData)', () => {
|
||
const tpl = DATA.waves.act1autochess_escaped_single;
|
||
const noWalk = { ...tpl, spawns: tpl.spawns.filter((s) => s.key !== TS.N) };
|
||
const data = { ...DATA, waves: { ...DATA.waves, act1autochess_escaped_single: noWalk } };
|
||
const gd = new GameData(data, 'mode_multi_normal');
|
||
const w = buildUniteWave(gd, [leak('a'), leak('a')], 1, 60);
|
||
const a0 = noWalk.spawns[0];
|
||
assert.ok(w.spawns.every((s) => s.actionIndex === 0 && s.routeIndex === a0.routeIndex));
|
||
const W = a0.count * a0.interval;
|
||
assert.deepEqual(w.spawns.map((s) => s.time), [a0.time, a0.time + Math.min(Math.max(W / 2, 0.05 * W), 5)]);
|
||
});
|
||
|
||
test('bounty units are inserted among the host action\'s own units (decoded _InsertSpActionToNormal), then spread', () => {
|
||
const gd = new GameData(DATA, 'mode_multi_normal');
|
||
const picks = [];
|
||
picks[1] = pickOf(DATA.factions.entries.enemy_1325_cbgpro_2, 1); // R1: N 2 @ 12 s / 3 s → 猎狗pro ×4, W = 6
|
||
const w = buildNormalWave(gd, createRng(1), { picks }, 1);
|
||
const hostIdx = DATA.waves.act1autochess_01.spawns.findIndex((s) => s.key === TS.N);
|
||
assert.equal(w.spawns.find((s) => s.actionIndex === hostIdx).count, 4);
|
||
const card = (count = 1) => ({ enemyKey: 'enemy_1007_slime', count, coin: 1, payout: 'kill' });
|
||
const units = (list) => {
|
||
const out = [];
|
||
for (const s of list) for (let i = 0; i < s.count; i++) out.push(`${s.tag === 'bounty' ? s.mods.bountyId : 'own'}@${(s.time + i * s.interval).toFixed(3)}`);
|
||
return out.sort((a, b) => Number(a.split('@')[1]) - Number(b.split('@')[1]));
|
||
};
|
||
const hostOf = (list) => list.filter((s) => s.actionIndex === hostIdx);
|
||
// 1 bounty: [o o B o o], step 6/5
|
||
assert.deepEqual(units(hostOf(withBounties(gd, 1, w, [{ id: 'B', card: card() }], 'p'))),
|
||
['own@12.000', 'own@13.200', 'B@14.400', 'own@15.600', 'own@16.800']);
|
||
assert.equal(bountySpawns(gd, 1, w, [{ id: 'B', card: card() }], 'p')[0].time, 12 + 2 * 1.2);
|
||
// 2 bounties: i=0 at floor(1·4/3) = 1, i=1 at floor(2·5/3) = 3 → [o B0 o B1 o o], step 1
|
||
assert.deepEqual(units(hostOf(withBounties(gd, 1, w, [{ id: 'B0', card: card() }, { id: 'B1', card: card() }], 'p'))),
|
||
['own@12.000', 'B0@13.000', 'own@14.000', 'B1@15.000', 'own@16.000', 'own@17.000']);
|
||
// a 3-unit card is inserted unit by unit: [o C C o C o o] — two specs for the card (runs)
|
||
const three = withBounties(gd, 1, w, [{ id: 'C', card: card(3) }], 'p');
|
||
const step = 6 / 7;
|
||
assert.deepEqual(units(hostOf(three)), [0, 1, 2, 3, 4, 5, 6].map((j) => `${[1, 2, 4].includes(j) ? 'C' : 'own'}@${(12 + j * step).toFixed(3)}`));
|
||
assert.equal(three.filter((s) => s.tag === 'bounty').length, 2);
|
||
// FLY round: the walker host action spawns nothing → the bounty list alone over the window (one unit at preDelay)
|
||
const fp = [];
|
||
fp[1] = pickOf(DATA.factions.entries.enemy_1355_mrfly, 1);
|
||
const fw = buildNormalWave(gd, createRng(1), { picks: fp }, 1);
|
||
assert.ok(!fw.spawns.some((s) => s.actionIndex === hostIdx));
|
||
const fb = withBounties(gd, 1, fw, [{ id: 'B0', card: card() }, { id: 'B1', card: card() }], 'p').filter((s) => s.tag === 'bounty');
|
||
const hostRoute = DATA.waves.act1autochess_01.spawns[hostIdx].routeIndex;
|
||
assert.deepEqual(fb.map((s) => [s.time, s.count, s.routeIndex]), [[12, 1, hostRoute], [15, 1, hostRoute]], 'step 6/2 on the walker route');
|
||
});
|
||
|
||
test('a bounty whose host action is not server-sent (the leader) spawns alone over that window; the leader is never re-timed', () => {
|
||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||
const picks = [];
|
||
picks[15] = pickOf(DATA.factions.entries.enemy_1006_shield_3, 15);
|
||
const w = buildBossWave(gd, createRng(1), { picks }, 15, { bossId: 'boss_9', solo: false });
|
||
const tpl = DATA.waves[w.templateId];
|
||
assert.equal(tpl.spawns[0].tag, 'boss', 'h08_02: action 0 is the leader (no lrsldr action ⇒ the host is action 0)');
|
||
assert.equal(w.actions.find((a) => a.index === 0).server, false);
|
||
const card = { enemyKey: 'enemy_1007_slime', count: 1, coin: 1, payout: 'kill' };
|
||
const all = withBounties(gd, 15, w, [{ id: 'B', card }], 'p', { side: 'L' });
|
||
const boss = w.spawns.find((s) => s.tag === 'boss');
|
||
assert.deepEqual(all.filter((s) => s.tag === 'boss').map((s) => [s.time, s.count]), [[boss.time, 1]], 'the leader keeps its own timing');
|
||
const b = all.find((s) => s.tag === 'bounty');
|
||
assert.equal(b.time, tpl.spawns[0].time, 'the bounty list alone: first unit at the action preDelay');
|
||
assert.equal(all.length, w.spawns.length + 1);
|
||
});
|
||
|
||
test('round multipliers hit every enemy but 炎佑 (aceffect_enemy_* enemy_exclude): leader ATK / speed, never its HP', () => {
|
||
const eff = DATA.effects;
|
||
// the data behind the PRTS table: 攻坚装备 ATK ×1.1 / HP ×1.2, III ATK ×0.7 / HP ×0.75, only 炎佑 excluded
|
||
// (补给线 also spares the TIMES tokens 瑞印 / 卷轴 / 镜 …, whose HP is a hit count — content's concern)
|
||
const leaders = Object.values(DATA.bosses).map((x) => x.enemyKey).filter(Boolean);
|
||
assert.ok(leaders.length >= 8);
|
||
for (const id of ['aceffect_enemy_1', 'aceffect_enemy_2', 'aceffect_enemy_3', 'aceffect_enemy_4', 'aceffect_enemy_5']) {
|
||
assert.equal(eff[id].effectType, 'ENEMY', id);
|
||
const ex = String(eff[id].params.enemy_exclude).split('|');
|
||
assert.ok(ex.includes('enemy_9012_acloon'), `${id}: 炎佑 excluded`);
|
||
assert.ok(!leaders.some((k) => ex.includes(k)), `${id}: no leader excluded`);
|
||
}
|
||
const gd = new GameData(DATA, 'mode_multi_abyss');
|
||
const scale = gd.enemyScale(15);
|
||
assert.ok(scale.atkMul > 2 && scale.speedMul > 1, 'sanity: 终极 hidden round multipliers');
|
||
const picks = [];
|
||
picks[15] = pickOf(DATA.factions.entries.enemy_1006_shield_3, 15);
|
||
const w = buildBossWave(gd, createRng(1), { picks }, 15, { bossId: 'boss_9', solo: false });
|
||
const boss = w.spawns.find((s) => s.tag === 'boss');
|
||
assert.equal(boss.mods.hpMul, undefined, 'the leader HP is the server pool');
|
||
assert.equal(boss.mods.atkMul, scale.atkMul);
|
||
assert.equal(boss.mods.speedMul, scale.speedMul);
|
||
const parts = w.spawns.filter((s) => s.tag === 'part');
|
||
assert.ok(parts.length >= 3);
|
||
for (const p of parts) assert.deepEqual([p.mods.hpMul, p.mods.atkMul, p.mods.speedMul], [scale.hpMul, scale.atkMul, scale.speedMul], p.enemyKey);
|
||
// solo 标准: the 攻坚装备III base (ATK ×0.7) weakens the leader too
|
||
const sgd = new GameData(DATA, 'mode_single_funny');
|
||
const sp = [];
|
||
sp[9] = pickOf(DATA.factions.entries.enemy_1006_shield_3, 9);
|
||
const sw = buildBossWave(sgd, createRng(1), { picks: sp }, 9, { bossId: 'boss_1', solo: true });
|
||
assert.equal(sw.spawns.find((s) => s.tag === 'boss').mods.atkMul, sgd.enemyScale(9).atkMul);
|
||
assert.ok(sgd.enemyScale(9).atkMul < 1);
|
||
});
|