feedback5: merge CB4 — community reports (enemies / bosses): a 突袭 member lands only on its own player's board (never a boss field's hand / 临时整备区 rows nor the other half of a 联防 or boss field), a silenced 萨卡兹枯朽战士 leaves no 污染秽蚀, 萨卡兹穿刺手's acceleration ends with a stun or 束缚 and its first hit after a block adds move speed × 600 as its own instance, 自制投石机 ignores its block when it picks a target (no attack on the 排气格栅's hidden blocker), the briefing types' 7–9 waves, 新硎's 断刃, a freed prisoner in 联防, 鸭爵's 0–2 swaps and the moving leaders' routes pinned, four reports not reproduced; §25.18; golden: vs feedback5 CB4's own rules — roster-016, boss-boss_2/3/4/5/6-pair, hidden-boss_8/10-pair, standin-04/06/08, diy-028, diy-124, matches solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1, coop2-HARD-4, coop4-ABYSS-7, coop2-NORMAL-8-serverrun; vs CB4 exactly what feedback5 changed since its base (CB2's roster 13 / bonds 7 / fields 6, CB5's 18 bot matches)

Merge fix: test/sim/facing-invariance.test.js — the 突袭 rotation check put its enemy on the board's top row (9, 9),
so the facing-RIGHT landing tile it expected, (8, 8), lies above the player's board, where CB4's onOwnBoard rule (the
official 再部署 goes where the player deploys; community reports 40 / 16.3) now refuses it; the member landed on
(10, 8). The enemy stands at (10, 6), inside rows 9–12 / cols 2–10, so all four diagonal tiles are candidates in every
direction and the test checks the facing tie-break as before. It failed on fb5-cb4 itself (the stream did not run the
file). The §25 intro's stream lines joined into one sentence again.

Golden moves outside CB4's commit list are its rules on feedback5's paths (instrumented): solo-HARD-1 and solo-ABYSS-1
now have silenced 枯朽战士 deaths (8 / 15) and 突袭 candidates the own-board filter refuses; coop2-ABYSS-10-boosted,
coop2-NORMAL-3 and coop2-NORMAL-14-standins (in CB4's list) have no silenced 枯朽战士 death on this path, and their
digests are identical with the own-board filter switched off (the refused tiles were never the chosen ones).

Gate: non-browser suites 4968 / 4968, npm run golden ok, test/golden.test.js 9 / 9, lint 0 errors, check:imports ok,
typecheck ok, docs-consistency + docs-paths 31 / 31.
This commit is contained in:
sganggs committed 2026-10-07 03:21:57 +08:00
commit 6ed3aed335
23 files changed
+1364 -598

No files matched your search

+7 -4
View File
@@ -111,18 +111,21 @@ test('战术家 援军 (伺夜): the tactical point is the tile in FRONT along t
});
test('突袭 (raidShip): the landing tile beside the enemy turns with the member direction (equal candidates)', () => {
// a diagonal-only range: two equally near landing tiles cover the enemy — the tie resolves in the member's frame
// a diagonal-only range: two equally near landing tiles cover the enemy — the tie resolves in the member's frame. The
// enemy stands inside the player's own board (rows 9–12, cols 2–10): a member lands only there since 0.2.0
// (Battle.onOwnBoard, DESIGN §25.18), so all four diagonal tiles around (10, 6) are candidates in every direction
const ER = 10, EC = 6;
const rec = chessRec({ id: 'r_diag', bonds: ['raidShip'], profession: 'WARRIOR', skill: null, rangeGrid: [[1, 1], [-1, 1]] });
const land = (dir) => {
const h = makeBattle({
stage: OPEN, rect: RECT, routes: FAR_ROUTE, defs: { chess: { r_diag: rec }, enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e6, speed: 0 }) } },
bonds: { raidShip: { count: 1, active: true, tier: 1, layers: 0 } },
units: [{ chessId: 'r_diag', row: 15, col: 3, dir, abs: true }],
enemies: [{ key: 'e_d', pos: [R0, C0], route: walkRoute([R0, C0]) }], autoFinish: false, timeLimit: 60,
enemies: [{ key: 'e_d', pos: [ER, EC], route: walkRoute([ER, EC]) }], autoFinish: false, timeLimit: 60,
});
const u = h.unit('r_diag');
assert.ok(h.runUntil(() => u.alive && u.deployed && Math.max(Math.abs(u.tileR - R0), Math.abs(u.tileC - C0)) <= 2, 30), `${dir}: jumped next to the enemy`);
return toLocal(u.tileR - R0, u.tileC - C0, dir);
assert.ok(h.runUntil(() => u.alive && u.deployed && Math.max(Math.abs(u.tileR - ER), Math.abs(u.tileC - EC)) <= 2, 30), `${dir}: jumped next to the enemy`);
return toLocal(u.tileR - ER, u.tileC - EC, dir);
};
const right = land('RIGHT');
assert.deepEqual(right, [-1, -1], 'RIGHT: the lower of the two diagonal tiles behind the enemy (tile-key order, as before)');
+56
View File
@@ -0,0 +1,56 @@
// test/sim/feedback5-catapult-smog.test.js — 自制投石机's 「索敌不受阻挡影响」 (community report of 2026-10-06, item 24, a
// player's report of the official game: 「投石车」敌人如果被蒸汽地板上的干员阻挡会不进行攻击; DESIGN §25.18).
// PRTS 自制投石机 天赋: 「普通攻击为3连击…索敌不受阻挡影响,且不会因丢失目标而结束攻击」. An operator on the 排气格栅 smog
// (战场#07 下半, tile_smog) is 隐匿 for enemy ranged attacks; any other enemy still attacks the operator that blocks it
// ("阻挡优先级最高"), but the catapult's targeting ignores its block, so that blocker is no target: alone, it is not attacked.
// Run: node --test test/sim/feedback5-catapult-smog.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, flatStage, checkInvariants } from '../helpers/battleHarness.js';
const KEY = 'enemy_10162_mnctpt'; // 自制投石机 (RANGED, range 4.0)
const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e9, def: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const other = chessRec({ id: 't_other', profession: 'SNIPER', stats: { atk: 0, maxHp: 1e9, def: 0, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
const kits = { t_wall: () => ({ trait: { noAttack: true } }), t_other: () => ({ trait: { noAttack: true } }) };
const route = { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] };
const shooter = enemyRec({ key: 'enemy_t_shooter', hp: 1e9, atk: 100, range: 3, speed: 1, bat: 2 }); // an ordinary ranged walker
function run({ smog, withOther = false, key = KEY }) {
const stage = flatStage();
if (smog) stage.rows[9] = stage.rows[9].slice(0, 6) + 'g' + stage.rows[9].slice(7); // the blocker's tile (9,6)
const units = [{ chessId: 't_wall', row: 9, col: 6 }];
if (withOther) units.push({ chessId: 't_other', row: 11, col: 6 });
const h = makeBattle({ stage, autoFinish: false, timeLimit: 60, defs: { chess: { t_wall: wall, t_other: other }, enemies: { enemy_t_shooter: shooter } }, kits, units, enemies: [{ key, route }] });
h.step();
const e = h.enemy(key);
assert.ok(h.runUntil(() => e.blockedBy, 30), 'blocked by the wall');
h.run(20);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
return { e, h, wall: h.unit('t_wall'), other: withOther ? h.unit('t_other') : null };
}
test('自制投石机 blocked by an operator on the 排气格栅, nobody else in range: no attack', () => {
const { e, wall: w } = run({ smog: true });
assert.ok(w.s.flags.stealth, 'the smog tile hides the blocker from ranged attacks');
assert.equal(e.blockedBy, w);
assert.equal(e.stats.attacks, 0);
assert.equal(w.stats.taken, 0);
});
test('自制投石机 blocked on the 排气格栅 shoots another operator in its range instead; off the smog it hits its blocker', () => {
const a = run({ smog: true, withOther: true });
assert.ok(a.e.stats.attacks >= 2);
assert.equal(a.wall.stats.taken, 0, 'the hidden blocker is never its target');
assert.ok(a.other.stats.taken > 0);
const b = run({ smog: false });
assert.ok(b.e.stats.attacks >= 2);
assert.ok(b.wall.stats.taken > 0, 'a visible blocker is a target as before');
});
test('any other ranged enemy still attacks the operator that blocks it on the 排气格栅 (阻挡优先级最高)', () => {
const { e, wall: w } = run({ smog: true, key: 'enemy_t_shooter' });
assert.ok(w.s.flags.stealth);
assert.ok(e.stats.attacks >= 2 && w.stats.taken > 0);
});
+249
View File
@@ -0,0 +1,249 @@
// 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`);
}
});
+87
View File
@@ -0,0 +1,87 @@
// test/sim/feedback5-raid-own-board.test.js — 突袭 lands only on its player's own board (community reports of
// 2026-10-06, items 40 and 16.3; DESIGN §25.18):
// 「…新约能天使在开了突袭之后蹦到了下方原本用来临时放置溢出的干员和道具的那一栏高台的最左侧位置」 — on a boss field the
// battle rect (BOSS_RECT rows 0–5) holds the hand row (0) and the 临时整备区 row (1), high ground buildable ALL, so a
// ranged member could land there;
// 「单人联防时突袭也可以跑到对面的场地上」 — on the 联防 map of one helper (escaped_single) the right half (cols 11–18)
// is in the rect but is nobody's board.
// raidTile now takes only tiles of Battle.onOwnBoard: the board region (rows 9–12, cols 2–10) mapped onto the field.
// Run: node --test test/sim/feedback5-raid-own-board.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const ID = 'chess_char_6_13_a'; // 新约能天使 (RANGED), given the 突袭 bond as the 转职球 / 突袭手雷 would
const raw = getDefaultSource().rawChess(ID);
const raider = { ...raw, bonds: [...raw.bonds, 'raidShip'] };
const BONDS = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
const dummy = enemyRec({ key: 'dummy', hp: 1e9, speed: 0, mass: 0 });
const defs = { enemies: { dummy }, chess: { [ID]: raider } };
function jump({ kind, stageId, players, enemyPos, seconds = 11 }) {
const h = makeBattle({ kind, stageId, autoFinish: false, timeLimit: 60, defs, players });
h.step();
const mine = h.b.allyUnits.filter((u) => u.defId === ID);
const homes = mine.map((u) => [u.tileR, u.tileC]);
h.spawn('dummy', { pos: enemyPos });
h.run(seconds);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
return { h, mine, homes };
}
const seat = (playerId, s, o = {}) => ({ playerId, seat: s, side: o.side ?? 'L', colOffset: o.colOffset ?? 0, bonds: BONDS, units: o.units ?? [] });
const unitAt = (row, col, uid = 1) => ({ uid, kind: 'chess', chessId: ID, row, col });
test('boss field (solo 最终攻势): a ranged 突袭 member never lands on the hand row 0 or the 临时整备区 row 1', () => {
// 战场#01: the enemy on the telin (1,3); before the fix the member landed on the hand tile (0,3)
const { h, mine } = jump({ kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [1, 3] });
const u = mine[0];
assert.deepEqual([u.tileR, u.tileC], [2, 3], 'the board tile next to it, not (0,3)');
assert.ok(h.b.onOwnBoard(u.player, u.tileR, u.tileC));
// every row-0 / row-1 tile is off the board, every board tile (rows 2–5, cols 2–10) is on it
for (let c = 0; c <= 20; c++) for (const r of [0, 1]) assert.equal(h.b.onOwnBoard(u.player, r, c), false, `(${r},${c})`);
for (let r = 2; r <= 5; r++) for (let c = 2; c <= 10; c++) assert.equal(h.b.onOwnBoard(u.player, r, c), true, `(${r},${c})`);
for (let r = 2; r <= 5; r++) for (let c = 11; c <= 20; c++) assert.equal(h.b.onOwnBoard(u.player, r, c), false, `(${r},${c}) is the right half`);
// an enemy only the temp row could reach: it stays home and does not hop
const far = jump({ kind: 'boss', stageId: 'act1autochess_m01', players: [seat('p1', 0, { units: [unitAt(12, 3)] })], enemyPos: [0, 9] });
assert.deepEqual([far.mine[0].tileR, far.mine[0].tileC], far.homes[0]);
});
test('boss field pair: the mirrored right player lands on its own half only', () => {
const { h, mine } = jump({
kind: 'boss', stageId: 'act1autochess_m01',
players: [seat('p1', 0), seat('p2', 1, { side: 'R', units: [unitAt(12, 3)] })], enemyPos: [3, 9],
});
const u = mine[0];
assert.equal(u.ownerId, 'p2');
// the enemy stands on the partner's half next to the middle: the member takes a tile of its own half that reaches it
// (before the fix: (2,9), the partner's road)
assert.ok(u.tileR >= 2 && u.tileC >= 10, `landed on (${u.tileR},${u.tileC})`);
assert.ok(h.b.onOwnBoard(u.player, u.tileR, u.tileC));
assert.equal(h.b.onOwnBoard(u.player, 3, 5), false, 'the left half is the partner\'s');
assert.equal(h.b.onOwnBoard(u.player, 3, 15), true);
});
test('联防 with one helper (escaped_single): 突袭 does not jump onto the right half', () => {
for (const pos of [[10, 15], [11, 17], [9, 12]]) {
const { h, mine, homes } = jump({ kind: 'unite', stageId: 'act1autochess_escaped_single', players: [seat('H', 0, { units: [unitAt(12, 9)] })], enemyPos: pos });
const u = mine[0];
assert.ok(u.tileC <= 10, `enemy ${pos}: landed on (${u.tileR},${u.tileC})`);
assert.ok(h.b.onOwnBoard(u.player, u.tileR, u.tileC), `enemy ${pos}`);
if (pos[1] >= 13) assert.deepEqual([u.tileR, u.tileC], homes[0], `enemy ${pos}: out of reach from the left half, it stays`);
}
// an enemy the left half can reach: it still jumps (onto the left half)
const { mine } = jump({ kind: 'unite', stageId: 'act1autochess_escaped_single', players: [seat('H', 0, { units: [unitAt(12, 3)] })], enemyPos: [9, 9] });
assert.ok(mine[0].tileC <= 10 && mine[0].tileC >= 7, `landed on (${mine[0].tileR},${mine[0].tileC})`);
});
test('联防 with two helpers (escaped_multi): each helper lands on its own half', () => {
const players = [seat('L', 0, { units: [unitAt(12, 3, 1)] }), seat('R', 1, { colOffset: 8, units: [unitAt(12, 9, 2)] })];
const { h, mine } = jump({ kind: 'unite', stageId: 'act1autochess_escaped_multi', players, enemyPos: [10, 15] });
const right = mine.find((u) => u.ownerId === 'R'), left = mine.find((u) => u.ownerId === 'L');
assert.ok(right.tileC >= 11, `the right helper lands on its half: (${right.tileR},${right.tileC})`);
assert.ok(h.b.onOwnBoard(right.player, right.tileR, right.tileC));
assert.ok(left.tileC <= 10, `the left helper stays on its half: (${left.tileR},${left.tileC})`);
});