Files
ark-stronghold-protocol/test/content/kits_t6.test.js
T
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

1071 lines
57 KiB
JavaScript
Raw 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.
// Content tests for the tier-6 kits (server/sim/content/kits/tier6.js) + 盟约·辅助干员 (chess_char_1_15).
// Every test runs a real battle through the harness and checks the signature effect of the kit.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
const DEFS = {
enemies: {
enemy_dummy: dummy(),
enemy_dummy2: dummy({ key: 'enemy_dummy2' }),
enemy_armored: dummy({ key: 'enemy_armored', def: 500 }),
enemy_elite: dummy({ key: 'enemy_elite', rank: 'ELITE' }),
enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1 }),
enemy_shooter: enemyRec({ key: 'enemy_shooter', hp: 1e7, speed: 0, atk: 50, range: 6 }),
},
chess: {
// synthetic allies (no skill) used as buff/heal/copy targets
test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 3000, atk: 800, def: 100 }, skill: null }),
test_lat_a: chessRec({
id: 'test_lat_a', profession: 'SNIPER', bonds: ['lateranoShip'], skill: null, stats: { atk: 1 },
rangeGrid: Array.from({ length: 11 }, (_, c) => [0, c]).concat(Array.from({ length: 11 }, (_, c) => [-2, c]), Array.from({ length: 11 }, (_, c) => [2, c])),
}),
},
};
/** Battle with the shared synthetic defs + a `damaged` recorder: h.dmg = [{ src, tgt, amount, type, element, tags, skill, attack, splash, t }]. */
function battle(opts) {
const log = [];
const h = makeBattle({
defs: DEFS, timeLimit: 400, seed: 7, ...opts,
setup(b) {
b.on('damaged', (c) => log.push({ src: c.source?.id ?? null, tgt: c.target.id, amount: c.amount, type: c.type, element: c.dmg?.element ?? null, tags: c.dmg?.tags ?? [], skill: !!c.dmg?.isSkill, attack: !!c.dmg?.isAttack, splash: !!c.dmg?.isSplash, t: b.time }), { priority: -999 });
if (opts.setup) opts.setup(b);
},
});
h.dmg = log;
return h;
}
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps * Math.max(1, Math.abs(b));
const enemyAt = (h, key, i = 0) => h.b.units.filter((u) => u.side === 'enemy' && u.defId === key)[i];
// --------------------------------------------------------------------------------------------------------------------
test('盟约·辅助干员: S1 ASPD +70 & 2 targets, attacks carry 18 % ATK neural damage', () => {
const h = battle({
units: [{ chessId: 'chess_char_1_15_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy2', pos: [10, 6] }],
});
const u = h.unit('chess_char_1_15_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 80), 'skill fires');
assert.equal(u.s.aspd, 100 + bb.attack_speed);
const t0 = h.b.time;
h.run(4);
const hitters = h.hooksOf('attack');
assert.ok(u.skill.active);
const neural = h.dmg.filter((d) => d.src === u.id && d.element === 'neural' && d.t > t0);
const tgts = new Set(neural.map((d) => d.tgt));
assert.equal(tgts.size, 2, 'both targets hit during the skill');
assert.ok(near(neural[0].amount, u.s.atk * u.def.talents[0].bb.ep_damage_ratio, 1e-3), 'neural = 18 % ATK');
assert.ok(hitters.length >= 0);
checkInvariants(h.b);
});
test('盟约·辅助干员 (elite): element damage vs elite enemies uses the module ratio', () => {
const h = battle({ units: [{ chessId: 'chess_char_1_15_b', row: 10, col: 4 }], enemies: [{ key: 'enemy_elite', pos: [10, 5] }] });
const u = h.unit('chess_char_1_15_b');
h.run(3);
const el = h.dmg.find((d) => d.src === u.id && d.type === 'element');
assert.ok(el);
assert.ok(near(el.amount, u.s.atk * u.def.talents[0].bb.ep_damage_ratio_boss, 1e-3));
});
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 % centre)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
hooks: ['ammoUsed', 'skillStart', 'skillEnd'],
});
const u = h.unit('chess_char_6_01_a');
const bb = u.def.skill.bb, t1 = u.def.talents[1].bb;
assert.ok(h.runUntil(() => u.skill.active, 60));
assert.ok(h.b.time >= t1.interval, 'talent 2 active (20 s on field) before the first cast');
const start = h.b.time;
assert.ok(u.findBuff('lemuen:extradition'), 'ATK +10 % after 20 s');
assert.ok(h.runUntil(() => !u.skill.active, 20));
const ammo = h.hooksOf('ammoUsed').filter((c) => c.unit === u);
assert.equal(ammo.length, bb['attack@trigger_time'] + t1.add_count);
assert.ok(Math.abs(h.b.time - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
const bombs = h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard'));
assert.equal(bombs.length, ammo.length, 'one bomb per lock');
assert.ok(near(bombs[0].amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 %');
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < h.b.time - 0.1).length, 0, 'no normal attacks while locking');
checkInvariants(h.b);
});
test('蕾缪安: elite enemies in a Laterano range get wanted (×1.15) and she can shoot them anywhere', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 12, col: 2 }, { chessId: 'test_lat_a', row: 10, col: 2 }],
enemies: [{ key: 'enemy_elite', pos: [10, 9] }],
});
const u = h.unit('chess_char_6_01_a');
h.step();
const e = enemyAt(h, 'enemy_elite');
h.run(5);
assert.ok(!e.findBuff('lemuen:wanted'));
assert.ok(!h.dmg.some((d) => d.src === u.id), 'out of her range before being wanted');
h.run(4);
assert.ok(e.findBuff('lemuen:wanted'), 'wanted after 8 s');
h.run(4);
const hit = h.dmg.find((d) => d.src === u.id && d.attack && d.tgt === e.id);
assert.ok(hit, 'she attacks the wanted target outside her range');
const expect = u.s.atk * u.def.talents[0].bb.damage_scale;
assert.ok(near(hit.amount, expect, 1e-3), `×1.15 wanted bonus (${hit.amount} vs ${expect})`);
});
test('圣聆初雪: snow damages walking enemies (75 % ATK) and slows them; S3 lures enemies next to her', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_02_a', row: 10, col: 5 }],
enemies: [{ key: 'enemy_walker', time: 6, pos: [9, 9], route: { motion: 'WALK', start: [9, 9], end: [9, 2], checkpoints: [] } }],
});
const u = h.unit('chess_char_6_02_a');
const t0 = u.def.talents[0].bb;
assert.ok(h.runUntil(() => h.dmg.some((d) => d.tags.includes('snow')), 30), 'snow hit');
const snowHit = h.dmg.find((d) => d.tags.includes('snow'));
assert.ok(near(snowHit.amount, u.s.atk * t0.talent_magic_scale, 1e-3));
const w = enemyAt(h, 'enemy_walker');
h.step();
const slow = w.findBuff('sbell2:snow');
assert.ok(slow && slow.mods.moveMul < 1, 'snow slows');
checkInvariants(h.b);
});
test('圣聆初雪: S3 诱导 — enemies in range become unblockable and WALK (own speed) next to her for attract_time, then resume', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_02_a', row: 10, col: 4, carryState: { sp: 56 } }],
enemies: [{ key: 'enemy_walker', pos: [10, 6], route: { motion: 'WALK', start: [10, 6], end: [10, 9], checkpoints: [] } }],
});
const u = h.unit('chess_char_6_02_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 2), 'CUSTOM_RANGE trigger');
const t0 = h.b.time;
const e = enemyAt(h, 'enemy_walker');
const st = e.findBuff('attract');
assert.ok(st && e.s.flags.unblockable, '诱导: the attract status, unblockable');
const x0 = e.x;
h.run(1);
// 0.5 tiles/s (speed 1 × MOVE_SCALE) toward her — not teleported, and against its own route (which heads right)
assert.ok(e.x < x0 - 0.3 && e.x > x0 - 0.7, `walks toward her at its own speed (${x0} → ${e.x})`);
h.run(2);
assert.ok(Math.hypot(e.x - u.x, e.y - u.y) <= 1.5 + 1e-6, 'arrives on a ground tile next to her');
const xs = e.x;
h.run(3);
assert.ok(Math.abs(e.x - xs) < 1e-6, 'waits there while lured');
h.runUntil(() => !e.findBuff('attract'), bb.attract_time + 1);
assert.ok(h.b.time - t0 >= bb.attract_time - 0.1, `lasts attract_time (${h.b.time - t0})`);
h.run(2);
assert.ok(e.x > xs + 0.3, 'resumes its route afterwards');
checkInvariants(h.b);
});
test('圣聆初雪: S3 180 % group arts attacks; 圣山的祝福 prevents death once and freezes range', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_02_a', row: 10, col: 4, carryState: { sp: 55 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy2', pos: [11, 6] }],
});
const u = h.unit('chess_char_6_02_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 10), 'CUSTOM_RANGE trigger');
const e = enemyAt(h, 'enemy_dummy');
assert.ok(e.findBuff('attract'), 'lured (immobile dummies stay where they are)');
h.run(3);
const hit = h.dmg.find((d) => d.src === u.id && d.attack && d.type === 'arts');
assert.ok(hit, 'attacks while the skill runs');
assert.ok(near(hit.amount, u.s.atk * bb['attack@atk_scale_s3'], 1e-3));
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && near(u.hp, u.s.maxHp), 'full heal instead of dying');
assert.ok(u.s.flags.freeze, 'self frozen');
assert.ok(e.s.flags.freeze, 'enemies in range frozen');
h.run(10);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false, 'only once');
});
test('余: blocked enemies take 40 % ATK arts + 12 % burn per second, 庇护 while blocking; S3 +50 % HP/ATK/DEF', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_03_a', row: 9, col: 5, carryState: { sp: 40 } }],
enemies: [{ key: 'enemy_walker', time: 0, route: 0 }],
});
const u = h.unit('chess_char_6_03_a');
const t0 = u.def.talents[0].bb, bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), 'blocks');
h.run(3);
const dot = h.dmg.filter((d) => d.src === u.id && d.tags.includes('talent') && d.type === 'arts');
assert.ok(dot.length >= 2);
const burn = h.dmg.filter((d) => d.src === u.id && d.element === 'burn');
assert.ok(burn.length >= 2);
// 庇护 (ba.protect): physical and arts damage only
assert.ok(u.findBuff('yu:shelter') && near(u.s.physTakenMul, 1 - t0.damage_resistance) && near(u.s.artsTakenMul, 1 - t0.damage_resistance));
assert.equal(u.s.trueTakenMul, 1, '庇护 does not reduce true damage');
assert.equal(u.s.dmgTakenMul, 1);
const base = u.base.maxHp;
assert.ok(h.runUntil(() => u.skill.active, 20), 'CUSTOM_RANGE trigger');
assert.ok(near(u.s.maxHp, base * (1 + bb.max_hp)));
assert.ok(near(dot[0].amount, u.base.atk * t0['yu_t_1[enemy].atk_scale'], 1e-3), 'DoT = 40 % ATK (before the skill)');
checkInvariants(h.b);
});
test('余: S3 fire wall adds burn to allied arts damage crossing it and clears crossing enemy bullets', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_03_a', row: 10, col: 5, carryState: { sp: 54 } }, { chessId: 'chess_char_1_15_a', row: 9, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_shooter', pos: [11, 8] }],
seed: 11,
});
const u = h.unit('chess_char_6_03_a');
assert.ok(h.runUntil(() => u.skill.active, 10));
h.run(15);
const wall = h.dmg.filter((d) => d.src === u.id && d.tags.includes('firewall'));
assert.ok(wall.length > 0, 'burn from the wall');
assert.ok(near(wall[0].amount, u.s.atk * u.def.skill.bb.ep_damage_ratio, 1e-3));
const blocked = h.eventsOf('fx').filter((e) => e[1] === 'firewallBlock');
assert.ok(blocked.length > 0, 'some enemy bullets cleared');
});
test('浊心斯卡蒂: heals as a bard, S3 = true damage in her + 海嗣 ranges, 鼓舞 +65 % of her ATK, no death from drain', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_04_a', row: 11, col: 4, carryState: { sp: 38 } },
{ chessId: 'test_guard_a', row: 10, col: 4 },
{ kind: 'token', tokenId: 'token_10017_skadi2_dedant', row: 10, col: 8, ownerUid: 1 },
],
enemies: [{ key: 'enemy_dummy', pos: [11, 5] }, { key: 'enemy_dummy2', pos: [9, 9] }],
});
const u = h.unit('chess_char_6_04_a');
const g = h.unit('test_guard_a');
const tok = h.b.allyUnits.find((x) => x.defId === 'token_10017_skadi2_dedant');
h.step();
assert.ok(tok && tok.alive && tok.profile.noAttack, '海嗣 deployed, inert');
g.hp = g.s.maxHp * 0.5;
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(2.2);
const baseAtk = g.base.atk;
const insp = g.findBuff('inspire');
assert.ok(insp, 'ally inspired');
assert.ok(near(g.s.atk - baseAtk, u.s.atk * u.def.skill.bb.atk, 1e-3), `inspire = 65 % of her ATK (${g.s.atk - baseAtk})`);
const tide = h.dmg.filter((d) => d.src === u.id && d.tags.includes('tide'));
const e1 = enemyAt(h, 'enemy_dummy'), e2 = enemyAt(h, 'enemy_dummy2');
assert.ok(tide.some((d) => d.tgt === e1.id) && tide.some((d) => d.tgt === e2.id), 'her range + seaborn range');
assert.ok(near(tide[0].amount, u.s.atk * u.def.skill.bb.atk_scale, 1e-3));
h.runUntil(() => !u.skill.active, 30);
assert.ok(u.alive && u.hp >= 1, 'drain is not lethal');
const hp0 = g.hp;
h.run(2);
assert.ok(g.hp > hp0, 'bard heal resumes after the skill');
checkInvariants(h.b);
});
test('浊心斯卡蒂: 海嗣 expires after its duration (25 s) and comes back after its redeploy time', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_04_a', row: 11, col: 4 }, { kind: 'token', tokenId: 'token_10017_skadi2_dedant', row: 10, col: 8, ownerUid: 1 }],
flags: { dpInit: 50 },
});
const find = () => h.b.allyUnits.filter((x) => x.defId === 'token_10017_skadi2_dedant' && x.alive);
h.step();
assert.equal(find().length, 1);
h.run(25.5);
assert.equal(find().length, 0, 'expired');
h.run(31);
assert.equal(find().length, 1, 're-summoned');
});
test('异客: S3 storm strikes 8 times over 4 s at the highest-HP enemy; 机理分析 ×1.2 vs ≥80 % HP targets', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_05_a', row: 10, col: 3, carryState: { sp: 34 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy2', pos: [10, 6], mods: { hpMul: 2 } }],
});
const u = h.unit('chess_char_6_05_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.activations > 0, 10));
const storm = h.eventsOf('fx').find((e) => e[1] === 'storm');
const big = enemyAt(h, 'enemy_dummy2');
assert.ok(storm && near(storm[2], big.x, 1e-2), 'storm on the highest-HP target');
const t0 = h.b.time;
h.run(bb.duration + 0.5);
const first = h.dmg.filter((d) => d.src === u.id && d.tags.includes('storm') && d.t >= t0);
const strikes = h.eventsOf('fx').filter((e) => e[1] === 'lightning');
assert.ok(strikes.length >= Math.round(bb.duration / bb.interval), `8 strikes (${strikes.length} bolts)`);
const main = first.find((d) => d.amount > 0);
const mul = u.def.talents[0].bb['pasngr_t_1[enhance].damage_scale'];
const ok = near(main.amount, u.s.atk * bb.atk_scale * mul, 1e-3) || near(main.amount, u.s.atk * bb.atk_scale * mul * (1 - u.profile.chain.falloff), 1e-3);
assert.ok(ok, 'storm hit boosted by 机理分析 (targets at full HP)');
assert.ok(big.findBuff(`pasngr:enhance:${u.id}`));
checkInvariants(h.b);
});
test('佩佩: S3 ATK +150 %, main target stunned, stacks up to 4 with a growing splash; 近卫 aura +16 %', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_06_a', row: 9, col: 5, carryState: { sp: 56 } }, { chessId: 'test_guard_a', row: 12, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy2', pos: [10, 6] }],
});
const u = h.unit('chess_char_6_06_a');
const g = h.unit('test_guard_a');
const bb = u.def.skill.bb;
h.run(1);
assert.ok(g.findBuff('pepe:lotus'), '弥漫莲香 on another 近卫');
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(10);
const main = enemyAt(h, 'enemy_dummy'), side = enemyAt(h, 'enemy_dummy2');
assert.equal(u.mem.pepeStack, bb['attack@max_stack_cnt']);
assert.ok(near(u.skill.spec.attack.splashRadius, 1 + bb['attack@ability_range_forward_extend'] * bb['attack@max_stack_cnt']));
const lotus = u.def.talents[1].bb.atk;
assert.ok(near(u.s.atk, u.base.atk * (1 + bb.atk + lotus + bb['attack@atk'] * bb['attack@max_stack_cnt']), 1e-6));
const stuns = h.hooksOf('statusApplied').filter((c) => c.status === 'stun' && c.source === u);
assert.ok(stuns.some((c) => c.target === main && near(c.duration, bb['attack@stun_main'])), 'main target stunned 1 s');
assert.ok(stuns.some((c) => c.target === side && near(c.duration, bb['attack@stun'])), 'splash target stunned 0.8 s');
checkInvariants(h.b);
});
test('维娜·维多利亚: first hit trembles, S3 summons 黄金盟誓, deals true damage to +1 target', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_07_a', row: 9, col: 5, carryState: { sp: 64 } }],
enemies: [{ key: 'enemy_armored', pos: [9, 6] }],
});
const u = h.unit('chess_char_6_07_a');
h.step();
const e = enemyAt(h, 'enemy_armored');
assert.ok(h.runUntil(() => u.skill.active, 10));
const lion = h.b.allyUnits.find((x) => x.defId === 'token_10040_siege2_vlion');
assert.ok(lion && lion.alive, '黄金盟誓 summoned');
assert.ok(Math.max(Math.abs(lion.tileR - u.tileR), Math.abs(lion.tileC - u.tileC)) <= 1);
h.run(3);
assert.ok(h.dmg.some((d) => d.src === u.id && d.attack && d.type === 'true'), 'true damage');
assert.ok(h.dmg.some((d) => d.src === lion.id && d.type === 'true'), 'lion hits with true damage');
assert.equal(u.profile.maxTargets + u.def.skill.bb['attack@max_target'] - 1 >= 2, true);
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'tremble' && c.target === e), '无拘的锋芒');
h.runUntil(() => !u.skill.active, 30);
h.step();
assert.equal(lion.alive, false, 'lion leaves with the skill');
checkInvariants(h.b);
});
test('焰影苇草: 灼痕 (ATK −20 %, arts fragile) ; S3 = 100 % application, 2 targets, DoT', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_08_a', row: 10, col: 3, carryState: { sp: 44 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy2', pos: [10, 6] }],
});
const u = h.unit('chess_char_6_08_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 10));
h.run(4);
const a = enemyAt(h, 'enemy_dummy'), b = enemyAt(h, 'enemy_dummy2');
assert.ok(a.findBuff('reed2:scorch') && b.findBuff('reed2:scorch'), 'both targets scorched');
assert.ok(near(a.s.artsTakenMul, u.def.talents[0].bb.damage_scale));
const dot = h.dmg.filter((d) => d.src === u.id && d.tags.includes('scorch'));
assert.ok(dot.length >= 2);
assert.ok(near(dot[0].amount, u.s.atk * bb['talent@s3_atk_scale'] * u.def.talents[0].bb.damage_scale, 1e-3), 'DoT 20 % ATK (× fragile)');
checkInvariants(h.b);
});
test('焰影苇草: 映耀 — she receives 50 % of the heal she gives', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_08_a', row: 10, col: 3 }, { chessId: 'test_guard_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
hooks: ['heal'], captureNoisy: true,
});
const u = h.unit('chess_char_6_08_a'), g = h.unit('test_guard_a');
h.step();
g.hp = g.s.maxHp * 0.3;
u.hp = u.s.maxHp * 0.3;
h.run(4);
const heals = h.hooksOf('heal').filter((c) => c.source === u);
const toG = heals.find((c) => c.target === g), self = heals.find((c) => c.target === u && c.opts?.reflect);
assert.ok(toG && self, 'heals the ally and itself');
assert.ok(near(self.amount, toG.amount * u.def.talents[1].bb.scale, 1e-6));
});
test('塑心: no normal attack; each charge hits 210 % arts + 85 % apoptosis (×1.2 in her range)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_09_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_09_a');
const bb = u.def.skill.bb, t1 = u.def.talents[1].bb;
h.run(12);
const arts = h.dmg.filter((d) => d.src === u.id && d.type === 'arts');
assert.ok(arts.length >= 2);
for (const d of arts) assert.ok(d.skill, 'every attack is a skill attack');
assert.ok(near(arts[0].amount, u.s.atk * bb.atk_scale, 1e-3));
assert.equal(arts.length, u.skill.activations - (u.skill.pending ? 1 : 0));
// one gauge fill of 85 % ATK × 1.2 (精神逆构, via elementHit — no separate 'amp' instance)
const i = h.dmg.findIndex((d) => d.src === u.id && d.element === 'apoptosis' && d.tags.includes('skill'));
assert.ok(i >= 0 && near(h.dmg[i].amount, u.s.atk * bb.ep_damage_ratio * t1.ep_damage_scale, 1e-3), 'skill apoptosis 85 % ATK × 1.2');
assert.ok(!h.dmg.some((d) => d.tags.includes('amp')), 'no extra amplification instance');
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'sluggish' && c.source === u), '无词哀歌 sluggish');
checkInvariants(h.b);
});
test('妮芙 (tier 6): S2 = 270 % arts + equal splash, fear, apoptosis 18 % of the damage', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_10_a', row: 10, col: 3, carryState: { sp: 16 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy2', pos: [11, 5] }],
});
const u = h.unit('chess_char_6_10_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => h.dmg.some((d) => d.src === u.id && d.skill && d.type === 'arts'), 10));
h.run(0.3);
const hits = h.dmg.filter((d) => d.src === u.id && d.skill && d.type === 'arts');
assert.equal(hits.length, 2, 'main + splash');
for (const d of hits) assert.ok(near(d.amount, u.s.atk * bb.atk_scale, 1e-3));
const apo = h.dmg.filter((d) => d.src === u.id && d.element === 'apoptosis');
assert.equal(apo.length, 2);
assert.ok(near(apo[0].amount, hits[0].amount * bb.ep_damage_ratio, 1e-3));
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'fear'), 'fear');
checkInvariants(h.b);
});
test('妮芙 (tier 6): 失魂 — attacking an enemy in apoptosis burst adds a 40 % ATK element DoT', () => {
const h = battle({ units: [{ chessId: 'chess_char_6_10_a', row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] });
const u = h.unit('chess_char_6_10_a');
h.step();
const e = enemyAt(h, 'enemy_dummy');
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(e.findBuff('apoptosisBurst'));
h.run(4);
const soul = h.dmg.filter((d) => d.src === u.id && d.tags.includes('elementDmg'));
assert.ok(soul.length >= 1);
assert.equal(soul[0].type, 'elemental', '元素伤害 = the elemental damage type (not true damage)');
assert.ok(near(soul[0].amount, u.s.atk * u.def.talents[0].bb.element_atk_scale, 1e-3));
assert.ok(u.findBuff('nymph:key'), '窥心钥 stack after an apoptosis burst in range');
});
test('缪尔赛思: 流形 copies the nearest operator (90 %), S3 gives 13 DP and +22 % ATK to her and 流形', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_11_a', row: 10, col: 3 },
{ chessId: 'test_guard_a', row: 11, col: 5 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 10, col: 5, ownerUid: 1 },
],
enemies: [{ key: 'enemy_dummy', pos: [10, 6], time: 20 }],
flags: { dpInit: 0, dpPerSec: 0 },
});
const u = h.unit('chess_char_6_11_a'), g = h.unit('test_guard_a');
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
h.run(0.2);
assert.ok(tok().base.atk < g.base.atk, 'not copied yet (token SP 95/100)');
h.run(6);
const scale = tok().def.talents[0].bb.scale;
assert.ok(near(tok().base.atk, g.base.atk * scale) && near(tok().base.maxHp, g.base.maxHp * scale), 'copied 90 % stats');
assert.equal(tok().mem.mlyss.ranged, false);
assert.ok(h.runUntil(() => u.skill.active, 60), 'skill');
assert.ok(near(h.b.getPlayer('p1').dp, u.def.skill.bb.cost), '+13 DP');
assert.ok(tok().findBuff('mlyss:s3'));
checkInvariants(h.b);
});
test('缪尔赛思: a destroyed 流形 comes back after 25 s; melee copy steals ATK/DEF', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3 },
{ chessId: 'test_guard_a', row: 12, col: 5 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 10, col: 5, ownerUid: 1 },
],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
h.run(8);
const e = enemyAt(h, 'enemy_dummy');
assert.ok(e.findBuff('mlyss:robbed'), 'enemy robbed');
assert.ok(tok().mem.mlyss.stolenAtk > 0 && tok().mem.mlyss.stolenDef > 0);
assert.equal(e.findBuff('mlyss:robbed').mods.atkFlat, -tok().mem.mlyss.stolenAtk, 'the enemy loses what the copy gains');
const t = tok();
h.b.dealDamage(null, t, { amount: 1e9, type: 'true' });
assert.equal(tok(), undefined);
h.run(24);
assert.equal(tok(), undefined);
h.run(2);
assert.ok(tok(), 'respawned');
});
test('迷迭香: S2 adds 2 aftershocks per attack, ignores 160 DEF (talent)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_12_a', row: 10, col: 3, carryState: { sp: 36 } }],
enemies: [{ key: 'enemy_armored', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_12_a');
h.step();
assert.equal(u.s.defIgnoreFlat, u.def.talents[0].bb.def_penetrate_fixed);
assert.ok(h.runUntil(() => u.skill.active, 10));
const t0 = h.b.time;
h.run(8);
const atks = h.dmg.filter((d) => d.src === u.id && d.attack && !d.splash && d.t > t0);
const shocks = h.dmg.filter((d) => d.src === u.id && d.tags.includes('aftershock') && d.t > t0);
assert.ok(atks.length >= 2);
const per = (u.profile.shockTimes - 1);
assert.equal(per, (u.def.traitBb['attack@times'] - 1) + u.def.skill.bb.add_times);
assert.ok(shocks.length >= (atks.length - 1) * per, `aftershocks ${shocks.length} for ${atks.length} attacks`);
const e = enemyAt(h, 'enemy_armored');
assert.ok(near(atks[0].amount, u.s.atk - (e.s.def - u.def.talents[0].bb.def_penetrate_fixed), 1e-3), 'DEF 500 − 160');
h.runUntil(() => !u.skill.active, 60);
assert.equal(u.profile.shockTimes, u.def.traitBb['attack@times'], 'restored after the skill');
});
test('新约能天使: S2 steals 70 ASPD from an ally in range, shields both, 35+5 ammo; 火力电台 heals per ammo', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_13_a', row: 10, col: 3, carryState: { sp: 29 } }, { chessId: 'test_guard_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
hooks: ['ammoUsed', 'skillEnd'],
});
const u = h.unit('chess_char_6_13_a'), g = h.unit('test_guard_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.equal(g.s.aspd, 100 - bb.steal);
assert.equal(u.s.aspd, 100 + bb.steal);
assert.ok(u.s.shield > 0 && g.s.shield > 0, 'barriers');
assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@trigger_time'] + bb.addtional_ammo_each, '35 + 5 bullets');
u.hp = u.s.maxHp * 0.5;
const hp0 = u.hp;
h.run(1);
assert.ok(u.hp > hp0, '火力电台 heal on ammo use');
h.runUntil(() => !u.skill.active, 60);
const used = h.hooksOf('ammoUsed').filter((c) => c.unit === u);
assert.equal(used.length, bb['attack@trigger_time'] + bb.addtional_ammo_each);
assert.equal(g.s.aspd, 100, 'ASPD returned');
checkInvariants(h.b);
});
test('流明: S3 bullets are spent only on abnormal allies (cleansed, 135 % heal); 应急处理 heals on status', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_14_a', row: 10, col: 3, carryState: { sp: 60 } }, { chessId: 'test_guard_a', row: 10, col: 5 }],
hooks: ['ammoUsed', 'heal'], captureNoisy: true,
});
const u = h.unit('chess_char_6_14_a'), g = h.unit('test_guard_a');
const bb = u.def.skill.bb;
h.step();
g.hp = g.s.maxHp * 0.2;
h.b.applyStatus(g, 'stun', { duration: 30 });
const emergency = h.hooksOf('heal').filter((c) => c.source === u && c.target === g);
assert.ok(emergency.length >= 1, '应急处理');
assert.ok(near(emergency[0].amount, u.s.atk * u.def.talents[1].bb.heal_scale, 1e-3));
assert.ok(g.findBuff('resist'), '凡人之愿: 抵抗 (engine resist status) on the healed ally');
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.ok(h.runUntil(() => !g.findBuff('stun'), 5), 'stun cleansed');
const used = h.hooksOf('ammoUsed').filter((c) => c.unit === u);
assert.equal(used.length, 1, 'one bullet for the abnormal heal');
const big = h.hooksOf('heal').filter((c) => c.source === u && c.target === g).at(-1);
assert.ok(near(big.amount, u.s.atk * bb.heal_scale, 1e-3), '135 % heal');
h.run(3);
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 1, 'normal heals are free');
assert.ok(u.skill.active);
h.b.applyStatus(g, 'stun', { duration: 10 });
assert.ok(g.findBuff('stun').timeLeft <= 10 * 0.5 + 1e-6, '抵抗 halves new statuses');
checkInvariants(h.b);
});
test('仇白: S3 arts damage on 3 targets with ASPD stacks; 落英 binds; 入隙 extra 40 % arts on bound targets', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_15_a', row: 10, col: 5, carryState: { sp: 60 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy2', pos: [10, 7] }, { key: 'enemy_dummy', pos: [11, 6] }],
seed: 3,
});
const u = h.unit('chess_char_6_15_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 10));
const t0 = h.b.time;
h.run(8);
const hits = h.dmg.filter((d) => d.src === u.id && d.attack && d.t > t0);
assert.ok(hits.every((d) => d.type === 'arts'), 'arts');
assert.equal(new Set(hits.map((d) => d.tgt)).size, 3, '3 targets');
assert.equal(u.s.aspd, 100 + bb.attack_speed * bb.max_stack_cnt);
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'bind' && c.source === u), '落英');
const gap = h.dmg.filter((d) => d.src === u.id && d.tags.includes('talent'));
assert.ok(gap.length > 0 && near(gap[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale, 0.1), '入隙');
checkInvariants(h.b);
});
test('溯光星源: 能源解析 fragile 10 % → 14 % after 7 s; S3 links 2 targets (15 % transfer)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_16_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy2', pos: [11, 5] }],
});
const u = h.unit('chess_char_6_16_a');
const t1 = u.def.talents[1].bb, bb = u.def.skill.bb;
h.run(1);
const e = enemyAt(h, 'enemy_dummy');
assert.ok(near(e.s.dmgTakenMul, t1['halo2_t_1[weak].damage_scale']));
h.run(7);
assert.ok(near(e.s.dmgTakenMul, t1['halo2_t_1[weak].damage_scale_max']));
assert.ok(u.mem.haloStack > 0, '数据建模 stacks from sluggish');
assert.ok(h.runUntil(() => u.skill.active, 40));
const t0 = h.b.time;
h.run(3);
const direct = h.dmg.filter((d) => d.src === u.id && d.attack && d.t > t0);
const link = h.dmg.filter((d) => d.tags.includes('link') && d.t > t0);
assert.ok(link.length > 0, 'linked transfer');
assert.ok(near(link[0].amount, direct[0].amount * bb['attack@atk_share'], 1e-2), `share ${link[0].amount} vs ${direct[0].amount}`);
checkInvariants(h.b);
});
test('耀骑士临光: S3 summons 耀阳 (90 % true + stun 3 s around it), true damage on blocked; 不畏苦暗 on redeploy', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_17_a', row: 10, col: 5, carryState: { sp: 54 } }],
enemies: [{ key: 'enemy_armored', pos: [10, 6] }],
});
const u = h.unit('chess_char_6_17_a');
const bb = u.def.skill.bb;
h.step();
const e = enemyAt(h, 'enemy_armored');
assert.ok(h.runUntil(() => u.skill.active, 10));
const sword = h.b.allyUnits.find((x) => x.defId === 'token_10019_nearl2_sword' && x.alive);
assert.ok(sword, '耀阳 summoned');
// the appear burst is done by the token kit (content/tokens.js) when present, else by the 耀骑士临光 kit
const burst = h.dmg.filter((d) => (d.src === u.id || d.src === sword.id) && d.type === 'true' && d.skill && !d.attack);
assert.ok(burst.length >= 1 && near(burst[0].amount, u.s.atk * bb.value, 1e-3), '90 % of her ATK, true');
assert.ok(e.s.flags.stun, 'stunned 3 s');
assert.ok(h.runUntil(() => e.blockedBy === sword, 6), '耀阳 blocks the enemy once the stun ends');
const tb = h.b.time;
h.run(3.5);
const hits = h.dmg.filter((d) => d.src === u.id && d.attack && d.tgt === e.id && d.t >= tb);
assert.ok(hits.length && hits.every((d) => d.type === 'true'), 'attacks on the blocked enemy deal true damage');
// talent: redeploy next to the enemy
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
h.run(0.1);
h.b.redeploy(u);
const tal = h.dmg.filter((d) => d.src === u.id && d.tags.includes('talent'));
assert.ok(tal.length >= 1 && near(tal[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale, 1e-3), '不畏苦暗');
assert.equal(u.s.defIgnorePct, u.def.talents[1].bb.def_penetrate, '破晓');
});
test('荒芜拉普兰德: S3 drones chase enemies anywhere, fear on catch, 100 % ATK arts per second around them', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3, carryState: { sp: 68 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy2', pos: [12, 9] }],
});
const u = h.unit('chess_char_6_18_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.equal(u.mem.drones.length, 1 + bb['attack@cnt']);
const far = enemyAt(h, 'enemy_dummy2');
assert.ok(h.runUntil(() => h.dmg.some((d) => d.tgt === far.id && d.tags.includes('drone')), 20), 'a drone reaches the far enemy');
const hit = h.dmg.find((d) => d.tgt === far.id && d.tags.includes('drone'));
assert.ok(near(hit.amount, u.s.atk * bb['attack@magic_atk_scale'], 1e-3));
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'fear' && c.target === far));
assert.ok(far.findBuff('whitw2:slow'));
checkInvariants(h.b);
});
test('荒芜拉普兰德: 叙拉古的荣幸 +5 initial SP for 叙拉古 operators', () => {
const h = battle({ units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3 }] });
const u = h.unit('chess_char_6_18_a');
h.step();
assert.ok(u.skill.sp >= u.def.skill.initSp + u.def.talents[1].bb.sp - 0.1);
});
test('锏: S3 = 10 slashes (180 % ×1.6 talent) on ≤5 enemies + a 300 % finisher, pulls enemies in', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_19_a', row: 10, col: 5, carryState: { sp: 34 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy2', pos: [10, 7] }],
hooks: ['skillStart', 'skillEnd', 'statusApplied'],
});
const u = h.unit('chess_char_6_19_a');
const bb = u.def.skill.bb, t0 = u.def.talents[0].bb;
assert.ok(h.runUntil(() => u.skill.active, 10));
const t = h.hooksOf('skillStart').find((c) => c.unit === u).t;
h.runUntil(() => !u.skill.active, 10);
const slashes = h.dmg.filter((d) => d.src === u.id && d.tags.includes('slash'));
assert.equal(slashes.length, 11 * 2, '10 slashes + finisher on 2 enemies');
assert.ok(near(slashes[0].amount, u.s.atk * bb.d_atk_scale * t0.atk_scale, 1e-3));
assert.ok(near(slashes.at(-1).amount, u.s.atk * bb.e_atk_scale_end * t0.atk_scale, 1e-3));
assert.ok(near(h.b.time - t, 10 * bb.d_hit_interval, 0.05));
const far = enemyAt(h, 'enemy_dummy2');
assert.ok(far.x < 7 - 0.4, 'pulled toward her');
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'tremble'), '战栗');
checkInvariants(h.b);
});
test('纯烬艾雅法拉: S3 heals across the whole field in 5 shots (35 %); 火山灰疗愈 max HP +6 %; 氤氲 HoT', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_20_a', row: 10, col: 3, carryState: { sp: 74 } },
{ chessId: 'test_guard_a', row: 12, col: 10 },
{ chessId: 'test_guard_a', row: 9, col: 10, uid: 9 },
{ chessId: 'test_guard_a', row: 10, col: 4, uid: 10 },
],
hooks: ['heal'], captureNoisy: true,
});
const u = h.unit('chess_char_6_20_a');
const bb = u.def.skill.bb, t1 = u.def.talents[1].bb;
h.run(0.6);
const guards = h.b.allyUnits.filter((a) => a.defId === 'test_guard_a');
const nearG = guards.find((g) => g.tileC === 4);
assert.ok(near(nearG.s.maxHp, nearG.base.maxHp * (1 + t1.max_hp)), 'max HP +6 % in range');
for (const g of guards) g.hp = g.s.maxHp * 0.3;
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3);
const farG = guards.find((g) => g.tileC === 10 && g.tileR === 12);
const heals = h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.hot);
assert.ok(heals.some((c) => c.target === farG), 'far ally healed');
assert.ok(near(heals.find((c) => c.target === farG).amount, u.s.atk * bb['attack@heal_scale'], 1e-3), '35 % heal per shot');
const byT = new Map();
for (const c of heals) byT.set(c.t, (byT.get(c.t) ?? 0) + 1);
const shots = Math.max(...byT.values());
assert.equal(shots, 5, '5 shots per attack');
assert.ok(near(nearG.s.maxHp, nearG.base.maxHp * (1 + t1.max_hp * bb.talent_scale)), 'talent ×2.9 during S3');
assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.opts?.hot), '氤氲 HoT');
checkInvariants(h.b);
});
test('all tier-6 kits (normal + elite) survive a real wave without content errors', () => {
const ids = ['1_15', '6_01', '6_02', '6_03', '6_04', '6_05', '6_06', '6_07', '6_08', '6_09', '6_10', '6_11', '6_12', '6_13', '6_14', '6_15', '6_16', '6_17', '6_18', '6_19', '6_20'];
for (const sfx of ['a', 'b']) {
for (let i = 0; i < ids.length; i += 7) {
const group = ids.slice(i, i + 7);
const spots = [[9, 5], [10, 5], [11, 5], [12, 5], [9, 3], [10, 3], [11, 3]];
const h = makeBattle({
units: group.map((id, k) => ({ chessId: `chess_char_${id}_${sfx}`, row: spots[k][0], col: spots[k][1], carryState: { sp: 99 } })),
waveTemplate: 'act1autochess_05', seed: 5 + i,
});
h.runToEnd(200);
const errs = h.b.errors.filter((e) => /tier6|kit|skill|talent|hook|buff|every|after|profile/.test(e.label + ' ' + (e.stack || '')));
assert.deepEqual(errs.map((e) => `${e.label} ${e.who}: ${e.message}`), [], `group ${group.join(',')} ${sfx}`);
checkInvariants(h.b);
}
}
});
test('tier-6 talent auras: 铳弹协约, 诸王的叹息, 感知稳定, 孤卒, 开源节流, 弥漫莲香', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_13_a', row: 10, col: 3 }, // 新约能天使
{ chessId: 'chess_char_6_01_a', row: 11, col: 3 }, // 蕾缪安 (拉特兰, ammo skill)
{ chessId: 'chess_char_6_14_a', row: 12, col: 3 }, // 流明 (ammo skill)
{ chessId: 'chess_char_6_07_a', row: 9, col: 5 }, // 维娜
{ chessId: 'test_guard_a', row: 10, col: 5 },
{ chessId: 'chess_char_6_12_a', row: 12, col: 7 }, // 迷迭香
{ chessId: 'chess_char_6_05_a', row: 11, col: 7 }, // 异客 (术师)
{ chessId: 'chess_char_6_11_a', row: 12, col: 9 }, // 缪尔赛思 (莱茵生命)
{ chessId: 'chess_char_2_02_a', row: 11, col: 9 }, // 赫默 (莱茵生命)
],
});
h.run(1);
const u = (id) => h.unit(id);
const angel = u('chess_char_6_13_a'), lem = u('chess_char_6_01_a'), lumen = u('chess_char_6_14_a');
const tAng = angel.def.talents[1].bb;
assert.ok(near(lem.findBuff('angel2:covenant').mods.atkPct, tAng.atk * tAng.mult), '拉特兰 ammo op: ×2');
assert.ok(near(lumen.findBuff('angel2:covenant').mods.atkPct, tAng.atk), 'other ammo op: +9 %');
const vina = u('chess_char_6_07_a'), g = u('test_guard_a');
assert.ok(near(g.s.physTakenMul, 1 - vina.def.talents[0].bb.damage_resistance), '诸王的叹息 phys reduction');
assert.ok(near(vina.findBuff('siege2:kings').mods.atkPct, vina.def.talents[0].bb.atk * 1), '+5 % per ally around');
const ros = u('chess_char_6_12_a'), pas = u('chess_char_6_05_a');
assert.ok(ros.findBuff('rosmon:stable') && pas.findBuff('rosmon:stable'), '感知稳定 on 迷迭香 and the caster');
assert.ok(near(pas.findBuff('pasngr:lone').mods.atkPct, pas.def.talents[1].bb.atk), '孤卒');
assert.ok(g.findBuff('pepe:lotus') === null, 'no 佩佩 on the field');
const mly = u('chess_char_6_11_a'), silent = u('chess_char_2_02_a');
const cut = mly.def.talents[1].bb;
const costs = [mly, silent].sort((a, b) => a.deploySeq - b.deploySeq);
assert.equal(costs[0].base.cost, costs[0].def.stats.cost + cut.cost + cut.runtime_cost, 'first 莱茵生命 op −3');
assert.equal(costs[1].base.cost, costs[1].def.stats.cost + cut.cost, 'other 莱茵生命 op −2');
checkInvariants(h.b);
});
// --------------------------------------------------------------------------------------------------------------------
// regression tests (fidelity pass)
DEFS.enemies.enemy_heavy = dummy({ key: 'enemy_heavy', mass: 10 });
DEFS.chess.test_caster_a = chessRec({ id: 'test_caster_a', profession: 'CASTER', dmgType: 'arts', skill: null, stats: { atk: 1000 }, rangeGrid: Array.from({ length: 6 }, (_, c) => [0, c]) });
test('summon tiles skip reserved home tiles: 黄金盟誓 never takes the tile of an operator waiting to redeploy', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_07_a', row: 9, col: 5, carryState: { sp: 64 } }, { chessId: 'test_guard_a', row: 10, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
flags: { dpInit: 50 },
});
const u = h.unit('chess_char_6_07_a'), g = h.unit('test_guard_a');
h.step();
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
assert.equal(g.alive, false);
assert.ok(h.runUntil(() => u.skill.active, 5));
const lion = h.b.allyUnits.find((x) => x.defId === 'token_10040_siege2_vlion' && x.alive);
assert.ok(lion, '黄金盟誓 summoned');
assert.ok(!(lion.tileR === 10 && lion.tileC === 5), 'not on the dead guard\'s home tile (closest to the enemy)');
assert.ok(h.runUntil(() => g.alive, g.base.respawnTime + 2), 'the guard redeploys on its tile');
checkInvariants(h.b);
});
test('余 (elite): 阻挡 ⇒ element ×1.15 (module, elementHit); S3 gives the ×1.14 arts vs burning targets to every operator (≥4 ops)', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_03_b', row: 10, col: 5 },
{ chessId: 'test_caster_a', row: 12, col: 3 },
{ chessId: 'test_guard_a', row: 12, col: 8 }, { chessId: 'test_guard_a', row: 9, col: 8, uid: 7 },
],
enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [10, 9], end: [10, 2], checkpoints: [] } }],
});
const u = h.unit('chess_char_6_03_b'), c = h.unit('test_caster_a');
const t0 = u.def.talents[0].bb;
assert.ok(h.runUntil(() => u.blocking.length > 0 && h.dmg.some((d) => d.src === u.id && d.element === 'burn'), 30), 'blocks + burn DoT');
const burn = h.dmg.find((d) => d.src === u.id && d.element === 'burn');
assert.ok(near(burn.amount, u.s.atk * t0['yu_t_1[enemy].ep_damage_ratio'] * u.def.traitBb.ep_damage_scale, 1e-6), `burn ${burn.amount} = ATK × 12 % × 1.15`);
const e = enemyAt(h, 'enemy_walker');
// force a burn burst, then compare the caster's arts hits before / during 余's S3
e.elem.burn = 0;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 5000 });
assert.ok(e.findBuff('burnBurst'));
const hitOf = () => { const n = h.dmg.length; h.b.dealDamage(c, e, { amount: 1000, type: 'arts' }); return h.dmg.slice(n).find((d) => d.src === c.id && d.type === 'arts')?.amount; };
const plain = hitOf();
u.skill.activate('test', { free: true });
assert.ok(u.skill.active);
const boosted = hitOf();
const ds = u.def.raw.talents.find((t) => t.index === -1 && t.bb.damage_scale).bb.damage_scale;
assert.ok(near(boosted, plain * ds, 1e-6), `other op ×${ds} vs burning while S3 runs (${plain} → ${boosted})`);
checkInvariants(h.b);
});
test('塑心: 精神逆构 multiplies every apoptosis fill in her range once (two 塑心 do not stack); elite ×1.18 vs elite enemies', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_09_a', row: 10, col: 4 }, { chessId: 'chess_char_6_09_a', row: 11, col: 4, uid: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_09_a');
h.step();
const e = enemyAt(h, 'enemy_dummy');
const got = h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 100 });
assert.ok(near(got, 100 * u.def.talents[1].bb.ep_damage_scale, 1e-9), `×1.2 once (${got})`);
const other = h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 100 });
assert.ok(near(other, 100, 1e-9), 'other elements untouched');
// elite vs an elite enemy: 95 % ATK × 1.33 (精神逆构 module) × 1.18 (强弱法)
const h2 = battle({ units: [{ chessId: 'chess_char_6_09_b', row: 10, col: 4 }], enemies: [{ key: 'enemy_elite', pos: [10, 5] }] });
const v = h2.unit('chess_char_6_09_b');
assert.ok(h2.runUntil(() => h2.dmg.some((d) => d.src === v.id && d.element === 'apoptosis' && d.tags.includes('skill')), 10));
const sk = h2.dmg.find((d) => d.src === v.id && d.element === 'apoptosis' && d.tags.includes('skill'));
const want = v.s.atk * v.def.skill.bb.ep_damage_ratio * v.def.talents[1].bb.ep_damage_scale * v.def.traitBb.ep_damage_scale;
assert.ok(near(sk.amount, want, 1e-6), `elite ${sk.amount} ≈ ${want}`);
checkInvariants(h.b);
});
test('锏: the S3 finisher (and its pull) uses the skill range, not her normal 1-tile range', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_19_a', row: 10, col: 5, carryState: { sp: 34 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_heavy', pos: [12, 5] }],
});
const u = h.unit('chess_char_6_19_a');
assert.ok(h.runUntil(() => u.skill.active, 10));
h.runUntil(() => !u.skill.active, 10);
h.step();
const heavy = enemyAt(h, 'enemy_heavy');
assert.ok(near(heavy.y, 12) && near(heavy.x, 5), 'too heavy to be pulled (mass 10)');
const hits = h.dmg.filter((d) => d.src === u.id && d.tgt === heavy.id && d.tags.includes('slash'));
assert.equal(hits.length, 11, '10 slashes + the finisher reach the enemy 2 tiles away (x-1 skill range)');
assert.ok(near(hits.at(-1).amount / hits[0].amount, u.def.skill.bb.e_atk_scale_end / u.def.skill.bb.d_atk_scale, 1e-6), 'finisher 300 %');
});
test('荒芜拉普兰德: 叙拉古的荣幸 +5 initial SP on every deployment; 头狼 stage 3 = one more drone hit per attack', () => {
const h = battle({ units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], flags: { dpInit: 99 } });
const u = h.unit('chess_char_6_18_a');
const sp = u.def.talents[1].bb.sp, init = u.def.skill.initSp;
h.step();
assert.ok(near(u.skill.sp, init + sp, 0.1), `first deployment ${u.skill.sp}`);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
h.b.redeploy(u);
assert.ok(u.alive);
assert.ok(near(u.skill.sp, init + sp, 1e-6), `redeployment also starts with +${sp} (${u.skill.sp})`);
assert.equal(u.profile.hits, 1);
assert.ok(h.runUntil(() => u.mem.wolfStage === 3, 3 * u.def.talents[0].bb.interval + 2));
assert.equal(u.profile.hits, 2, '数量+1');
const ts = h.b.time;
const e = enemyAt(h, 'enemy_dummy');
// (S3 may be running — its drones do not normal-attack): wait for a normal attack after stage 3
assert.ok(h.runUntil(() => h.dmg.some((d) => d.src === u.id && d.attack && d.tgt === e.id && d.t > ts), 60));
const last = h.dmg.filter((d) => d.src === u.id && d.attack && d.tgt === e.id).at(-1);
const same = h.dmg.filter((d) => d.src === u.id && d.attack && d.t === last.t);
assert.equal(same.length, 2, 'two drone hits per attack');
});
test('缪尔赛思: 流形 copies stats/range/damage type but not the attack shape (锏 2-hit); a capped melee copy steals nothing more', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_11_a', row: 10, col: 3 },
{ chessId: 'chess_char_6_19_a', row: 11, col: 5 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 10, col: 5, ownerUid: 1 },
],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
});
const d = h.unit('chess_char_6_19_a');
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
h.run(6);
const t = tok();
const tal = t.def.talents.find((x) => x.bb.scale != null).bb;
assert.equal(t.mem.mlyss.from, d.id, 'copies the nearest operator (锏)');
assert.ok(near(t.base.atk, d.base.atk * tal.scale) && t.base.blockCnt === d.base.blockCnt && t.base.bat === d.base.bat);
assert.deepEqual(t.rangeGrid, d.rangeGrid, 'range copied');
assert.equal(t.profile.dmgType, 'phys');
assert.equal(t.profile.attack, 'melee');
assert.equal(t.profile.hits, 1, 'the 2-hit trait of 剑豪 is not a listed attribute');
const steal = t.def.talents.find((x) => x.bb.steal_atk != null).bb;
h.runUntil(() => t.mem.mlyss.stolenAtk >= steal.steal_atk_max, 80);
const e = enemyAt(h, 'enemy_dummy');
h.run(5);
assert.equal(t.mem.mlyss.stolenAtk, steal.steal_atk_max, 'capped at 250');
assert.equal(e.findBuff('mlyss:robbed').mods.atkFlat, -steal.steal_atk_max, 'the enemy never loses more than was stolen');
checkInvariants(h.b);
});
test('浊心斯卡蒂: an enemy in the 海嗣 range alone triggers S3; she never keeps a 鼓舞', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_04_a', row: 12, col: 3, carryState: { sp: 39 } }, { kind: 'token', tokenId: 'token_10017_skadi2_dedant', row: 9, col: 8, ownerUid: 1 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 9] }],
});
const u = h.unit('chess_char_6_04_a');
h.step();
assert.ok(!h.b.enemiesInKeys(u.baseRangeKeys, u, u.profile).length, 'the enemy is outside her own range');
assert.ok(h.runUntil(() => u.skill.active, 1), 'S3 fires from the 海嗣 range');
h.b.addBuff(u, { key: 'inspire', mods: { atkFlat: 500 } });
h.run(0.6);
assert.equal(u.findBuff('inspire'), null, '自身不受鼓舞影响');
checkInvariants(h.b);
});
test('焰影苇草 / 溯光星源: 灼痕 and 能源解析 use the 法术脆弱 / 脆弱 statuses (同名效果取最高, no stacking)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_08_a', row: 10, col: 3, carryState: { sp: 44 } }, { chessId: 'chess_char_6_16_a', row: 11, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const reed = h.unit('chess_char_6_08_a');
assert.ok(h.runUntil(() => reed.skill.active, 5));
h.run(2);
const e = enemyAt(h, 'enemy_dummy');
assert.ok(e.findBuff('reed2:scorch') && e.findBuff('artsFragile'), '灼痕 marker + 法术脆弱');
h.b.applyStatus(e, 'artsFragile', { duration: 5, value: 0.2 });
assert.ok(near(e.s.artsTakenMul, reed.def.talents[0].bb.damage_scale), 'a weaker 法术脆弱 does not stack');
assert.ok(e.findBuff('fragile'), '能源解析 = 脆弱 status');
assert.ok(near(e.s.dmgTakenMul, h.unit('chess_char_6_16_a').def.talents[1].bb['halo2_t_1[weak].damage_scale']));
checkInvariants(h.b);
});
test('流明: stat debuffs (虚弱) are not 异常状态 — no 应急处理 heal, no bullet, not cleansed', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_14_a', row: 10, col: 3, carryState: { sp: 60 } }, { chessId: 'test_guard_a', row: 10, col: 5 }],
hooks: ['ammoUsed', 'heal'], captureNoisy: true,
});
const u = h.unit('chess_char_6_14_a'), g = h.unit('test_guard_a');
h.step();
h.b.applyStatus(g, 'weaken', { duration: 30, value: 0.3 });
assert.equal(h.hooksOf('heal').filter((c) => c.source === u).length, 0, 'no 应急处理');
g.hp = g.s.maxHp * 0.5;
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(4);
assert.ok(g.findBuff('weaken'), 'not cleansed');
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 0, 'normal heals spend no bullet');
});
test('异客: the S3 storm stops when she leaves the field', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_05_a', row: 10, col: 3, carryState: { sp: 34 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_05_a');
assert.ok(h.runUntil(() => u.skill.activations > 0, 10));
h.run(0.6);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
const n = h.eventsOf('fx').filter((e) => e[1] === 'lightning').length;
h.run(5);
assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'lightning').length, n, 'no strike after her retreat');
});
test('蕾缪安: 通缉 needs a continuous 8 s stay in a 拉特兰 range (leaving restarts the count)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 12, col: 2 }, { chessId: 'test_lat_a', row: 10, col: 2 }],
enemies: [{ key: 'enemy_elite', pos: [10, 9] }],
});
h.step();
const e = enemyAt(h, 'enemy_elite');
h.run(5);
e.y = 9; // out of every 拉特兰 range for a second
h.run(1);
e.y = 10;
h.run(5);
assert.equal(e.findBuff('lemuen:wanted'), null, '5 s + 5 s is not a continuous 8 s stay');
h.run(3.5);
assert.ok(e.findBuff('lemuen:wanted'), 'wanted after 8 s in range');
});
test('缪尔赛思: MLYSS_WTRMAN — an enemy in a copied 流形\'s range triggers S3 even outside her own range', () => {
const h = battle({
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 2, carryState: { sp: 42 } },
{ chessId: 'test_guard_a', row: 9, col: 9 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 8, ownerUid: 1 },
],
enemies: [{ key: 'enemy_dummy', pos: [9, 9] }],
});
const u = h.unit('chess_char_6_11_a');
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
h.step();
assert.ok(!h.b.enemiesInKeys(u.baseRangeKeys, u, u.profile).length, 'outside her own range');
h.run(2);
assert.ok(!u.skill.active && !tok().mem.mlyss, 'no copy yet ⇒ no trigger');
assert.ok(h.runUntil(() => u.skill.active, 6), 'fires once the 流形 copied and sees the enemy');
assert.ok(tok().mem.mlyss && !tok().mem.mlyss.ranged, 'melee copy of the guard');
checkInvariants(h.b);
});
test('skill stat mods match the official text at both levels (normal Lv4 / elite Lv7)', () => {
// [chess, mods key, text regex] — the number in the skill text must be what the running skill buff applies
const rows = [
['6_02', 'atkPct', /攻击力\+(\d+)%/], ['6_02', 'aspd', /攻击速度\+(\d+)/],
['6_03', 'hpPct', /生命上限\+(\d+)%/], ['6_03', 'atkPct', /攻击力\+(\d+)%/], ['6_03', 'defPct', /防御力\+(\d+)%/],
['6_06', 'atkPct', /攻击力\+(\d+)%/], ['6_07', 'atkPct', /攻击力\+(\d+)%/], ['6_08', 'atkPct', /攻击力\+(\d+)%/],
['6_11', 'atkPct', /攻击力\+(\d+)%/], ['6_12', 'atkPct', /攻击力\+(\d+)%/], ['6_14', 'atkPct', /攻击力\+(\d+)%/],
['6_14', 'aspd', /攻击速度\+(\d+)/], ['6_15', 'atkPct', /攻击力\+(\d+)%/], ['6_16', 'atkPct', /攻击力\+(\d+)%/],
['6_17', 'atkPct', /攻击力\+(\d+)%/], ['6_17', 'defPct', /防御力\+(\d+)%/], ['6_18', 'atkPct', /攻击力\+(\d+)%/],
['1_15', 'aspd', /攻击速度\+(\d+)/],
];
for (const sfx of ['a', 'b']) {
for (const [id, key, re] of rows) {
const cid = `chess_char_${id}_${sfx}`;
const h = makeBattle({ units: [{ chessId: cid, row: 10, col: 4 }], timeLimit: 30 });
h.step();
const u = h.unit(cid);
assert.ok(u.skill.activate('test', { free: true }), `${cid} activates`);
const m = String(u.def.skill.description).match(re);
assert.ok(m, `${cid} text has ${re}`);
const want = key === 'aspd' ? +m[1] : +m[1] / 100;
const buff = u.findBuff(`skill:${u.id}`);
assert.ok(buff && near(buff.mods[key], want, 1e-9), `${cid} ${key} ${buff?.mods?.[key]} ≠ text ${want}`);
checkInvariants(h.b);
}
}
});
test('蕾缪安 (elite): after 15 s ATK +18 %, own ammo +2 and other 拉特兰 ammo skills +1 (新约能天使 S2)', () => {
const ammoAtStart = new Map();
const h = battle({
units: [{ chessId: 'chess_char_6_01_b', row: 12, col: 2 }, { chessId: 'chess_char_6_13_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 5], time: 16 }], // in both ranges
setup(b) { b.on('skillStart', (c) => ammoAtStart.set(c.unit.defId, c.skill.ammoLeft), { priority: -1000 }); },
});
const lem = h.unit('chess_char_6_01_b'), ang = h.unit('chess_char_6_13_a');
const t1 = lem.def.talents[1].bb;
h.run(t1.interval + 0.1);
assert.ok(near(lem.findBuff('lemuen:extradition').mods.atkPct, t1.atk), 'ATK +18 %');
assert.ok(h.runUntil(() => ammoAtStart.has(ang.defId) && ammoAtStart.has(lem.defId), 60), 'both skills fired');
const ab = ang.def.skill.bb;
const stole = ang.mem.angelVictim ? ab.addtional_ammo_each : 0;
assert.equal(ammoAtStart.get(ang.defId), ab['attack@trigger_time'] + stole + t1.ex_add_count, '新约能天使 +1 (other 拉特兰)');
assert.equal(ammoAtStart.get(lem.defId), lem.def.skill.bb['attack@trigger_time'] + t1.add_count, '蕾缪安 +2');
checkInvariants(h.b);
});
test('default skills: attack-interval rule of the AK calculator — a shortening is flat seconds, a lengthening scales', () => {
// regression: 溯光星源 S3 / 新约能天使 S2 / 维娜 S3 applied their −0.7 / −0.7 / −0.25 as ×0.3 / ×0.3 / ×0.75
// (溯光星源 1.9 s → 0.57 s instead of 1.2 s); 佩佩 S3 +0.2 and 迷迭香 S2 +0.5 scale the base time (×1.2 / ×1.5)
const cases = [['chess_char_6_16', 'flat'], ['chess_char_6_13', 'flat'], ['chess_char_6_07', 'flat'], ['chess_char_6_06', 'mul'], ['chess_char_6_12', 'mul']];
for (const [base, rule] of cases) {
for (const cid of [`${base}_a`, `${base}_b`]) {
const h = battle({ units: [{ chessId: cid, row: 10, col: 4 }] });
h.run(0.2);
const u = h.unit(cid);
const v = u.def.skill.bb.base_attack_time;
assert.ok(Number.isFinite(v) && v !== 0, `${cid} has base_attack_time`);
assert.ok(u.skill.activate('test', { free: true }), `${cid} activates`);
const bat = rule === 'flat' ? u.base.bat + v : u.base.bat * (1 + v);
assert.ok(near(u.s.interval, bat * 100 / u.s.aspd, 1e-9), `${cid} (${rule} ${v}): interval ${u.s.interval} ≠ ${bat * 100 / u.s.aspd}`);
checkInvariants(h.b);
}
}
});