Files
ark-stronghold-protocol/test/content/kits_t5.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

1454 lines
77 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.
// Tier-5 operator kits (server/sim/content/kits/tier5.js): every chess runs a real battle through the harness and its
// signature effect is asserted with numbers taken from its own blackboards (normal Lv4 / elite Lv7).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import KITS from '../../server/sim/content/kits/tier5.js';
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≉ ${b}`);
const dummy = (key = 'enemy_dummy', o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const ally = (id, o = {}) => chessRec({ id, skill: null, stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0, ...(o.stats || {}) }, ...o });
const bbOf = (u) => u.def.skill.bb;
const tal = (u, i) => (u.def.raw.talents || []).find((t) => t.index === i)?.bb ?? {};
const tagged = (h, tag, target = null) => h.hooksOf('damaged').filter((c) => (c.dmg?.tags || []).includes(tag) && (!target || c.target === target));
const fxOf = (h, kind) => h.eventsOf('fx').filter((e) => e[1] === kind);
const absKeysHas = (grid, u, r, c) => grid.some(([dr, dc]) => u.tileR + dr === r && u.tileC + dc * u.facing === c);
const clean = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); checkInvariants(h.b); };
test('tier-5 registry: all 23 non-DIY tier-5 chess have a hand-authored kit', () => {
const ids = Object.keys(KITS).sort();
assert.equal(ids.length, 23);
for (let i = 1; i <= 23; i++) assert.ok(KITS[`chess_char_5_${String(i).padStart(2, '0')}_a`], `kit for 5_${i}`);
for (const id of ids) {
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }], autoFinish: false, timeLimit: 3 });
h.step();
assert.equal(h.unit(id).kit.generic, undefined, `${id} uses its own kit`);
}
});
// ------------------------------------------------------------------------------------------------------------------
test('圣约送葬人 S2: ATK/DEF +45 %, block +1, 12 ammo +1 per Laterano op on the field; 受选之人 extra attacks', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_lat: ally('t_lat', { bonds: ['lateranoShip'] }) } },
units: [{ chessId: 'chess_char_5_01_a', row: 9, col: 5 }, { chessId: 't_lat', row: 12, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], hooks: ['ammoUsed', 'skillEnd'], seed: 5, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_01_a');
const bb = bbOf(u);
h.step();
const atk0 = u.s.atk, def0 = u.s.def, blk0 = u.s.blockCnt;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(u.s.atk, atk0 * (1 + bb.atk));
approx(u.s.def, def0 * (1 + bb.def));
assert.equal(u.s.blockCnt, blk0 + bb.block_cnt);
assert.ok(h.runUntil(() => !u.skill.active, 60));
assert.equal(h.hooksOf('ammoUsed').length, bb['attack@trigger_time'] + 2, '12 + himself + t_lat');
h.run(30);
assert.ok(fxOf(h, 'extraAttack').length > 0, 'extra attacks happen');
clean(h);
});
test('圣约送葬人 S2: melee attacks are dodged with prob and refill ammo', () => {
const h = makeBattle({
defs: { enemies: { enemy_hitter: dummy('enemy_hitter', { atk: 300, bat: 0.5 }) } },
units: [{ chessId: 'chess_char_5_01_a', row: 9, col: 5 }],
enemies: [{ key: 'enemy_hitter', pos: [9, 5] }], hooks: ['ammoUsed', 'skillStart', 'skillEnd', 'dodge'], seed: 11, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_01_a');
h.step();
u.skill.gainSp(1000);
h.runUntil(() => u.skill.active, 10);
h.runUntil(() => !u.skill.active, 90);
const dodges = fxOf(h, 'dodge').filter((e) => e[4].id === u.id).length;
assert.ok(dodges > 0, 'dodged some melee hits');
assert.equal(h.hooksOf('dodge').filter((c) => c.target === u).length, dodges, 'the engine dodge hook sees the skill dodges');
assert.equal(h.hooksOf('ammoUsed').length, bbOf(u)['attack@trigger_time'] + 1 + dodges * bbOf(u).recover_cnt);
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('缇缇 S3: ATK +110 %, 2 targets, sleep on hit, wake-up burst (×max scale after a full sleep), ally sleep guard; T1 damage on sleepers', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_ally: ally('t_ally') } },
units: [{ chessId: 'chess_char_5_02_a', row: 10, col: 4 }, { chessId: 't_ally', row: 10, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 7] }], hooks: ['damaged', 'statusApplied'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_02_a');
h.step();
const e = h.enemy('enemy_dummy');
const bb = bbOf(u), t0 = tal(u, 0);
const atk0 = u.s.atk;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(u.s.atk, atk0 * (1 + bb.atk));
assert.equal(h.b.effectiveProfile(u).maxTargets, bb['attack@max_target']);
assert.ok(h.runUntil(() => e.s.flags.sleep, 5), 'the skill hit puts the target to sleep');
const slept = h.b.time;
// ally sleep guard: lethal damage on an ally in range → asleep instead of dead
const a = h.unit('t_ally');
h.b.dealDamage(null, a, { amount: 1e9, type: 'true' });
assert.ok(a.alive && a.hasBuff('titi:allySleep') && a.s.flags.sleep);
h.b.dealDamage(null, a, { amount: 1e9, type: 'true' });
assert.ok(a.alive, 'still protected while asleep');
a.hp = a.s.maxHp;
h.run(0.3);
assert.ok(!a.hasBuff('titi:allySleep'), 'wakes up at full HP');
// wake-up burst after a full sleep: max_atk_scale × ATK arts
assert.ok(h.runUntil(() => tagged(h, 'titiWake', e).length > 0, 8));
const w = tagged(h, 'titiWake', e)[0];
assert.ok(w.t - slept >= bb.sleep - 0.2);
approx(w.amount, u.s.atk * bb.max_atk_scale, 1e-6, 'wake burst');
// T1: sleeping enemies take damage_atk_scale × ATK per second; extra 15 % on non-moving targets
const dream = tagged(h, 'titiDream', e);
assert.ok(dream.length >= 3);
approx(dream[0].amount, atk0 * (1 + bb.atk) * t0.damage_atk_scale, 1e-6, 'dream');
assert.ok(tagged(h, 'titiStill', e).length > 0);
clean(h);
});
test('缇缇 T2 勇气的报偿: Sargon/Minos ops above 50 % HP get +20 ASPD', () => {
const h = makeBattle({
defs: { chess: { t_sar: ally('t_sar', { bonds: ['sargonShip'] }), t_other: ally('t_other') } },
units: [{ chessId: 'chess_char_5_02_a', row: 10, col: 4 }, { chessId: 't_sar', row: 12, col: 4 }, { chessId: 't_other', row: 12, col: 5 }],
autoFinish: false, timeLimit: 10,
});
h.run(0.5);
const u = h.unit('chess_char_5_02_a');
const s = h.unit('t_sar'), o = h.unit('t_other');
assert.equal(s.s.aspd, 100 + tal(u, 1).attack_speed);
assert.equal(o.s.aspd, 100);
s.hp = s.s.maxHp * 0.4;
h.run(1);
assert.equal(s.s.aspd, 100, 'below 50 %: no vigour');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('烛煌 S3: hits every enemy in the skill range, BAT −1.3 s, burn bursts refill ammo; T1 熔点引爆 350 % + heal; T2 downed → revive', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_03_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [9, 5] }],
hooks: ['damaged', 'ammoUsed'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_03_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
const atk0 = u.s.atk;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(u.s.atk, atk0 * (1 + bb.atk));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-6, 'BAT 1.6 − 1.3 s');
h.run(1);
const hitIds = new Set(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isSkill && c.dmg.isAttack).map((c) => c.target.id));
assert.equal(hitIds.size, 3, 'group attack');
// a burn burst anywhere: +ammo, 熔点引爆 elemental damage and heal
const e = h.b.enemies[0];
u.hp = 100;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
const melt = tagged(h, 'blazeMelt', e);
assert.equal(melt.length, 1);
approx(melt[0].amount, u.s.atk * t0.ep_damage_scale, 1e-6, 'meltdown');
approx(u.hp, 100 + u.s.maxHp * t0.hp_ratio, 1e-6, 'heal 12 %');
h.runUntil(() => !u.skill.active, 60);
assert.equal(h.hooksOf('ammoUsed').length, bb['attack@trigger_time'] + bb.ammo_recover);
assert.ok(tagged(h, 'blazeBurn', e).length > 0, 'extra elemental damage vs the burning target');
// 绝处重燃
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && u.hasBuff('blaze2:downed'));
approx(u.s.shield, t1.dynamic, 1e-6, 'shield');
assert.ok(u.s.flags.disarm && u.s.flags.noHeal);
assert.equal(h.b.heal(h.unit('chess_char_5_03_a'), u, 0) === 0, true);
const downHp = u.hp;
const e2 = h.b.enemies[1];
h.b.dealDamage(null, e2, { type: 'element', element: 'burn', amount: 1000 });
assert.equal(tagged(h, 'blazeMelt', e2).length, 1, '熔点引爆 still hits while she is downed');
assert.ok(u.hp - downHp < u.s.maxHp * t0.hp_ratio * 0.5, '… but its heal does not reach her (无法被治疗)');
u.hp = u.s.maxHp;
h.run(0.3);
assert.ok(!u.hasBuff('blaze2:downed'), 'revived');
assert.ok(h.b.enemies.filter((x) => Math.hypot(x.x - u.x, x.y - u.y) <= 1.5).every((x) => x.s.flags.stun), 'nearby enemies stunned');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('华法琳 S1: the next heal on a target below half HP adds hp_ratio × max HP; charges only spent below 50 %; T1 SP on kills', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_ally: ally('t_ally') } },
units: [{ chessId: 'chess_char_5_04_a', row: 10, col: 3 }, { chessId: 't_ally', row: 10, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_04_a');
const a = h.unit('t_ally');
const bb = bbOf(u), t0 = tal(u, 0);
h.step();
u.skill.gainSp(1000);
const ch = u.skill.charges;
assert.equal(ch, u.skill.maxCharges);
a.hp = 6000;
assert.ok(h.runUntil(() => a.hp > 6000, 10));
approx(a.hp - 6000, u.s.atk, 1e-6, 'plain heal above half HP');
assert.equal(u.skill.charges, ch, 'no charge spent');
a.hp = 3000;
assert.ok(h.runUntil(() => a.hp > 3000, 10));
approx(a.hp - 3000, u.s.atk + a.s.maxHp * bb.hp_ratio, 1e-6, 'bandage bonus');
assert.equal(u.skill.charges, ch - 1);
assert.equal(u.skill.sp, 0, 'the heal made by the skill recovers no attack SP');
a.hp = 8000;
assert.ok(h.runUntil(() => u.skill.sp > 0, 10), 'plain heals recover SP again');
assert.equal(u.skill.sp, 1);
// 血液样本回收
const sp = u.skill.sp;
h.b.dealDamage(null, h.b.enemies[0], { amount: 1e9, type: 'true' });
approx(u.skill.sp, Math.min(u.skill.spCost, sp + t0['bldsk_t_1[self].sp']), 1e-6, 'SP +2 on a kill in range');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('乌尔比安 S3: anchor 135 % ATK + stun on the first enemy ahead, moves there (marker at home) and returns at the end', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_05_a', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 7] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_05_a');
h.step();
const e = h.enemy('enemy_dummy');
const bb = bbOf(u);
const hp0 = u.s.maxHp, atk0 = u.s.atk;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2), 'CUSTOM_RANGE: enemy 4 tiles ahead');
approx(u.s.maxHp, hp0 * (1 + bb.max_hp));
approx(u.s.atk, atk0 * (1 + bb.atk));
const hit = tagged(h, 'anchor', e);
assert.equal(hit.length, 1);
approx(hit[0].amount, u.s.atk * bb.atk_scale, 1e-6, 'anchor damage');
assert.ok(e.s.flags.stun);
assert.deepEqual([u.tileR, u.tileC], [9, 7], 'moved onto the anchor tile');
const marker = h.b.allyUnits.find((x) => x.kind === 'token' && x.alive);
assert.ok(marker && marker.tileR === 9 && marker.tileC === 3 && marker.s.flags.untargetable);
assert.ok(h.runUntil(() => !u.skill.active, 30));
assert.deepEqual([u.tileR, u.tileC], [9, 3], 'back home');
assert.equal(marker.alive, false);
clean(h);
});
test('乌尔比安 T1 本性的坚守 heals per hit taken; T2 血脉的哺养 +HP/ATK per kill (abyssal allies half); elite healing ×1.2', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_abyss: ally('t_abyss', { charId: 'char_143_ghost' }) } },
units: [{ chessId: 'chess_char_5_05_a', row: 9, col: 3 }, { chessId: 't_abyss', row: 12, col: 3 }], autoFinish: false, timeLimit: 20,
});
const u = h.unit('chess_char_5_05_a');
const t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
const max = u.s.maxHp;
h.b.dealDamage(null, u, { amount: 500, type: 'true' });
approx(u.hp, max - 500 + t0.value1, 1e-6, 'heal 100');
h.b.dealDamage(null, u, { amount: u.hp - max * 0.3, type: 'true' });
approx(u.hp, max * 0.3 + t0.value2, 1e-6, 'heal 160 below 50 %');
const e = h.spawn('enemy_dummy', { pos: [11, 8] });
const atk0 = u.s.atk, ab = h.unit('t_abyss'), abHp = ab.s.maxHp;
h.b.dealDamage(u, e, { amount: 1e9, type: 'true' });
approx(u.s.maxHp, max + t1.max_hp);
approx(u.s.atk, atk0 + t1.atk);
approx(ab.s.maxHp, abHp + t1['ulpia_t_1[abyssal].max_hp']);
const g = makeBattle({ units: [{ chessId: 'chess_char_5_05_b', row: 9, col: 3 }], autoFinish: false, timeLimit: 5 });
g.step();
approx(g.unit('chess_char_5_05_b').s.healingTakenMul, g.unit('chess_char_5_05_b').def.traitBb.heal_scale);
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('隐德来希 S3: candles for the 3 highest-HP ground enemies (60 % HP), only he hurts them (≥35 % ATK), the original loses the same HP', () => {
const h = makeBattle({
defs: { enemies: {
e_a: dummy('e_a', { hp: 1e6, def: 3000 }), e_b: dummy('e_b', { hp: 2e6 }), e_c: dummy('e_c', { hp: 3e6 }), e_d: dummy('e_d', { hp: 4e5 }),
} },
units: [{ chessId: 'chess_char_5_06_a', row: 9, col: 4 }],
enemies: [{ key: 'e_a', pos: [10, 5] }, { key: 'e_b', pos: [10, 6] }, { key: 'e_c', pos: [9, 6] }, { key: 'e_d', pos: [9, 5] }],
autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_06_a');
const bb = bbOf(u);
h.step();
u.skill.gainSp(1000);
let hpBefore = null;
assert.ok(h.runUntil(() => { if (!u.skill.active) hpBefore = new Map(h.b.enemies.map((e) => [e.defId, e.hp])); return u.skill.active; }, 5));
const candles = h.b.enemies.filter((e) => e.alive && e.mem.candleOwner === u);
assert.equal(candles.length, bb['attack@max_target']);
assert.deepEqual(candles.map((c) => c.mem.candleOf.defId.replace(/^enemy_/, '')).sort(), ['e_a', 'e_b', 'e_c'], 'highest HP first');
for (const c of candles) approx(c.s.maxHp, hpBefore.get(c.mem.candleOf.defId) * bb['attack@max_hp_scale'], 0.01);
const ca = candles.find((c) => c.mem.candleOf.defId.endsWith('e_a'));
const orig = ca.mem.candleOf;
assert.equal(h.b.dealDamage(null, ca, { amount: 5000, type: 'true' }), 0, 'others cannot hurt a candle');
const o0 = orig.hp;
const dealt = h.b.dealDamage(u, ca, { amount: u.s.atk, type: 'phys', isAttack: true });
approx(dealt, u.s.atk * 0.35, 1e-6, 'min 35 % ATK through 3000 DEF');
approx(o0 - orig.hp, dealt, 1e-6, 'original loses the same HP');
const cb = candles.find((c) => c.mem.candleOf.defId.endsWith('e_b'));
h.b.leak(cb.mem.candleOf);
assert.equal(cb.alive, false, 'a leaked original takes its candle along');
const cc = candles.find((c) => c.mem.candleOf.defId.endsWith('e_c'));
h.b.dealDamage(null, cc.mem.candleOf, { amount: 1e9, type: 'true' });
assert.equal(cc.alive, false, 'a killed original too');
assert.ok(h.runUntil(() => !u.skill.active, 25));
assert.equal(h.b.enemies.filter((e) => e.alive && e.mem.candleOwner).length, 0, 'candles removed at skill end');
clean(h);
});
test('隐德来希 T1 萃血 steals max HP + 200 arts/s DoT; T2 重盈 once below 25 % → heal 50 %, phys taken −10 %', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_06_a', row: 9, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_06_a');
h.step();
const e = h.enemy('enemy_dummy');
const t0 = tal(u, 0), t1 = tal(u, 1);
const base = u.base.maxHp, eMax = e.base.maxHp;
h.run(4);
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack && c.target === e).length;
assert.ok(hits >= 2);
approx(u.s.maxHp, base + Math.min(t0['attack@steal_hp_max'], hits * t0['attack@steal_hp']));
approx(e.s.maxHp, eMax - hits * t0['attack@steal_hp']);
const dot = tagged(h, 'dot', e);
assert.ok(dot.length >= 2);
approx(dot[0].amount, t0.magic_value, 1e-6, 'DoT');
h.b.dealDamage(null, u, { amount: u.hp - u.s.maxHp * 0.2, type: 'true' });
approx(u.hpRatio, 0.2 + t1['etlchi_t_2[heal].hp_ratio'], 1e-3);
approx(u.s.physTakenMul, 1 - t1.damage_resistance);
h.b.dealDamage(null, u, { amount: u.hp - u.s.maxHp * 0.2, type: 'true' });
approx(u.hpRatio, 0.2, 1e-3, 'only once');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('史尔特尔 S3 黄昏: full heal, max HP +5000, ATK +210 %, range +2, 3 targets, ramping HP loss; T1 −20 RES; T2 余烬 8 s then withdraw', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { res: 20 }) } },
units: [{ chessId: 'chess_char_5_07_a', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy', pos: [9, 8] }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_07_a');
h.step();
const [near, far] = h.b.enemies;
const bb = bbOf(u);
const hp0 = u.base.maxHp, atk0 = u.s.atk;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === near), 3));
const first = h.hooksOf('damaged').find((c) => c.source === u && c.target === near);
approx(first.amount, atk0, 1e-6, '熔火: 20 RES ignored');
u.hp = 500;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
const t0 = h.b.time;
approx(u.s.maxHp, hp0 + bb.max_hp);
assert.ok(u.hp > u.s.maxHp - 50, 'healed to full');
approx(u.s.atk, atk0 * (1 + bb.atk));
assert.equal(h.b.effectiveProfile(u).maxTargets, bb['attack@max_target']);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === far), 3), 'range +2');
h.run(10);
const t = h.b.time - t0;
approx(u.hpRatio, 1 - bb.hp_ratio * t * t / (2 * bb.duration), 0.02, 'HP loss ramp');
// 余烬
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && u.hp >= 1);
h.run(tal(u, 1)['surtr_t_2[withdraw].interval'] - 0.5);
assert.ok(u.alive, 'still fighting');
h.run(1);
assert.equal(u.alive, false);
assert.equal(u.removeReason, 'retreat');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('号角 S3: ATK +25 %, BAT −1.2 s, then overload ATK +50 % with HP loss; T1 Defenders ATK +20 %; T2 血战', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_tank: ally('t_tank', { profession: 'TANK', stats: { atk: 100 } }) } },
units: [{ chessId: 'chess_char_5_08_a', row: 9, col: 3 }, { chessId: 't_tank', row: 12, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 7] }], autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_08_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.run(0.5);
approx(h.unit('t_tank').s.atk, 100 * (1 + t0.atk), 1e-6, '军事要塞 on another Defender');
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-6, '… and on herself');
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, '重装 TAKE_DAMAGE: not before a hit');
h.b.dealDamage(h.enemies()[0], u, { amount: 10, type: 'phys' });
assert.ok(h.runUntil(() => u.skill.active, 5));
const start = h.b.time;
const total = u.def.skill.duration, ov = bb['horn_s_3[overload_start].damage_duration'];
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-6, 'BAT 2.8 − 1.2 s');
// PRTS: a two-segment gauge — 过载 starts at the half of the 24 s duration and lasts the other 12 s
h.runUntil(() => u.hasBuff('horn:overload'), 30);
approx(h.b.time - start, total - ov, 0.05, 'overload at the half of the gauge');
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb['horn_s_3[overload_start].atk']));
const hp = u.hp;
h.run(6);
assert.ok(u.hp < hp, 'overload drains HP');
assert.ok(h.runUntil(() => !u.skill.active, 10));
approx(h.b.time - start, total, 0.1, 'the whole skill lasts the data duration (24 s)');
approx(u.hpRatio, 1 - bb['horn_s_3[overload_start].hp_ratio'] * ov / 2, 0.02, 'ramp to 12 %/s over the 12 s overload');
assert.ok(!u.hasBuff('horn:overload'));
u.hp = u.s.maxHp;
// 血战
const max = u.s.maxHp;
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive);
approx(u.s.maxHp, max * (1 - t1.max_hp));
approx(u.hp, u.s.maxHp, 1e-6, 'full HP');
assert.equal(u.s.aspd, u.base.aspd + t1.attack_speed);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false, 'once per deployment');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('魔王 S3: inspire +65 % of her max HP to others in range, HP equalised every 2 s; T1 orbiting motes ×1.5 aura heal; T2 −10 % from Sarkaz', () => {
const sark = dummy('enemy_sark');
sark.tags = ['sarkaz'];
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_sark: sark }, chess: { t_a: ally('t_a'), t_b: ally('t_b'), t_c: ally('t_c') } },
units: [{ chessId: 'chess_char_5_09_a', row: 10, col: 5 }, { chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 12, col: 5 }, { chessId: 't_c', row: 11, col: 6 }],
autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_09_a');
const a = h.unit('t_a'), b = h.unit('t_b'), c = h.unit('t_c');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
const aura = () => u.s.atk * u.def.traitBb['attack@atk_to_hp_recovery_ratio'];
h.step();
a.hp = 2000; b.hp = 8000; c.hp = 2000;
h.run(1.05);
// the 3 motes orbit at range_radius 1.15 (dynamic_spd 30°/s, 120° apart): none sits on the left neighbour at t = 1 s
assert.ok(!a.hasBuff('cetsyr:mote'), 'motes orbit: not on the left neighbour yet');
approx(a.hp - 2000, aura(), 1e-6, 'plain aura before the mote arrives');
approx(b.hp - 8000, aura(), 1e-6, 'plain aura (2 tiles away)');
assert.ok(h.runUntil(() => a.hasBuff('cetsyr:mote'), 2), 'an orbiting mote reaches the adjacent operator');
const hpA = a.hp;
h.run(1);
approx(a.hp - hpA, aura() * t0['attack@trait_mul'], 1e-6, 'mote ×1.5');
assert.ok(h.runUntil(() => c.hasBuff('cetsyr:mote'), 12), 'a diagonal neighbour (1.41 tiles) is on the orbit too');
h.run(8);
assert.ok(!fxOf(h, 'mote').some((e) => e[4].id === b.id), 'two tiles away: off the orbit');
// Sarkaz damage −10 %
const s = h.spawn('enemy_sark', { pos: [9, 9] });
approx(h.b.dealDamage(s, b, { amount: 1000, type: 'true' }), 1000 * (1 - t1.damage_resistance));
const n = h.spawn('enemy_dummy', { pos: [10, 6] });
approx(h.b.dealDamage(n, b, { amount: 1000, type: 'true' }), 1000);
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2));
h.step();
approx(a.s.maxHp, 10000 + u.s.maxHp * bb.max_hp, 1e-6, 'inspire');
assert.ok(h.runUntil(() => fxOf(h, 'hpShare').length > 0, 3));
approx(a.hpRatio, b.hpRatio, 1e-9, 'HP equalised');
approx(a.hpRatio, u.hpRatio, 1e-9);
assert.equal(u.profile.auraRatio, bb['attack@atk_to_hp_recovery_ratio']);
h.runUntil(() => !u.skill.active, 40);
approx(a.s.maxHp, 10000, 1e-6, 'inspire removed');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('铃兰 T2 画地为牢 fragile 20 % on sluggish enemies (×1.4 in S3); S3 no attack, range-wide sluggish, heals; T1 Supporter SP aura', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_sup: chessRec({ id: 't_sup', profession: 'SUPPORT', stats: { atk: 0 } }), t_hurt: ally('t_hurt') } },
units: [{ chessId: 'chess_char_5_10_a', row: 10, col: 4 }, { chessId: 't_sup', row: 12, col: 3 }, { chessId: 't_hurt', row: 11, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_10_a');
h.step();
const e = h.enemy('enemy_dummy');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
assert.ok(h.runUntil(() => e.findBuff('fragile'), 5));
approx(e.s.dmgTakenMul, t1.damage_scale, 1e-9, 'fragile 20 % (the standard 脆弱 status)');
h.run(0.3);
approx(h.unit('t_sup').findBuff('aura:spRecovery').mods.spRecoveryFlat, t0.sp_recovery_per_sec);
approx(u.findBuff('aura:spRecovery').mods.spRecoveryFlat, t0.sp_recovery_per_sec, 1e-9, 'she is a Supporter herself');
const hurt = h.unit('t_hurt');
hurt.hp = 5000;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
const lastAtk = u.lastAttackAt;
h.run(2.1);
assert.equal(u.lastAttackAt, lastAtk, 'no attacks during S3');
assert.ok(e.s.flags && e.findBuff('sluggish'), 'enemies in range are sluggish');
approx(e.s.dmgTakenMul, 1 + (t1.damage_scale - 1) * bb.scale_delta_to_one, 1e-9, 'T2 ×1.4');
approx(hurt.hp - 5000, 2 * u.s.atk * bb['attack@atk_to_hp_recovery_ratio'], 1e-6, 'heals 9 % ATK/s');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('塞雷娅 S2: heals every injured ally in the skill range for 95 % ATK (+1 SP each, T2); T1 +5 % ATK every 20 s', () => {
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 10000, atk: 0 }, skill: { spCost: 100, initSp: 0 } }), t_b: ally('t_b'), t_far: ally('t_far') } },
units: [{ chessId: 'chess_char_5_11_a', row: 10, col: 5 }, { chessId: 't_a', row: 10, col: 6 }, { chessId: 't_b', row: 12, col: 5 }, { chessId: 't_far', row: 9, col: 9 }],
hooks: ['spGain'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_11_a');
const a = h.unit('t_a'), b = h.unit('t_b'), far = h.unit('t_far');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
a.hp = b.hp = far.hp = 5000;
assert.ok(h.runUntil(() => u.skill.activations > 0, 12));
approx(a.hp - 5000, u.s.atk * bb.heal_scale, 1e-6);
approx(b.hp - 5000, u.s.atk * bb.heal_scale, 1e-6);
assert.equal(far.hp, 5000, 'outside the skill range');
const gains = h.hooksOf('spGain').filter((c) => c.unit === a && c.reason === 'talent');
assert.equal(gains.length, 1, '精神回复');
assert.equal(gains[0].amount, t1.sp);
h.run(41 - h.b.time);
assert.equal(u.findBuff('saria:suit').stacks, 2);
approx(u.s.atk, u.base.atk * (1 + 2 * t0.atk));
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('夕 S1: next attack 180 % ATK with an expanded splash; T2 点睛 summons 小自在 on the first target (25 s); T1 化境 ATK per kill', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_12_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 7] }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_12_a');
h.step();
const [e1, e2] = h.b.enemies;
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
assert.ok(h.runUntil(() => h.b.allyUnits.some((x) => x.kind === 'token' && x.alive), 5));
const tok = h.b.allyUnits.find((x) => x.kind === 'token');
assert.deepEqual([tok.tileR, tok.tileC], [10, 6], 'on the target tile');
assert.equal(tok.defId, 'token_10015_dusk_drgn');
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill && c.target === e2), 10), 'expanded splash reaches 1.41 tiles');
const sk = h.hooksOf('damaged').find((c) => c.source === u && c.dmg.isSkill && c.target === e1);
approx(sk.amount, u.s.atk * bb.atk_scale, 1e-6, '180 %');
assert.ok(!h.hooksOf('damaged').some((c) => c.source === u && !c.dmg.isSkill && c.target === e2), 'plain splash never reaches it');
const atk0 = u.s.atk;
h.b.dealDamage(tok, e2, { amount: 1e9, type: 'true' });
approx(u.s.atk, atk0 * (1 + t0.atk) / (1 + (u.findBuff('dusk:realm').stacks - 1) * t0.atk), 1e-6, '化境 via 小自在');
h.run(t1['attack@tokenduration']);
assert.equal(tok.alive, false, 'expires');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('归溟幽灵鲨 S2: never below 1 HP during the skill, then knocked out → substitute; T1 substitute slows + 40 % ATK/s; T2 abyssal HP +20 %', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_13_a');
h.step();
const e = h.enemy('enemy_dummy');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
approx(u.s.maxHp, u.base.maxHp * (1 + t1.max_hp), 1e-6, '阿戈尔的深邃 on herself');
const atk0 = u.s.atk;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.atk, atk0 * (1 + bb.atk));
assert.equal(u.s.aspd, u.base.aspd + bb.attack_speed);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && u.hp >= 1 && !u.trait.doll, 'undying during the skill');
assert.ok(h.runUntil(() => !u.skill.active, 20));
h.step();
assert.ok(u.alive && u.trait.doll && u.hasBuff('trait:substitute'), 'counts as knocked out → substitute');
h.run(2);
assert.ok(e.findBuff('ghost2:embrace'));
approx(e.s.moveSpeed, e.base.moveSpeed * (1 + t0.move_speed));
const emb = tagged(h, 'embrace', e);
assert.ok(emb.length >= 1);
approx(emb[0].amount, u.s.atk * t0.atk_scale, 1e-6);
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('【深海猎人】 = groupId abyssal: 归溟 T2 / 乌尔比安 T2 reach 幽灵鲨 but not 浊心斯卡蒂 (groupId null)', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }, { chessId: 'chess_char_5_05_a', row: 9, col: 3 },
{ chessId: 'chess_char_2_07_a', row: 12, col: 5 }, { chessId: 'chess_char_6_04_a', row: 12, col: 3 }],
autoFinish: false, timeLimit: 10,
});
h.step();
const ghost = h.unit('chess_char_2_07_a'), skadi2 = h.unit('chess_char_6_04_a'), ulpia = h.unit('chess_char_5_05_a');
assert.ok(ghost.hasBuff('ghost2:abyss') && ulpia.hasBuff('ghost2:abyss'));
assert.ok(!skadi2.hasBuff('ghost2:abyss'), '浊心斯卡蒂 is not an Abyssal Hunter');
const e = h.spawn('enemy_dummy', { pos: [9, 4] });
h.b.dealDamage(ulpia, e, { amount: 1e9, type: 'true' });
assert.ok(ghost.hasBuff('ulpia:bloodShare') && !skadi2.hasBuff('ulpia:bloodShare'));
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('凛御银灰 S2: 6 front enemies in the skill range take 260 % ATK phys + cold/reveal; T1 SP +4 left of the eye; T2 Kjerag DEF/regen/freeze-immune', () => {
const tiles = [[9, 4], [9, 5], [9, 6], [10, 3], [10, 4], [10, 5], [11, 3], [11, 4]];
const h = makeBattle({
defs: {
enemies: { enemy_dummy: dummy() },
chess: {
t_kj: ally('t_kj', { bonds: ['kjeragShip'], profession: 'TANK', stats: { def: 100 } }),
t_cheap: chessRec({ id: 't_cheap', stats: { cost: 10, atk: 0 }, skill: { spCost: 100, initSp: 0 } }),
},
},
units: [{ chessId: 'chess_char_5_14_a', row: 9, col: 3 }, { chessId: 't_kj', row: 12, col: 3 }, { chessId: 't_cheap', row: 12, col: 5 }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_14_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
approx(u.skill.sp, u.def.skill.initSp + t0.sp, 0.1, '开放性开局 on himself');
approx(h.unit('t_cheap').skill.sp, t0.sp, 0.1, '… and on a cheaper op');
h.run(0.3);
const kj = h.unit('t_kj');
approx(kj.s.def, 100 + t1.def, 1e-6, 'DEF +60');
h.b.applyStatus(kj, 'cold', { duration: 5 });
h.b.applyStatus(kj, 'cold', { duration: 5 });
assert.ok(!kj.s.flags.freeze, 'freeze immune');
for (const p of tiles) h.spawn('enemy_dummy', { pos: p });
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.activations > 0, 2));
const hits = tagged(h, 'svash2');
assert.equal(hits.length, bb.max_target);
for (const c of hits) {
approx(c.amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(c.target.findBuff('cold') && c.target.findBuff('reveal'));
}
h.run(15.5 - h.b.time);
approx(kj.s.def, 100 + 2 * t1.def, 1e-6, 'doubled after 15 s');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('凛御银灰 S2 auto-cast: SKILL_RANGE — an enemy inside the skill range casts it, no attack needed (PRTS 技能策略)', () => {
// PRTS 卫戍协议/帮助 技能操作: "携带拥有技能范围的技能(非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人
// (无视其不可选中)时释放技能" (it used to wait for an enemy in his melee range — user playtest #6 report 15)
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_14_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [11, 4] }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_14_a');
assert.equal(u.skill.rule, 'SKILL_RANGE');
h.step();
u.skill.gainSp(1000);
h.run(0.2);
assert.equal(u.skill.activations, 1, 'enemies only in the skill range: cast');
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill).length, 0, 'no attack needed');
const hits = tagged(h, 'svash2');
assert.equal(new Set(hits.map((c) => c.target.id)).size, 2, 'the burst covers the skill range');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, heals allies; T1 ATK +10 %; T2 ASPD ±10 on long roads', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_near: ally('t_near') } },
units: [{ chessId: 'chess_char_5_15_a', row: 10, col: 3 }, { chessId: 't_near', row: 11, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_15_a');
h.run(0.5);
const e = h.enemy('enemy_dummy');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
approx(u.s.atk, u.base.atk * (1 + t0.atk));
assert.equal(h.unit('t_near').s.aspd, 100 + t1.attack_speed_ally + t1.attack_speed_ally_extra, 'flat stage rows are ≥6-tile straight roads');
assert.equal(e.s.aspd, 100 + t1.attack_speed_enemy + t1.attack_speed_enemy_extra);
const near = h.unit('t_near');
near.hp = 5000;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => (u.mem.zones || []).length > 0, 5));
assert.equal(u.mem.zones[0].dur, bb.projectile_delay_time + t0.projectile_extend);
h.run(1.3);
approx(e.s.healingTakenMul, bb.heal_scale);
const ticks = tagged(h, 'alchemy', e);
assert.ok(ticks.length >= 1);
approx(ticks[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(near.hp > 5000, 'allies inside heal');
h.run(bb.projectile_delay_time + t0.projectile_extend);
assert.equal(u.mem.zones.length, 0, 'expired');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('白面鸮 S2: BAT −1.8 s and a larger range; T1 SP aura +0.3/s on every ally (highest wins)', () => {
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 10000, atk: 0 }, skill: { spCost: 100 } }) } },
units: [{ chessId: 'chess_char_5_16_a', row: 10, col: 3 }, { chessId: 't_a', row: 10, col: 5 }, { chessId: 'chess_char_5_10_a', row: 12, col: 3 }],
autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_16_a');
const bb = bbOf(u), t0 = tal(u, 0);
h.run(0.5);
approx(h.unit('t_a').findBuff('aura:spRecovery').mods.spRecoveryFlat, t0.sp_recovery_per_sec);
approx(h.unit('chess_char_5_10_a').findBuff('aura:spRecovery').mods.spRecoveryFlat, tal(h.unit('chess_char_5_10_a'), 0).sp_recovery_per_sec, 1e-9, '铃兰\'s higher Supporter aura wins on Supporters');
const a = h.unit('t_a');
a.hp = 3000;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-6);
assert.ok(u.rangeKeys.length > u.baseRangeKeys.length);
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('山 S2 stance: DEF −30 %, ATK +35 %, block +1, hits all blocked, regen; T1 20 % ×1.6 punches weaken; T2 DEF +10 %, 15 % dodge', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_17_a', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 5] }, { key: 'enemy_dummy', pos: [9, 5] }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 120, seed: 3,
});
const u = h.unit('chess_char_5_17_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
approx(u.s.def, u.base.def * (1 + t1.def));
approx(u.s.dodgePhys, t1.prob);
assert.equal(u.blocking.length, 1);
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.def, u.base.def * (1 + t1.def + bb.def));
approx(u.s.atk, u.base.atk * (1 + bb.atk));
h.run(3);
assert.equal(u.blocking.length, 2, 'block +1');
const [e1, e2] = h.b.enemies;
const t = h.b.time;
h.run(2);
const recent = h.hooksOf('damaged').filter((c) => c.source === u && c.t >= t);
assert.ok(recent.some((c) => c.target === e1) && recent.some((c) => c.target === e2), 'hits every blocked enemy');
u.hp = u.s.maxHp * 0.5;
const hp = u.hp;
h.run(1);
approx(u.hp - hp, u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio, 0.02, 'regen 4 %/s');
h.run(20);
const plain = u.s.atk;
const crits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack && Math.abs(c.amount - plain * t0.atk_scale) < 1e-6);
assert.ok(crits.length > 0 && fxOf(h, 'crit').length > 0, '巨力重拳 procs');
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && Math.abs(c.amount - plain) < 1e-6), 'normal hits too');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('百炼嘉维尔 S2: drags unblocked enemies in front of her; T1 ATK/DEF +10 % (+4 % per extra blocked); T2 healing +20 % (+40 % low)', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_18_a', row: 9, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [9, 4] }],
autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_18_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.run(0.5);
assert.equal(u.blocking.length, 3);
approx(u.s.atk, u.base.atk * (1 + t0.atk + 2 * t0.atk_add));
approx(u.s.def, u.base.def * (1 + t0.def + 2 * t0.def_add));
u.hp = u.s.maxHp * 0.8;
approx(h.b.heal(null, u, 100), 100 * t1.heal_scale_1);
u.hp = u.s.maxHp * 0.3;
approx(h.b.heal(null, u, 100), 100 * t1.heal_scale_2);
for (const e of h.b.enemies) h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
const near = h.spawn('enemy_dummy', { pos: [9, 5] });
const far = h.spawn('enemy_dummy', { pos: [9, 6] });
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk));
h.b.dealDamage(null, near, { amount: 1e9, type: 'true' });
h.run(1.5);
assert.ok(far.x < 5.6, `dragged in front of her by the extended skill range (x=${far.x})`);
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('玛恩纳 S3: trait ×2, 5 targets at 125 % ATK (×1.1 游侠), Kazimierz attacks add 10 % ATK true; T2 taunt +1 and reflect 15 % ATK', () => {
const tiles = [[9, 5], [9, 6], [10, 5], [10, 6], [11, 5], [11, 4]];
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_kaz: ally('t_kaz', { bonds: ['kazimierzShip'], stats: { atk: 100 } }) } },
units: [{ chessId: 'chess_char_5_19_a', row: 9, col: 4 }, { chessId: 't_kaz', row: 12, col: 3 }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_19_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
assert.equal(u.s.taunt, t1.taunt_level);
const kaz = h.unit('t_kaz');
const foe = h.spawn('enemy_dummy', { pos: [12, 8] });
h.b.dealDamage(foe, kaz, { amount: 100, type: 'phys', isAttack: true });
const refl = tagged(h, 'reflect', foe);
assert.equal(refl.length, 1);
approx(refl[0].amount, u.s.atk * t1.atk_scale, 1e-6, 'reflect');
h.b.dealDamage(null, foe, { amount: 1e9, type: 'true' });
h.run(10);
for (const p of tiles) h.spawn('enemy_dummy', { pos: p });
const ramp = u.trait.ramp;
assert.ok(ramp > 0.4);
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2));
approx(u.s.atk, u.base.atk * (1 + ramp * bb.trait_up), 1e-6, 'trait ×2');
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill && c.dmg.isAttack), 3));
const first = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isSkill && c.dmg.isAttack);
const t = first[0].t;
const vol = first.filter((c) => c.t === t);
assert.equal(vol.length, bb['attack@max_target'], '5 targets');
for (const c of vol) approx(c.amount, u.s.atk * bb['attack@atk_scale'] * t0.atk_scale_base, 1e-6);
const target = h.b.enemies.find((e) => e.alive);
const before = tagged(h, 'mlynarMark', target).length;
assert.ok(before > 0, 'his own attacks are Kazimierz attacks too');
h.b.dealDamage(kaz, target, { amount: 10, type: 'phys', isAttack: true });
const mark = tagged(h, 'mlynarMark', target);
assert.equal(mark.length, before + 1);
approx(mark[before].amount, u.s.atk * bb.atk_scale, 1e-6, 'Kazimierz mark');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('安洁莉娜: no attack off-skill; S3 (SEARCH) weightless enemies, ATK +90 %, 4 targets; T1 ASPD +7 all; T2 20 HP/s off-skill', () => {
const tiles = [[10, 5], [10, 6], [11, 5], [11, 6], [9, 5]];
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { mass: 4 }) }, chess: { t_a: ally('t_a') } },
units: [{ chessId: 'chess_char_5_20_a', row: 10, col: 4 }, { chessId: 't_a', row: 12, col: 4 }],
enemies: tiles.map((p) => ({ key: 'enemy_dummy', pos: p })), hooks: ['attack'], captureNoisy: true, autoFinish: false, timeLimit: 120,
});
const u = h.unit('chess_char_5_20_a');
const a = h.unit('t_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
a.hp = 5000;
h.run(2);
assert.equal(u.stats.attacks, 0, 'no normal attack while the skill is off');
assert.equal(a.s.aspd, 100 + t0.attack_speed);
approx(a.hp, 5000 + 2 * t1.hp_recovery_per_sec, 0.01, '兼职工作');
const atk0 = u.s.atk;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2));
approx(u.s.atk, atk0 * (1 + bb.atk));
assert.ok(h.b.enemies.every((e) => e.findBuff('aglina:weightless') && e.s.massLevel === 3 && e.weight === 3 && e.base.massLevel === 4), 'weightless: weight −1 level (PRTS; engine massFlat, base untouched)');
h.run(1);
const vol = h.hooksOf('attack').filter((c) => c.attacker === u);
assert.ok(vol.length > 0);
for (const c of vol) assert.equal(c.targets.length, bb['attack@max_target']);
const hp = a.hp;
h.run(1);
approx(a.hp, hp, 1e-6, 'no regen during the skill');
h.runUntil(() => !u.skill.active, 20);
assert.ok(h.b.enemies.every((e) => !e.findBuff('aglina:weightless') && e.s.massLevel === 4), 'weight restored');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('寒檀 S2: ice spikes on random range tiles every 0.5 s — 65 % ATK arts + 1 s cold around; T1 after 20 s ATK +15 % and 抵抗', () => {
const tiles = [];
for (const dr of [-1, 0, 1]) for (let dc = 0; dc <= 3; dc++) if (dr || dc) tiles.push([10 + dr, 3 + dc]);
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_21_a', row: 10, col: 3 }],
enemies: tiles.map((p) => ({ key: 'enemy_dummy', pos: p })), hooks: ['damaged', 'statusApplied'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_21_a');
const bb = bbOf(u), t0 = tal(u, 0);
h.step();
const atk0 = u.s.atk;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.interval, u.base.bat + bb.base_attack_time, 1e-6, 'BAT 2.9 − 2.4 s');
const t = h.b.time;
h.run(5);
const spikes = fxOf(h, 'iceSpike').length;
assert.ok(spikes >= 9 && spikes <= 11, `≈ 2 spikes/s (${spikes})`);
const ice = tagged(h, 'iceSpike');
assert.ok(ice.length > 0);
for (const c of ice) approx(c.amount, atk0 * bb['attack@atk_scale'], 1e-6);
assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'cold' && c.t >= t && c.duration === bb['attack@cold']));
assert.ok(!h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isAttack && !(c.dmg.tags || []).includes('iceSpike') && c.t > t + 0.1), 'no normal attacks during S2');
assert.ok(ice.every((c) => c.dmg.isAttack), 'the spikes are her attacks');
h.run(t0.interval + 0.6 - h.b.time);
approx(u.s.atk, u.base.atk * (1 + t0.atk));
h.b.applyStatus(u, 'stun', { duration: 4 });
approx(u.findBuff('stun').timeLeft, 4 * (1 + t0.one_minus_status_resistance), 1e-9, '抵抗 halves the stun');
// 抵抗 never shortens a longer status she already has
const left = u.findBuff('stun').timeLeft;
h.b.applyStatus(u, 'stun', { duration: 1 });
approx(u.findBuff('stun').timeLeft, left, 1e-9, 'the longer stun is kept');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('妮芙 S2: 270 % ATK + equal splash, fear, apoptosis = 18 % of damage; T1 失魂 DoT on burst targets (70 % from the skill); T2 ATK per burst', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_22_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [10, 6] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_22_a');
const bb = bbOf(u), t0 = tal(u, 0), t1 = tal(u, 1);
h.step();
const [a, b] = h.b.enemies;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill && c.type === 'arts'), 5));
h.step();
const sk = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isSkill && c.type === 'arts');
const main = sk.find((c) => !c.dmg.isSplash), splash = sk.find((c) => c.dmg.isSplash);
approx(main.amount, u.s.atk * bb.atk_scale, 1e-6);
approx(splash.amount, main.amount, 1e-6, 'equal splash');
assert.ok(main.target.findBuff('fear'));
approx(main.target.elem.apoptosis, main.amount * bb.ep_damage_ratio, 1e-6, 'apoptosis 18 % of the damage');
approx(splash.target.elem.apoptosis, splash.amount * bb.ep_damage_ratio, 1e-6);
// burst on the first target → normal attacks start the 40 % DoT
const atk0 = u.s.atk;
h.b.dealDamage(null, a, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(a.findBuff('apoptosisBurst'));
approx(u.s.atk, atk0 * (1 + t1.atk) / 1, 1e-6, '窥心钥 +2 %');
assert.ok(h.runUntil(() => a.findBuff(`nymph:soul:${u.id}`), 5));
h.run(2.1);
const soul = tagged(h, 'nymphSoul', a);
assert.ok(soul.length >= 2);
approx(soul[0].amount, u.s.atk * t0.element_atk_scale, 1e-6, '40 % ATK/s');
u.skill.gainSp(1000);
h.runUntil(() => a.findBuff(`nymph:soul:${u.id}`)?.data.scale === bb.element_atk_scale, 5);
assert.equal(a.findBuff(`nymph:soul:${u.id}`).data.scale, bb.element_atk_scale, 'skill hit raises it to 70 %');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
test('录武官 S2: ATK +45 %; healed allies heal 80 HP per hit taken for 10 s; T1 +1 SP and ASPD +16 when an op in range casts', () => {
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 10000, atk: 0 }, skill: { spCost: 100 } }) } },
units: [{ chessId: 'chess_char_5_23_a', row: 10, col: 3 }, { chessId: 't_a', row: 10, col: 5 }],
autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_23_a');
const a = h.unit('t_a');
const bb = bbOf(u), t0 = tal(u, 0);
h.step();
const sp = u.skill.sp;
a.skill.activate('test', { free: true });
approx(u.skill.sp, sp + t0.sp, 1e-6, '学成于聚 SP');
assert.equal(u.s.aspd, u.base.aspd + t0.attack_speed);
h.run(t0.duration + 0.1);
assert.equal(u.s.aspd, u.base.aspd);
const atk0 = u.s.atk;
a.hp = 3000;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.atk, atk0 * (1 + bb.atk));
assert.ok(h.runUntil(() => a.hasBuff('reckpr:guard'), 5));
approx(a.findBuff('reckpr:guard').timeLeft, bb['attack@buff_duration'], 0.1);
const hp = a.hp;
h.b.dealDamage(null, a, { amount: 500, type: 'true' });
approx(a.hp, hp - 500 + bb['attack@fixed_heal_value'], 1e-6);
// "治疗干员后": a summon healed by her gets no guard (纸偶: a summon without 禁疗 — “小自在” holds it, PRTS)
const tok = h.b.spawnToken(a, 'token_10022_kazema_shadow', 11, 3, { kit: { skill: null } });
tok.hp = 100;
assert.ok(h.runUntil(() => tok.hp > 100, 5));
assert.ok(!tok.hasBuff('reckpr:guard'));
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
// regressions of the 2026-09-28 fidelity review (PRTS 备注 of the base operators; see the tier5.js header)
test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and never reaches ammoUsed listeners', () => {
const log = [];
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_01_a', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], hooks: ['ammoUsed'], seed: 5, autoFinish: false, timeLimit: 120,
setup(b) {
b.on('attack', (c) => { if (c.attacker.defId === 'chess_char_5_01_a') log.push({ extra: !!c.attacker.mem.extraAttack, active: c.attacker.skill.active }); });
},
});
const u = h.unit('chess_char_5_01_a');
const bb = bbOf(u);
h.step();
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.ok(h.runUntil(() => !u.skill.active, 60));
const ammo = bb['attack@trigger_time'] + 1; // + himself (Laterano)
const normal = log.filter((x) => x.active && !x.extra).length, extra = log.filter((x) => x.active && x.extra).length;
assert.ok(extra > 0, 'extra attacks happened during the skill');
assert.equal(normal, ammo, 'every bullet is one normal attack');
assert.equal(h.hooksOf('ammoUsed').length, ammo, 'extra attacks are invisible to ammoUsed listeners');
clean(h);
});
test('元素伤害 uses the engine elemental type: × elementalTakenMul (元素脆弱), never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
// one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
const arena = () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_03_a', row: 9, col: 3 }, { chessId: 'chess_char_5_22_a', row: 11, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
h.b.addBuff(e, { key: 't:elem', mods: { elementalTakenMul: 1.5 } });
return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
};
const a = arena();
a.h.b.dealDamage(null, a.e, { type: 'element', element: 'burn', amount: 1000 });
const melt = tagged(a.h, 'blazeMelt', a.e);
assert.equal(melt.length, 1);
assert.equal(melt[0].type, 'elemental');
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elementalTakenMul only');
clean(a.h);
const { h, e, nymph } = arena();
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
const soul = tagged(h, 'nymphSoul', e)[0];
assert.equal(soul.type, 'elemental');
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elementalTakenMul only');
clean(h);
});
test('烛煌 T2 绝处重燃: the revive stuns enemies within 1.7 tiles (PRTS)', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_03_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4.6] }, { key: 'enemy_dummy', pos: [10, 4.8] }], autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_03_a');
h.step();
const [near, far] = h.b.enemies;
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.hasBuff('blaze2:downed'));
u.hp = u.s.maxHp;
h.run(0.3);
assert.ok(!u.hasBuff('blaze2:downed'));
assert.ok(near.s.flags.stun, '1.6 tiles away: stunned');
assert.ok(!far.s.flags.stun, '1.8 tiles away: not stunned');
clean(h);
});
test('夕 S1 splash widens to 1.7 (PRTS); T2 点睛 summons only on a free deployable target tile', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_12_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 7.3] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_12_a');
h.step();
const [, e2] = h.b.enemies;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill && c.target === e2), 10), '1.64 tiles from the target: inside the expanded splash');
// target on a non-deployable floor tile (the col-10 lane): nothing is summoned, not even next to it
const g = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_12_a', row: 10, col: 8 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 10] }], autoFinish: false, timeLimit: 30,
});
const d = g.unit('chess_char_5_12_a');
assert.ok(g.runUntil(() => d.stats.attacks > 0, 5));
g.run(1);
assert.equal(g.b.allyUnits.filter((x) => x.kind === 'token').length, 0, 'floor tile: no 小自在');
assert.ok(d.mem.duskSummoned, 'the talent is spent for this deployment');
// target standing on an ally's tile (blocked there): no summon either
const k = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_blk: ally('t_blk', { stats: { blockCnt: 1 } }) } },
units: [{ chessId: 'chess_char_5_12_a', row: 10, col: 4 }, { chessId: 't_blk', row: 10, col: 6 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 30,
});
const d2 = k.unit('chess_char_5_12_a');
assert.ok(k.runUntil(() => d2.stats.attacks > 0, 5));
k.run(1);
assert.equal(k.b.allyUnits.filter((x) => x.kind === 'token').length, 0, 'occupied tile: no 小自在');
clean(h); clean(g); clean(k);
});
test('寒檀 S2: icicles cycle left row → right row → own row, splash 1.5 (PRTS), and count as attacks', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_21_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4.5] }], hooks: ['damaged', 'attack'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_21_a');
h.step();
const e = h.enemy('enemy_dummy');
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.ok(h.runUntil(() => !u.skill.active, 20));
h.run(0.5);
const spikes = fxOf(h, 'iceSpike');
assert.ok(spikes.length >= 20);
assert.ok(spikes.every((f) => f[4].r === 1.5));
const rows = spikes.map((f) => f[3]);
rows.forEach((r, i) => assert.equal(r, [11, 9, 10][i % 3], `icicle ${i} row`));
const expected = spikes.filter((f) => Math.hypot(f[2] - e.x, f[3] - e.y) <= 1.5 + 1e-9).length;
assert.ok(spikes.some((f) => f[3] !== 10 && Math.hypot(f[2] - e.x, f[3] - e.y) <= 1.5), 'side-row icicles reach 1.12 tiles away');
assert.equal(tagged(h, 'iceSpike', e).length, expected, 'every icicle within 1.5 tiles hits');
assert.ok(h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill && c.targets.includes(e)).length > 0, '"攻击时" hooks see the icicles');
clean(h);
});
test('缇缇 S3: the wake-up chain sleeps the highest-aggro enemy within 1.5 (PRTS), not merely the nearest', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_taunt: dummy('enemy_taunt', { taunt: 1 }) } },
units: [{ chessId: 'chess_char_5_02_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_taunt', pos: [11, 7] }],
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_02_a');
h.step();
const [a, near, taunt] = h.b.enemies;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => a.s.flags.sleep, 5));
assert.ok(!near.s.flags.sleep && !taunt.s.flags.sleep, 'the other two are out of her range');
assert.ok(h.runUntil(() => tagged(h, 'titiWake', a).length > 0, 8));
assert.ok(taunt.s.flags.sleep, 'the taunting enemy (1.41 tiles) is put to sleep');
assert.ok(!near.s.flags.sleep, 'the nearer one (1 tile) is not');
clean(h);
});
test('乌尔比安 S3: lands one tile beyond an occupied anchor tile; the anchor stops in front of a crate', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_blk: ally('t_blk', { stats: { blockCnt: 1 } }) } },
units: [{ chessId: 'chess_char_5_05_a', row: 9, col: 3 }, { chessId: 't_blk', row: 9, col: 6 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_05_a');
h.step();
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2));
assert.deepEqual([u.tileR, u.tileC], [9, 7], 'anchor tile taken by an ally → the tile beyond');
assert.ok(h.runUntil(() => !u.skill.active, 30));
assert.deepEqual([u.tileR, u.tileC], [9, 3], 'back home');
const g = makeBattle({
flat: { crates: [[9, 5]] },
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_05_a', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 7] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 60,
});
const v = g.unit('chess_char_5_05_a');
g.step();
v.skill.gainSp(1000);
assert.ok(g.runUntil(() => v.skill.active, 2));
const anchor = fxOf(g, 'anchor')[0];
assert.equal(anchor[2], 4, 'stopped in front of the crate');
assert.deepEqual([v.tileR, v.tileC], [9, 4]);
assert.equal(tagged(g, 'anchor').length, 0, 'the enemy 3 tiles further is out of the 1.8 radius');
clean(h); clean(g);
});
test('引星棘刺 S2: with no enemy in range the unit is thrown at the farthest tile straight ahead (PRTS)', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_5_15_a', row: 10, col: 3 }], autoFinish: false, timeLimit: 30 });
const u = h.unit('chess_char_5_15_a');
h.step();
u.skill.gainSp(1000);
assert.ok(u.skill.activate('test'));
const z = u.mem.zones[0];
assert.ok(z, 'a unit was thrown');
assert.deepEqual([z.y, z.x], [10, 5]);
assert.ok(z.vx > 0 && z.vy === 0, 'drifting away from her');
clean(h);
});
test('隐德来希 S3: candles have their own record (neutral, no attack, original weight) and ignore other sources entirely', () => {
const h = makeBattle({
defs: { enemies: { e_a: dummy('e_a', { hp: 1e6, mass: 3 }) } },
units: [{ chessId: 'chess_char_5_06_a', row: 9, col: 4 }],
enemies: [{ key: 'e_a', pos: [10, 5] }], autoFinish: false, timeLimit: 60,
});
const u = h.unit('chess_char_5_06_a');
h.step();
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 5));
const cd = h.b.enemies.find((e) => e.alive && e.mem.candleOwner === u);
assert.ok(cd);
assert.equal(cd.def.name, '心烛');
assert.equal(cd.def.dmgType, 'none');
assert.equal(cd.counted, false);
assert.equal(cd.base.massLevel, 3);
h.b.dealDamage(null, cd, { type: 'element', element: 'burn', amount: 5000 });
assert.equal(cd.elem.burn, 0, 'other element sources cannot fill a candle gauge');
assert.equal(h.b.applyStatus(cd, 'stun', { duration: 3, source: null }), false, 'nor apply statuses');
h.run(1);
assert.ok(cd.alive && !cd.moving, 'it never walks off (no leak)');
assert.equal(h.result?.()?.perPlayer?.p1?.leaked?.length ?? 0, 0);
clean(h);
});
test('隐德来希 T1 萃血: stealing max HP never lowers a wounded target\'s current HP (only caps it); never below 1 max HP', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_tiny: dummy('enemy_tiny', { hp: 100 }) } },
units: [{ chessId: 'chess_char_5_06_a', row: 9, col: 4 }], autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_06_a');
const t0 = tal(u, 0);
h.step();
const e = h.spawn('enemy_dummy', { pos: [12, 8] });
e.hp = e.s.maxHp * 0.5;
const hp = e.hp, max = e.s.maxHp;
h.b.dealDamage(u, e, { amount: 1, type: 'true', isAttack: true });
approx(e.s.maxHp, max - t0['attack@steal_hp']);
approx(e.hp, hp - 1, 1e-9, 'only the hit itself');
const tiny = h.spawn('enemy_tiny', { pos: [12, 7] });
h.b.dealDamage(u, tiny, { amount: 1, type: 'true', isAttack: true });
assert.ok(tiny.alive && tiny.s.maxHp >= 1 && tiny.hp <= tiny.s.maxHp);
approx(tiny.s.maxHp, 100 - Math.min(t0['attack@steal_hp'], 99));
clean(h);
});
test('凛御银灰 S2: the slash ignores 隐匿 (PRTS) and reveals the stealthed enemy', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_14_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 5] }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_14_a');
h.step();
const [, hid] = h.b.enemies;
h.b.applyStatus(hid, 'stealth', { duration: 99 });
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.activations > 0, 3));
assert.equal(tagged(h, 'svash2', hid).length, 1, 'the stealthed enemy is slashed');
assert.ok(hid.findBuff('reveal'), '… and revealed');
clean(h);
});
test('华法琳 S1: the bandage bonus is part of the heal (healer healingDealtMul applies)', () => {
const h = makeBattle({
defs: { chess: { t_ally: ally('t_ally') } },
units: [{ chessId: 'chess_char_5_04_a', row: 10, col: 3 }, { chessId: 't_ally', row: 10, col: 5 }], autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_04_a'), a = h.unit('t_ally');
h.step();
h.b.addBuff(u, { key: 't:heal', mods: { healingDealtMul: 1.5 } });
u.skill.gainSp(1000);
a.hp = 3000;
assert.ok(h.runUntil(() => a.hp > 3000, 10));
approx(a.hp - 3000, (u.s.atk + a.s.maxHp * bbOf(u).hp_ratio) * 1.5, 1e-6);
clean(h);
});
test('elite 夕 / 白面鸮: the module range is part of the initial range the DEFAULT trigger checks', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_12_b', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_12_b');
h.step();
assert.ok(!absKeysHas(u.def.raw.rangeGrid, u, 10, 6), 'the enemy stands on the module tile only (not in the base range)');
assert.ok(absKeysHas(u.rangeGrid, u, 10, 6), 'the module grid adds that centre tile');
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'cast on the enemy in the module range');
clean(h);
});
test('安洁莉娜 T2 兼职工作: an HP-regen attribute — allies under 禁疗 regenerate too (PRTS 备注 "不受治疗加成和禁疗影响")', () => {
const h = makeBattle({
defs: { chess: { t_a: ally('t_a'), t_b: ally('t_b') } },
units: [{ chessId: 'chess_char_5_20_a', row: 10, col: 4 }, { chessId: 't_a', row: 12, col: 4 }, { chessId: 't_b', row: 12, col: 6 }],
autoFinish: false, timeLimit: 30,
});
h.step();
const a = h.unit('t_a'), b = h.unit('t_b');
h.b.applyStatus(b, 'noHeal', { duration: 20 });
a.hp = b.hp = 5000;
h.run(2);
assert.ok(a.hp > 5000);
assert.ok(b.hp > 5000, '禁疗 does not stop a 生命回复速度 attribute (PRTS 异常效果 禁疗)');
approx(b.hp, a.hp, 1e-6, 'same regen');
assert.equal(h.b.heal(h.unit('chess_char_5_20_a'), b, 100), 0, 'while heals still miss it');
clean(h);
});
test('铃兰 T2 画地为牢 is an aura: any 停顿 enemy in her range is 脆弱 (standard status, highest wins, not stacked)', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_10_a', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 5] }, { key: 'enemy_dummy', pos: [9, 5] }, { key: 'enemy_dummy', pos: [10, 8] }],
autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_10_a');
const t1 = tal(u, 1);
h.step();
const [s1, s2, out] = h.b.enemies;
h.b.addBuff(u, { key: 't:disarm', flags: { disarm: true } }); // she never attacks here: only others' 停顿
h.b.applyStatus(s1, 'sluggish', { duration: 3 });
h.b.applyStatus(s2, 'sluggish', { duration: 3 });
h.b.applyStatus(s2, 'fragile', { duration: 10, value: 0.3 });
h.b.applyStatus(out, 'sluggish', { duration: 3 });
h.run(0.5);
approx(s1.s.dmgTakenMul, t1.damage_scale, 1e-9, 'someone else\'s 停顿 counts');
approx(s2.s.dmgTakenMul, 1.3, 1e-9, 'a stronger 脆弱 wins, no stacking');
approx(out.s.dmgTakenMul, 1, 1e-9, 'outside her range');
h.run(3.2);
approx(s1.s.dmgTakenMul, 1, 1e-9, 'ends with the 停顿');
clean(h);
});
// ------------------------------------------------------------------------------------------------------------------
// elite (精锐) module trait upgrades
test('elite modules: burst ×1.1 (烛煌/妮芙), heal ×1.15 below 50 % (华法琳/塞雷娅/录武官), ×1.1 vs blocked (号角/百炼嘉维尔)', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_a: ally('t_a') } },
units: [
{ chessId: 'chess_char_5_03_b', row: 9, col: 3 }, { chessId: 'chess_char_5_22_b', row: 9, col: 4 }, { chessId: 'chess_char_5_04_b', row: 10, col: 3 },
{ chessId: 'chess_char_5_11_b', row: 10, col: 4 }, { chessId: 'chess_char_5_23_b', row: 11, col: 3 }, { chessId: 'chess_char_5_08_b', row: 11, col: 4 },
{ chessId: 'chess_char_5_18_b', row: 12, col: 3 }, { chessId: 't_a', row: 12, col: 9 },
],
autoFinish: false, timeLimit: 10,
});
h.step();
const e = h.spawn('enemy_dummy', { pos: [12, 8] });
for (const id of ['chess_char_5_03_b', 'chess_char_5_22_b']) approx(h.b.dealDamage(h.unit(id), e, { amount: 1000, type: 'true' }), 1000, 1e-9, id);
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
for (const id of ['chess_char_5_03_b', 'chess_char_5_22_b']) approx(h.b.dealDamage(h.unit(id), e, { amount: 1000, type: 'true' }), 1000 * h.unit(id).def.traitBb.damage_scale, 1e-9, id);
const a = h.unit('t_a');
for (const id of ['chess_char_5_04_b', 'chess_char_5_11_b', 'chess_char_5_23_b']) {
const m = h.unit(id).def.traitBb;
a.hp = 8000;
approx(h.b.heal(h.unit(id), a, 100), 100, 1e-9, id);
a.hp = 3000;
approx(h.b.heal(h.unit(id), a, 100), 100 * m.heal_scale, 1e-9, id);
}
for (const id of ['chess_char_5_08_b', 'chess_char_5_18_b']) {
const u = h.unit(id);
assert.equal(u.profile.dmgMul(h.b, u, { blockedBy: a }), u.def.traitBb.atk_scale, id);
assert.equal(u.profile.dmgMul(h.b, u, { blockedBy: null }), 1, id);
}
clean(h);
});
test('elite modules: range = the module grid (夕/白面鸮), ASPD +8 unblocking (史尔特尔), +10 above half HP (山), SP +0.2 with enemies (铃兰), ATK +8 % (魔王)', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_a: ally('t_a'), t_b: ally('t_b') } },
units: [
{ chessId: 'chess_char_5_12_b', row: 12, col: 3 }, { chessId: 'chess_char_5_16_b', row: 12, col: 4 }, { chessId: 'chess_char_5_07_b', row: 9, col: 3 },
{ chessId: 'chess_char_5_17_b', row: 9, col: 8 }, { chessId: 'chess_char_5_10_b', row: 11, col: 7 }, { chessId: 'chess_char_5_09_b', row: 10, col: 5 },
{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 },
],
autoFinish: false, timeLimit: 10,
});
h.run(0.5);
// integration review (like tier4 莫斯提马 SPC-X): the range becomes the module's own grid — ONE centre tile [0,3] more
// (not a whole +1 column, which also widened skill ranges)
for (const id of ['chess_char_5_12_b', 'chess_char_5_16_b']) {
const u = h.unit(id);
const rel = u.rangeKeys.map((k) => `${Math.floor(k / 21) - u.tileR},${(k % 21) - u.tileC}`);
assert.equal(u.s.rangeExtend, 0, id);
assert.equal(rel.length, u.def.raw.rangeGrid.length + 1, `${id}: exactly one tile more`);
assert.ok(rel.includes('0,3') && !rel.includes('1,3') && !rel.includes('-1,3'), `${id}: ${rel.join(' ')}`);
}
for (const id of ['chess_char_5_12_a', 'chess_char_5_16_a']) {
const n = makeBattle({ units: [{ chessId: id, row: 12, col: 3 }], autoFinish: false, timeLimit: 3 });
n.step();
assert.equal(n.unit(id).s.rangeExtend, 0, id);
assert.equal(n.unit(id).rangeKeys.length, n.unit(id).def.raw.rangeGrid.length, id);
}
const surtr = h.unit('chess_char_5_07_b');
assert.equal(surtr.s.aspd, surtr.base.aspd + surtr.def.traitBb.attack_speed);
const mtn = h.unit('chess_char_5_17_b');
assert.equal(mtn.s.aspd, mtn.base.aspd + mtn.def.traitBb.attack_speed);
mtn.hp = mtn.s.maxHp * 0.4;
h.run(0.5);
assert.equal(mtn.s.aspd, mtn.base.aspd, '山 below 50 %');
const cet = h.unit('chess_char_5_09_b');
approx(cet.s.atk, cet.base.atk * (1 + (cet.def.raw.talents.find((t) => t.index === -1).bb.atk)), 1e-9, '魔王 ≥2 ops in range');
const lisa = h.unit('chess_char_5_10_b');
assert.equal(lisa.findBuff('lisa:module'), null, 'no enemy in range yet');
h.spawn('enemy_dummy', { pos: [11, 8] });
h.run(0.5);
assert.ok(lisa.findBuff('lisa:module'));
clean(h);
});
test('elite modules: 归溟幽灵鲨 substitute ATK +15 %; 引星棘刺 2 charges and +0.1 SP/s while a unit is out', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_13_b', row: 9, col: 5 }, { chessId: 'chess_char_5_15_b', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], autoFinish: false, timeLimit: 30,
});
const g = h.unit('chess_char_5_13_b'), t = h.unit('chess_char_5_15_b');
h.step();
const atk0 = g.s.atk;
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
assert.ok(g.trait.doll);
h.run(0.3);
approx(g.s.atk, atk0 * (1 + g.def.traitBb.atk));
assert.equal(t.skill.maxCharges, 2);
assert.equal(t.kit.skill.kind, 'charges');
t.skill.gainSp(1000);
assert.ok(h.runUntil(() => (t.mem.zones || []).length > 0, 5));
h.run(0.3);
assert.ok(t.findBuff('thorn2:module'));
clean(h);
});
test('determinism: a full tier-5 lineup on a real wave replays identically', () => {
const run = () => {
const h = makeBattle({
waveTemplate: 'act1autochess_05', seed: 42,
units: [
{ chessId: 'chess_char_5_05_a', row: 9, col: 3 }, { chessId: 'chess_char_5_02_b', row: 10, col: 3 }, { chessId: 'chess_char_5_12_a', row: 11, col: 3 },
{ chessId: 'chess_char_5_06_a', row: 12, col: 4 }, { chessId: 'chess_char_5_15_a', row: 10, col: 5 }, { chessId: 'chess_char_5_14_a', row: 9, col: 6 },
{ chessId: 'chess_char_5_20_a', row: 11, col: 6 }, { chessId: 'chess_char_5_09_a', row: 12, col: 7 },
],
});
const r = h.runToEnd(200);
clean(h);
return JSON.stringify({ t: r.time, k: r.killed, s: r.perPlayer.p1.unitStats });
};
assert.equal(run(), run());
});
test('soak: random tier-5 lineups (normal + elite) on real stages/waves — normal, unite and boss fields — zero content errors', async () => {
const { getDefaultSource, spawnsFromTemplate } = await import('../../server/sim/simdata.js');
const { Battle } = await import('../../server/sim/Battle.js');
const { createRng } = await import('../../server/sim/rng.js');
const ds = getDefaultSource();
const T5 = ds.chessIds().filter((id) => /^chess_char_5_\d+_[ab]$/.test(id));
const stages = ds.stageIds().filter((id) => { const s = ds.getStage(id); return s && s.raw && s.raw.active !== false && s.rows && s.rows.length === 19; });
const waves = ds.waveIds().filter((id) => { const w = ds.getWave(id); return w && (w.kind === 'normal' || (!w.kind && /_0\d$|_h0[1-6]$/.test(id))); });
const boss = ds.waveIds().filter((id) => ['boss', 'hidden'].includes(ds.getWave(id)?.kind));
const escaped = ds.waveIds().filter((id) => ds.getWave(id)?.kind === 'escaped');
if (!stages.length || !waves.length) return;
const rng = createRng(Number(process.env.SIM_T5_SEED) || 2026);
const place = (stage, n, where) => {
const dt = stage.raw.deployTiles?.[where] ?? (where === 'normal' ? { melee: stage.raw.normalDeployTiles?.melee_or_any, rangedOnly: stage.raw.normalDeployTiles?.rangedOnly } : null);
const melee = dt?.melee ?? [], ranged = dt?.rangedOnly ?? [];
const used = new Set(), units = [];
for (let i = 0; i < n * 3 && units.length < n; i++) {
const id = rng.pick(T5);
const free = (ds.getChess(id).position === 'RANGED' ? ranged.concat(melee) : melee).filter(([r, c]) => !used.has(`${r},${c}`));
if (!free.length) continue;
const [r, c] = rng.pick(free);
used.add(`${r},${c}`);
units.push({ uid: units.length + 1, kind: 'chess', chessId: id, row: r, col: c, abs: true });
}
return units;
};
const N = Number(process.env.SIM_T5_N) || 12;
let casts = 0;
for (let i = 0; i < N; i++) {
const stage = ds.getStage(rng.pick(stages));
const kind = i % 4 === 3 && boss.length ? 'boss' : i % 4 === 2 && escaped.length ? 'unite' : 'normal';
const tpl = ds.getWave(rng.pick(kind === 'boss' ? boss : kind === 'unite' ? escaped : waves));
const { routes, spawns, maxPlayTime } = spawnsFromTemplate(tpl, { mods: { hpMul: 1.5, atkMul: 1.5 } });
let players, sharedBoss = null;
if (kind === 'boss') {
players = [{ playerId: 'A', side: 'L', units: place(stage, 6, 'bossLeft') }, { playerId: 'B', seat: 1, side: 'R', units: place(stage, 6, 'bossRight') }];
sharedBoss = { hp: 3e5, maxHp: 3e5, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
} else if (kind === 'unite') {
players = [{ playerId: 'A', side: 'L', units: place(stage, 6, 'normal').map((u) => ({ ...u, carryState: { hpPct: 0.5, sp: 5, skillActive: rng() < 0.5 } })) },
{ playerId: 'B', seat: 1, side: 'L', colOffset: 8, units: place(stage, 6, 'normal').map((u) => ({ ...u, col: u.col + 8 })) }];
} else players = [{ playerId: 'P', side: 'L', units: place(stage, 8, 'normal') }];
const b = new Battle({ seed: 100 + i, kind: tpl.kind === 'hidden' ? 'hidden' : kind, stageId: stage.id, routes, spawns, players, sharedBoss,
timeLimit: kind === 'boss' ? Infinity : (maxPlayTime ?? 60), logger: { error() {}, warn() {} }, quiet: true, modeId: 'mode_multi_normal', round: 6 });
for (let n = 0; !b.finished && b.time < 90; n++) { b.step(); if (n % 45 === 0) checkInvariants(b); }
if (!b.finished) b.forceEnd('forced');
checkInvariants(b);
assert.deepEqual(b.errors.map((e) => `${e.label} ${e.who}: ${e.message}`), [], `battle ${i} (${kind})`);
const ops = b.allyUnits.filter((u) => u.kind === 'op');
assert.ok(ops.length >= 3, `battle ${i}: lineup deployed`);
casts += ops.reduce((s, u) => s + u.skill.activations, 0);
}
assert.ok(casts >= N, `skills were cast (${casts})`);
});