Files
ark-stronghold-protocol/test/content/kits_t3.test.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

1183 lines
61 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-3 operator kits (server/sim/content/kits/tier3.js): every chess runs a real battle through the harness and
// its signature skill / talent / module effect is asserted with numbers taken from its own blackboards.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import KITS from '../../server/sim/content/kits/tier3.js';
const ds = getDefaultSource();
const D = (id) => ds.getChess(id);
const BB = (id) => D(id).skill.bb;
const TB = (id, i) => D(id).talents[i].bb;
const TR = (id) => D(id).traitBb;
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, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, def: 0, res: 0, ...o });
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
const tagged = (h, tag, src = null) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag) && (!src || c.source === src));
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
/** defs.chess override: the chess without its 特质 (garrisons have their own tests) — isolates kit damage numbers/types. */
const noGarrison = (...ids) => Object.fromEntries(ids.map((id) => [id, { ...ds.rawChess(id), garrisonIds: [] }]));
test('tier3 kit registry covers every tier-3 chess (19 visible + 2 hidden)', () => {
const ids = ds.chessIds().filter((id) => /^chess_char_3_\d+_a$/.test(id));
assert.equal(ids.length, 21);
for (const id of ids) assert.equal(typeof KITS[id], 'function', id);
for (const id of ids) for (const v of [id, id.replace(/_a$/, '_b')]) {
const d = D(v);
const kit = KITS[id](d.skill.bb, d.raw, d);
assert.ok(kit && kit.skill && kit.skill.kind, `${v} has a skill spec`);
}
});
// ---------------------------------------------------------------------------------------------------------------
test('3_01 能天使: 过载模式 5-hit bursts at a shorter interval; talents ASPD/ATK/HP + one blessed ally', () => {
const id = 'chess_char_3_01_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 120, hooks: ['damaged', 'attack'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 12, col: 7 }], enemies: [{ key: 'enemy_d', pos: [10, 6] }],
});
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
h.step(3);
approx(u.s.aspd, 100 + t0.attack_speed);
approx(u.s.atk, u.base.atk * (1 + t1.atk));
approx(u.s.maxHp, u.base.maxHp * (1 + t1.max_hp));
assert.ok(ally.findBuff('talent:angel_bless_ally'), 'the only other operator got the blessing');
approx(ally.s.maxHp, ally.base.maxHp * (1 + t1.max_hp));
assert.ok(h.runUntil(() => u.skill.active, 60));
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd);
assert.equal(effectiveProfile(u).hits, bb['attack@times']);
h.runUntil(() => !u.skill.active, 30);
h.run(1);
const skillAtks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length;
const skillHits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.dmg.isSkill).length;
assert.ok(skillAtks >= 5);
assert.equal(skillHits, skillAtks * bb['attack@times']);
done(h);
});
test('3_02 断崖: 浮游刃 arts hits + blade damage on enemies blocked by adjacent allies; 索敌援助 ASPD aura; 精锐 module arts', () => {
for (const id of ['chess_char_3_02_a', 'chess_char_3_02_b']) {
const bb = BB(id), t0 = TB(id, 0), tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }], enemies: [{ key: 'enemy_d', pos: [11, 4] }],
});
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
h.run(0.5);
approx(ally.s.aspd, 100 + t0.attack_speed, 1e-6, 'aura on the adjacent operator');
approx(u.s.aspd, u.base.aspd + t0.attack_speed, 1e-6, 'and on herself');
const e = h.enemy('enemy_d');
assert.equal(e.blockedBy, ally);
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
h.run(4);
const blades = tagged(h, 'ayerBlade', u);
assert.ok(blades.length >= 2, 'one blade hit per attack');
for (const c of blades) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, 'blade'); }
const main = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.dmg.isSkill);
assert.ok(main.length && main.every((c) => c.type === 'arts'), 'skill attacks are arts');
if (tb.atk_scale_m) {
const m = tagged(h, 'module', u);
assert.ok(m.length >= 3);
approx(m[m.length - 1].amount, u.s.atk * tb.atk_scale_m, 1e-6, 'module arts');
} else assert.equal(tagged(h, 'module', u).length, 0);
done(h);
}
});
test('3_03 诗怀雅: 近距离作战指导 melee ATK aura, ×talent_scale during 协同作战', () => {
const id = 'chess_char_3_03_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }, { chessId: 'chess_char_3_09_a', row: 11, col: 5 }],
enemies: [{ key: 'enemy_d', pos: [10, 5] }],
});
const u = h.unit(id), melee = h.unit('chess_char_3_16_a'), ranged = h.unit('chess_char_3_09_a');
h.run(0.5);
approx(melee.s.atk, melee.base.atk * (1 + t0.atk));
approx(ranged.s.atk, ranged.base.atk, 1e-6, 'ranged allies are not coached');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
h.step();
approx(melee.s.atk, melee.base.atk * (1 + t0.atk * bb.talent_scale));
approx(u.s.atk, u.base.atk * (1 + bb.atk + t0.atk * bb.talent_scale));
h.runUntil(() => !u.skill.active, 30);
h.run(0.5);
approx(melee.s.atk, melee.base.atk * (1 + t0.atk));
done(h);
});
test('3_04 琳琅诗怀雅: coins → champagne bomb (ATK% + 停顿), 大买家 coin/ATK per DP payment, 破财消灾 DP revive', () => {
const id = 'chess_char_3_04_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const booms = [];
const h = makeBattle({
defs: { enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } }, timeLimit: 60, hooks: ['damaged', 'deploy'], captureNoisy: true,
units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'enemy_w', route: 0 }],
setup: (b) => b.on('damaged', (c) => { if (c.dmg?.tags?.includes('trap')) booms.push({ amount: c.amount, atk: c.source.s.atk, t: b.time }); }),
});
const u = h.unit(id);
h.step();
assert.equal(u.mem.coins, t0.sp, '开启技能 coin');
h.run(3.1);
assert.equal(u.mem.coins, t0.sp + t0.trait_sp, 'coin from the first trait payment');
assert.equal(u.findBuff('talent:swire2_buyer')?.stacks, 1);
approx(u.s.atk, u.base.atk * (1 + t0.atk));
assert.ok(h.runUntil(() => booms.length > 0, 20), 'bomb exploded');
assert.ok(h.hooksOf('deploy').some((c) => c.unit.defId === 'token_10031_swire2_gdtrap'), 'bomb token placed');
approx(booms[0].amount, booms[0].atk * bb.atk_scale, 1e-6, 'bomb damage');
const e = h.enemy('enemy_w');
assert.ok(e.findBuff('sluggish'), '停顿');
assert.ok(u.mem.coins <= bb.sp);
// 破财消灾
const p = h.b.getPlayer('p1');
p.dp = 30;
h.b.dealDamage(null, u, { type: 'true', amount: 1e6 });
assert.equal(u.alive, true);
approx(u.hp, u.s.maxHp * t1.hp_ratio);
approx(p.dp, 30 - Math.abs(t1.cost));
h.b.dealDamage(null, u, { type: 'true', amount: 1e6 });
assert.equal(u.alive, true);
approx(p.dp, 30 - Math.abs(t1.cost) * (1 + t1.cost_multi), 1e-6, 'cost doubled');
p.dp = 0;
h.b.dealDamage(null, u, { type: 'true', amount: 1e6 });
assert.equal(u.alive, false, 'no DP ⇒ no save');
done(h);
});
test('3_05 斯卡蒂: 涌潮悲歌 ATK/DEF/HP; 深海掠食者 on Abyssal Hunters only; 迅捷出击 redeploy; 精锐 module revive', () => {
const id = 'chess_char_3_05_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_2_07_a', row: 11, col: 4 }, { chessId: 'chess_char_3_16_a', row: 12, col: 4 }],
enemies: [{ key: 'enemy_d', pos: [10, 5] }],
});
const u = h.unit(id), ghost = h.unit('chess_char_2_07_a'), cuora = h.unit('chess_char_3_16_a');
h.step();
assert.equal(ghost.findBuff('talent:skadi_predator')?.mods.atkPct, t0.atk, '幽灵鲨 is an Abyssal Hunter');
assert.equal(cuora.findBuff('talent:skadi_predator'), null);
approx(u.s.atk, u.base.atk * (1 + t0.atk));
assert.equal(u.base.respawnTime, D(id).stats.respawnTime + t1.respawn_time);
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk));
approx(u.s.def, u.base.def * (1 + bb.def));
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp));
done(h);
const gid = 'chess_char_3_05_b', tb = TR(gid);
const g = makeBattle({ units: [{ chessId: gid, row: 10, col: 4 }], timeLimit: 30 });
const s = g.unit(gid);
g.step();
g.b.dealDamage(null, s, { type: 'true', amount: 1e7 });
assert.equal(s.alive, true, 'module: does not retreat');
approx(s.s.maxHp, s.base.maxHp * (1 - tb.value));
approx(s.hp, s.s.maxHp * tb.hp_ratio);
approx(s.s.aspd, s.base.aspd + tb.attack_speed);
g.b.dealDamage(null, s, { type: 'true', amount: 1e7 });
assert.equal(s.alive, false, 'once per deployment');
done(g);
});
test('3_06 菲莱: 冥河诅咒 no attacks, HP +, counter arts + apoptosis (cd), ATK + after element damage; 神河谕使; 精锐 element ×', () => {
for (const id of ['chess_char_3_06_a', 'chess_char_3_06_b']) {
const bb = BB(id), t0 = TB(id, 0), tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 150, bat: 0.5 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 5 }], enemies: [{ key: 'enemy_h', pos: [10, 5] }],
});
const u = h.unit(id);
h.run(0.2);
const e = h.enemy('enemy_h');
// 神河谕使: a 元素损伤 multiplier on the hit (the gauge fill), not elemTakenMul (元素伤害 / 元素脆弱)
approx(u.s.elemTakenMul, 1);
const sp0 = u.skill.sp, a0 = u.elem.apoptosis;
h.b.dealDamage(e, u, { type: 'element', element: 'apoptosis', amount: 10 });
approx(u.elem.apoptosis - a0, 10 * (1 - t0.damage_resistance), 1e-6, 'element taken −damage_resistance');
approx(u.skill.sp, sp0 + t0.sp, 1e-6, '+SP on apoptosis');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
const atks = u.stats.attacks;
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp));
h.run(5);
assert.equal(u.stats.attacks, atks, '停止攻击');
const counters = tagged(h, 'counter', u);
assert.ok(counters.length >= 2 && counters.length <= Math.ceil(5 / bb.aoe_cd) + 1, `aoe_cd respected (${counters.length})`);
for (let i = 1; i < counters.length; i++) assert.ok(counters[i].t - counters[i - 1].t >= bb.aoe_cd - 1e-6);
approx(counters[0].amount, u.s.atk * bb.atk_scale, 1e-6, 'counter');
const el = h.hooksOf('damaged').filter((c) => c.source === u && c.type === 'element');
approx(el[0].amount, u.s.atk * bb.ep_damage_ratio * (tb.ep_damage_scale ?? 1), 1e-6, 'apoptosis (module × while blocking)');
assert.equal(u.findBuff('skill:philae_rage'), null);
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 10 });
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, 'rage');
h.runUntil(() => !u.skill.active, 60);
assert.equal(u.findBuff('skill:philae_rage'), null, 'until the skill ends');
done(h);
}
});
test('3_07 见行者: 惊爆射击 pushes enemies forward + stun (wall / collision); 技巧射击 DEF ignore vs heavy; 精锐 refund', () => {
const id = 'chess_char_3_07_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { mass: 1 }), enemy_b: dummy('enemy_b', { mass: 1 }), enemy_c: dummy('enemy_c', { mass: 1 }) } },
timeLimit: 60, hooks: ['statusApplied'],
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_a', pos: [10, 5] }, { key: 'enemy_c', pos: [10, 6.55] }],
});
const u = h.unit(id);
h.step();
const a = h.enemy('enemy_a'), c = h.enemy('enemy_c');
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
assert.ok(a.x > 5.9, `pushed forward (${a.x})`);
const stun = (x) => h.hooksOf('statusApplied').find((s) => s.target === x && s.status === 'stun');
approx(stun(a).duration, bb['forcer_s_2[hit_directly].stun']);
approx(stun(c).duration, bb['forcer_s_2[brush].stun'], 1e-6, 'collided enemy');
done(h);
const w = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { mass: 1 }) } }, timeLimit: 30, hooks: ['statusApplied'],
units: [{ chessId: id, row: 12, col: 9 }], enemies: [{ key: 'enemy_a', pos: [12, 10] }],
});
const v = w.unit(id);
w.step();
fill(v);
assert.ok(w.runUntil(() => v.skill.activations === 1, 5));
approx(w.hooksOf('statusApplied').find((s) => s.status === 'stun').duration, bb.stun, 1e-6, 'slammed into the edge');
done(w);
const k = makeBattle({
defs: { enemies: { enemy_heavy: dummy('enemy_heavy', { mass: t0.value, def: 300 }), enemy_light: dummy('enemy_light', { mass: t0.value - 1, def: 300 }) } },
timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_heavy', pos: [10, 4] }, { key: 'enemy_light', pos: [10, 4] }],
});
const f = k.unit(id);
k.run(3);
const hitsOn = (key) => k.hooksOf('damaged').filter((x) => x.source === f && x.target.defId === key && x.dmg?.isAttack);
approx(hitsOn('enemy_heavy')[0].amount, f.s.atk - (300 - t0.def_penetrate_fixed));
approx(hitsOn('enemy_light')[0].amount, f.s.atk - 300, 1e-6, 'light enemies keep their DEF');
done(k);
const gid = 'chess_char_3_07_b';
const r = makeBattle({ units: [{ chessId: gid, row: 10, col: 2 }], timeLimit: 30, flags: { dpPerSec: 0 } });
const g = r.unit(gid);
r.step();
const p = r.b.getPlayer('p1');
r.b.dealDamage(null, g, { type: 'true', amount: 1e7 });
p.dp = 50;
r.b.redeploy(g, { free: false });
approx(p.dp, 50 - g.base.cost + g.base.cost * TR(gid).value, 1e-6, 'half the cost back on a ranged tile');
done(r);
});
test('3_08 薄绿: 聚能涡旋 pull + ATK% arts, end burst; 地质学者 DEF aura / taunt −1; 精锐 keeps part of the guard', () => {
for (const id of ['chess_char_3_08_a', 'chess_char_3_08_b']) {
const bb = BB(id), t0 = TB(id, 0), tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d', { mass: 0 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }], enemies: [{ key: 'enemy_d', pos: [10, 6] }],
});
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
h.run(0.5);
const e = h.enemy('enemy_d');
approx(ally.s.def, ally.base.def * (1 + TB('chess_char_3_16_a', 0).def + t0.def), 1e-6, 'geo aura');
approx(u.s.def, u.base.def * (1 + t0.def + tb.def), 1e-6, 'guard + own aura');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(0.5);
approx(u.s.taunt, t0.taunt_level);
approx(ally.s.def, ally.base.def * (1 + TB('chess_char_3_16_a', 0).def), 1e-6, 'aura off during the skill');
const keep = tb['soil_e_002[buff].def'] ?? 0;
approx(u.s.def, u.base.def * (1 + keep));
approx(u.s.res, u.base.res + (tb['soil_e_002[buff].magic_resistance'] ?? 0));
h.run(3);
assert.ok(e.x < 6 - 0.2, `pulled toward her front tile (${e.x})`);
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.target === e);
approx(hits[0].amount, u.s.atk * bb['attack@atk_scale']);
h.runUntil(() => !u.skill.active, 30);
const burst = tagged(h, 'burst', u);
assert.equal(burst.length, 1);
approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6, 'end burst');
assert.equal(u.s.taunt, 0);
done(h);
}
});
test('3_09 海霓: 测绘器材 fragile on non-elite enemies; 阻滞性显色剂 ATK, 2 targets, slow, kills raise the talent', () => {
const id = 'chess_char_3_09_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_n: dummy('enemy_n'), enemy_e: dummy('enemy_e', { rank: 'ELITE' }), enemy_f: dummy('enemy_f', { hp: 1 }) } },
timeLimit: 60, hooks: ['attack'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_n', pos: [10, 4] }, { key: 'enemy_e', pos: [10, 5] }],
});
const u = h.unit(id);
h.run(0.5);
const n = h.enemy('enemy_n'), el = h.enemy('enemy_e');
approx(n.s.dmgTakenMul, t0.damage_scale);
approx(el.s.dmgTakenMul, 1, 1e-9, 'elites are not marked');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.step();
approx(u.s.atk, u.base.atk * (1 + bb.atk));
assert.equal(effectiveProfile(u).maxTargets, bb['attack@max_target']);
approx(n.findBuff('skill:haini_slow').mods.moveMul, 1 + bb['attack@move_speed']);
assert.equal(el.findBuff('skill:haini_slow'), null);
h.run(2);
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u && c.targets.length === 2), 'attacks two targets');
h.spawn('enemy_f', { pos: [9, 4] });
assert.ok(h.runUntil(() => !h.enemy('enemy_f').alive, 5), 'fodder killed in range');
h.run(0.3);
approx(u.mem.hainiMul, 1 + bb['attack@talent_up']);
approx(n.s.dmgTakenMul, 1 + (t0.damage_scale - 1) * (1 + bb['attack@talent_up']));
h.runUntil(() => !u.skill.active, 40);
h.run(0.5);
approx(n.s.dmgTakenMul, t0.damage_scale, 1e-6, 'back to 1× after the skill');
assert.equal(n.findBuff('skill:haini_slow'), null);
done(h);
});
test('3_10 松果: 电能过载 ATK grows with each use + shorter range; 便携电源 SP regen for 60 s', () => {
const id = 'chess_char_3_10_a', bb = BB(id), t0 = TB(id, 0);
const steps = Object.keys(bb).filter((k) => /\[[a-z]\]\.atk$/.test(k)).sort().map((k) => bb[k]);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 200,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 4] }],
});
const u = h.unit(id);
h.step();
approx(u.s.spRecovery, 1 + t0.sp_recovery_per_sec);
const wide = u.rangeKeys.length;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.atk, u.base.atk * (1 + steps[0]));
assert.equal(u.rangeKeys.length, D(id).skill.rangeGrid.length, 'range shortened');
assert.ok(u.rangeKeys.length < wide);
h.runUntil(() => !u.skill.active, 30);
approx(u.s.atk, u.base.atk);
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.atk, u.base.atk * (1 + steps[1]), 1e-6, 'second use');
h.runUntil(() => h.b.time > t0.duration + 0.5 && !u.skill.active, 60);
approx(u.s.spRecovery, 1, 1e-9, 'talent expired');
done(h);
});
test('3_11 雪猎: 风雪连弩 instant double shot (non-moving ×), 裂云一击 ATK% + cold; empty-mag cast; 精锐 extra reload', () => {
const id = 'chess_char_3_11_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged', 'statusApplied'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 6] }],
});
const u = h.unit(id);
h.step();
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
h.run(1);
const shots = tagged(h, 'snhunt', u);
assert.equal(shots.length, 2);
for (const s of shots) approx(s.amount, u.s.atk * bb.atk_scale_2, 1e-6, 'vs a non-moving target');
const beast = tagged(h, 'cloudbeast', u);
assert.equal(beast.length, 1);
approx(beast[0].amount, u.s.atk * t0.atk_scale);
assert.ok(h.hooksOf('statusApplied').some((s) => s.status === 'cold' && s.source === u && Math.abs(s.duration - t0.cold) < 1e-9));
// magazine empty: the special bullets still fire
u.trait.ammo = 0;
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 2, 2), 'cast with an empty magazine');
done(h);
const gid = 'chess_char_3_11_b', tb = TR(gid);
const g = makeBattle({ units: [{ chessId: gid, row: 10, col: 3 }], timeLimit: 30 });
const s = g.unit(gid);
g.step();
s.trait.ammo = 0;
assert.ok(g.runUntil(() => s.trait.ammo > 0, 5));
assert.equal(s.trait.ammo, 1 + tb.extra_add, 'reload from empty adds the extra round');
done(g);
});
test('3_12 瑕光: 先贤化身 bonus arts + heals another ally; 剑盾骑士 SP on attack; 仁慈 sleeping targets ×; 精锐 guard', () => {
const id = 'chess_char_3_12_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 50, bat: 2 }) } }, timeLimit: 60, hooks: ['damaged', 'heal', 'spGain'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }], enemies: [{ key: 'enemy_h', pos: [10, 4] }],
});
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
h.run(3);
assert.ok(h.hooksOf('spGain').some((c) => c.unit === u && c.reason === 'talent' && c.amount === t0.sp), '剑盾骑士: hurt-SP skill gains SP on attack');
ally.hp = ally.s.maxHp * 0.3;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.def, u.base.def * (1 + bb.def));
h.run(3);
const bonus = tagged(h, 'blemshBonus', u);
assert.ok(bonus.length >= 2);
approx(bonus[0].amount, u.s.atk * bb['attack@blemsh_s_3_extra_dmg[magic].atk_scale']);
const heals = h.hooksOf('heal').filter((c) => c.source === u && c.target === ally);
assert.ok(heals.length >= 2, 'heals the other ally');
approx(heals[0].amount, u.s.atk * bb.heal_scale);
done(h);
const s = makeBattle({
defs: { enemies: { enemy_z: dummy('enemy_z') } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_z', pos: [10, 5] }],
});
const b = s.unit(id);
s.step();
s.b.applyStatus(s.enemy('enemy_z'), 'sleep', { duration: 20 });
const tSleep = s.b.time;
s.run(3);
const hits = s.hooksOf('damaged').filter((c) => c.source === b && c.dmg?.isAttack && c.t >= tSleep);
assert.ok(hits.length >= 1, 'attacks the sleeping enemy');
approx(hits[0].amount, b.s.atk * t1.atk_scale);
done(s);
const gid = 'chess_char_3_12_b';
const g = makeBattle({ units: [{ chessId: gid, row: 10, col: 4 }], timeLimit: 10 });
g.step();
approx(g.unit(gid).s.dmgTakenMul, 1 - TR(gid).damage_resistance);
done(g);
});
test('3_12 瑕光 仁慈 (hitSleep): she picks the sleeper over an awake blocked enemy; 沉睡 stays 无敌 for everyone else', () => {
const id = 'chess_char_3_12_a', t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a'), enemy_z: dummy('enemy_z') } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_1_01_a', row: 10, col: 2 }],
enemies: [{ key: 'enemy_a', pos: [10, 5] }, { key: 'enemy_z', pos: [10, 5] }],
});
const u = h.unit(id), sniper = h.unit('chess_char_1_01_a');
h.step();
const z = h.enemy('enemy_z');
assert.ok(h.b.applyStatus(z, 'sleep', { duration: 20 }));
const t0 = h.b.time;
h.run(4);
const mine = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.t >= t0);
assert.ok(mine.length >= 2 && mine.every((c) => c.target === z), '优先攻击沉睡的目标');
approx(mine[0].amount, u.s.atk * t1.atk_scale, 1e-6, '×atk_scale vs the sleeper');
assert.ok(!h.hooksOf('damaged').some((c) => c.source === sniper && c.target === z && c.t >= t0), 'others never damage a sleeper');
done(h);
});
test('3_13 至简: 神工意匠 charges: ATK% arts ×2 per cast; 忽有所悟 crits ×atk_scale; funnel ramp kept', () => {
const id = 'chess_char_3_13_a', bb = BB(id), t0 = TB(id, 0), tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 80, hooks: ['damaged', 'attack'], captureNoisy: true, seed: 3,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 5] }],
});
const u = h.unit(id);
assert.equal(u.skill.maxCharges, bb.ct);
h.run(60);
const normal = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill).map((c) => c.amount / u.s.atk);
const ramp = (v) => [tb.max_atk_scale, tb.max_atk_scale * t0.atk_scale].some((x) => Math.abs(v - x) < 1e-6);
assert.ok(normal.slice(10).every(ramp), 'ramped drone hits: max or max × crit');
assert.ok(normal.some((v) => Math.abs(v - tb.max_atk_scale * t0.atk_scale) < 1e-6), 'some crits');
assert.ok(normal.some((v) => Math.abs(v - tb.max_atk_scale) < 1e-6), 'some plain hits');
const skillAtks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length;
const sk = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isSkill).map((c) => c.amount / u.s.atk);
assert.ok(skillAtks >= 2);
assert.equal(sk.length, skillAtks * 2, 'two hits per cast');
for (const v of sk) assert.ok([bb.atk_scale, bb.atk_scale * t0.atk_scale].some((x) => Math.abs(v - x) < 1e-6), `skill hit ${v}`);
done(h);
});
test('3_14 初雪: 自然震慑 DEF/RES shred aura; 虚弱化 fragile under 40 % HP; 双响 two targets', () => {
const id = 'chess_char_3_14_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 500, res: 50 }), enemy_b: dummy('enemy_b', { def: 500 }) } }, timeLimit: 80, hooks: ['attack'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_a', pos: [10, 4] }, { key: 'enemy_b', pos: [10, 5] }],
});
const u = h.unit(id);
h.run(2);
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u && c.targets.length === t1['attack@max_target']));
const a = h.enemy('enemy_a'), b = h.enemy('enemy_b');
b.hp = b.s.maxHp * (t0.hp_ratio - 0.05);
h.run(0.3);
approx(b.s.dmgTakenMul, t0.damage_scale);
approx(a.s.dmgTakenMul, 1, 1e-9);
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.step();
approx(a.s.def, 500 * (1 + bb.def));
approx(a.s.res, 50 * (1 + bb.magic_resistance));
h.runUntil(() => !u.skill.active, 30);
h.step();
approx(a.s.def, 500);
done(h);
});
test('3_15 巫恋: 诅咒娃娃 placed where enemies are, −ATK/DEF around it, gone after its lifetime; 溃败暗示 fragile', () => {
const id = 'chess_char_3_15_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 80,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_a', pos: [11, 5] }, { key: 'enemy_b', pos: [10, 5] }],
});
const u = h.unit(id);
h.step();
const a = h.enemy('enemy_a'), b = h.enemy('enemy_b');
a.hp = a.s.maxHp * (t0.hp_ratio / 2);
h.run(0.3);
approx(a.s.dmgTakenMul, t0.damage_scale);
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const doll = u.mem.doll;
assert.ok(doll && doll.alive && doll.defId === 'token_10006_vodfox_doll');
assert.ok(Math.abs(doll.tileR - 10.5) <= 1 && Math.abs(doll.tileC - 5) <= 1, 'next to both enemies');
h.run(0.3);
approx(b.s.def, 200 * (1 + bb.def));
approx(b.s.atk, 100 * (1 + bb.atk));
const life = ds.getToken('token_10006_vodfox_doll', id).skill.duration;
h.run(life);
assert.equal(doll.alive, false, 'destroyed');
h.run(0.3);
approx(b.s.def, 200);
done(h);
});
test('3_16 蛇屠箱: 壳状防御 stops attacks, block +1, DEF +, regen; 防御专精; 精锐 DEF + while blocking', () => {
for (const id of ['chess_char_3_16_a', 'chess_char_3_16_b']) {
const bb = BB(id), t0 = TB(id, 0), tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 300, bat: 1 }) } }, timeLimit: 60,
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_h', pos: [10, 4] }],
});
const u = h.unit(id);
h.run(0.5);
approx(u.s.def, u.base.def * (1 + t0.def + (tb.def ?? 0)));
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const atks = u.stats.attacks;
approx(u.s.def, u.base.def * (1 + t0.def + (tb.def ?? 0) + bb.def));
assert.equal(u.s.blockCnt, u.base.blockCnt + bb.block_cnt);
approx(u.s.hpRegen, u.s.maxHp * bb.hp_recovery_per_sec_by_max_hp_ratio);
h.run(5);
assert.equal(u.stats.attacks, atks, '停止攻击');
done(h);
}
});
test('3_17 流星: 碎甲击·扩散 hits ≤5 enemies for ATK% + DEF shred; 空射专精 × vs flying (× module fly bonus on 精锐)', () => {
for (const id of ['chess_char_3_17_a', 'chess_char_3_17_b']) {
const bb = BB(id), t0 = TB(id, 0), tb = TR(id);
const tiles = [[10, 4], [10, 5], [11, 4], [11, 5], [9, 4], [9, 5]];
const h = makeBattle({
// kit numbers in isolation: 流星's 特质 (garrison_137 / garrison_01 弱点伤害) would re-type the hits by DEF/RES
defs: { enemies: { enemy_g: dummy('enemy_g', { def: 100 }), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) }, chess: noGarrison(id) }, timeLimit: 60,
hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_fly', pos: [10, 6], route: 2 }, ...tiles.map((p) => ({ key: 'enemy_g', pos: p }))],
});
const u = h.unit(id);
h.run(1.5);
const fly = h.hooksOf('damaged').find((c) => c.source === u && c.target.defId === 'enemy_fly' && c.dmg?.isAttack);
approx(fly.amount, u.s.atk * t0.atk_scale * (tb.atk_scale ?? 1), 1e-6, 'flying target');
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const burst = tagged(h, 'burst', u);
assert.equal(burst.length, 5, 'at most 5 enemies');
const g = burst.find((c) => c.target.defId === 'enemy_g');
approx(g.amount, u.s.atk * bb.atk_scale - 100, 1e-6, 'ATK% phys');
approx(g.target.findBuff('skill:shotst_shred').mods.defPct, bb.def);
approx(g.target.s.def, 100 * (1 + bb.def));
h.run(bb.duration + 0.2);
assert.ok(h.b.enemies.every((e) => !e.findBuff('skill:shotst_shred')), `shred lasts ${bb.duration} s`);
done(h);
}
});
test('3_18 忍冬: 隐狐之艺 DP, decaying ASPD, all blocked + stun, camouflage after a kill; 追凶 bonus arts; 蓄势 DP/regen', () => {
const id = 'chess_char_3_18_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h') } }, timeLimit: 80, hooks: ['damaged', 'attack', 'statusApplied'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_h', pos: [10, 4], count: 2 }], flags: { dpPerSec: 0 },
});
const u = h.unit(id);
h.run(2);
const hunt = tagged(h, 'vulpisHunt', u);
assert.ok(hunt.length >= 1, '追凶 on repeated hits');
approx(hunt[0].amount, u.s.atk * t0.atk_scale);
const p = h.b.getPlayer('p1');
const dp0 = p.dp;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(p.dp, dp0 + bb.cost);
approx(u.s.aspd, u.base.aspd + bb.attack_speed, 0.02);
h.run(D(id).skill.duration / 2);
approx(u.s.aspd, u.base.aspd + bb.attack_speed / 2, 0.03, 'decays linearly');
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill && c.targets.length === 2), 'hits every blocked enemy');
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && Math.abs(c.duration - bb['attack@stun']) < 1e-9));
h.runUntil(() => !u.skill.active, 20);
assert.ok(!u.s.flags.stealth, 'no kill ⇒ no camouflage');
done(h);
const c = makeBattle({
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }) } }, timeLimit: 60, autoFinish: false,
units: [{ chessId: id, row: 10, col: 4 }],
});
const z = c.unit(id);
c.step();
fill(z);
c.spawn('enemy_f', { pos: [10, 5] });
assert.ok(c.runUntil(() => z.skill.active, 5));
c.runUntil(() => !z.skill.active, 20);
assert.ok(z.s.flags.stealth, 'camouflage after a kill during the skill');
fill(z);
c.spawn('enemy_f', { pos: [10, 5] });
assert.ok(c.runUntil(() => z.skill.activations === 2, 5));
assert.ok(!z.s.flags.stealth, 'until the next cast');
done(c);
const q = makeBattle({ units: [{ chessId: id, row: 10, col: 4 }], timeLimit: 30 });
const v = q.unit(id);
q.run(5);
approx(q.b.getPlayer('p1').dp, 10 + 5 * t1.delta_cost_increase_time, 0.01, '蓄势 DP regen');
assert.ok(v.findBuff('talent:vulpis_regen'), 'regen after 4 s without damage');
approx(v.s.hpRegen, v.s.maxHp * t1['vulpis_t_2[heal][interval].hp_recovery_per_sec_by_max_hp_ratio']);
q.b.dealDamage(null, v, { type: 'true', amount: 10 });
q.step();
assert.equal(v.findBuff('talent:vulpis_regen'), null);
done(q);
});
test('3_19 伺夜: wolf pack (2 → 3 wolves, block/bites, lose a wolf instead of dying), DEF ignore, ×trait; 领袖的尊严 DP/3 hits/bonus', () => {
const id = 'chess_char_3_19_a', bb = BB(id), t1 = TB(id, 1), tb = TR(id);
const h = makeBattle({
// kit numbers in isolation: 伺夜's 特质 (garrison_152/153/01 弱点伤害) would turn these phys hits into arts (RES 0)
// the pack takes the tactical point (Battle.findTacticalPoint): the flat stage’s enemy path tile (9,3) in range
defs: { enemies: { enemy_d: dummy('enemy_d', { def: 300 }) }, chess: noGarrison(id) }, timeLimit: 120, hooks: ['damaged', 'attack'], captureNoisy: true, flags: { dpPerSec: 0 },
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [9, 3], time: 0.5 }],
});
const u = h.unit(id);
h.step();
const w = u.trait.reinforcement;
assert.ok(w && w.alive && w.defId === 'token_10028_vigil_wolf', 'pack summoned on the tactical point');
assert.equal(w.mem.wolves, 2);
assert.equal(w.s.blockCnt, 2);
h.run(3);
const e = h.enemy('enemy_d');
assert.equal(e.blockedBy, w);
const mine = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg?.isAttack);
approx(mine[0].amount, u.s.atk * tb.atk_scale - (300 - t1.def_penetrate_fixed), 1e-6, 'trait × and 狼群天性');
const bites = h.hooksOf('attack').filter((c) => c.attacker === w).length;
const wd = h.hooksOf('damaged').filter((c) => c.source === w && c.dmg?.isAttack);
assert.equal(wd.length, bites * 2, 'one bite per wolf');
approx(wd[0].amount, w.s.atk - (300 - t1.def_penetrate_fixed));
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'phys' }), 1000 - 300, 1e-6, '狼群天性: attacks only (a non-attack hit keeps the full DEF)');
const p = h.b.getPlayer('p1');
const dp0 = p.dp;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(2);
const sAtk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length;
const sHit = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.dmg.isSkill).length;
assert.equal(sHit, sAtk * 3, '三连击');
const bonus = tagged(h, 'vigilBonus', u);
assert.ok(bonus.length >= sHit);
approx(bonus[0].amount, u.s.atk * bb['attack@vigil_s_3.atk_scale']);
const packBonus = tagged(h, 'vigilBonus', w);
assert.ok(packBonus.length >= 2, 'each bite on a pack-blocked enemy adds the bonus, dealt by the pack');
approx(packBonus[0].amount, u.s.atk * bb['attack@vigil_s_3.atk_scale'], 1e-6, 'sized on 伺夜 ATK');
h.runUntil(() => !u.skill.active, 20);
approx(p.dp, dp0 + bb.value, 1e-6, 'DP over the skill');
h.runUntil(() => w.mem.wolves === 3, 30);
assert.equal(w.s.blockCnt, 3);
h.b.dealDamage(null, w, { type: 'true', amount: 1e7 });
assert.equal(w.alive, true);
assert.equal(w.mem.wolves, 2);
approx(w.hp, w.s.maxHp);
done(h);
const gid = 'chess_char_3_19_b';
const g = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 400, bat: 1 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true, flags: { dpPerSec: 0 },
units: [{ chessId: gid, row: 10, col: 3 }], enemies: [{ key: 'enemy_h', pos: [9, 3], time: 0.5 }],
});
const v = g.unit(gid);
g.run(3);
const gw = v.trait.reinforcement;
const taken = g.hooksOf('damaged').filter((c) => c.target === gw && c.source?.defId === 'enemy_h');
const scale = ds.getToken('token_10028_vigil_wolf', gid).talents.find((t) => t.bb.damage_scale != null).bb.damage_scale;
approx(taken[0].amount, (400 - gw.s.def) * scale, 1e-6, 'module: less damage from blocked enemies');
const gp = g.b.getPlayer('p1');
const gdp = gp.dp;
fill(v);
assert.ok(g.runUntil(() => v.skill.active, 5));
g.runUntil(() => !v.skill.active, 20);
approx(gp.dp, gdp + BB(gid).value, 1e-6, '精锐 DP over the full duration');
g.b.dealDamage(null, gw, { type: 'true', amount: 1e7 });
g.b.dealDamage(null, gw, { type: 'true', amount: 1e7 });
assert.equal(gw.alive, false);
g.run(gw.base.respawnTime + 0.5);
assert.ok(v.trait.reinforcement !== gw && v.trait.reinforcement?.alive, 'pack re-summoned after its respawn time');
done(g);
});
test('3_17 流星 / 3_19 伺夜 with their 特质 弱点伤害: kit damage is re-typed by the garrison, which sees the kit’s DEF ignore', () => {
// 流星: the 碎甲击 burst (phys) vs DEF 100 / RES 0 becomes arts at full ATK% (the DEF shred still lands)
const sid = 'chess_char_3_17_a', sbb = BB(sid);
assert.ok(ds.rawChess(sid).garrisonIds.includes('garrison_01_a'), 'real data: 流星 carries 弱点伤害');
const h = makeBattle({
defs: { enemies: { enemy_g: dummy('enemy_g', { def: 100 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: sid, row: 10, col: 3 }], enemies: [{ key: 'enemy_g', pos: [10, 5] }],
});
const u = h.unit(sid);
h.step();
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const b0 = tagged(h, 'burst', u)[0];
assert.equal(b0.type, 'arts');
approx(b0.amount, u.s.atk * sbb.atk_scale, 1e-6, 'weakness burst');
assert.ok(b0.target.findBuff('skill:shotst_shred'));
done(h);
// 伺夜: vs DEF 300 / RES 40, phys after 狼群天性 (A − 125) beats arts (0.6 A) — only if the garrison sees the ignore
const vid = 'chess_char_3_19_a', t1 = TB(vid, 1), tb = TR(vid);
assert.ok(ds.rawChess(vid).garrisonIds.includes('garrison_01_a'), 'real data: 伺夜 carries 弱点伤害');
const g = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d', { def: 300, res: 40 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true, flags: { dpPerSec: 0 },
units: [{ chessId: vid, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [9, 3], time: 0.5 }],
});
const v = g.unit(vid);
g.run(3);
const e = g.enemy('enemy_d');
assert.equal(e.blockedBy, v.trait.reinforcement);
const mine = g.hooksOf('damaged').filter((c) => c.source === v && c.target === e && c.dmg?.isAttack);
assert.ok(mine.length > 0);
const A = v.s.atk * tb.atk_scale;
assert.ok(A - (300 - t1.def_penetrate_fixed) > A * 0.6 && A - 300 < A * 0.6, 'the scenario discriminates');
assert.equal(mine[0].type, 'phys');
approx(mine[0].amount, A - (300 - t1.def_penetrate_fixed), 1e-6, 'phys with the pack DEF ignore');
done(g);
});
test('3_20 耶拉: 低眉 ATK (ground tiles in range); 心随意动 +1 drone, ATK +, cold procs; per-target drone ramp', () => {
const id = 'chess_char_3_20_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a'), enemy_b: dummy('enemy_b') } }, timeLimit: 120, hooks: ['damaged', 'attack', 'statusApplied'], captureNoisy: true, seed: 2,
units: [{ chessId: id, row: 10, col: 2 }], enemies: [{ key: 'enemy_a', pos: [10, 4] }, { key: 'enemy_b', pos: [11, 4] }],
});
const u = h.unit(id);
h.step();
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk']));
h.run(4);
const first = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.amount / u.s.atk);
assert.ok(first[1] > first[0], 'drone ramps on the same target');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk'] + bb.atk));
h.runUntil(() => !u.skill.active, 30);
const sk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.ok(sk.length > 5 && sk.every((c) => c.targets.length === 1 + bb['attack@cnt']), 'two drone locks');
const colds = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'cold');
assert.ok(colds.length >= 1 && colds.length < sk.length * 2, `cold procs (${colds.length}/${sk.length * 2})`);
approx(colds[0].duration, bb['attack@cold']);
done(h);
});
test('3_21 空弦: 箭矢·暴风 3 hits × 2 targets, range +1; 铁弦 shield → +SP; 兰登战术 sniper SP; 精锐 ASPD vs ground', () => {
const id = 'chess_char_3_21_a', bb = BB(id), t0 = TB(id, 0), t1 = TB(id, 1);
const h = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 100, bat: 1 }), enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['spGain', 'attack', 'damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_3_17_a', row: 12, col: 3 }],
enemies: [{ key: 'enemy_h', pos: [10, 3], time: 1 }, { key: 'enemy_d', pos: [10, 5] }],
});
const u = h.unit(id), met = h.unit('chess_char_3_17_a');
h.step();
assert.ok(u.findBuff('talent:archet_shield'));
h.run(3);
assert.equal(u.findBuff('talent:archet_shield'), null, 'shield broken');
assert.ok(h.hooksOf('spGain').some((c) => c.unit === u && c.reason === 'talent' && c.amount === t1.sp), '+SP when it breaks');
const tact = h.hooksOf('spGain').filter((c) => c.unit === met && c.reason === 'talent');
assert.ok(tact.length >= 1 && tact[0].amount === t0.sp, '兰登战术 on an attack-SP sniper');
approx(tact[0].t % t0.interval, 0, 0.05);
const wide = u.rangeKeys.length;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const p = effectiveProfile(u);
assert.equal(p.hits, bb['attack@times']);
assert.equal(p.maxTargets, bb['attack@max_target']);
assert.ok(u.rangeKeys.length > wide, 'range +1');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
h.run(2);
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill && c.targets.length === 2));
done(h);
const gid = 'chess_char_3_21_b';
const mod = D(gid).raw.talents.find((t) => t.hidden && t.fromModule).bb;
const g = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 30,
units: [{ chessId: gid, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 5], time: 2 }],
});
const v = g.unit(gid);
g.run(1);
approx(v.s.aspd, v.base.aspd);
g.run(1.5);
approx(v.s.aspd, v.base.aspd + mod.attack_speed, 1e-6, 'ground enemy in range');
done(g);
});
// ---------------------------------------------------------------------------------------------------------------
// verification pass: text fidelity fixes, board-piece summons, tile hygiene, 精锐 numbers, every variant casts
test('3_02 断崖: 浮游刃 blades hit enemies blocked by the allies around her, never the ones she blocks herself', () => {
const id = 'chess_char_3_02_b', bb = BB(id);
const h = makeBattle({
defs: { enemies: { enemy_own: dummy('enemy_own'), enemy_ally: dummy('enemy_ally') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }],
enemies: [{ key: 'enemy_own', pos: [10, 4] }, { key: 'enemy_ally', pos: [11, 4] }],
});
const u = h.unit(id);
h.run(0.5);
assert.equal(h.enemy('enemy_own').blockedBy, u);
assert.equal(h.enemy('enemy_ally').blockedBy, h.unit('chess_char_3_16_a'));
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
h.run(4);
const blades = tagged(h, 'ayerBlade', u);
assert.ok(blades.length >= 2);
assert.ok(blades.every((c) => c.target.defId === 'enemy_ally'), 'only the adjacent ally’s blocked enemy');
approx(blades[0].amount, u.s.atk * bb.atk_scale);
done(h);
});
test('3_04 琳琅诗怀雅 精锐: MER-X drain, coin cap, armed bomb hits twice; bombs never land on a dead operator’s tile', () => {
const gid = 'chess_char_3_04_b', bb = BB(gid), t0 = TB(gid, 0), tb = TR(gid);
// drain + coin cap (no enemy: no attack spends a coin)
const q = makeBattle({ units: [{ chessId: gid, row: 9, col: 5 }], timeLimit: 60, flags: { dpPerSec: 0 } });
const s = q.unit(gid);
q.step();
q.b.getPlayer('p1').dp = 50;
q.run(3.1);
approx(q.b.getPlayer('p1').dp, 50 - Math.abs(tb.cost), 1e-6, 'module: 2 DP per payment');
q.run(tb.interval * (bb.sp + 1));
const paid = Math.floor((q.b.time + 1e-9) / tb.interval);
assert.equal(s.mem.coins, bb.sp, 'coin cap = bb.sp');
assert.equal(s.findBuff('talent:swire2_buyer').stacks, Math.min(t0.max_stack_cnt, paid), 'ATK stack per payment (coins capped, stacks not)');
done(q);
// a bomb that stayed duration_switch s on the field hits twice
const booms = [];
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_w: enemyRec({ key: 'enemy_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } }, timeLimit: 60,
units: [{ chessId: gid, row: 9, col: 5 }], enemies: [{ key: 'enemy_d', pos: [9, 5] }, { key: 'enemy_w', route: 0, time: 1 }],
setup: (b) => b.on('damaged', (c) => { if (c.dmg?.tags?.includes('trap')) booms.push({ target: c.target.defId, amount: c.amount, atk: c.source.s.atk, t: b.time }); }),
});
const u = h.unit(gid);
const switchT = ds.getToken('token_10031_swire2_gdtrap', gid).skill.bb.duration_switch;
assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.alive && t.defId === 'token_10031_swire2_gdtrap'), 5), 'bomb placed in front of her');
const bomb = h.b.allyUnits.find((t) => t.alive && t.defId === 'token_10031_swire2_gdtrap');
assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]);
assert.ok(h.runUntil(() => booms.length > 0, 30), 'the walker triggered it');
assert.ok(booms[0].t - bomb.deployedAt >= switchT);
assert.equal(booms.length, 2, 'armed bomb: one extra hit');
for (const b of booms) { assert.equal(b.target, 'enemy_w'); approx(b.amount, b.atk * bb.atk_scale); }
assert.ok(h.enemy('enemy_w').findBuff('sluggish'));
done(h);
// the tile in front belongs to a dead operator waiting to redeploy: no bomb there (no coin spent)
const k = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 30,
units: [{ chessId: gid, row: 9, col: 5 }, { chessId: 'chess_char_3_16_a', row: 9, col: 6 }], enemies: [{ key: 'enemy_d', pos: [9, 5], time: 0.5 }],
});
const v = k.unit(gid), cu = k.unit('chess_char_3_16_a');
k.step();
k.b.dealDamage(null, cu, { type: 'true', amount: 1e7 });
assert.equal(cu.alive, false);
const coins = v.mem.coins;
k.run(5);
assert.ok(v.stats.attacks >= 2, 'she kept attacking');
assert.equal(k.b.allyUnits.filter((t) => t.defId === 'token_10031_swire2_gdtrap').length, 0, 'no bomb on the dead operator’s tile');
assert.ok(v.mem.coins >= coins, 'no coin spent');
done(k);
});
test('3_07 见行者: 惊爆射击 fires on an enemy inside the skill range only; 精锐 refund only on a ranged tile', () => {
const id = 'chess_char_3_07_a', bb = BB(id);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { mass: 1 }) } }, timeLimit: 30, hooks: ['statusApplied'],
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_a', pos: [10, 6] }],
});
const u = h.unit(id);
h.step();
const e = h.enemy('enemy_a');
assert.equal(h.b.enemiesInKeys(u.baseRangeKeys, u, u.profile).length, 0, 'outside her attack range');
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), 'skill range (3 tiles ahead) triggers it');
assert.ok(e.x > 6.9, `pushed (${e.x})`);
approx(h.hooksOf('statusApplied').find((s) => s.target === e && s.status === 'stun').duration, bb['forcer_s_2[hit_directly].stun']);
done(h);
const gid = 'chess_char_3_07_b';
const r = makeBattle({ units: [{ chessId: gid, row: 10, col: 4 }], timeLimit: 30, flags: { dpPerSec: 0 } });
const g = r.unit(gid);
r.step();
assert.equal(g.ground, true);
r.b.dealDamage(null, g, { type: 'true', amount: 1e7 });
const p = r.b.getPlayer('p1');
p.dp = 50;
assert.ok(r.b.redeploy(g, { free: false }));
approx(p.dp, 50 - g.base.cost, 1e-6, 'ground tile: no refund');
done(r);
});
test('3_10 松果: 电能过载 opens only once an enemy stands inside the shortened skill range', () => {
const id = 'chess_char_3_10_a';
const h = makeBattle({
defs: { enemies: { enemy_far: dummy('enemy_far'), enemy_near: dummy('enemy_near') } }, timeLimit: 60, autoFinish: false,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_far', pos: [10, 5] }],
});
const u = h.unit(id);
h.step();
fill(u);
h.run(3);
assert.ok(u.stats.attacks > 0, 'attacks the enemy two tiles ahead');
assert.equal(u.skill.activations, 0, 'not inside the 1-3 skill range');
h.spawn('enemy_near', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.active, 1));
done(h);
});
test('3_15 巫恋: a cast replaces the doll placed in the prep phase (one doll), never on a dead operator’s tile; 精锐 −30 %', () => {
const gid = 'chess_char_3_15_b', bb = BB(gid);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 60,
units: [{ chessId: gid, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10006_vodfox_doll', ownerUid: 1, row: 10, col: 5, uid: 2 },
{ chessId: 'chess_char_3_16_a', row: 11, col: 4, uid: 3 }],
enemies: [{ key: 'enemy_a', pos: [12, 4] }, { key: 'enemy_b', pos: [11, 5] }],
});
const u = h.unit(1), piece = h.unit(2), cu = h.unit(3);
h.step();
assert.ok(piece.alive && piece.ownerUnit === u, 'board doll deployed');
h.b.dealDamage(null, cu, { type: 'true', amount: 1e7 });
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const dolls = h.b.allyUnits.filter((t) => t.alive && t.defId === 'token_10006_vodfox_doll');
assert.equal(dolls.length, 1, 'deploy limit 1');
assert.equal(piece.alive, false, 'the prep-phase doll was replaced');
assert.notDeepEqual([dolls[0].tileR, dolls[0].tileC], [11, 4], 'not on the dead operator’s home tile');
h.run(0.3);
const b = h.enemy('enemy_b');
approx(b.s.def, 200 * (1 + bb.def));
assert.equal(b.findBuff('skill:vodfox_doll').mods.atkPct, bb.atk, 'ATK −30 % (her module weaken stacks on top)');
done(h);
});
test('3_19 伺夜: the pack never takes a later board unit’s tile; a 狼群 piece placed in prep IS the pack (single effects)', () => {
const id = 'chess_char_3_19_a', bb = BB(id), t1 = TB(id, 1);
const a = makeBattle({ units: [{ chessId: id, row: 12, col: 3 }, { chessId: 'chess_char_3_16_a', row: 12, col: 4 }], timeLimit: 20 });
a.step();
const cu = a.unit('chess_char_3_16_a'), w0 = a.unit(id).trait.reinforcement;
assert.ok(cu.deployed && cu.tileR === 12 && cu.tileC === 4, '蛇屠箱 deployed on its own tile');
assert.ok(w0 && w0.alive && !(w0.tileR === 12 && w0.tileC === 4));
done(a);
// the piece sits lower on the board than 伺夜 (deploys after him): it is deployed early, no second pack
const h = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d', { def: 300 }) } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true, flags: { dpPerSec: 0 },
units: [{ chessId: id, row: 12, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }],
enemies: [{ key: 'enemy_d', pos: [11, 5], time: 0.5 }],
});
const u = h.unit(1), piece = h.unit(2);
h.step();
const packs = h.b.allyUnits.filter((t) => t.alive && t.defId === 'token_10028_vigil_wolf');
assert.equal(packs.length, 1, 'one pack');
assert.equal(packs[0], piece);
assert.equal(u.trait.reinforcement, piece);
assert.deepEqual([piece.tileR, piece.tileC], [11, 5], 'on the tactical point the player chose');
h.run(3);
const e = h.enemy('enemy_d');
assert.equal(e.blockedBy, piece);
const bite = h.hooksOf('damaged').find((c) => c.source === piece && c.dmg?.isAttack);
approx(bite.amount, piece.s.atk - (300 - t1.def_penetrate_fixed), 1e-6, '狼群天性 applied once');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const n0 = h.hooksOf('damaged').length;
h.run(3);
const later = h.hooksOf('damaged').slice(n0);
const hits = later.filter((c) => (c.source === piece || c.source === u) && c.dmg?.isAttack && c.target === e).length;
const bonus = later.filter((c) => c.target === e && c.type === 'arts' && (c.dmg?.tags?.includes('vigilBonus') || c.dmg?.tags?.includes('vigil')));
assert.ok(hits > 0);
assert.equal(bonus.length, hits, 'one S3 bonus per damage instance');
approx(bonus[0].amount, u.s.atk * bb['attack@vigil_s_3.atk_scale']);
done(h);
});
test('3_20 耶拉: 心随意动 with a single enemy — both drones lock it and ramp in parallel', () => {
const id = 'chess_char_3_20_b', bb = BB(id), tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a') } }, timeLimit: 80, hooks: ['damaged', 'attack'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 2 }], enemies: [{ key: 'enemy_a', pos: [10, 4] }],
});
const u = h.unit(id);
h.run(12);
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.amount / u.s.atk);
approx(plain[plain.length - 1], tb.max_atk_scale, 1e-6, 'module ramp cap');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const n0 = h.hooksOf('damaged').length;
h.run(8);
const sk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.ok(sk.length >= 3 && sk.every((c) => c.targets.length === 1 + bb['attack@cnt'] && c.targets.every((t) => t.defId === 'enemy_a')));
const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack);
assert.ok(hits.length >= 2 * (sk.length - 1));
for (let i = 0; i + 1 < hits.length; i += 2) approx(hits[i].amount, hits[i + 1].amount, 1e-9, 'same volley, same scale');
done(h);
});
test('精锐 numbers from the golden blackboards (module traits handled by the profession layer)', () => {
// 能天使 MAR-X: ×1.1 vs flying (no skill); 诗怀雅 INS-X ×1.3 vs unblocked + 协同作战 ×2.7 aura
const e1 = 'chess_char_3_01_b';
const f = makeBattle({
defs: { enemies: { enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) } }, timeLimit: 20, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: e1, row: 10, col: 3 }], enemies: [{ key: 'enemy_fly', pos: [10, 5], route: 2 }],
});
const a = f.unit(e1);
f.run(1.5);
const fly = f.hooksOf('damaged').find((c) => c.source === a && c.dmg?.isAttack && !c.dmg.isSkill);
approx(fly.amount, a.s.atk * TR(e1).atk_scale);
done(f);
const e3 = 'chess_char_3_03_b';
const s = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: e3, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }, { chessId: 'chess_char_3_16_a', row: 10, col: 5 }],
enemies: [{ key: 'enemy_d', pos: [10, 5] }],
});
const sw = s.unit(e3), mate = s.unit(2);
s.run(1.5);
const hit = s.hooksOf('damaged').filter((c) => c.source === sw && c.dmg?.isAttack).pop();
approx(hit.amount, sw.s.atk * TR(e3).atk_scale, 1e-6, 'enemy blocked by the ally in front, not by her');
fill(sw);
assert.ok(s.runUntil(() => sw.skill.active, 10));
s.step();
approx(mate.s.atk, mate.base.atk * (1 + TB(e3, 0).atk * BB(e3).talent_scale));
done(s);
// 海霓 / 初雪 UMD-X: 10 % weaken for 2 s on hit
for (const gid of ['chess_char_3_09_b', 'chess_char_3_14_b']) {
const w = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d', { atk: 200 }) } }, timeLimit: 20, hooks: ['statusApplied'],
units: [{ chessId: gid, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 4] }],
});
const x = w.unit(gid);
w.step();
assert.ok(w.runUntil(() => w.enemy('enemy_d').findBuff('weaken'), 5), `${gid} weakens`);
approx(w.enemy('enemy_d').s.atk, 200 * (1 + TR(gid).atk));
approx(w.hooksOf('statusApplied').find((c) => c.status === 'weaken' && c.source === x).duration, TR(gid).duration);
done(w);
}
// 松果 RPR-X ×1.6 on the front row; 至简 FUN-Y ramp cap 1.2 and 3 charges
const e10 = 'chess_char_3_10_b';
const p = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 20, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: e10, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 4] }],
});
const pc = p.unit(e10);
p.run(3);
const ph = p.hooksOf('damaged').find((c) => c.source === pc && c.dmg?.isAttack && !c.dmg.isSkill);
approx(ph.amount, pc.s.atk * TR(e10).atk_scale);
done(p);
const e13 = 'chess_char_3_13_b';
const m = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true, seed: 5,
units: [{ chessId: e13, row: 10, col: 3 }], enemies: [{ key: 'enemy_d', pos: [10, 5] }],
});
const ml = m.unit(e13);
assert.equal(ml.skill.maxCharges, BB(e13).ct);
m.run(25);
const ramps = m.hooksOf('damaged').filter((c) => c.source === ml && c.dmg?.isAttack && !c.dmg.isSkill).map((c) => c.amount / ml.s.atk);
assert.ok(ramps.some((v) => Math.abs(v - TR(e13).max_atk_scale) < 1e-6), 'module cap 120 %');
assert.ok(ramps.every((v) => v <= TR(e13).max_atk_scale * TB(e13, 0).atk_scale + 1e-6));
done(m);
// 瑕光 精锐 heal; 忍冬 SOL-X ATK/DEF while blocking
const e12 = 'chess_char_3_12_b';
const g = makeBattle({
defs: { enemies: { enemy_h: dummy('enemy_h', { atk: 50, bat: 2 }) } }, timeLimit: 30, hooks: ['heal'], captureNoisy: true,
units: [{ chessId: e12, row: 10, col: 4 }, { chessId: 'chess_char_3_16_a', row: 11, col: 4 }, { chessId: 'chess_char_3_16_b', row: 12, col: 7 }],
enemies: [{ key: 'enemy_h', pos: [10, 4] }],
});
const bl = g.unit(e12), near = g.unit('chess_char_3_16_a'), far = g.unit('chess_char_3_16_b');
g.step();
near.hp = near.s.maxHp * 0.3;
far.hp = far.s.maxHp * 0.1;
fill(bl);
assert.ok(g.runUntil(() => bl.skill.active, 5));
g.run(3);
const heals = g.hooksOf('heal').filter((c) => c.source === bl);
assert.ok(heals.length >= 1 && heals.every((c) => c.target === near), 'the heal stays inside the skill range');
approx(heals[0].amount, bl.s.atk * BB(e12).heal_scale);
done(g);
const e18 = 'chess_char_3_18_b';
const v = makeBattle({
defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 20, units: [{ chessId: e18, row: 10, col: 4 }], enemies: [{ key: 'enemy_d', pos: [10, 4], time: 1 }],
});
const vu = v.unit(e18);
v.run(0.5);
approx(vu.s.atk, vu.base.atk);
v.run(1);
approx(vu.s.atk, vu.base.atk * (1 + TR(e18).atk));
approx(vu.s.def, vu.base.def * (1 + TR(e18).def));
done(v);
});
test('忍冬 迷彩: after a kill during 隐狐之艺 ranged enemies stop targeting her until the next cast', () => {
const id = 'chess_char_3_18_a';
const h = makeBattle({
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }), enemy_r: dummy('enemy_r', { atk: 100, range: 3, applyWay: 'RANGED', bat: 1 }) } }, timeLimit: 60, autoFinish: false,
hooks: ['damaged'], captureNoisy: true, units: [{ chessId: id, row: 10, col: 4 }],
});
const u = h.unit(id);
h.step();
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.stealth);
h.spawn('enemy_r', { pos: [11, 6] });
h.run(4);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source?.defId === 'enemy_r').length, 0, 'not targeted while camouflaged');
done(h);
});
test('every tier-3 variant (normal + 精锐) fights and casts in a real battle without content errors', () => {
const ids = ds.chessIds().filter((id) => /^chess_char_3_\d+_[ab]$/.test(id)).sort();
assert.equal(ids.length, 42);
for (const id of ids) {
const melee = D(id).position === 'MELEE';
const h = makeBattle({
defs: { enemies: { enemy_w: enemyRec({ key: 'enemy_w', hp: 2e4, speed: 1, def: 100, res: 10, atk: 200, bat: 1.5 }), enemy_fl: enemyRec({ key: 'enemy_fl', hp: 5000, speed: 1, motion: 'FLY', atk: 50 }) } },
timeLimit: 25, seed: 11, autoFinish: false,
units: [{ chessId: id, row: melee ? 9 : 10, col: 6 }],
enemies: [{ key: 'enemy_w', route: 0, count: 4, interval: 3 }, { key: 'enemy_fl', route: 2, time: 2 }],
});
const u = h.unit(id);
for (let t = 0; t < 20; t++) { h.run(1); if (t % 5 === 1 && u.alive && u.skill.kind !== 'passive') u.skill.gainSp(u.skill.spCost, 'test'); }
if (u.skill.kind === 'passive') assert.ok(u.skill.active, `${id} passive on`);
else assert.ok(u.skill.activations >= 1, `${id} cast (${u.skill.activations})`);
assert.ok(u.stats.dmg > 0 || u.skill.spec.attack?.noAttack, `${id} dealt damage`);
done(h);
}
});
test('忍冬 追凶 never feeds on itself: a hit turned into HP loss credited to her (频次-style enemy) adds one bonus, no recursion', () => {
const id = 'chess_char_3_18_a';
const h = makeBattle({
defs: { enemies: { enemy_times: dummy('enemy_times', { hp: 1000 }) } }, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'enemy_times', pos: [10, 5] }],
// every phys/arts instance on this enemy is cancelled and replaced by 1 HP of HP loss credited to the attacker
setup: (b) => b.on('hit', (c) => {
if (c.target.defId === 'enemy_times' && (c.dmg.type === 'phys' || c.dmg.type === 'arts')) { c.dmg.cancel = true; b.loseHp(c.target, 1, { source: c.source }); }
}, { priority: 100 }),
});
const u = h.unit(id);
h.run(5);
const e = h.enemy('enemy_times');
const atks = u.stats.attacks;
assert.ok(atks >= 3);
// first attack marks the enemy (1 HP), every later attack: 1 HP + one 追凶 bonus instance (1 HP)
assert.equal(e.s.maxHp - e.hp, 1 + 2 * (atks - 1));
done(h);
});