Files
ark-stronghold-protocol/test/sim/feedback5-cb4-pins.test.js
sganggs e3e3a7dc20 feedback5 CB4: the moving leaders' routes checked on every boss field (item 29); 野鬃 pushing enemies off the map (38), 锏 pulling a weight-10 leader (39), 昆图斯's footprint as 3×3 (47) and the leaders' low attack rate (52) not reproduced — pinned, no change (community reports of 2026-10-06)
Item 29: 铳 / 卢西恩 walk their PATROL_MOVE checkpoints in the official order over walkable tiles on all 8 boss fields,
solo and pair (mirrored copy on the right half), looped (卢西恩 never reaches the goal). Item 38: 1392 battles with 野鬃
before a red gate (S2 running, also v0.1.4): no pushed enemy off a walkable tile, leaks only at route ends. Item 39: a
leader is never displaced; 力度 1 / 2 move no weight >= 5 enemy (卢西恩's 不祥幻影, weight 2, is pulled). Item 47: 5 x 3
in the sim, the hits and the prep's red tiles. Item 52: 胄 / 昆图斯 / 阿利斯泰尔 / 萨米 attack at their data intervals
(same counts as v0.1.4); 铳 opens at the minimum ASPD 20 and ramps per same-target attack.
Golden unchanged. Tests: test/sim/feedback5-cb4-pins.test.js (+5). Docs: DESIGN §25.18.9-§25.18.13.
2026-10-07 02:40:50 +08:00

250 lines
17 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.
// test/sim/feedback5-cb4-pins.test.js — community reports of 2026-10-06 that this tree already answers, pinned (DESIGN
// §25.18; they pass on the parent commit — nothing changed for them):
// item 6 「每局游戏开始展示的敌人类型(折射、飞行等等),实际出现波次在7到9波,而且每个最多三波,其余波次用特异类型填充」 — the
// official type schedule (client RandomEnemyGenerater, research 08 §2.1; act2autochess specialEnemyRandomTypeDict count 3,
// constData maxLevelCnt 15 / specialEnemyNum 3 / enemyTypeIdentifierToFillRandom 1): each briefing type owns 3 of the
// 15 round slots, SPECIAL (特异) the other 6, shuffled — R14 (leader) and R15 (hidden core) take slots too, so the 13
// normal rounds hold 7–9 type waves, at most 3 per type;
// item 35 「鸭爵策略的悬赏现在有时候会一波出现多只,比如多只鸭子」 — PRTS 下半/PRTS盟约记录 鸭爵 备注 「每回合将有0~2名敌人被
// 替换为上述敌人之一」: each player's wave gets 0–2 swaps (bands/meta.js duckReplace), each one of the four at random, so
// two ducks in one wave is official (≈ 1 wave in 12 here); never more than 2 per player's wave;
// item 29 「会移动的关底boss在各个地图的移动路线复核下是否正确」 — the only leaders that walk are 铳 and 卢西恩 (the 巨型单位 are
// 自缚: content/bosses.js SELF_BOUND); their official PATROL_MOVE checkpoints (level_act1autochess_h07_02 route 6,
// act2 h07_05: (3,9) → (2,3) → (5,9), looped) are walked on every boss field of the season over walkable tiles only,
// the pair's mirrored copy on the right half ((3,11) → (2,17) → (5,11)), and the loop never runs on to the goal;
// item 38 「干员野鬃放在红门前两格的时候有概率会把怪顶出地图显示漏怪」 — not reproduced: her pushes (Battle.push → displace)
// stop at the rect and at tiles a ground enemy cannot walk, so no pushed enemy stands off the map;
// item 39 「锏能拉动10重的关底boss」 — not reproduced: a leader (tag boss) is never displaced (Battle._displaceable), and
// her pulls (力度 1 / 2) move no weight ≥ 5 enemy (受力等级 ≤ −3, PRTS 推与拉);
// item 47 「昆图斯占地少最左边和最右边两排」 — not reproduced: its hit area is PRTS' 4.95 × 2.95 up 1.0 — 5 × 3 tiles — and an
// operator whose range reaches only its leftmost column hits it;
// item 52 「最终boss跟原版相比攻击欲望感觉低了」 — every leader attacks at its data interval while a target is in range;
// item 57 「囚犯敌人的解放状态联防时不应继承」 — a leaked prisoner re-enters 联防 as a new spawn (unite.js planUnite →
// waves.js buildUniteWave: its key and spawn mods only), confined again (archetypes.js prisoner spawn); the freed look
// the report saw was 普通 / 老练囚犯's red clip set drawn from the gate, fixed with the prisoners' forms (§25.14.1).
// Run: node --test test/sim/feedback5-cb4-pins.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildUniteWave, buildBossWave, buildNormalWave } from '../../server/match/waves.js';
import { bodyKeys } from '../../server/sim/body.js';
import { planUnite } from '../../server/match/unite.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA, makeMatch } from '../match/harness.js';
test('item 6: the three briefing types fill 7–9 of the 13 normal waves (险境 / 绝境 / 终极), at most 3 each; the rest are SPECIAL', () => {
for (const modeId of ['mode_multi_normal', 'mode_single_hard', 'mode_multi_abyss']) {
const gd = new GameData(DATA, modeId);
assert.equal(gd.bossRound, 14);
const totals = new Set();
for (let seed = 1; seed <= 200; seed++) {
const s = setupMatchWaves(gd, createRng(seed));
const normal = s.typeSlots.slice(0, gd.bossRound - 1);
const per = s.factions.map((f) => normal.filter((t) => t === f).length);
for (const n of per) assert.ok(n >= 1 && n <= 3, `${modeId} seed ${seed}: ${per}`);
const total = per.reduce((a, b) => a + b, 0);
assert.ok(total >= 7 && total <= 9, `${modeId} seed ${seed}: ${total}`);
assert.equal(normal.filter((t) => t === 'SPECIAL').length, normal.length - total, 'every other wave is SPECIAL');
totals.add(total);
}
assert.deepEqual([...totals].sort(), [7, 8, 9], `${modeId}: all three totals occur`);
}
});
test('item 57: a prisoner freed in its own combat leaks and re-enters 联防 confined (no freed form, the confinement buff, freed only by its own attacks there)', () => {
const KEY = 'enemy_1116_liprr'; // 普通囚犯
const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e9, def: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const kits = { t_wall: () => ({ trait: { noAttack: true } }) };
const h = makeBattle({ autoFinish: false, timeLimit: 120, defs: { chess: { t_wall: wall } }, kits, units: [{ chessId: 't_wall', row: 9, col: 6 }], enemies: [{ key: KEY, route: 0 }] });
assert.ok(h.runUntil(() => h.enemy(KEY) && h.enemy(KEY).form === 'liberty', 60), 'freed by its own attacks');
const p = h.enemy(KEY);
assert.ok(p.findBuff('ab:liberty') && !p.findBuff('ab:confined'));
h.b.retreat(h.unit('t_wall'));
assert.ok(h.runUntil(() => !p.alive, 60), 'it walks on and leaks');
const r = h.result().perPlayer.p1;
assert.deepEqual(r.leaked.filter((l) => l.counted !== false).map((l) => l.enemyKey), [KEY]);
checkInvariants(h.b);
// 联防: the leaker's counted leak re-enters on the escaped template of one helper
const gd = new GameData(DATA, 'mode_multi_normal');
const seat = (id, s) => ({ playerId: id, seat: s, deployCount: 1, bonds: {}, layers: {}, board: new Map(), bounties: [] });
const m = { isSolo: false, alivePlayers: () => [seat('p1', 1), seat('H', 0)], gd: { unite: { maxHelpers: 2 }, enemy: (k) => gd.enemy(k) } };
const plan = planUnite(m, new Map([['p1', r], ['H', { perfect: true, leaked: [], unitsEnd: [] }]]));
assert.deepEqual(plan.leaked.map((l) => l.enemyKey), [KEY]);
assert.deepEqual(Object.keys(plan.leaked[0]).sort(), ['bounty', 'enemyKey', 'lpr', 'mods', 'sourcePlayerId', 'tag'], 'nothing of its state travels');
const wave = buildUniteWave(gd, plan.leaked, 1, 60);
const u = makeBattle({
kind: 'unite', stageId: 'act1autochess_escaped_single', autoFinish: false, timeLimit: 60, routes: wave.routes,
defs: { chess: { t_wall: wall } }, kits, units: [{ chessId: 't_wall', row: 9, col: 8 }],
enemies: wave.spawns.map((s) => ({ key: s.enemyKey, time: s.time, route: s.routeIndex, mods: s.mods, sourcePlayerId: s.sourcePlayerId })),
});
assert.ok(u.runUntil(() => u.enemy(KEY), 30), 're-entered');
const q = u.enemy(KEY);
assert.equal(q.form ?? null, null, 'drawn confined');
assert.ok(q.findBuff('ab:confined') && !q.findBuff('ab:liberty'), 'confined again');
assert.ok(u.runUntil(() => q.stats.attacks >= 1, 40), 'blocked, it attacks');
assert.equal(q.form ?? null, null, 'still confined after its first 联防 attack');
assert.ok(u.runUntil(() => q.form === 'liberty', 60), 'freed again only by its own attacks there');
assert.equal(q.stats.attacks, 4, 'at its 4th attack in 联防 (confinement.times), as in a fresh spawn');
checkInvariants(u.b);
});
test('item 35: the 鸭爵 strategy swaps 0–2 enemies of each player\'s wave, each one of its four at random (two ducks in one wave included), never more', () => {
const hist = [0, 0, 0];
let twoSame = 0;
for (const seed of [31, 32]) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seed, fake: true }).start();
h.toPrep(6, { band: 'band_ducklord' });
for (const ps of h.m.alivePlayers()) {
for (let k = 0; k < 40; k++) {
const ducks = h.m._normalOpts(ps).spawns.filter((x) => x.tag === 'duck');
assert.ok(ducks.length <= 2, `${ducks.length} swaps in one wave`);
hist[ducks.length]++;
if (ducks.length === 2 && ducks[0].enemyKey === ducks[1].enemyKey) twoSame++;
for (const d of ducks) assert.deepEqual(d.bounty, { coins: 1, ownerPlayerId: ps.playerId });
}
}
h.m.dispose();
}
assert.ok(hist.every((n) => n > 0), `0, 1 and 2 swaps all occur: ${hist}`);
assert.ok(twoSame > 0, 'two of the same kind (e.g. two ducks) in one wave occurs');
});
const BIG_POOL = () => ({ hp: 1e12, maxHp: 1e12, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } });
const PAIR = [{ playerId: 'p1', seat: 0, side: 'L', units: [], bonds: {} }, { playerId: 'p2', seat: 1, side: 'R', units: [], bonds: {} }];
/** A leader field of the real template (the match passes waveId: content/bosses.js templateOf). */
function leaderField(bossId, { solo = true, stageId = 'act1autochess_m01', round = 14, mode = 'mode_multi_hard', units = undefined, players = undefined, timeLimit = 600 } = {}) {
const gd = new GameData(DATA, mode);
const bw = buildBossWave(gd, createRng(5), setupMatchWaves(gd, createRng(1)).factions, round, { bossId, solo });
const L = bw.spawns.find((x) => x.tag === 'boss');
return makeBattle({
kind: round === 15 ? 'hidden' : 'boss', stageId, seed: 3, routes: bw.routes, sharedBoss: BIG_POOL(), autoFinish: false, timeLimit, units,
players: players ?? (solo ? undefined : PAIR), enemies: [{ key: L.enemyKey, time: L.time, route: L.routeIndex, mods: L.mods, tag: 'boss' }],
setup(b) { b.opts.waveId = bw.templateId; },
});
}
test('item 29: 铳 patrols (3,9) → (2,3) → (5,9) on every boss field over walkable tiles (the pair\'s copy the mirrored points); 卢西恩 loops without reaching the goal', () => {
const stages = Object.keys(DATA.stages).filter((id) => DATA.stages[id].active !== false && DATA.stages[id].weight > 0);
assert.equal(stages.length, 8);
for (const stageId of stages) {
for (const solo of [true, false]) {
const h = leaderField('boss_2', { solo, stageId });
const seen = new Map();
for (let t = 0; t < 240 * 30; t++) {
h.b.step();
for (const e of h.b.enemies) {
if (!e.alive || !e.isBoss) continue;
const r = Math.round(e.y), c = Math.round(e.x);
assert.ok(h.b.grid.walkable(r, c, true), `${stageId} ${solo ? 'solo' : 'pair'}: off the road at (${r},${c})`);
// the patrol targets in the order it walks them (the leg it is on)
const l = seen.get(e.id) ?? []; seen.set(e.id, l);
const leg = e.route.legs[e.route.legIdx];
const k = leg && leg.r != null ? `${leg.r},${leg.c}` : '-';
if (l[l.length - 1] !== k) l.push(k);
}
}
const tracks = [...seen.values()].map((l) => l.join(' '));
assert.equal(tracks.length, solo ? 1 : 2, stageId);
assert.ok(tracks.some((x) => x.startsWith('3,9 2,3 5,9 3,9')), `${stageId}: ${tracks}`);
if (!solo) assert.ok(tracks.some((x) => x.startsWith('3,11 2,17 5,11 3,11')), `${stageId}: the mirrored copy ${tracks}`);
checkInvariants(h.b);
}
}
const h = leaderField('boss_5');
const leaks = [];
h.b.on('enemyLeak', ({ enemy }) => leaks.push(enemy.defId));
let wrapped = false;
for (let t = 0; t < 420 * 30; t++) { h.b.step(); const e = h.b.enemies.find((x) => x.alive && x.isBoss); if (e && e.route.legIdx === 0 && h.b.time > 300) wrapped = true; }
assert.ok(wrapped, '卢西恩 starts its patrol again after its 19 checkpoints');
assert.deepEqual(leaks, []);
});
test('item 38: 野鬃\'s pushes towards the red gate never leave a ground enemy off the map (real waves, S2 running)', () => {
const gd = new GameData(DATA, 'mode_single_hard');
const setup = setupMatchWaves(gd, createRng(2));
let pushes = 0;
for (const r of [6, 12]) {
const w = buildNormalWave(gd, createRng(200 + r), setup.factions, r);
for (const [row, col] of [[9, 8], [12, 8], [12, 9]]) {
const h = makeBattle({ stageId: 'act1autochess_m01', seed: 2, routes: w.routes, timeLimit: w.timeLimit, units: [{ chessId: 'chess_char_1_19_b', row, col, dir: 'RIGHT' }],
enemies: w.spawns.map((x) => ({ key: x.enemyKey, time: x.time, route: x.routeIndex, count: x.count, interval: x.interval, mods: x.mods })) });
const u = h.unit('chess_char_1_19_b');
h.step();
h.b.on('tick', () => {
if (u.alive && u.skill && !u.skill.active) u.skill.gainSp(999, 'test');
for (const e of h.b.enemies) {
if (!e.alive || e.hidden || e.isFlying) continue;
assert.ok(h.b.grid.walkable(Math.round(e.y), Math.round(e.x), true), `R${r} (${row},${col}): ${e.defId} off the map at (${e.y.toFixed(2)},${e.x.toFixed(2)})`);
}
}, { priority: -1000 });
const orig = h.b.push.bind(h.b);
h.b.push = (...a) => { const m = orig(...a); if (m > 0) pushes++; return m; };
h.runToEnd(200);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
checkInvariants(h.b);
}
}
assert.ok(pushes >= 60, `she pushed (${pushes} pushes)`);
});
test('item 39: 锏\'s S3 never moves a leader (every leader of the season), nor any weight-10 enemy', () => {
for (const bossId of ['boss_1', 'boss_4', 'boss_6', 'boss_7']) {
const h = leaderField(bossId, { units: [{ chessId: 'chess_char_6_19_b', row: 9, col: 7, skillIndex: 3 }] });
h.step();
const u = h.unit('chess_char_6_19_b');
const boss = h.b.enemies.find((e) => e.isBoss);
boss.mods = { ...(boss.mods || {}), speedMul: 0 };
boss.markDirty?.();
const at = [boss.x, boss.y];
let moved = 0;
const orig = h.b.displace.bind(h.b);
h.b.displace = (e, ...a) => { const m = orig(e, ...a); if (e === boss) moved += m; return m; };
for (let t = 0; t < 40 * 30; t++) { h.b.step(); if (u.alive && u.skill && !u.skill.active) u.skill.gainSp(999, 'test'); }
assert.ok(u.skill.activations >= 3, `${bossId}: S3 cast`);
assert.equal(boss.s.massLevel, 10);
assert.equal(moved, 0, `${bossId}: never displaced`);
assert.deepEqual([boss.x, boss.y], at);
}
// a weight-10 enemy that is no leader (自在 as a bounty): 力度 2 − 10 ⇒ no pull
const h = makeBattle({ autoFinish: false, timeLimit: 30, units: [{ chessId: 'chess_char_6_19_b', row: 10, col: 6 }], enemies: [{ key: 'enemy_1517_xi', pos: [10, 8], mods: { speedMul: 0 } }] });
h.step();
const xi = h.enemy('enemy_1517_xi');
assert.equal(xi.s.massLevel, 10);
assert.equal(h.b.pull(xi, 2, { to: { x: 6, y: 10 } }), 0);
assert.equal(h.b.pull(xi, 5, { to: { x: 6, y: 10 } }), 0, 'not even 特大力');
});
test('item 47: 昆图斯 occupies 5 × 3 tiles (PRTS 4.95 × 2.95, up 1.0); an operator reaching only its leftmost column hits it', () => {
const h = makeBattle({ kind: 'boss', stageId: 'flat', sharedBoss: BIG_POOL(), autoFinish: false, timeLimit: 30,
units: [{ chessId: 'chess_char_1_12_a', row: 10, col: 7 }], enemies: [{ key: 'enemy_1521_dslily', pos: [3, 10], mods: { speedMul: 0 }, tag: 'boss' }] });
h.step();
const boss = h.b.enemies.find((e) => e.isBoss);
const tiles = bodyKeys(boss).map((k) => [Math.floor(k / 21), k % 21]);
assert.deepEqual([...new Set(tiles.map((t) => t[1]))], [8, 9, 10, 11, 12], '5 columns');
assert.deepEqual([...new Set(tiles.map((t) => t[0]))], [3, 4, 5], '3 rows (up 1.0)');
const u = h.unit('chess_char_1_12_a');
assert.deepEqual([u.tileR, u.tileC], [3, 7]);
h.run(8);
assert.ok(u.stats.attacks >= 3, `艾丝黛尔 next to its leftmost column attacks it (${u.stats.attacks})`);
});
test('item 52: each leader attacks at its data interval while it has a target (150 s, walls in range)', () => {
const units = [[9, 3], [10, 4], [11, 5], [12, 6], [9, 8], [10, 9], [12, 9], [11, 8]].map(([row, col], i) => ({ chessId: `t_w${i}`, row, col }));
const chess = {}, kits = {};
units.forEach((x) => { chess[x.chessId] = chessRec({ id: x.chessId, profession: 'TANK', stats: { atk: 1, maxHp: 1e9, def: 300, res: 30, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }); kits[x.chessId] = () => ({ trait: { noAttack: true } }); });
for (const bossId of ['boss_1', 'boss_4', 'boss_6', 'boss_7']) {
const gd = new GameData(DATA, 'mode_multi_hard');
const bw = buildBossWave(gd, createRng(5), setupMatchWaves(gd, createRng(1)).factions, 14, { bossId, solo: true });
const L = bw.spawns.find((x) => x.tag === 'boss');
const h = makeBattle({ kind: 'boss', stageId: 'act1autochess_m01', seed: 3, routes: bw.routes, sharedBoss: BIG_POOL(), autoFinish: false, timeLimit: 160,
defs: { chess }, kits, units, enemies: [{ key: L.enemyKey, time: L.time, route: L.routeIndex, mods: L.mods, tag: 'boss' }], setup(b) { b.opts.waveId = bw.templateId; } });
h.run(150);
const boss = h.b.enemies.find((e) => e.isBoss);
const max = Math.floor(150 / boss.s.interval);
assert.ok(boss.stats.attacks >= max - 1, `${bossId} ${L.enemyKey}: ${boss.stats.attacks} attacks, ${max} at its ${boss.s.interval} s interval`);
}
});