Files
ark-stronghold-protocol/test/content/kits_t1t2.test.js
T
sganggs a0a7660287 feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout
Ported by hand:
- #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃
  chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝
  chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines
  are identical to #109's tier1/2/3/4/6 diffs.
- #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes
  it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the
  skill's own mods), and W5D's 冲锋号令 DP start effect is kept.
- #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty
  name (the Chinese text is the PR's), plus its English line in public/i18n/en.json.
- data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and
  bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/
  file changed.
- docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are
  always on"), SIM §7.4 (both generic passive cases).
- Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js /
  snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js /
  plan.mjs, #74 plan.mjs / docs.

Where the PRs met feedback5 work:
- 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a
  #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this
  change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so.
- Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test
  (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the
  generic:passive buff).

Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and
all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed
and fields the unit that explains it.
- #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low,
  bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low,
  bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1,
  琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype).
- #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退):
  roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high,
  bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun.
- #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3,
  coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted.
- #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low,
  bond-visiShip-high.
- #66 + #160: roster-008, roster-027.
- #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06,
  coop2-HARD-4, coop2-NORMAL-14-standins.
- all three: roster-010, roster-012, roster-031.
69 scenarios. Every other digest is unchanged.
2026-10-05 23:59:05 +08:00

1400 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.
// Content tests for the tier 1 / tier 2 operator kits (server/sim/content/kits/ops/chess_char_1_*.js, chess_char_2_*.js).
// Every chess (normal + elite where the elite adds something) runs a real battle through the harness and the test
// asserts its signature effect with numbers taken from the chess blackboards (data/chess.json).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { ELEMENT_GAUGE_MAX, PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
import { sortEnemyTargets } from '../../server/sim/targeting.js';
const ds = getDefaultSource();
const raw = (id) => ds.rawChess(id);
const bbOf = (id) => raw(id).skill.bb;
const tal = (id, i = 0) => raw(id).talents.filter((t) => t.index !== -1)[i].bb;
const hid = (id) => Object.assign({}, ...raw(id).talents.filter((t) => t.index === -1).map((t) => t.bb));
const tbOf = (id) => raw(id).trait.bb;
const approx = (a, b, msg = '', rel = 1e-6) => assert.ok(Math.abs(a - b) <= rel * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const READY = { sp: 999 }; // carryState: skill fully charged at deployment
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'ammoUsed', 'attack', 'death', 'deploy'];
/** Battle with captured noisy hooks, no auto-finish, long time limit. */
function run(o) {
return makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o });
}
const dealt = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && f(c));
const statuses = (h, key, f = () => true) => h.hooksOf('statusApplied').filter((c) => c.status === key && f(c));
const heals = (h, u, f = () => true) => h.hooksOf('heal').filter((c) => c.source === u && f(c));
const tagged = (tag) => (c) => (c.dmg?.tags || []).includes(tag);
/** defs.chess override: the chess without its 特质 (garrisons have their own tests) — isolates kit damage numbers/types. */
const noGarrison = (id) => ({ [id]: { ...raw(id), garrisonIds: [] } });
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
// =================================================================================================================
// Tier 1
test('1_01 隐现: ammo skill (trigger_time rounds), ATK +atk, interval −30 %, taunt −1, prefers ranged enemies', () => {
const id = 'chess_char_1_01_a', bb = bbOf(id);
const h = run({
defs: { enemies: { e_m: dummy('e_m'), e_r: dummy('e_r', { range: 3 }) } },
units: [{ chessId: id, row: 10, col: 4 }],
enemies: [{ key: 'e_m', pos: [10, 5] }, { key: 'e_r', pos: [10, 7] }],
});
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 40));
approx(u.s.atk, u.base.atk * (1 + bb.atk), 'ATK');
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'interval');
assert.equal(u.s.taunt, -1);
const t0 = h.b.time;
h.runUntil(() => !u.skill.active, 40);
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@trigger_time']);
const skillHits = dealt(h, u, (c) => c.t >= t0 && c.dmg.isAttack);
assert.ok(skillHits.length > 0 && skillHits.every((c) => c.target.defId.endsWith('e_r')), 'the ranged enemy is shot first');
done(h);
});
test('1_01 隐现 elite: +self_ammo after `duration` s on field; a random other 【拉特兰】 ammo operator +ally_ammo', () => {
const id = 'chess_char_1_01_b', t = tal(id);
const lat = chessRec({
id: 't_lat', profession: 'SNIPER', subProfessionId: 'fastshot', bonds: ['lateranoShip'], rangeGrid: raw(id).rangeGrid,
skill: { durationType: 'AMMO', duration: 0, spCost: 10, initSp: 0, bb: { 'attack@trigger_time': 5 } },
});
const starts = [];
const h = run({
defs: { enemies: { e: dummy('e') }, chess: { t_lat: lat } },
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_lat', row: 11, col: 4 }],
enemies: [{ key: 'e', pos: [10, 6] }],
setup: (b) => b.on('skillStart', (c) => starts.push({ id: c.unit.defId, ammo: c.skill.ammoLeft, t: b.time }), { priority: -500 }),
});
const u = h.unit(id);
h.runUntil(() => u.skill.activations >= 2, 120);
const mine = starts.filter((s) => s.id === id);
assert.ok(mine[0].t < t.duration && mine[0].ammo === bbOf(id)['attack@trigger_time'], 'before 20 s: base magazine');
assert.ok(mine[1].t >= t.duration);
assert.equal(mine[1].ammo, bbOf(id)['attack@trigger_time'] + t.self_ammo);
const ally = starts.filter((s) => s.id === 't_lat');
assert.equal(ally[0].ammo, 5);
const later = ally.find((s) => s.t > t.duration);
assert.ok(later, 'ally cast again after 20 s');
assert.equal(later.ammo, 5 + t.ally_ammo);
done(h);
});
test('1_02 角峰: 抗寒体质 (重装 TAKE_DAMAGE) HP/DEF/RES ×(1+x); 雪原卫士 RES +flat (normal and elite)', () => {
for (const id of ['chess_char_1_02_a', 'chess_char_1_02_b']) {
const bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
approx(u.s.res, u.base.res + t.magic_resistance, `${id} talent RES`);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast by the first hit');
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp), 'HP');
approx(u.s.def, u.base.def * (1 + bb.def), 'DEF');
approx(u.s.res, (u.base.res + t.magic_resistance) * (1 + bb.magic_resistance), 'RES');
done(h);
}
});
test('1_03 惊蛰: chain jumps lose no damage during 初雷 (ATK +atk); 通流无阻 ×atk_scale vs unblocked enemies', () => {
const id = 'chess_char_1_03_a', bb = bbOf(id), t = tal(id);
const mk = (carry) => run({
defs: { enemies: { e: dummy('e') } },
units: [{ chessId: id, row: 10, col: 4, carryState: carry }],
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 6] }],
});
// before the skill: 100 % → 85 % → 72.25 %, all ×1.1 (unblocked)
const h1 = mk(null);
const u1 = h1.unit(id);
h1.runUntil(() => dealt(h1, u1).length >= 3, 10);
const a1 = dealt(h1, u1).slice(0, 3).map((c) => c.amount);
approx(a1[0], u1.s.atk * t.atk_scale, 'primary');
approx(a1[1], u1.s.atk * t.atk_scale * 0.85, 'jump 1');
approx(a1[2], u1.s.atk * t.atk_scale * 0.85 * 0.85, 'jump 2');
// skill: every jump at full damage, ATK +80 %
const h2 = mk(READY);
const u2 = h2.unit(id);
h2.runUntil(() => dealt(h2, u2).length >= 3, 10);
approx(u2.s.atk, u2.base.atk * (1 + bb.atk));
for (const c of dealt(h2, u2).slice(0, 3)) approx(c.amount, u2.s.atk * t.atk_scale, 'no falloff');
done(h1); done(h2);
});
test('1_04 深巡: fin darts pierce attack@max_target enemies on the line + 1 s 停顿; talent arts DoT (×2 vs 海怪)', () => {
const id = 'chess_char_1_04_a', bb = bbOf(id), t = tal(id);
const sea = dummy('e_sea');
sea.tags = ['seamonster'];
const h = run({
defs: { enemies: { e: dummy('e'), e_sea: sea } },
units: [{ chessId: id, row: 9, col: 4, carryState: READY }],
enemies: [{ key: 'e_sea', pos: [9, 5] }, { key: 'e', pos: [9, 6] }, { key: 'e', pos: [9, 7] }],
});
const u = h.unit(id);
h.step();
// 技能策略 (issue #4; the deliberate deviation of DESIGN §21.29, and since 0.2.0 the owner's ACTIVE_RANGE on top): an
// enemy inside the 3-2 she attacks with is enough — no hit required
assert.equal(u.skill.rule, 'ACTIVE_RANGE', '行动能力剥夺 is an offensive ranged skill, not a 重装 TAKE_DAMAGE one');
h.run(3.5);
assert.ok(u.skill.active);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'ACTIVE_RANGE', 'cast on its running range');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const firstAtk = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(firstAtk.targets.length, bb['attack@max_target'], 'hits every enemy of the line');
const sl = statuses(h, 'sluggish', (c) => c.source === u);
assert.ok(sl.length >= 3 && sl.every((c) => c.duration === bb['attack@sluggish']));
const dots = dealt(h, u, tagged('dot'));
assert.ok(dots.some((c) => c.target.defId.endsWith('e_sea') && Math.abs(c.amount - t.damage_seamonster) < 1e-6), '海怪 DoT');
assert.ok(dots.some((c) => !c.target.defId.endsWith('e_sea') && Math.abs(c.amount - t.damage) < 1e-6), 'normal DoT');
assert.ok(dots.every((c) => Math.abs(c.amount - (c.target.defId.endsWith('e_sea') ? t.damage_seamonster : t.damage)) < 1e-6));
done(h);
});
test('1_04 深巡 / 1_20 雷蛇 S2 技能策略: both 哨戒铁卫 S2s cast with an enemy in range, no hit needed (issue #4); 深巡 on her 3-2', () => {
// both S2s are offensive (深巡: range up + ATK/ASPD + piercing darts; 雷蛇: ATK +125 % + arts on 3 + stun); the official
// 下半 重装 row (TAKE_DAMAGE for every MANUAL 重装 skill) made both wait for a hit, and the owner's deliberate deviation
// from it (DESIGN §21.29, after community feedback) gives them the basic strategy — 雷蛇's data and kit say DEFAULT;
// 深巡's 3-2 strictly contains her 2-2, so since 0.2.0 the owner's ACTIVE_RANGE rule casts it on the 3-2 (2026-10-05).
for (const id of ['chess_char_1_04_a', 'chess_char_1_04_b', 'chess_char_1_20_a', 'chess_char_1_20_b']) {
const udflow = id.startsWith('chess_char_1_04');
// (9,7) = [0,3]: on 深巡's 3-2, not on her 2-2
const h = run({ units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, udflow ? 7 : 6] }] });
const u = h.unit(id);
const rule = udflow ? 'ACTIVE_RANGE' : 'DEFAULT';
assert.equal(u.skill.rule, rule, `${id}: an offensive skill, not the 重装 TAKE_DAMAGE row`);
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5), `${id}: casts while an enemy is in range and untouched`);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, rule, `${id}: cast by ${rule}`);
done(h);
}
});
test('1_04 深巡 / 1_20 雷蛇 elite module: stealthed enemies inside the range are revealed', () => {
for (const id of ['chess_char_1_04_b', 'chess_char_1_20_b']) {
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 4 }], enemies: [{ key: 'e', pos: [9, 6] }] });
h.step();
const e = h.enemies()[0];
h.b.applyStatus(e, 'stealth', { duration: 100 });
h.run(0.5);
assert.ok(e.s.flags.reveal, `${id} reveals`);
done(h);
}
});
test('1_05 红豆 (hidden): 槌音 ATK +atk; 蛮力穿刺 procs ATK +atk on single attacks; elite ×atk_scale vs low-HP enemies', () => {
const id = 'chess_char_1_05_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 4 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
h.runUntil(() => u.skill.activations >= 1 && !u.skill.active, 120);
const ratios = new Set(dealt(h, u).map((c) => Math.round((c.amount / u.base.atk) * 1000) / 1000));
const allowed = [1, 1 + t.atk, 1 + bb.atk, 1 + bb.atk + t.atk].map((x) => Math.round(x * 1000) / 1000);
for (const r of ratios) assert.ok(allowed.includes(r), `ratio ${r}`);
assert.ok(ratios.has(allowed[1]) || ratios.has(allowed[3]), 'a proc happened');
assert.ok(ratios.has(allowed[2]) || ratios.has(allowed[3]), 'skill hits');
done(h);
const idb = 'chess_char_1_05_b', tb = tbOf(idb);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 9, col: 4 }], enemies: [{ key: 'e', pos: [9, 5] }] });
h2.step();
const e = h2.enemies()[0];
e.hp = e.s.maxHp * (tb.hp_ratio - 0.1);
const u2 = h2.unit(idb);
h2.run(5);
const low = dealt(h2, u2, (c) => c.t > 0);
assert.ok(low.length > 0 && low.every((c) => [1, 1 + tal(idb).atk].some((k) => Math.abs(c.amount - u2.base.atk * k * tb.atk_scale) < 1e-6)), 'elite ×1.15');
done(h2);
});
test('1_06 刺玫: 荆藤庇荫 taunts the highest-HP ally in range and counters its attackers; 土壤基肥改良 heal bonus', () => {
const id = 'chess_char_1_06_a', bb = bbOf(id), t = tal(id);
const h = run({
defs: { enemies: { e: dummy('e', { atk: 400, bat: 1 }) } },
units: [{ chessId: id, row: 10, col: 3, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 5 }],
enemies: [{ key: 'e', pos: [9, 5] }],
});
const u = h.unit(id), yak = h.unit('chess_char_1_02_a');
h.run(4);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
assert.equal(yak.s.taunt, bb.taunt_level, 'protected ally taunts');
const counters = dealt(h, u, tagged('counter'));
assert.ok(counters.length >= 2);
for (const c of counters) approx(c.amount, u.s.atk * bb.atk_scale, 'counter = 20 % ATK arts');
assert.ok(heals(h, u).every((c) => c.target === yak), 'trait heal only on the protected ally during the skill');
// talent: the highest max-HP ally in range receives more healing
yak.hp = yak.s.maxHp * 0.3;
approx(h.b.heal(null, yak, 100), 100 * t.heal_scale, 'heal ×1.08');
h.runUntil(() => !u.skill.active, 20);
assert.equal(yak.s.taunt, 0, 'taunt removed at the end');
done(h);
});
test('1_07 普罗旺斯: 狼眼 +12 % ATK per 20 % HP lost (continuous, PRTS); 狩猎箭头 20 % procs to 140 %', () => {
const id = 'chess_char_1_07_a', bb = bbOf(id), t = tal(id);
const ratio = new Map(); // DamageInfo → target HP ratio when the hit landed (狼眼 reads it continuously)
const h = run({
defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 6] }],
setup: (b) => b.on('hit', (c) => ratio.set(c.dmg, c.target.hpRatio), { priority: 1000 }),
});
h.step();
const e = h.enemies()[0];
e.hp = e.s.maxHp * 0.5;
const u = h.unit(id);
h.run(40);
const kOf = (c) => 1 + ((1 - ratio.get(c.dmg)) / bb.hp_ratio_drop) * bb.atk_scale_up;
const hits = dealt(h, u, (c) => c.t > 0);
approx(kOf(hits[0]), 1 + 2.5 * bb.atk_scale_up, 'at 50 % HP: ×(1 + 2.5 × atk_scale_up), not 2 steps', 1e-3);
let procs = 0;
for (const c of hits) {
const k = kOf(c);
if (Math.abs(c.amount - u.s.atk * k * t.atk_scale) < 1e-6 * c.amount) procs++;
else approx(c.amount, u.s.atk * k, 'passive');
}
assert.ok(procs > 0 && procs < hits.length, `procs ${procs}/${hits.length}`);
done(h);
});
test('1_07 普罗旺斯: 狼眼 (an ATK increase) scales her attacks only, not fixed damage she sources (叙拉古 proc)', () => {
for (const id of ['chess_char_1_07_a', 'chess_char_1_07_b']) {
const bb = bbOf(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 6] }] });
h.step();
const u = h.unit(id), e = h.enemies()[0];
e.hp = e.s.maxHp * 0.3;
const k = 1 + (0.7 / bb.hp_ratio_drop) * bb.atk_scale_up; // continuous: 3.5 × atk_scale_up at 30 % HP
assert.ok(k > 1, `${id} 狼眼 active`);
const fixed = h.b.dealDamage(u, e, { amount: 5000, type: 'true', canDodge: false, tags: ['bond:siracusa'] });
approx(fixed, 5000, `${id} fixed true proc`);
e.hp = e.s.maxHp * 0.3; // (狼眼 is continuous: back to exactly 30 % HP)
// an attack instance (splash ⇒ no 狩猎箭头 roll) still gets the 狼眼 multiplier
const atk = h.b.dealDamage(u, e, { amount: 1000, type: 'true', canDodge: false, isAttack: true, isSplash: true });
approx(atk, 1000 * k, `${id} attack`);
done(h);
}
});
test('1_08 德克萨斯: 战术快递 +DP at start; 剑雨 +cost DP, 2×atk_scale arts on every enemy in the skill grid, stun', () => {
const id = 'chess_char_1_08_a', bb = bbOf(id), t = tal(id);
const h0 = run({ units: [{ chessId: id, row: 10, col: 5 }] });
h0.step();
approx(h0.b.players[0].dp, 10 + t.cost + h0.b.dt, 'initial DP');
const h = run({
defs: { enemies: { e: dummy('e') } },
units: [{ chessId: id, row: 10, col: 5, carryState: READY }],
enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }, { key: 'e', pos: [10, 9] }],
});
const u = h.unit(id);
h.step();
approx(h.b.players[0].dp, 10 + t.cost + bb.cost + h.b.dt, 'DP after 剑雨');
const [near1, near2, far] = h.enemies();
for (const e of [near1, near2]) {
const d = dealt(h, u, (c) => c.target === e && c.dmg.isSkill);
assert.equal(d.length, 2);
for (const c of d) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * bb.atk_scale); }
assert.ok(e.s.flags.stun);
}
assert.equal(dealt(h, u, (c) => c.target === far).length, 0);
assert.ok(statuses(h, 'stun').every((c) => c.duration === bb.stun));
done(h);
});
test('1_09 跃跃: 乐趣加倍 throws cnt boomerangs per attack, ATK +atk; 戏耍随心 procs; elite ×1.1 on adjacent enemies', () => {
const id = 'chess_char_1_09_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
h.run(6);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t < 5).length;
const hits = dealt(h, u, (c) => c.t < 5);
assert.equal(hits.length, atks * bb.cnt, 'cnt hits per attack');
h.runUntil(() => !u.skill.active, 30);
const all = dealt(h, u, (c) => c.dmg.isSkill);
assert.ok(all.every((c) => Math.abs(c.amount - u.base.atk * (1 + bb.atk)) < 1e-6 || Math.abs(c.amount - u.base.atk * (1 + bb.atk) * t.atk_scale) < 1e-6));
const procs = all.filter((c) => Math.abs(c.amount - u.base.atk * (1 + bb.atk) * t.atk_scale) < 1e-6).length;
assert.ok(procs > 0 && procs < all.length, `procs ${procs}/${all.length}`);
done(h);
const idb = 'chess_char_1_09_b', tb = tbOf(idb), tb2 = tal(idb);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u2 = h2.unit(idb);
h2.run(5);
const d2 = dealt(h2, u2);
assert.ok(d2.length > 0 && d2.every((c) => [1, tb2.atk_scale].some((k) => Math.abs(c.amount - u2.s.atk * k * tb.atk_scale) < 1e-6)), 'adjacent ×1.1');
done(h2);
});
test('1_09 跃跃 回环射手: each attack is a boomerang (out 15, back 3.75 tiles/s); faster than its flight she waits for the catch; 乐趣加倍 hits land together', () => {
const id = 'chess_char_1_09_a', bb = bbOf(id);
const flight = (d) => d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
// ASPD +200 (interval 0.333 s) at 3 tiles: the out-and-back flight (1.0 s) sets the pace
const h = run({
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 7] }],
setup(b) { b.on('deploy', ({ unit }) => { if (unit.kind === 'op') b.addBuff(unit, { key: 'test:aspd', mods: { aspd: 200 } }); }); },
});
const u = h.unit(id);
h.run(6);
assert.ok(u.s.interval < flight(3) / 2);
const at = h.hooksOf('attack').filter((c) => c.attacker === u).map((c) => c.t);
assert.ok(at.length >= 4 && at.length <= 7, `${at.length} attacks in 6 s`);
for (let i = 1; i < at.length; i++) assert.ok(Math.abs(at[i] - at[i - 1] - flight(3)) <= 2 * h.TICK, `gap ${(at[i] - at[i - 1]).toFixed(3)} ≈ ${flight(3)}`);
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
done(h);
// 乐趣加倍: cnt boomerangs on the same path — cnt hits at the same moment, then one catch
const h2 = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 7] }] });
const u2 = h2.unit(id);
h2.run(5);
assert.ok(u2.skill.active);
const byT = new Map();
for (const c of dealt(h2, u2, (x) => x.dmg.isSkill)) byT.set(c.t, (byT.get(c.t) ?? 0) + 1);
assert.ok(byT.size >= 3 && [...byT.values()].every((n) => n === bb.cnt), `hits per landing ${[...byT.values()]}`);
done(h2);
});
test('1_10 古米: 备用军粮 (自动触发: an injured ally of the skill range) heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
const bb = bbOf(id), tb = tbOf(id), t = tal(id);
const h = run({
defs: { enemies: { e: dummy('e', { atk: 300 }) } },
units: [{ chessId: id, row: 9, col: 5, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }],
enemies: [{ key: 'e', pos: [9, 5] }],
});
const u = h.unit(id), yak = h.unit('chess_char_1_02_a');
h.step();
yak.hp = yak.s.maxHp * 0.3;
h.runUntil(() => heals(h, u, (c) => c.target === yak).length > 0, 10);
const hh = heals(h, u, (c) => c.target === yak)[0];
const k = tb.hp_ratio != null ? tb.heal_scale : 1;
approx(hh.amount, u.s.atk * bb.heal_scale * k, `${id} heal`);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'SKILL_RANGE');
// talent: pan procs stun the enemy t.stun s
h.runUntil(() => statuses(h, 'stun', (c) => c.source === u).length > 0, 200);
const st = statuses(h, 'stun', (c) => c.source === u)[0];
assert.equal(st.duration, t.stun);
done(h);
}
});
test('1_11 地灵 (hidden): ATK +atk; elite 地质勘探 lengthens the trait 停顿 by +sluggish', () => {
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: 'chess_char_1_11_a', row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u = h.unit('chess_char_1_11_a');
h.step();
approx(u.s.atk, u.base.atk * (1 + bbOf('chess_char_1_11_a').atk));
done(h);
const idb = 'chess_char_1_11_b';
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 5] }] });
h2.run(2);
const s = statuses(h2, 'sluggish', (c) => c.source === h2.unit(idb));
assert.ok(s.length > 0);
approx(s[0].duration, tbOf(idb).sluggish + tal(idb).sluggish, 'sluggish 1.3 s');
done(h2);
});
test('1_12 艾丝黛尔: 舍身突击 ATK +atk and no healing from others; 自愈能力 heals on nearby kills; elite phys −20 % above 50 %', () => {
const id = 'chess_char_1_12_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 6] }] });
const u = h.unit(id);
h.step();
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
u.hp = u.s.maxHp * 0.5;
assert.equal(h.b.heal(null, u, 100), 0, 'not a heal target during the skill');
// talent: an enemy falling on a surrounding tile heals hp_ratio × max HP
const weak = h.spawn('e', { pos: [10, 6] });
const before = u.hp;
h.b.kill(weak, null);
approx(u.hp - before, u.s.maxHp * t.hp_ratio, 'self heal');
const far = h.spawn('e', { pos: [9, 9] });
const b2 = u.hp;
h.b.kill(far, null);
assert.equal(u.hp, b2, 'no heal for distant deaths');
done(h);
const idb = 'chess_char_1_12_b', tb = tbOf(idb);
const h2 = run({ defs: { enemies: { e: dummy('e', { atk: 600, bat: 1 }) } }, units: [{ chessId: idb, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u2 = h2.unit(idb);
h2.runUntil(() => h2.hooksOf('damaged').some((c) => c.target === u2), 5);
const hit = h2.hooksOf('damaged').find((c) => c.target === u2);
approx(hit.amount, (600 - u2.s.def) * (1 - tb.damage_resistance), 'elite −20 %');
done(h2);
});
test('1_13 波登可: 孢子扩散 cloud — projectile_delay_time pulses of atk_scale × ATK arts + 停顿 + silence; 园丁 SUPPORT ATK aura', () => {
const id = 'chess_char_1_13_a', bb = bbOf(id), t = tal(id);
const h = run({
defs: { enemies: { e: dummy('e') } },
units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_11_a', row: 12, col: 3 }],
enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 6] }, { key: 'e', pos: [10, 9] }],
});
const u = h.unit(id), goat = h.unit('chess_char_1_11_a');
h.run(8);
approx(u.s.atk, u.base.atk * (1 + t.atk), 'self is SUPPORT');
approx(goat.s.atk, goat.base.atk * (1 + t.atk), 'other SUPPORT');
const [a, b, far] = h.enemies();
// PRTS 备注 "孢子群范围半径为0.9": the vial lands on one enemy; the next tile (centre distance 1) is outside 0.9 and inside the old 1.2
const zone = h.eventsOf('fx').find((e) => e[1] === 'zone' && e[4]?.skill === 'spores');
assert.equal(zone[4].radius, 0.9);
let inside = 0;
for (const e of [a, b, far]) {
const d = Math.hypot(e.x - zone[2], e.y - zone[3]);
const z = dealt(h, u, (c) => c.target === e && (c.dmg.tags || []).includes('zone'));
if (d <= 0.9) {
inside++;
assert.equal(z.length, bb.projectile_delay_time, 'one pulse per second');
for (const c of z) approx(c.amount, u.s.atk * bb.atk_scale);
assert.ok(statuses(h, 'silence', (c) => c.target === e).length > 0 && statuses(h, 'sluggish', (c) => c.target === e).length > 0);
} else assert.equal(z.length, 0, `outside 0.9 (d=${d})`);
}
assert.equal(inside, 1, 'only the enemy the vial landed on');
done(h);
// elite module: +sp_recovery_per_sec SP/s while an enemy is in range
const idb = 'chess_char_1_13_b', mb = hid(idb);
const sp = (withEnemy) => {
const hh = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 4 }], enemies: [{ key: 'e', pos: withEnemy ? [10, 5] : [9, 9] }] });
hh.run(5);
return hh.unit(idb).skill.sp;
};
approx(sp(true) - sp(false), 5 * mb.sp_recovery_per_sec, 'module SP', 1e-3);
});
test('1_14 格雷伊: 静电场 停顿 on the attacked target, ×talent_scale during 静电释放 (ASPD +attack_speed)', () => {
const id = 'chess_char_1_14_a', bb = bbOf(id), t = tal(id);
const h1 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 6] }] });
h1.run(1);
approx(statuses(h1, 'sluggish', (c) => c.source === h1.unit(id))[0].duration, t.sluggish);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 6] }] });
h2.run(1);
const u = h2.unit(id);
assert.ok(u.skill.active);
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
approx(statuses(h2, 'sluggish', (c) => c.source === u)[0].duration, t.sluggish * bb.talent_scale);
done(h1); done(h2);
});
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经 + 灼燃 + 凋亡 per damage; skill 2 targets + ASPD; elite boost vs ELITE', () => {
const id = 'chess_char_1_15_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
h.run(2);
assert.ok(u.skill.active);
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
assert.equal(h.hooksOf('attack').find((c) => c.attacker === u).targets.length, bb['attack@max_target']);
const el = dealt(h, u, (c) => c.type === 'element');
assert.ok(el.length >= 6);
// every damage she deals attaches all three, in the official order (神经 "(优先)", then 灼燃, then 凋亡)
for (let i = 0; i + 2 < el.length; i += 3) assert.deepEqual(el.slice(i, i + 3).map((c) => c.dmg.element), ['neural', 'burn', 'apoptosis']);
for (const c of el) approx(c.amount, u.s.atk * t.ep_damage_ratio);
done(h);
const idb = 'chess_char_1_15_b', tb = tal(idb);
const h2 = run({ defs: { enemies: { e: dummy('e', { rank: 'ELITE' }) } }, units: [{ chessId: idb, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 5] }] });
h2.run(1);
const u2 = h2.unit(idb);
approx(dealt(h2, u2, (c) => c.type === 'element')[0].amount, u2.s.atk * tb.ep_damage_ratio_boss, 'elite rank');
done(h2);
});
for (const [idA, idB] of [['chess_char_1_16_a', 'chess_char_1_16_b'], ['chess_char_2_19_a', 'chess_char_2_19_b']]) {
test(`${idA.slice(11, 15)} 锡人: “大拉里” zone — ground enemies take atk_scale × ATK arts/s, allies inside get 生命回复速度 (PRTS 备注: no heal); elite 凋敝魂灵 + module SP`, () => {
const bb = bbOf(idA);
const flyer = dummy('e_fly', { motion: 'FLY' });
const h = run({
defs: { enemies: { e: dummy('e'), e_fly: flyer } },
units: [{ chessId: idA, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 6 }],
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e_fly', pos: [10, 5] }],
});
const u = h.unit(idA), yak = h.unit('chess_char_1_02_a');
h.step();
yak.hp = yak.s.maxHp * 0.3;
h.run(bb.projectile_delay_time + 1);
const [g, f] = h.enemies();
const z = dealt(h, u, (c) => c.target === g && (c.dmg.tags || []).includes('zone'));
assert.equal(z.length, bb.projectile_delay_time);
for (const c of z) approx(c.amount, u.s.atk * bb.atk_scale);
assert.equal(dealt(h, u, (c) => c.target === f && (c.dmg.tags || []).includes('zone')).length, 0, 'air units unaffected');
assert.equal(heals(h, u, (c) => c.target === yak).length, 0, 'no heal of hers: an hpRegen buff (PRTS 备注 「增加目标的“生命回复速度”属性」)');
done(h);
// the ally inside: 生命回复速度 + her ATK at the cast × hp_recovery_per_sec_ratio for the unit's life, then gone
const h3 = run({
defs: { enemies: { e: dummy('e') } },
units: [{ chessId: idA, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 6 }],
enemies: [{ key: 'e', pos: [10, 5] }],
});
const u3 = h3.unit(idA), yak3 = h3.unit('chess_char_1_02_a');
const zone = () => yak3.buffs.find((b) => String(b.key).startsWith('tinman:zone:'));
assert.ok(h3.runUntil(() => !!zone(), 2), 'the unit is down');
const v = u3.s.atk * bb.hp_recovery_per_sec_ratio;
approx(zone().mods.hpRegen, v, 'ATK × ratio');
yak3.hp = yak3.s.maxHp * 0.3;
const hp0 = yak3.hp, t1 = h3.b.time;
h3.run(5);
assert.ok(Math.abs(yak3.hp - hp0 - v * (h3.b.time - t1)) <= 1.5, `regenerated ${yak3.hp - hp0} ≈ ${v * 5}`);
assert.ok(h3.runUntil(() => !zone(), bb.projectile_delay_time + 1), 'gone with the unit');
done(h3);
const bbB = bbOf(idB), w = tal(idB, 1)['skill@damage_scale'], mb = hid(idB);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idB, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u2 = h2.unit(idB);
h2.runUntil(() => u2.skill.activations === 1, 30);
const sp0 = u2.skill.sp, t0 = h2.b.time;
h2.run(4);
approx((u2.skill.sp - sp0) / (h2.b.time - t0), u2.s.spRecovery + mb.sp_recovery_per_sec, 'SP rate with a unit on the field', 1e-3);
const z2 = dealt(h2, u2, (c) => (c.dmg.tags || []).includes('zone'));
assert.ok(z2.length >= 4);
for (const c of z2) approx(c.amount, u2.s.atk * bbB.atk_scale * w, '×damage_scale');
done(h2);
});
}
test('1_17 深靛: 光影迷宫 interval ×0.7, bound enemies take 12 % ATK every 0.5 s and are never targeted; 柔光缚目 binds', () => {
const id = 'chess_char_1_17_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }] });
const u = h.unit(id);
h.step();
assert.ok(u.skill.active);
approx(u.s.interval, u.base.bat * bb.base_attack_time, 'interval');
const [a, b] = h.enemies();
h.b.applyStatus(a, 'bind', { duration: 30 });
h.b.applyStatus(b, 'bind', { duration: 0.01 }); // b is free again right away
const t0 = h.b.time;
h.run(6);
const dot = dealt(h, u, (c) => c.t > t0 && (c.dmg.tags || []).includes('dot'));
assert.ok(dot.length >= 10 && dot.every((c) => c.target === a || c.target.s.flags.bind));
for (const c of dot.filter((c) => c.target === a)) approx(c.amount, u.s.atk * bb['indigo_s_2[damage].atk_scale']);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0);
assert.ok(atks.length > 0 && atks.every((c) => !c.targets.includes(a)), 'bound enemy never targeted');
done(h);
// talent binds happen (prob × talent_scale during the skill), duration from the talent
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }, { key: 'e', pos: [11, 6] }] });
h2.runUntil(() => statuses(h2, 'bind').length > 0, 200);
assert.equal(statuses(h2, 'bind')[0].duration, t.duration);
done(h2);
});
test('1_18 宴: 落地斩·破门 −50 % HP, ATK +atk and arts damage for `duration` s; 认真模式 ASPD; elite 庇护 below 50 %', () => {
const id = 'chess_char_1_18_a', bb = bbOf(id), t = tal(id);
// kit semantics in isolation: her 特质 garrison_01 (弱点伤害) would re-type every hit by the target's DEF/RES
const h = run({ defs: { enemies: { e: dummy('e', { def: 200 }) }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 6] }] });
const u = h.unit(id);
h.step();
const expAs = t.min_attack_speed * Math.min(1, (1 - u.hpRatio) / (1 - t.min_hp_ratio));
assert.ok(Math.abs(u.s.aspd - u.base.aspd - expAs) <= 0.5, `坚忍 ASPD ${u.s.aspd}`);
h.run(bb.duration + 3);
const hits = dealt(h, u, (c) => c.dmg.isAttack);
assert.ok(hits.filter((c) => c.t < bb.duration - 0.1).every((c) => c.type === 'arts'));
assert.ok(hits.filter((c) => c.t > bb.duration + 0.1).length > 0 && hits.filter((c) => c.t > bb.duration + 0.1).every((c) => c.type === 'phys'));
done(h);
const idb = 'chess_char_1_18_b', tb = tal(idb);
const h2 = run({ defs: { enemies: { e: dummy('e', { atk: 900, bat: 1 }) } }, units: [{ chessId: idb, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u2 = h2.unit(idb);
h2.step();
u2.hp = u2.s.maxHp * 0.2;
const n0 = h2.hooksOf('damaged').length;
h2.runUntil(() => h2.hooksOf('damaged').slice(n0).some((c) => c.target === u2), 3);
const hit = h2.hooksOf('damaged').slice(n0).find((c) => c.target === u2);
approx(hit.amount, (900 - u2.s.def) * (1 - tb.damage_resistance), '庇护');
done(h2);
});
test('1_19 野鬃: 夹枪冲锋 wider range, ATK +atk, pushes targets away; elite 一致向前 cuts dead 【近卫】 redeploy cost', () => {
const id = 'chess_char_1_19_a', bb = bbOf(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
h.run(3);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const e = h.enemies()[0];
assert.ok(e.x >= 6.5, `pushed to x=${e.x}`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace'));
done(h);
const idb = 'chess_char_1_19_b', t = tal(idb);
const h2 = run({ units: [{ chessId: idb, row: 9, col: 4 }, { chessId: 'chess_char_1_12_a', row: 9, col: 6 }], flags: { dpInit: 99 } });
h2.step();
const wm = h2.unit(idb), est = h2.unit('chess_char_1_12_a');
const cost0 = est.base.cost;
h2.b.kill(est, null);
h2.b.kill(wm, null);
h2.b.redeploy(wm);
assert.equal(est.base.cost, cost0 + t.value, 'undeployed 近卫 costs 1 less');
h2.b.redeploy(est);
assert.equal(est.base.cost, cost0, 'the discount is consumed by the deployment');
done(h2);
});
test('1_20 雷蛇: 反击电弧 arts on up to 3 enemies, self-stun `stun` s afterwards; 战术防御 SP on hits; elite 雷抗', () => {
const id = 'chess_char_1_20_a', bb = bbOf(id);
const h = run({
defs: { enemies: { e: dummy('e'), atk: dummy('atk', { atk: 200 }) } },
units: [{ chessId: id, row: 9, col: 4, carryState: READY }],
enemies: [{ key: 'atk', pos: [9, 4] }, { key: 'e', pos: [9, 5] }, { key: 'e', pos: [9, 6] }],
});
const u = h.unit(id);
h.run(3);
assert.ok(u.skill.active);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'DEFAULT', 'an offensive skill: cast with an enemy in range');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'attack interval +70 % (PRTS 增大(+70%): a ratio, not +0.7 s)');
const a0 = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(a0.targets.length, 3);
assert.ok(dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill).every((c) => c.type === 'arts'));
h.runUntil(() => !u.skill.active, 30);
h.step();
const st = statuses(h, 'stun', (c) => c.target === u);
assert.equal(st.length, 1);
assert.equal(st[0].duration, bb.stun);
done(h);
// talent: each hit taken gives +sp to herself and to a random neighbour
const t = tal(id);
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 50 } });
const h2 = run({ defs: { enemies: { atk: dummy('atk', { atk: 200 }) }, chess: { t_mate: mate } }, units: [{ chessId: id, row: 9, col: 4 }, { chessId: 't_mate', row: 10, col: 4 }], enemies: [{ key: 'atk', pos: [9, 4] }] });
h2.run(6.1);
const hitsTaken = h2.hooksOf('damaged').filter((c) => c.target === h2.unit(id) && c.source?.side === 'enemy').length;
assert.ok(hitsTaken >= 2);
approx(h2.unit('t_mate').skill.sp, hitsTaken * t.sp, 'neighbour SP');
done(h2);
const h3 = run({ units: [{ chessId: 'chess_char_1_20_b', row: 9, col: 4 }] });
const lb = h3.unit('chess_char_1_20_b');
approx(lb.s.res, lb.base.res + tal('chess_char_1_20_b', 1).magic_resistance);
});
// =================================================================================================================
// Tier 2
test('2_01 送葬人: 最终旅程 double hits and shorter interval; 终结改装 ignores def_penetrate_fixed DEF', () => {
const id = 'chess_char_2_01_a', bb = bbOf(id), t = tal(id), tb = tbOf(id);
const h = run({ defs: { enemies: { e: dummy('e', { def: 100 }) } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u = h.unit(id);
h.run(3);
assert.ok(u.skill.active);
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time −0.5 s (flat seconds)');
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t < 2.5).length;
const hits = dealt(h, u, (c) => c.t < 2.5);
assert.equal(hits.length, 2 * atks);
for (const c of hits) approx(c.amount, u.s.atk * tb.atk_scale - Math.max(0, 100 - t.def_penetrate_fixed), 'front column ×1.5, DEF ignored');
done(h);
});
test('2_02 赫默: 医疗无人机 lands on the tile its piece was placed on and expires; 强化注射 MEDIC ASPD; elite ×1.15 on ground units', () => {
const id = 'chess_char_2_02_a', t = tal(id);
// the drone is a hand piece the player placed next to the ally (user playtest #6); S2 brings it there
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }, { chessId: 'chess_char_2_14_a', row: 12, col: 4 },
{ kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 9, col: 5 }] });
const u = h.unit(id), yak = h.unit('chess_char_1_02_a'), fl = h.unit('chess_char_2_14_a');
h.step();
yak.hp = yak.s.maxHp * 0.3;
h.run(1);
approx(u.s.aspd - u.base.aspd, t.attack_speed);
approx(fl.s.aspd - fl.base.aspd, t.attack_speed, 'other medic');
approx(yak.s.aspd, yak.base.aspd, 'non-medic unaffected');
const drone = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(drone && drone.alive, 'drone deployed');
assert.deepEqual([drone.tileR, drone.tileC], [9, 5], 'on its placed tile, next to the injured ally');
assert.ok(drone.s.flags.untargetable);
h.run(10);
assert.equal(drone.alive, false, 'self-destructs after its lifetime');
assert.ok(drone.stats.heal > 0);
done(h);
const idb = 'chess_char_2_02_b', tb = tbOf(idb);
const h2 = run({ units: [{ chessId: idb, row: 10, col: 4 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }] });
const s2 = h2.unit(idb), y2 = h2.unit('chess_char_1_02_a');
h2.step();
y2.hp = y2.s.maxHp * 0.3;
h2.runUntil(() => heals(h2, s2, (c) => c.target === y2).length > 0, 10);
approx(heals(h2, s2, (c) => c.target === y2)[0].amount, s2.s.atk * tb.heal_scale, 'ground unit heal');
done(h2);
});
test('2_03 崖心 (hidden): 束缚链 drags max_target enemies to the front tile, true damage, stun; 雪境猎手; elite drag damage', () => {
const id = 'chess_char_2_03_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [11, 7] }, { key: 'e', pos: [9, 7] }] });
const u = h.unit(id);
h.step();
approx(u.s.atk, u.base.atk * (1 + t.atk), 'not blocking: ATK +6 %');
const pulled = h.enemies().filter((e) => e.x < 6);
assert.equal(pulled.length, bb.max_target);
for (const e of pulled) {
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉)
approx(Math.hypot(e.x - 4, e.y - 10), 0.6708, `at her front (${e.x},${e.y})`, 1e-6);
const tr = dealt(h, u, (c) => c.target === e && c.type === 'true');
assert.equal(tr.length, 1);
approx(tr[0].amount, u.s.atk * bb.atk_scale);
assert.ok(statuses(h, 'stun', (c) => c.target === e && c.duration === bb.stun).length === 1);
}
done(h);
// blocking an enemy removes the talent buff
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 4] }] });
h2.run(0.5);
approx(h2.unit(id).s.atk, h2.unit(id).base.atk, 'blocking');
// elite module: arts damage proportional to the dragged distance
const idb = 'chess_char_2_03_b', tb = tbOf(idb);
const h3 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 7] }] });
h3.step();
const drag = dealt(h3, h3.unit(idb), tagged('drag'));
assert.equal(drag.length, 1);
approx(drag[0].amount, tb.value * (3 - 0.6708) / tb.dist, 'value per dist tiles', 0.06);
done(h3);
});
test('2_04 小满: 乡音沉沉 sleeps 3 enemies, holds fire while they sleep, then ASPD +x on 3 targets; 好好听话', () => {
const id = 'chess_char_2_04_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }, { key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
h.run(bb.sleep + 3);
const sl = statuses(h, 'sleep');
assert.equal(sl.length, bb.max_target);
assert.ok(sl.every((c) => c.duration === bb.sleep));
const start = h.hooksOf('skillStart').find((c) => c.unit === u).t;
const atk = h.hooksOf('attack').filter((c) => c.attacker === u);
assert.equal(atk.filter((c) => c.t > start + 1e-9 && c.t < start + bb.sleep - 1e-9).length, 0, 'no attack while they sleep');
const after = atk.filter((c) => c.t >= start + bb.sleep);
assert.ok(after.length > 0 && after.some((c) => c.targets.length === bb.max_target));
approx(u.s.aspd - u.base.aspd, bb.attack_speed + t.attack_speed);
done(h);
// 野生动物 (infection tag): trait 停顿 +sluggish_addition
const beast = dummy('e_b');
beast.tags = ['infection'];
const h2 = run({ defs: { enemies: { e: dummy('e'), e_b: beast } }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e_b', pos: [10, 5] }] });
h2.run(1);
approx(statuses(h2, 'sluggish')[0].duration, tbOf(id).sluggish + t.sluggish_addition);
done(h2);
});
test('2_05 哈洛德: 重症优先 heals the heaviest element load first with ×trait_scale recovery; 我即军营 element resistance', () => {
const id = 'chess_char_2_05_a', bb = bbOf(id), t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 11, col: 5 }] });
const u = h.unit(id), low = h.unit('chess_char_1_02_a'), elem = h.unit('chess_char_1_10_a');
h.step();
low.hp = low.s.maxHp * 0.3;
elem.elem.neural = 900;
h.step();
const first = h.hooksOf('attack').find((c) => c.attacker === u);
assert.equal(first.targets[0], elem, 'element-damaged ally first');
approx(elem.elem.neural, 900 - u.s.atk * tbOf(id).ep_heal_ratio * bb.trait_scale, 'recovery ×1.6');
h.run(0.3);
// 我即军营 cuts the 元素损伤 (gauge fill) of an ally over half, on the hit itself — not elemTakenMul (元素伤害 / 元素脆弱)
assert.ok(elem.elem.neural > 500, 'still over half');
const b0 = elem.elem.burn;
h.b.dealDamage(null, elem, { type: 'element', element: 'burn', amount: 100 });
approx(elem.elem.burn - b0, 100 * (1 - t.ep_damage_resistance), '我即军营');
approx(elem.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
// under half: no cut
const n0 = low.elem.neural;
h.b.dealDamage(null, low, { type: 'element', element: 'neural', amount: 100 });
approx(low.elem.neural - n0, 100, 'gauge under half: full fill');
done(h);
});
test('2_05 哈洛德 (user playtest #5 #3): 侵蚀 counts as element damage for 重症优先 and 我即军营', () => {
const id = 'chess_char_2_05_a', t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 11, col: 5 }] });
const u = h.unit(id), low = h.unit('chess_char_1_02_a'), eroded = h.unit('chess_char_1_10_a');
h.step();
low.hp = low.s.maxHp * 0.3;
eroded.elem.erosion = 900;
h.step();
const first = h.hooksOf('attack').find((c) => c.attacker === u);
assert.equal(first.targets[0], eroded, 'the eroded ally is the heaviest element load');
const e0 = eroded.elem.erosion;
h.b.dealDamage(null, eroded, { type: 'element', element: 'erosion', amount: 100 });
approx(eroded.elem.erosion - e0, 100 * (1 - t.ep_damage_resistance), '我即军营 on an eroded ally over half');
done(h);
});
test('2_06 莎草: 巧思乍现 next heal heal_scale × ATK; 博览古卷 barrier scale × ATK (×shield_scale_skill from the skill)', () => {
const id = 'chess_char_2_06_a', bb = bbOf(id), t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }] });
const u = h.unit(id), yak = h.unit('chess_char_1_02_a');
h.step();
yak.hp = yak.s.maxHp * 0.2;
h.step();
const hh = heals(h, u)[0];
approx(hh.amount, u.s.atk * bb.heal_scale, 'skill heal');
approx(yak.findBuff('papyrs:shield').shield, u.s.atk * t['attack@scale'] * bb.shield_scale_skill, 'skill barrier');
done(h);
const h2 = run({ units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }] });
const u2 = h2.unit(id), y2 = h2.unit('chess_char_1_02_a');
h2.step();
y2.hp = y2.s.maxHp * 0.2;
h2.step();
approx(heals(h2, u2)[0].amount, u2.s.atk);
approx(y2.findBuff('papyrs:shield').shield, u2.s.atk * t['attack@scale'], 'talent barrier');
assert.equal(y2.findBuff('papyrs:shield').timeLeft <= t['attack@shield_duration'], true);
done(h2);
});
test('2_07 幽灵鲨: 肉斩骨断 HP ≥ 1 while active, stun `stun` s afterwards; HP talent; elite regen and ×1.1 vs blocked', () => {
const id = 'chess_char_2_07_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e', { atk: 3000, bat: 0.5 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
approx(u.s.maxHp, u.base.maxHp * (1 + t.max_hp), 'HP +10 %');
h.step();
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
h.runUntil(() => !u.skill.active, 20);
const end = h.hooksOf('skillEnd').find((x) => x.unit === u);
assert.ok(end && end.reason === 'duration', 'the skill ran its full duration');
assert.ok(!h.hooksOf('death').some((c) => c.unit === u && c.t < end.t), 'no death during the skill');
assert.ok(h.hooksOf('damaged').filter((c) => c.target === u).length >= 10, 'kept taking lethal hits');
assert.equal(statuses(h, 'stun', (c) => c.target === u)[0]?.duration, bb.stun);
done(h);
const idb = 'chess_char_2_07_b', tb2 = tal(idb), tb = tbOf(idb);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u2 = h2.unit(idb);
h2.run(2);
approx(u2.s.hpRegen, u2.s.maxHp * tb2.hp_recovery_per_sec_by_max_hp_ratio);
for (const c of dealt(h2, u2)) approx(c.amount, u2.s.atk * tb.atk_scale, 'blocked ×1.1');
done(h2);
});
test('2_08 泡泡: “挨打” no attacks, DEF +def, taunt, counter atk_scale × DEF phys; 尖刺盾 ATK −5 %; elite DEF while blocking', () => {
const id = 'chess_char_2_08_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
h.run(5);
assert.ok(u.skill.active);
approx(u.s.def, u.base.def * (1 + bb.def));
assert.equal(u.s.taunt, bb.taunt_level);
const t0 = h.hooksOf('skillStart').find((c) => c.unit === u).t;
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length, 0, 'stops attacking');
const counters = dealt(h, u, tagged('counter'));
assert.ok(counters.length >= 3);
for (const c of counters) approx(c.amount, u.s.def * bb.atk_scale);
const e = h.enemies()[0];
approx(e.s.atk, e.base.atk * (1 + t.atk), 'spike');
done(h);
const idb = 'chess_char_2_08_b';
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
h2.run(0.2);
const u2 = h2.unit(idb);
approx(u2.s.def, u2.base.def * (1 + tbOf(idb).def), 'blocking');
done(h2);
});
test('2_09 休谟斯: 高效处理 block +1 and 精力充沛 by HP thresholds; 再回收 overflow healing becomes a barrier', () => {
const id = 'chess_char_2_09_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 6] }] });
const u = h.unit(id);
h.step();
assert.ok(u.skill.active);
assert.equal(u.s.blockCnt, u.base.blockCnt + bb.block_cnt);
approx(u.s.atk, u.base.atk * (1 + bb['humus_s_2[peak_2].peak_performance.atk']), '>80 %');
u.hp = u.s.maxHp * 0.65;
h.step();
approx(u.s.atk, u.base.atk * (1 + bb['humus_s_2[peak_1].peak_performance.atk']), '>50 %');
u.hp = u.s.maxHp * 0.3;
h.step();
approx(u.s.atk, u.base.atk, '≤50 %');
// 再回收
u.hp = u.s.maxHp;
const sh0 = u.findBuff('humus:recycle')?.shield ?? 0;
h.b.heal(u, u, 300, { self: true });
approx(u.findBuff('humus:recycle').shield - sh0, 300, 'overflow → barrier');
h.b.heal(u, u, 1e5, { self: true });
approx(u.findBuff('humus:recycle').shield, u.s.maxHp * t.max_hp_ratio, 'capped');
done(h);
});
test('2_10 洛洛: 自负此轭 ASPD; 过载 half-way ATK +atk and funnel cap ×scale; stun as long as the overload; 立于磐石', () => {
const id = 'chess_char_2_10_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
const cap0 = u.profile.funnel.max;
h.step();
assert.ok(u.skill.active);
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
const D = u.skill.duration;
h.run(D / 2 + 0.2);
approx(u.profile.funnel.max, cap0 * bb.scale, 'overload cap');
approx(u.s.atk, u.base.atk * (1 + bb.atk + t.atk * Math.floor(h.b.time / t.interval)), 'overload ATK + rock stacks');
h.runUntil(() => !u.skill.active, D);
h.step();
approx(u.profile.funnel.max, cap0, 'restored');
const st = statuses(h, 'stun', (c) => c.target === u)[0];
approx(st.duration, D / 2, 'stun = overload time', 0.01);
h.run(15);
approx(u.s.atk, u.base.atk * (1 + t.atk * t.max_stack_cnt), 'rock stacks capped');
done(h);
});
test('2_11 风丸: 纸艺·双影 −50 % HP, ATK +atk, summons the <替身> whose entrance deals damage_scale × its ATK; substitution too', () => {
const id = 'chess_char_2_11_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 6] }, { key: 'e', pos: [9, 9] }] });
const u = h.unit(id);
h.step();
assert.ok(u.skill.active);
approx(u.hpRatio, 1 - bb.hp_ratio, 'HP loss');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const doll = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(doll && doll.alive && doll.defId === 'token_10022_kazema_shadow');
const [near, far] = h.enemies();
const burst = h.hooksOf('damaged').filter((c) => c.source === doll && (c.dmg.tags || []).includes('talent'));
assert.equal(burst.length, 1);
assert.equal(burst[0].target, near);
approx(burst[0].amount, doll.s.atk * t.damage_scale);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === far).length, 0);
h.runUntil(() => !u.skill.active, 30);
h.step();
assert.equal(doll.alive, false, 'the <替身> leaves with the skill');
done(h);
// trait substitution also sets off 折纸生花 (token ATK)
const h2 = run({ defs: { enemies: { e: dummy('e', { atk: 5000, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u2 = h2.unit(id);
h2.runUntil(() => u2.findBuff('trait:substitute'), 20);
h2.step();
const tokAtk = ds.getToken('token_10022_kazema_shadow', id).stats.atk;
const b2 = dealt(h2, u2, tagged('talent'));
assert.equal(b2.length, 1);
approx(b2[0].amount, tokAtk * t.damage_scale);
done(h2);
});
test('2_12 砾: 鼠群 barrier hp_ratio × max HP decaying over `duration` s; 快速部署 cost −1; elite 小个子支援 DEF aura', () => {
const id = 'chess_char_2_12_a', bb = bbOf(id), t = tal(id);
const h = run({ units: [{ chessId: id, row: 9, col: 5 }] });
const u = h.unit(id);
assert.equal(u.base.cost, raw(id).stats.cost + t.cost);
h.step();
const full = u.s.maxHp * bb.hp_ratio;
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.ready, false);
assert.deepEqual(h.snapshot().units.find((t) => t[0] === u.id).slice(5, 7), [Math.round(u.skill.timeLeft * 10) / 10, bb.duration]);
approx(u.s.shield, full, 'full barrier', 1e-2);
h.run(bb.duration / 2 + 0.5);
approx(u.s.shield, full / 2, 'half decayed', 1e-2);
h.run(0.4);
approx(u.s.shield, full / 2, 'PRTS: the capacity steps once per second', 1e-2);
h.run(bb.duration / 2);
assert.equal(u.s.shield, 0);
assert.equal(u.skill.active, false);
assert.equal(u.skill.ready, false);
assert.equal(u.findBuff('gravel:rats'), null);
done(h);
// PRTS "受到伤害后,剩余容量根据屏障最大容量等比变化": a damaged barrier shrinks in proportion, not to the capacity
const hd = run({ units: [{ chessId: id, row: 9, col: 5 }] });
const ud = hd.unit(id);
hd.step();
const cap0 = ud.s.shield;
hd.b.dealDamage(null, ud, { amount: cap0 * 0.6, type: 'true' });
approx(ud.s.shield, cap0 * 0.4, 'absorbed', 1e-3);
hd.run(bb.duration / 2 + 0.5);
approx(ud.s.shield, cap0 * 0.4 / 2, 'remaining × capacity ratio (a plain cap would keep 40 %)', 1e-2);
assert.ok(ud.hp === ud.s.maxHp);
hd.b.retreat(ud);
assert.equal(ud.findBuff('gravel:rats'), null);
assert.equal(ud.skill.active, false);
assert.ok(hd.b.redeploy(ud, { free: true }));
approx(ud.skill.timeLeft, bb.duration);
approx(ud.s.shield, cap0, 'fresh shield on redeployment');
done(hd);
const idb = 'chess_char_2_12_b', tb = tal(idb);
const cheapRec = chessRec({ id: 't_cheap', stats: { cost: tb['cond.cost'] } });
const h2 = run({ defs: { chess: { t_cheap: cheapRec } }, units: [{ chessId: idb, row: 9, col: 5 }, { chessId: 't_cheap', row: 10, col: 4 }, { chessId: 'chess_char_1_08_a', row: 11, col: 4 }] });
h2.step();
const cheap = h2.unit('t_cheap'), dear = h2.unit('chess_char_1_08_a'), g = h2.unit(idb);
assert.ok(raw('chess_char_1_08_a').stats.cost > tb['cond.cost']);
approx(g.s.def, g.base.def * (1 + tb.def), 'self (cost 8)');
approx(cheap.s.def, cheap.base.def * (1 + tb.def), 'cost ≤ cond.cost');
approx(dear.s.def, dear.base.def, 'expensive unit unaffected');
done(h2);
});
test('2_13 蒂比: an incoming attack triggers 紧急赶场通知 and is dodged; airborne blocks flyers only; 片场工作指南 dodge', () => {
const id = 'chess_char_2_13_a', bb = bbOf(id), t = tal(id);
const h = run({
defs: { enemies: { e: dummy('e', { atk: 400, bat: 1 }), f: dummy('f', { motion: 'FLY' }) } },
units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }],
});
const u = h.unit(id);
h.step();
const e = h.enemies()[0];
assert.equal(e.blockedBy, u, 'ground enemy blocked before takeoff');
h.runUntil(() => u.skill.active, 3);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
assert.equal(u.stats.taken, 0, 'the triggering hit was dodged');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
assert.equal(u.s.flags.liftoff, true, 'airborne (起飞)');
assert.equal(u.ground, true, 'still a ground unit on her low tile');
h.step();
assert.equal(e.blockedBy, null, 'airborne: ground enemies are released');
const fl = h.spawn('f', { pos: [9, 5] });
h.step();
assert.equal(fl.blockedBy, u, 'airborne: flyers are blocked');
h.run(3);
const atk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.ok(atk.length > 0);
assert.equal(hits.length, 3 * atk.length, '3 shots per attack');
h.runUntil(() => !u.skill.active, 20);
h.step();
assert.equal(fl.blockedBy, null, 'landed: flyers released');
done(h);
// talent: the first attack after stack_time s without being attacked is dodged, the next one lands
const h2 = run({ defs: { enemies: { e: dummy('e', { atk: 400, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u2 = h2.unit(id);
h2.run(0.9);
assert.equal(u2.stats.taken, 0, 'first attack dodged');
h2.run(1.5);
assert.ok(u2.stats.taken > 0, 'second attack lands');
assert.ok(t.stack_time > 0);
done(h2);
});
test('2_14 调香师: 精调 ATK +atk / ASPD −50; 熏衣草 every ally 生命回复速度 +atk_to_hp_recovery_ratio × ATK (PRTS 备注: no heal); elite heals 4', () => {
const id = 'chess_char_2_14_a', bb = bbOf(id), t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 9 }, { chessId: 'chess_char_1_10_a', row: 10, col: 5 }] });
const u = h.unit(id), far = h.unit('chess_char_1_02_a'), near = h.unit('chess_char_1_10_a');
h.step();
far.hp = far.s.maxHp * 0.3;
near.hp = near.s.maxHp * 0.3;
h.run(3.1);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.aspd, u.base.aspd + bb.attack_speed);
const v = u.s.atk * t.atk_to_hp_recovery_ratio;
for (const x of [far, near, u]) approx(x.s.hpRegen - x.base.hpRecoveryPerSec, v, `${x.defId}: 生命回复速度 +ATK × ratio (out of range too)`);
assert.equal(heals(h, u, (c) => c.target === far).length, 0, 'no heal of hers');
const hp0 = far.hp, t1 = h.b.time;
h.run(2);
assert.ok(Math.abs(far.hp - hp0 - far.s.hpRegen * (h.b.time - t1)) <= 1.5, `regenerated ${far.hp - hp0}`);
done(h);
const idb = 'chess_char_2_14_b';
const ids = ['chess_char_1_02_a', 'chess_char_1_10_a', 'chess_char_1_12_a', 'chess_char_2_07_a', 'chess_char_1_18_a'];
const h2 = run({ units: [{ chessId: idb, row: 10, col: 4 }, ...ids.map((c, i) => ({ chessId: c, row: [9, 10, 11, 9, 11][i], col: [4, 5, 5, 5, 4][i] }))] });
h2.step();
for (const c of ids) { const x = h2.unit(c); x.hp = x.s.maxHp * 0.4; }
h2.runUntil(() => h2.hooksOf('attack').some((c) => c.attacker === h2.unit(idb)), 5);
assert.equal(h2.hooksOf('attack').find((c) => c.attacker === h2.unit(idb)).targets.length, hid(idb)['attack@max_target']);
done(h2);
});
test('2_15 协律 (hidden): 震爆调谐 sonic booms at other operators in range; 律脉同构 ATK; elite distance scaling', () => {
const id = 'chess_char_2_15_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 8] }] });
const u = h.unit(id);
h.step();
approx(u.s.atk, u.base.atk * (1 + t.atk), 'another operator in range');
done(h);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 3, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 8] }] });
const u2 = h2.unit(id);
h2.run(3);
assert.ok(u2.skill.active);
approx(u2.s.atk, u2.base.atk * (1 + t.atk + bb.atk));
const sonic = dealt(h2, u2, tagged('sonic'));
assert.ok(sonic.length >= 1 && sonic.every((c) => c.target === h2.enemies()[0]));
for (const c of sonic) approx(c.amount, u2.s.atk * bb['attack@aoe_atk_scale']);
done(h2);
const idb = 'chess_char_2_15_b', tb = tbOf(idb);
const h3 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 3 }], enemies: [{ key: 'e', pos: [10, 3 + tb.max_dist] }] });
h3.run(1);
const u3 = h3.unit(idb);
approx(dealt(h3, u3)[0].amount, u3.s.atk * (1 + tb.damage_scale), 'max distance ×1.1');
done(h3);
});
test('2_16 拉普兰德: 狼魂 arts on one extra target without the ranged ×0.8; 精神摧毁 silence; elite +10 % arts', () => {
const id = 'chess_char_2_16_a', bb = bbOf(id), t = tal(id), tb = tbOf(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 4 }], enemies: [{ key: 'e', pos: [9, 6] }, { key: 'e', pos: [9, 7] }] });
const u = h.unit(id);
h.run(1);
const first = dealt(h, u)[0];
assert.equal(first.type, 'phys');
approx(first.amount, u.s.atk * tb.atk_scale, 'ranged ×0.8');
assert.equal(statuses(h, 'silence', (c) => c.source === u)[0].duration, t.duration);
h.runUntil(() => u.skill.active, 60);
h.run(2);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const s0 = h.hooksOf('skillStart').find((c) => c.unit === u).t;
const sk = dealt(h, u, (c) => c.t > s0 + 0.05);
assert.ok(sk.length >= 2 && sk.every((c) => c.type === 'arts'));
for (const c of sk) approx(c.amount, u.s.atk, 'no ranged reduction');
assert.ok(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > s0).some((c) => c.targets.length === 2));
done(h);
const idb = 'chess_char_2_16_b', tbb = tbOf(idb);
const h2 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 9, col: 4 }], enemies: [{ key: 'e', pos: [9, 6] }] });
h2.run(1);
const u2 = h2.unit(idb);
approx(dealt(h2, u2, tagged('module'))[0].amount, u2.s.atk * tbb.atk_scale_m);
done(h2);
});
test('2_17 折桠: 生存决心 trembles ground enemies around, ATK/DEF up, hits all blocked; 简易包扎 heal at the end; elite −15 %', () => {
const id = 'chess_char_2_17_a', bb = bbOf(id), t = tal(id);
// the blocked enemy and the ranged one next to her can attack (atk > 0), so 战栗 is observable
const h = run({
defs: { enemies: { e: dummy('e', { atk: 50, bat: 1 }), r: dummy('r', { atk: 50, bat: 1, range: 2 }) } },
units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }, { key: 'r', pos: [10, 6] }, { key: 'e', pos: [9, 8] }],
});
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 2), '重装 TAKE_DAMAGE: cast by the first hit');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.def, u.base.def * (1 + bb.def));
const [blk, adj, far] = h.enemies();
for (const e of [blk, adj]) assert.equal(statuses(h, 'tremble', (c) => c.target === e)[0]?.duration, bb.not_combat);
assert.equal(statuses(h, 'tremble', (c) => c.target === far).length, 0);
assert.ok(blk.s.flags.tremble && !blk.s.flags.fear, '战栗 (not 恐惧)');
assert.equal(blk.blockedBy, u);
const a0 = blk.stats.attacks, r0 = adj.stats.attacks;
h.run(Math.min(2, bb.not_combat - 0.2));
assert.equal(blk.stats.attacks, a0, '战栗: the blocked enemy cannot attack');
assert.ok(adj.stats.attacks > r0, '战栗 only stops attacks while blocked: the unblocked ranged enemy keeps shooting');
h.run(bb.not_combat);
assert.ok(!blk.s.flags.tremble && blk.stats.attacks > a0, 'after not_combat s the blocked enemy attacks again');
u.hp = u.s.maxHp * 0.3;
h.runUntil(() => !u.skill.active, 20);
const end = heals(h, u, (c) => c.target === u && (c.opts?.tags || []).includes('talent'));
assert.equal(end.length, 1);
approx(end[0].amount, u.s.maxHp * t.hp_ratio);
done(h);
const idb = 'chess_char_2_17_b', tb = tbOf(idb);
const h2 = run({ defs: { enemies: { e: dummy('e', { atk: 1500, bat: 1 }) } }, units: [{ chessId: idb, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u2 = h2.unit(idb);
h2.runUntil(() => h2.hooksOf('damaged').some((c) => c.target === u2), 3);
approx(h2.hooksOf('damaged').find((c) => c.target === u2).amount, (1500 - u2.s.def) * tb.damage_scale, 'blocked attacker −15 %');
done(h2);
});
test('2_18 灰毫: 炮术研习 ATK +atk (ground surroundings: ashlok_t_1.atk); skill ATK +atk cast with an enemy in range (DEFAULT, DESIGN §21.29); elite ×1.1 vs blocked', () => {
const id = 'chess_char_2_18_a', bb = bbOf(id), t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_2_18_b', row: 10, col: 3 }] });
h.step();
const inner = h.unit(id), edge = h.unit('chess_char_2_18_b');
approx(inner.s.atk, inner.base.atk * (1 + t['ashlok_t_1.atk']), 'all four neighbours are ground');
approx(edge.s.atk, edge.base.atk * (1 + tal('chess_char_2_18_b').atk), 'a high tile next to it');
const h2 = run({ defs: { enemies: { e: dummy('e', { atk: 400 }) } }, units: [{ chessId: id, row: 10, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u = h2.unit(id);
h2.runUntil(() => u.skill.active, 5);
// the owner's deliberate deviation from the 重装 TAKE_DAMAGE row: the basic strategy, no hit needed — she casts before the
// enemy she holds has hit her
assert.equal(u.skill.rule, 'DEFAULT');
assert.equal(h2.hooksOf('skillStart').find((c) => c.unit === u).reason, 'DEFAULT');
assert.ok(!h2.hooksOf('damaged').some((c) => c.target === u), 'not hit before the cast');
approx(u.s.atk, u.base.atk * (1 + t['ashlok_t_1.atk'] + bb.atk));
done(h2);
const idb = 'chess_char_2_18_b', tb = tbOf(idb);
const h3 = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 5 }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u3 = h3.unit(idb);
h3.run(4);
const d = dealt(h3, u3);
assert.ok(d.length > 0);
for (const c of d) approx(c.amount, u3.s.atk * tb.atk_scale, 'blocked ×1.1');
done(h3);
});
// =================================================================================================================
// Fidelity fixes (verification pass)
test('1_01 隐现 elite: the ally magazine bonus is one grant per deployment (never stacks) and lapses when 隐现 leaves', () => {
const id = 'chess_char_1_01_b', t = tal(id);
const lat = chessRec({
id: 't_lat', profession: 'SNIPER', subProfessionId: 'fastshot', bonds: ['lateranoShip'], rangeGrid: raw(id).rangeGrid,
skill: { durationType: 'AMMO', duration: 0, spCost: 999, initSp: 0, bb: { 'attack@trigger_time': 5 } },
});
const h = run({ defs: { enemies: { e: dummy('e') }, chess: { t_lat: lat } }, units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_lat', row: 11, col: 4 }], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id), l = h.unit('t_lat');
const magazine = () => {
if (l.skill.active) l.skill.end('test');
l.skill.activate('test', { free: true });
const n = l.skill.ammoLeft;
l.skill.end('test');
return n;
};
h.run(1);
assert.equal(magazine(), 5, 'before `duration` s: base magazine');
for (let i = 0; i < 3; i++) { h.run(t.duration + 1); h.b.kill(u, null); h.b.redeploy(u); }
h.run(t.duration + 1);
assert.equal(magazine(), 5 + t.ally_ammo, 'one grant after four deployments');
h.b.kill(u, null);
assert.equal(magazine(), 5, 'the source left: no bonus');
done(h);
});
test('1_03 惊蛰 elite: module chain (attack@max_target targets, −10 % per jump) and no falloff during 初雷', () => {
const id = 'chess_char_1_03_b', tb = tbOf(id), t = tal(id);
const mk = (carry) => run({
defs: { enemies: { e: dummy('e') } },
units: [{ chessId: id, row: 10, col: 4, carryState: carry }],
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 6] }, { key: 'e', pos: [11, 7] }],
});
const h1 = mk(null), u1 = h1.unit(id);
h1.runUntil(() => dealt(h1, u1).length >= tb['attack@max_target'], 10);
const a1 = dealt(h1, u1).slice(0, tb['attack@max_target']).map((c) => c.amount);
a1.forEach((a, k) => approx(a, u1.s.atk * t.atk_scale * tb['attack@chain.atk_scale'] ** k, `jump ${k}`));
const h2 = mk(READY), u2 = h2.unit(id);
h2.runUntil(() => dealt(h2, u2).length >= tb['attack@max_target'], 10);
for (const c of dealt(h2, u2).slice(0, tb['attack@max_target'])) approx(c.amount, u2.s.atk * t.atk_scale, 'no falloff');
done(h1); done(h2);
});
test('1_10 古米: 平底锅专精 stuns only when the proc hit lands (a dodged hit stuns nothing)', () => {
const id = 'chess_char_1_10_b';
const h = run({
defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }],
setup: (b) => b.on('enemySpawn', ({ enemy }) => b.addBuff(enemy, { key: 'test:dodge', mods: { dodgePhys: 1 } })),
});
const u = h.unit(id);
h.run(60);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'crit' && f[4]?.id === u.id), 'procs rolled');
assert.equal(statuses(h, 'stun', (c) => c.source === u).length, 0, 'no stun from dodged hits');
done(h);
});
test('1_16 锡人 elite: 凋敝魂灵 multiplies damage over time only, never instantaneous bursts', () => {
const id = 'chess_char_1_16_b', w = tal(id, 1)['skill@damage_scale'];
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }] });
const u = h.unit(id);
h.run(1.5);
const e = h.enemies()[0];
assert.ok(e.findBuff('tinman:wither'), 'inside the alchemy unit');
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'true', tags: ['dot'] }), 1000 * w, 'DoT ×damage_scale');
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'true', tags: ['burst', 'necrosis'] }), 1000 * w, 'necrosis burst tick');
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'true', tags: ['skill', 'burst'] }), 1000, 'instant burst unchanged');
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'true', tags: ['burst', 'burn'] }), 1000, 'burn burst unchanged');
done(h);
});
test('1_18 宴 elite: 庇护 reduces physical and arts damage only (true damage is not reduced)', () => {
const id = 'chess_char_1_18_b', t = tal(id);
const h = run({ units: [{ chessId: id, row: 9, col: 5 }] });
const u = h.unit(id);
h.step();
u.hp = u.s.maxHp * (t.hp_ratio - 0.2);
approx(h.b.dealDamage(null, u, { amount: 100, type: 'arts' }), 100 * (1 - t.damage_resistance), 'arts');
approx(h.b.dealDamage(null, u, { amount: 100, type: 'true' }), 100, 'true');
done(h);
});
test('1_18 宴 with her 特质 弱点伤害: the garrison re-types every hit after the skill’s arts conversion (both ways)', () => {
const id = 'chess_char_1_18_a', bb = bbOf(id);
assert.ok(raw(id).garrisonIds.includes('garrison_01_a'), 'real data: 宴 carries 弱点伤害');
for (const [stats, want] of [[{ def: 0, res: 60 }, 'phys'], [{ def: 400, res: 0 }, 'arts']]) {
const h = run({ defs: { enemies: { e: dummy('e', stats) } }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 6] }] });
h.run(bb.duration + 3);
const hits = dealt(h, h.unit(id), (c) => c.dmg.isAttack);
assert.ok(hits.some((c) => c.t < bb.duration - 0.1) && hits.some((c) => c.t > bb.duration + 0.1), 'hits during and after 落地斩·破门');
assert.ok(hits.every((c) => c.type === want), `vs ${JSON.stringify(stats)} every hit is ${want}`);
done(h);
}
});
test('1_19 野鬃 normal: 一致向前 only on the first deployment', () => {
const id = 'chess_char_1_19_a', t = tal(id);
assert.equal(t.flag, 0);
const h = run({ units: [{ chessId: id, row: 9, col: 4 }, { chessId: 'chess_char_1_12_a', row: 9, col: 6 }], flags: { dpInit: 99 } });
h.step();
const wm = h.unit(id), est = h.unit('chess_char_1_12_a');
const cost0 = est.base.cost;
h.b.kill(est, null);
h.b.kill(wm, null);
h.b.redeploy(wm);
assert.equal(est.base.cost, cost0, 'a redeployment gives no discount');
done(h);
});
test('2_02 赫默: the drone takes its own placed tile, never the home tile of a knocked-out operator', () => {
const h = run({ units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, carryState: READY, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 9, col: 5 },
{ kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 11, col: 5 }] });
h.step();
const yak = h.unit('chess_char_1_02_a'), gum = h.unit('chess_char_1_10_a');
gum.hp = gum.s.maxHp * 0.3;
h.b.kill(yak, null);
h.run(0.5);
const d = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(d && d.alive, 'drone deployed');
assert.deepEqual([d.tileR, d.tileC], [11, 5], 'its placed tile');
assert.ok(!(d.tileR === yak.homeR && d.tileC === yak.homeC), 'not on the dead operator tile');
done(h);
});
test('2_04 小满 elite module: +sp_recovery_per_sec SP/s while an enemy is in range', () => {
const idb = 'chess_char_2_04_b', mb = hid(idb);
const sp = (withEnemy) => {
const hh = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: idb, row: 10, col: 4 }], enemies: [{ key: 'e', pos: withEnemy ? [10, 5] : [9, 9] }] });
hh.run(5);
return hh.unit(idb).skill.sp;
};
approx(sp(true) - sp(false), 5 * mb.sp_recovery_per_sec, 'module SP', 1e-3);
});
test('2_10 洛洛: 自负此轭 drones stay locked on their target while the skill runs', () => {
const id = 'chess_char_2_10_a';
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
h.run(2);
assert.ok(u.skill.active);
const first = h.enemies()[0];
const other = h.spawn('e', { pos: [10, 5] });
h.b.addBuff(other, { key: 'test:taunt', mods: { taunt: 5 } }); // normally the preferred target
h.step();
const normal = h.b.enemiesInKeys(u.rangeKeys, u, u.profile);
sortEnemyTargets(h.b, u, normal, u.profile.priority);
assert.equal(normal[0], other, 'without the lock the newcomer would be the target');
const t0 = h.b.time;
h.run(4);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0);
assert.ok(atks.length > 0 && atks.every((c) => c.targets.length === 1 && c.targets[0] === first), 'locked');
done(h);
});
test('2_11 风丸: while substituted she fights with the <替身> ATK/DEF (+module ATK on elite)', () => {
for (const id of ['chess_char_2_11_a', 'chess_char_2_11_b']) {
const tb = tbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e', { atk: 9000, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
h.runUntil(() => u.findBuff('trait:substitute'), 20);
h.step();
const ts = ds.getToken('token_10022_kazema_shadow', id).stats;
approx(u.s.atk, ts.atk * (1 + (tb.atk || 0)), `${id} substitute ATK`);
approx(u.s.def, ts.def, `${id} substitute DEF`);
approx(dealt(h, u, tagged('talent'))[0].amount, u.s.atk * t.damage_scale, `${id} 折纸生花 on substitution`);
done(h);
}
});
test('2_13 蒂比: any damage instance sets off 紧急赶场通知 (PRTS 修正 "受到伤害前触发") and a physical one is dodged; a 流失 does not; a true-damage attack triggers it but is not dodged', () => {
const id = 'chess_char_2_13_a';
const g = run({ units: [{ chessId: id, row: 9, col: 5, carryState: READY }] });
const v = g.unit(id);
g.step();
g.b.loseHp(v, 50);
g.step();
assert.equal(v.skill.active, false, 'a 流失 never triggers it');
const hp = v.hp;
g.b.dealDamage(null, v, { amount: 50, type: 'phys' });
assert.ok(v.skill.active, 'a non-attack (sourceless) damage instance triggers it');
assert.equal(v.hp, hp, '…and the physical damage is dodged');
done(g);
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1, dmgType: 'true' }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }] });
const u = h.unit(id);
h.step();
h.spawn('e', { pos: [9, 5] });
h.runUntil(() => u.skill.active, 3);
assert.ok(u.skill.active, 'the attack triggered it');
assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source?.side === 'enemy' && c.type === 'true'), 'true damage landed');
done(h);
});
test('every tier 1/2 chess (normal & elite, hidden included) fights 40 s with its kit and no content errors', () => {
const ids = ds.chessIds().filter((id) => /^chess_char_[12]_\d+_[ab]$/.test(id));
assert.ok(ids.length >= 78);
for (const id of ids) {
const h = makeBattle({
stageId: 'act2autochess_m04', seed: 5,
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 20000, atk: 300, bat: 2, speed: 0.6 }) } },
units: [{ chessId: id, row: 9, col: 7 }, { chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { chessId: 'chess_char_2_14_a', row: 10, col: 5 }],
enemies: [{ key: 'enemy_dummy', count: 4, interval: 2 }, { key: 'enemy_dummy', route: 1, count: 2, interval: 3 }],
timeLimit: 60,
});
h.run(40);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, `${id}: ${JSON.stringify(h.b.errors[0])}`);
assert.equal(h.unit(id).kit.generic, undefined, `${id} uses its hand-authored kit`);
}
});