Files
ark-stronghold-protocol/test/content/kits_t1t2.test.js
T
sganggs 0aba602ecf Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
2026-09-29 23:42:55 +08:00

1293 lines
70 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/tier1.js, tier2.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 角峰: 抗寒体质 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') } }, 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`);
h.step();
assert.ok(u.skill.active);
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.run(3.5);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const firstAtk = h.hooksOf('attack').find((c) => c.attacker === u);
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 雷蛇 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 古米: TAKE_DAMAGE 备用军粮 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, 'TAKE_DAMAGE');
// 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();
for (const e of [a, b]) {
const z = dealt(h, u, (c) => c.target === e && (c.dmg.tags || []).includes('zone'));
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);
}
assert.equal(dealt(h, u, (c) => c.target === far && (c.dmg.tags || []).includes('zone')).length, 0);
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 hit; 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 >= 2 && el.every((c) => c.dmg.element === 'neural'));
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 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');
const zh = heals(h, u, (c) => c.target === yak);
assert.equal(zh.length, bb.projectile_delay_time);
for (const c of zh) approx(c.amount, u.s.atk * bb.hp_recovery_per_sec_ratio);
done(h);
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);
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);
assert.equal(a0.targets.length, 3);
assert.ok(dealt(h, u, (c) => c.dmg.isAttack).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 next to the injured ally and expires; 强化注射 MEDIC ASPD; elite ×1.15 on ground units', () => {
const id = 'chess_char_2_02_a', t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }, { chessId: 'chess_char_2_14_a', row: 12, col: 4 }] });
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.ok(Math.max(Math.abs(drone.tileR - yak.tileR), Math.abs(drone.tileC - yak.tileC)) <= 1, '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) {
assert.ok(Math.hypot(e.x - 5, e.y - 10) < 0.3, `at the front tile (${e.x},${e.y})`);
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 * 2 / 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);
approx(elem.s.elemTakenMul, 1 - t.ep_damage_resistance, '我即军营');
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;
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);
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);
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.ground, false);
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; 熏衣草 heals every ally atk_to_hp_recovery_ratio × ATK per s; 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 aura = heals(h, u, (c) => c.target === far);
assert.equal(aura.length, 3, 'one pulse per second, out of range too');
for (const c of aura) approx(c.amount, u.s.atk * t.atk_to_hp_recovery_ratio);
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);
h.step();
assert.ok(u.skill.active);
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); TAKE_DAMAGE skill ATK +atk; 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);
assert.equal(h2.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
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 never lands on the home tile of a knocked-out operator', () => {
const h = run({ units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 9, 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.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 蒂比: only enemy attacks set off 紧急赶场通知; a true-damage attack triggers it but is not dodged', () => {
const id = 'chess_char_2_13_a';
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.b.dealDamage(null, u, { amount: 50, type: 'phys' });
h.step();
assert.equal(u.skill.active, false, 'non-attack damage never triggers it');
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`);
}
});