Files
ark-stronghold-protocol/test/content/kits_alt_t6.test.js
T
sganggs 227ab9e7ca feedback7 R23-TICK: an attack interval or a time-SP cost of a whole number of frames takes exactly that many — 1 s is 30 frames, not 31 (PR #402 by @Cloudnyco, ported onto 0.2.2); golden: every family moves (roster, bonds, fields, matches, standins, diy — regenerated in the next commit)
Seen (PR #402, measured again on 0.2.2): TICK = 1/30 is not a binary fraction. An attack cooldown counted down from 1 s
stops at 2.1e-16 after thirty steps and the fire check `atkCd > 0` waited a 31st frame; 300 time gains of 1/30 SP add up
to 9.999999999999975 and `sp >= cost` charged a 10-SP skill a frame late. Over the 265 golden battles 30.85 % of the
consecutive attacks on a whole-frame interval came a frame late (control, other intervals: 0.09 %), and 4 157
end-of-tick states sat within 1e-9 under a cost. The engine already took timers within 1e-9 as done (skill timeLeft,
buffs, every() / after(), enemy abilities, nextCastAt, the 失衡 end); these two did not.

Official: PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」 — one frame = 1/30 s; since 2019-12-24 a non-integer frame count
is rounded (四舍五入); an exact 1 s is 30 frames under any rule.

Now: ai.js attackCountdown(cd, dt) (left within 1e-9 of 0 is 0) for the operators' cooldown (the canAct guard kept) and
the enemies' (not while stunned or hidden; the 0.2.2 失衡 swing cut untouched) — an enemy's whole-frame wind-up is exact
too (0.4 s: 12 frames, 13 before). The same countdown for the two strict `cd -= dt; if (cd > 0)` attack clocks of the
same shape: the 双眼皮 turrets (content/devices.js; BAT 4 s: 121 → 120 frames) and 寒檀's S2 icicles (0.5 s: 16 → 15).
SkillRuntime.gainSp takes SP within 1e-9 of the cost as full for every charge (a tiny negative remainder → 0);
_normalize compares the same way. Left as they are: a non-integer count still ends on the frame that crosses 0
(⌈30 × interval⌉ — the official 四舍五入 is an older, separate difference); the count-up content clocks that restart from
0 (bosses.js self-timers, kit auras — PR #402's list); attack / hurt SP (+1 on an integer cost) unchanged.

Tests: test/sim/tick-tolerance.test.js (new, 7: the PR's four adapted — residue, attackCountdown over bat 0.1–10 s ×
14 ASPD = ⌈30 × interval⌉, 1 s operator every 30 / 3 s ranged enemy every 90, time SP 4/10/16/20/30/50 at exactly 30 × N
gains for one and two charges — plus the 1.2 s / 1.25 s control (36 / 38), the turret at 0 layers (120) and 寒檀's
icicles (15); the four engine tests fail on the parent: 31 / 91, cost 4 at 121, 121, 16). The PR's seven test edits,
re-run: the same 8 assertions failed on the new timing and each edit passes on the parent and on the fix —
feedback2-surtr-stealth (count once no arrow is in flight), kits_alt_t2 拉普兰德 S1 (dummy ATK 300 → 250: 120 × 15 = her
1791 HP by 59.5 s), kits_alt_t6 (medic smoke at half HP; 余 S1 up to the first 燃烧 burst), op_jesca2 (the T6 shield
returns a tick after the cast), op_lmlee (hits before the burst), op_logos (count after the clear), op_narant (the first
attack thrown after the new enemies). node --test (with the golden of the next commit): 5703 tests, 5701 pass, 0 fail,
2 skipped; eslint 0 errors (135 warnings, the parent's 135); typecheck, check:imports, smoke ok.

feedback7 R23-TICK: golden for whole-frame attack intervals and SP costs (PR #402) — 281 of 283 scenarios move: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, standins 10/10, diy 136/138; solo-NORMAL-1 now loses its Final Assault (below)

npm run golden:update on the previous commit. Every family moves because both sides attack on time (more enemy kinds
than operator forms sit on whole-frame intervals: 3 s, 4 s, 1 s …) and time SP charges a frame sooner. Measured over
the 265 battles: whole-frame consecutive attacks a frame late 30.85 % → 0.04 % (control 0.09 % → 0.13 %; 1 s gaps at
30 frames 256 → 3 776 of ~3 800, 3 s at 90 frames 0 → 6 657 of 6 657), SP float-short states 4 157 → 0. Moved battles'
totals: kills 10 647 → 10 625, leaks 12 398 → 12 445, deaths 2 913 → 2 944. Unchanged: diy-040, diy-069.

End results that flip (one frame cascades; the causes as measured with the matches' battles instrumented):
- solo-NORMAL-1: won → lost in round 14 (passed 14 → 13, team LP 8 → 0): the Final Assault on 卢西恩 (pool 390 000) runs
  as before for two minutes (his 0.4 s wind-up a frame shorter), then the team's damage on the pool trails — 884 left
  when the overtime drain (from 300 game s) takes the last of the 17 LP at 334 s; before, it emptied at 318 s, 8 LP left.
- solo-HARD-1: LP 10 → 12 — round 8 lets one more through (26 → 25; rounds 11–12 even it out) and the Final Assault ends
  at 300.6 s, before the overtime drain (304.8 s and 2 LP before).
- solo-NORMAL-2: LP 9 → 25 — round 5 lets one more through (26 → 25, a coin less), the bot's shop path parts from round
  6 and its later board (突袭, 232 layers at round 13 against 94) clears rounds 12–13 with no leak (7 + 18 before).
- coop2-NORMAL-3: team LP 63 → 68 — ai_1's round-13 battle leaks 1 instead of 6 (its round-12 leaker now goes to 联防).
- coop2-ABYSS-10-boosted: team LP 583 → 630 — the Final Assault ends at 606.6 s (639.6) and the Hidden Core at 335.9 s
  (397.8): 47 LP less of overtime drain.
- coop2-NORMAL-14-standins: team LP 41 → 40 — ai_0's round-13 battle lets one 潜伏者 through (23 → 22).
- roster-016: cleared at 220.3 s → time-out at 230 s (主角阵营角色 leaks instead of falling; kills 101 → 105, leaks 26 → 22).
- roster-041: time-out → cleared at 189.0 s, perfect (the last 粉碎攻坚组长 falls inside the 190 s).
- bond-suntShip-high: cleared at 164.2 s → time-out at 190 s (kills 11 → 13, deaths 4 → 2).
- standin-04: perfect → 10 leaks (deaths 8 → 10; 萨卡兹大剑组长 / 萨卡兹穿刺手 / 澪 get through).
- diy-005 / diy-008 (艾丽妮 / 赫拉格): cleared at 225.3 s → time-out at 230 s (a last 宿主重装士兵 / 深池方阵指挥官 walking).
- diy-127 (白铁 / 酒神): time-out → cleared at 225.8 s (deaths 7 → 13, leaks 57 → 72).
- diy-131 (死芒): perfect → 1 leak (a 掠海漂移体).
The other matches keep their ends (solo-FUNNY-1/2, solo-HARD-2, solo-ABYSS-1/2, coop2-HARD-4, coop3-ABYSS-5,
coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-NORMAL-70-diy). PR #402's own
0.2.1 table (coop2-NORMAL-14-standins loss → win, solo-HARD-2 / solo-ABYSS-2 earlier exits) was measured before §27 and
does not repeat here.
2026-10-09 01:37:32 +08:00

1657 lines
92 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.
// Operator loadouts (DESIGN §16) for the tier-6 kits (server/sim/content/kits/ops/): every selectable NON-default
// skill of every visible tier-6 chess is hand-authored (tools/kit-coverage.mjs) and shows its signature effect for the
// normal (Lv4) and the elite (Lv7) chess — numbers from the selected skill's blackboard (data/chess.json skills[]) —
// and the elite's non-default module choices (Y / RA modules, or 'none') change what the kit does.
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 { KITS, skillSpecSource } from '../../server/sim/content/index.js';
import { kitCoverage } from '../../tools/kit-coverage.mjs';
import { COLS } from '../../server/sim/constants.js';
const ds = getDefaultSource();
const raw = (id) => ds.rawChess(id);
const rec = (id, sid) => raw(id).skills.find((s) => s.skillId === sid);
const bbOf = (id, sid) => rec(id, sid).bb;
/** Named talent blackboard / trait blackboard / hidden module parts of the loadout-resolved def. */
const tal = (id, lo = null, i = 0) => ds.getChess(id, lo).talents[i]?.bb ?? {};
const tbOf = (id, lo = null) => ds.getChess(id, lo).traitBb ?? {};
const both = (base) => [`${base}_a`, `${base}_b`];
const MOD = (id) => ({ moduleId: id });
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 };
const IDLE = { sp: 0 };
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'death', 'deploy', 'ammoUsed', 'elementHit'];
const plain = (id, o = {}) => chessRec({ id, skill: null, ...o });
function run(o) {
return makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o });
}
/** Unit entry with skill `sid` selected (plus loadout / carry extras). */
const U = (id, sid, row, col, o = {}) => ({ chessId: id, row, col, skillIndex: rec(id, sid).index, ...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);
const started = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
function usesSkill(u, sid) {
assert.equal(u.skill.id, sid, `${u.defId} runs ${sid}`);
assert.equal(u.kit.skillSource, 'skills', `${u.defId}: hand-authored spec`);
}
/** Synthetic enemy records are keyed `enemy_<key>` by the data source. */
const isKey = (x, key) => !!x && (x.defId === key || x.defId === `enemy_${key}`);
const enemyOf = (h, key, i = 0) => h.b.units.filter((x) => x.side === 'enemy' && isKey(x, key))[i];
const skillBuff = (u) => u.findBuff(`skill:${u.id}`);
// =================================================================================================================
// coverage
test('kit coverage: every selectable skill of every visible tier-6 chess is hand-authored (normal + elite, every module)', () => {
const rep = kitCoverage({ tier: 6 });
assert.equal(rep.summary.chess, 19);
assert.equal(rep.summary.covered, rep.summary.skills, JSON.stringify(rep.chess.filter((r) => r.skills.some((s) => !s.covered)).map((r) => r.name)));
for (const r of rep.chess) {
for (const s of r.skills.filter((x) => !x.isDefault)) {
assert.deepEqual([s.normal, s.elite], ['skills', 'skills'], `${r.name} S${s.index + 1} ${s.name}`);
const gid = raw(r.chessId).goldenId;
for (const moduleId of [null, 'none', ...(raw(gid).modules || []).map((m) => m.uniEquipId)]) {
const def = ds.getChess(gid, { skillIndex: s.index, moduleId });
assert.equal(def.skill.id, s.skillId);
assert.equal(skillSpecSource(def, KITS), 'skills', `${gid} ${s.skillId} module ${moduleId}`);
}
}
}
});
test('every tier-6 skill × module choice (normal + elite) survives a real wave without content errors and casts', () => {
for (const r of kitCoverage({ tier: 6 }).chess) {
const base = raw(r.chessId);
for (const id of [r.chessId, base.goldenId]) {
const mods = id === base.goldenId ? [null, 'none', ...(raw(id).modules || []).map((m) => m.uniEquipId)] : [null];
for (const s of r.skills) for (const moduleId of mods) {
const h = makeBattle({
seed: 3, timeLimit: 60,
units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index, moduleId, carryState: READY }, { chessId: 'chess_char_1_01_a', row: 9, col: 4 }, { chessId: 'chess_char_2_06_a', row: 11, col: 5 }],
enemies: [{ key: 'enemy_1007_slime', count: 8, interval: 1.5 }, { key: 'enemy_1007_slime', route: 1, count: 8, interval: 1.5, time: 3 }],
});
// (a medic acts on the hurt only, and the slimes may fall before they reach anyone: the team starts at half HP)
if (base.profession === 'MEDIC') { h.step(); for (const a of h.b.allyUnits) a.hp = a.s.maxHp / 2; }
h.runToEnd(90);
done(h);
const u = h.b.allyUnits.find((x) => x.defId === id);
assert.equal(u.skill.id, s.skillId);
// (a TAKE_DAMAGE skill needs an attacker: these slimes walk past)
if (u.skill.kind !== 'passive' && u.skill.rule !== 'TAKE_DAMAGE') assert.ok(u.skill.activations > 0, `${id} ${s.name} (module ${moduleId}) casts`);
}
}
}
});
// =================================================================================================================
// 6_01 蕾缪安
test('6_01 蕾缪安 S1 重逢问候: 5 bullets, each attack hits 2 enemies at attack@atk_scale × ATK', () => {
for (const id of both('chess_char_6_01')) {
const sid = 'skchr_lemuen_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 10));
const t0 = h.b.time;
assert.equal(u.skill.ammoLeft + h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length, bb['attack@trigger_time'], '5 bullets');
assert.ok(h.runUntil(() => !u.skill.active, 60));
h.run(1); // arrows in flight
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
void t0;
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.equal(atks.length, bb['attack@trigger_time'], 'one bullet per attack');
assert.equal(hits.length, 2 * atks.length, 'two targets per attack');
for (const x of hits) approx(x.amount, u.s.atk * bb['attack@atk_scale'], `${id} ×atk_scale`);
done(h);
}
});
test('6_01 蕾缪安 S2 归乡邀约: ASPD/ATK +; a wanted enemy is aimed at for aim_duration s, then sniped at fin_atk_scale × ATK (no dodge)', () => {
for (const id of both('chess_char_6_01')) {
const sid = 'skchr_lemuen_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { el: dummy('el', { rank: 'ELITE' }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'el', pos: [10, 6] }],
setup(b) { b.on('enemySpawn', ({ enemy }) => b.addBuff(enemy, { key: 'lemuen:wanted' })); },
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(skillBuff(u).mods.aspd, bb.attack_speed, 'ASPD +');
approx(skillBuff(u).mods.atkPct, bb.atk, 'ATK +');
assert.ok(h.runUntil(() => !!u.mem.lemAim, 5), 'aims at the wanted enemy');
const ammo = u.skill.ammoLeft, t0 = h.b.time;
const atks0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
assert.ok(h.runUntil(() => !u.mem.lemAim, 10));
approx(h.b.time - t0, bb['attack@aim_duration'], 'aims the full time (1e7 HP)', 0.03);
const snipe = dealt(h, u, tagged('snipe'));
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t < snipe[0].t).length, atks0, 'no normal attack while aiming');
assert.ok(u.skill.ammoLeft <= ammo, 'the aim spent its bullet when it began');
assert.equal(snipe.length, 1);
assert.equal(snipe[0].dmg.canDodge, false);
const wanted = u.def.bonds.includes('lateranoShip') ? tal(id).damage_scale : 1;
approx(snipe[0].amount, u.s.atk * bb['attack@fin_atk_scale'] * wanted, `${id} fin × wanted`);
done(h);
}
});
test('6_01 蕾缪安 S2: a low-HP wanted target is sniped at once (ATK × scale > HP + DEF)', () => {
const id = 'chess_char_6_01_a', sid = 'skchr_lemuen_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { el: dummy('el', { rank: 'ELITE', hp: 800 }), big: dummy('big') } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'big', pos: [10, 5] }, { key: 'el', pos: [10, 7], time: 2 }],
setup(b) { b.on('enemySpawn', ({ enemy }) => { if (isKey(enemy, 'el')) b.addBuff(enemy, { key: 'lemuen:wanted' }); }); },
});
const u = h.unit(id);
assert.ok(h.runUntil(() => dealt(h, u, tagged('snipe')).length > 0, 10));
const sn = dealt(h, u, tagged('snipe'))[0];
assert.ok(sn.t - 2 < 0.2, `fired at once (${sn.t})`);
assert.ok(isKey(sn.target, 'el'));
assert.ok(sn.amount >= u.s.atk * bb['attack@main_atk_scale'] - 1e-6);
done(h);
});
// =================================================================================================================
// 6_02 圣聆初雪
test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range); atk_scale × ATK arts + cold on every enemy in range, pushed along her direction, snow spreads 5 tiles forward', () => {
for (const id of both('chess_char_6_02')) {
const sid = 'skchr_sbell2_1', bb = bbOf(id, sid);
// weight-2 dummies: 中力 (1) − 2 = 受力等级 −1 ⇒ the official 0.44-tile push (PRTS 游戏数据基础 §重量公式), so they stay
// in her range for the second charge
const h = run({ defs: { enemies: { e: dummy('e', { mass: 2 }) } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.equal(u.skill.maxCharges, rec(id, sid).maxChargeTime);
h.step(2);
const s = started(h, u);
assert.ok(s.length >= 1, 'cast at once (enemies in range)');
const t0 = s[0].t;
const burst = dealt(h, u, (c) => c.t === t0 && c.dmg.isSkill && c.type === 'arts');
assert.equal(burst.length, 2);
for (const x of burst) approx(x.amount, u.s.atk * bb.atk_scale * u.s.dmgDealtMul, 'atk_scale × ATK');
const colds = statuses(h, 'cold', (c) => c.source === u && c.t === t0);
assert.equal(colds.length, 2);
for (const c of colds) approx(c.duration, bb.cold, 'cold');
const [e1] = h.enemies();
approx(e1.x, 5 + 0.44 * started(h, u).length, 'pushed forward along her direction by the official 0.44 tiles per cast');
approx(e1.y, 10, 'no sideways push');
// (the pushed enemies clear the snow of the tiles they leave: "首个敌人离开该地块时积雪消失")
for (let d = 3; d <= bb.trig_cnt; d++) assert.ok((u.mem.snow.get(10 * COLS + 4 + d) ?? 0) >= 1, `snow ${d} tiles ahead`);
h.run(2.5);
assert.equal(started(h, u).length, 1, 'the next automatic cast waits the 3 s operation cooldown ("自动操作具有3s冷却")');
h.run(1);
assert.equal(started(h, u).length, 2, 'the second charge fires too');
assert.ok(started(h, u)[1].t - t0 >= 3 - 1e-6);
done(h);
}
});
// PRTS 圣聆初雪 S2 霜涛覆岭 "积雪在目标点积累至5层时,使目标点变为冻结状态", 备注 "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标
// (冻结状态)(无视部署属性),并去除相应地块上的积雪(且存在自身的该召唤物的地块不会积雪)", "可以被'变为冻结状态'的目标点包括常规的保护目标点…";
// the token's page: "技能发动后于保护目标叠加5层积雪". Community report #32: until 0.1.3 any free standable tile froze and the blue
// gate never did (nobody can stand on it).
test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2 × ATK; ground enemies on snow take 20 %/s, leaving snow chills, 5 layers on the protection point freeze it (保护目标(冻结状态)), no other tile', () => {
for (const id of both('chess_char_6_02')) {
const sid = 'skchr_sbell2_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e'), w: enemyRec({ key: 'w', hp: 1e7, speed: 1 }) } },
units: [U(id, sid, 10, 3, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'w', route: 0, time: 1 }, { key: 'w', route: 0, time: 40 }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.kind, 'toggle');
const e = enemyOf(h, 'e');
const k = 10 * COLS + 5;
u.mem.snow.set(k, 1); // snow under the dummy
u.mem.snow.set(9 * COLS + 7, 1); // on the walker's path
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isAttack && c.target === e).length > 0, 10));
const hit = dealt(h, u, (c) => c.dmg.isAttack && c.target === e)[0];
approx(hit.amount, u.s.atk * bb['attack@atk_scale_s2'] * u.s.dmgDealtMul, 'atk_scale_s2');
h.run(1.5);
const dot = dealt(h, u, (c) => c.target === e && c.dmg.isSkill && !c.dmg.isAttack && (c.dmg.tags || []).includes('snow'));
assert.ok(dot.length >= 1, 'snow DoT');
approx(dot[0].amount, u.s.atk * bb['talent@s2_magic_scale'] * u.s.dmgDealtMul, '20 % ATK per second');
assert.ok(h.runUntil(() => statuses(h, 'cold', (c) => c.source === u && isKey(c.target, 'w')).length > 0, 20), 'leaving snow ⇒ cold');
approx(statuses(h, 'cold', (c) => c.source === u && isKey(c.target, 'w'))[0].duration, bb['talent@cold'], 'cold duration');
const iceOf = () => h.b.allyUnits.find((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive);
// a free ground tile of her range reaching 5 layers: no freeze (until 0.1.3 it turned into the token)
const fk = 11 * COLS + 4;
assert.ok(u.rangeKeys.includes(fk));
u.mem.snow.set(fk, 4);
h.run(7);
assert.equal(u.mem.snow.get(fk), 5);
assert.equal(iceOf(), undefined, 'only a protection point freezes');
// the blue gate (9,2) — build NONE, nobody may stand there — at 4 layers: the next layer freezes it
const gk = 9 * COLS + 2;
assert.equal(h.b.grid.tile(9, 2).special, 'end');
assert.ok(u.rangeKeys.includes(gk));
u.mem.snow.set(gk, 4);
assert.ok(h.runUntil(() => !!iceOf(), 7), '保护目标(冻结状态)');
const ice = iceOf();
assert.deepEqual([ice.tileR, ice.tileC], [9, 2]);
assert.equal(ice.s.blockCnt, 3);
assert.equal(u.mem.snow.get(gk), undefined, 'its snow is used up');
h.run(7);
assert.equal(u.mem.snow.get(gk), undefined, 'no snow gathers under her token');
// a walker reaching the gate is held there by it, not leaked
const leaks0 = h.result().perPlayer.p1.leaked.length;
assert.ok(h.runUntil(() => h.b.enemies.some((x) => x.alive && isKey(x, 'w') && x.blockedBy === ice), 60), 'blocked by the frozen gate');
assert.equal(h.result().perPlayer.p1.leaked.length, leaks0);
done(h);
}
});
// =================================================================================================================
// 6_03 余
test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF +, every attack taken burns the attacker (ep_damage_ratio × ATK)', () => {
for (const id of both('chess_char_6_03')) {
const sid = 'skchr_yu_1', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { hit: enemyRec({ key: 'hit', hp: 1e7, speed: 0, atk: 300, bat: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'hit', pos: [10, 4] }], hooks: [...HOOKS, 'elementBurst'],
});
const u = h.unit(id);
usesSkill(u, sid);
h.step();
assert.equal(u.s.taunt, bb.taunt_level, 'passive taunt');
assert.ok(h.runUntil(() => u.skill.active, 10), 'cast when hit');
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
approx(skillBuff(u).mods.hpPct, bb.max_hp);
approx(skillBuff(u).mods.defPct, bb.def);
const t0 = h.b.time;
h.run(4);
// the burns build a 燃烧 burst on the attacker, which refuses same-element fills while it runs: compare up to it
const end = h.hooksOf('elementBurst').find((c) => c.element === 'burn' && c.t > t0)?.t ?? Infinity;
const burns = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'burn' && c.t > t0 && c.t <= end && (c.dmg.tags || []).includes('skill'));
const taken = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.isAttack && c.t > t0 && c.t <= end);
assert.ok(burns.length >= 1 && burns.length === taken.length, `one burn per attack taken (${burns.length}/${taken.length})`);
approx(burns[0].dmg.amount, u.s.atk * bb.ep_damage_ratio, 'ep_damage_ratio × ATK');
done(h);
}
// the default skill carries no taunt
const d = run({ units: [{ chessId: 'chess_char_6_03_a', row: 10, col: 4 }] });
d.step();
assert.equal(d.unit('chess_char_6_03_a').s.taunt, 0);
});
test('6_03 余 S2 厚礼上宾: cast with an enemy on its x-1 (SKILL_RANGE — a deliberate deviation from the 重装 TAKE_DAMAGE, DESIGN §22.10); atk_scale × ATK arts around him, reachable ground enemies teleported onto his tile; block +2, HP/ATK +, arts attacks', () => {
for (const id of both('chess_char_6_03')) {
const sid = 'skchr_yu_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e', { atk: 100, bat: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })],
enemies: [{ key: 'e', pos: [10, 4] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.equal(started(h, u)[0].reason, 'SKILL_RANGE');
const t0 = started(h, u)[0].t;
const burst = dealt(h, u, (c) => c.t === t0 && c.dmg.isSkill && !c.dmg.isAttack);
assert.equal(burst.length, 3);
for (const x of burst) { assert.equal(x.type, 'arts'); approx(x.amount, u.s.atk * bb.atk_scale, 'atk_scale × ATK'); }
for (const e of h.enemies()) assert.deepEqual([Math.round(e.y), Math.round(e.x)], [10, 4], 'teleported');
h.step();
assert.equal(u.s.blockCnt, u.base.blockCnt + bb.block_cnt);
assert.ok(h.enemies().every((e) => e.blockedBy === u), 'all blocked by him');
approx(skillBuff(u).mods.atkPct, bb.atk);
h.run(3);
const atk = dealt(h, u, (c) => c.dmg.isAttack && c.t > t0);
assert.ok(atk.length > 0 && atk.every((c) => c.type === 'arts'), 'normal attacks deal arts');
done(h);
}
});
// =================================================================================================================
// 6_04 浊心斯卡蒂
test('6_04 浊心斯卡蒂 S1 同归殊途之吟: SP_FULL, full HP + max HP +; trait (生命回复速度) raised; 50 % of the damage of allies in range goes to her', () => {
for (const id of both('chess_char_6_04')) {
const sid = 'skchr_skadi2_1', bb = bbOf(id, sid);
const h = run({
defs: { chess: { ally_a: plain('ally_a', { stats: { def: 0, maxHp: 1e6 } }) }, enemies: { hit: enemyRec({ key: 'hit', hp: 1e7, speed: 0, atk: 1000, bat: 2 }) } },
units: [U(id, sid, 10, 4, { carryState: READY }), { chessId: 'ally_a', row: 10, col: 5 }], enemies: [{ key: 'hit', pos: [10, 5], time: 1 }],
});
const u = h.unit(id), a = h.unit('ally_a');
usesSkill(u, sid);
h.step(2);
assert.ok(u.skill.active, 'SP_FULL');
approx(skillBuff(u).mods.hpPct, bb.max_hp);
assert.ok(u.hp >= u.s.maxHp - 1, 'full HP');
h.run(5);
const onAlly = h.hooksOf('damaged').filter((c) => c.target === a && isKey(c.source, 'hit'));
const onHer = h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('transfer'));
assert.ok(onAlly.length >= 1 && onHer.length === onAlly.length, 'every hit on the ally is shared');
approx(onAlly[0].amount, 1000 * (1 - bb.damage_resistance), 'the ally takes the rest');
approx(onHer[0].amount, 1000 * bb.damage_resistance, 'she takes the transferred part');
assert.equal(onHer[0].type, 'true');
// the trait is an hpRegen buff on the ally (PRTS 分支特性信息 吟游者; professions.js bardRegen), no heal of hers
approx(a.findBuff(`trait:bard:${u.id}`)?.mods.hpRegen ?? 0, u.s.atk * bb['attack@atk_to_hp_recovery_ratio'], 'trait raised');
assert.equal(heals(h, u, (c) => c.target === a).length, 0, 'no heal of hers');
done(h);
}
});
test('6_04 浊心斯卡蒂 S2 同葬无光之愿: toggle; 鼓舞 ATK and DEF (atk / def × hers) on the other allies of her range, trait 16/17 % (生命回复速度)', () => {
for (const id of both('chess_char_6_04')) {
const sid = 'skchr_skadi2_2', bb = bbOf(id, sid);
const h = run({
defs: { chess: { ally_a: plain('ally_a', { stats: { maxHp: 1e5, atk: 400, def: 100 } }) }, enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 4, { carryState: READY }), { chessId: 'ally_a', row: 10, col: 5 }], enemies: [{ key: 'e', pos: [10, 6] }],
});
const u = h.unit(id), a = h.unit('ally_a');
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.kind, 'toggle');
h.run(1.1);
const ia = a.findBuff('inspire'), id2 = a.findBuff('inspire:def');
assert.ok(ia && id2, '鼓舞 ATK + DEF');
approx(ia.data.val, u.s.atk * bb.atk, 'ATK 鼓舞', 1e-3);
approx(id2.data.val, u.s.def * bb.def, 'DEF 鼓舞', 1e-3);
approx(a.s.def, a.base.def + u.s.def * bb.def, 'DEF raised by the flat value', 1e-3);
assert.ok(!u.findBuff('inspire') && !u.findBuff('inspire:def'), 'never on herself');
a.hp = a.s.maxHp * 0.5;
const hp0 = a.hp, t1 = h.b.time;
h.run(1.1);
const v = u.s.atk * bb['attack@atk_to_hp_recovery_ratio'];
approx(a.findBuff(`trait:bard:${u.id}`)?.mods.hpRegen ?? 0, v, 'trait ratio');
assert.ok(Math.abs(a.hp - hp0 - (a.s.hpRegen) * (h.b.time - t1)) <= 1.5, `regenerated ${a.hp - hp0}`);
done(h);
}
});
test('6_04 浊心斯卡蒂 S2 同葬无光之愿 (自动触发, effects on her allies only): on as soon as its SP is full — no enemy needed', () => {
for (const id of both('chess_char_6_04')) {
const sid = 'skchr_skadi2_2';
const h = run({
defs: { chess: { ally_a: plain('ally_a', { stats: { maxHp: 1e5, atk: 400, def: 100 } }) } },
units: [U(id, sid, 10, 4, { carryState: READY }), { chessId: 'ally_a', row: 10, col: 5 }],
});
const u = h.unit(id), a = h.unit('ally_a');
usesSkill(u, sid);
assert.equal(u.skill.rule, 'SP_FULL');
assert.ok(h.runUntil(() => u.skill.active, 2), 'on with nobody on the field (the data DEFAULT waited for an enemy in her range)');
h.run(1.1);
assert.ok(a.findBuff('inspire') && a.findBuff('inspire:def'), '鼓舞 on the ally beside her');
done(h);
}
});
// =================================================================================================================
// 6_05 异客
test('6_05 异客 S1 电能之触: next attack atk_scale × ATK, 4 bounces with the trait falloff (module: 0.9 / none: 0.85), 1.5 s 停顿', () => {
for (const id of both('chess_char_6_05')) {
const sid = 'skchr_pasngr_1', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 4, { carryState: { sp: 5 } })], // (孤卒's aura settled before the cast)
enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }, { key: 'e', pos: [11, 7] }, { key: 'e', pos: [11, 8] }, { key: 'e', pos: [12, 8] }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5));
h.run(0.8); // bolt in flight
const a = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
const aid = hits[0].dmg.attackId;
const one = hits.filter((c) => c.dmg.attackId === aid);
assert.equal(one.length, bb['pasngr_s_1.max_target'], '4 targets');
const main = one[0];
approx(main.amount, u.s.atk * bb['pasngr_s_1.atk_scale'] * tal(id)['pasngr_t_1[enhance].damage_scale'], 'main (机理分析 on a full-HP target)');
const tb = tbOf(id);
const fall = tb['skill@pasngr_s_1.chain.atk_scale'] != null ? 1 - tb['skill@pasngr_s_1.chain.atk_scale'] : 0.15;
for (let k = 1; k < one.length; k++) approx(one[k].amount / main.amount, Math.pow(1 - fall, k), `bounce ${k}`);
const sl = statuses(h, 'sluggish', (c) => c.source === u && c.t >= a.t);
assert.ok(sl.some((c) => Math.abs(c.duration - bb['pasngr_s_1.sluggish']) < 1e-9), '1.5 s 停顿');
done(h);
}
});
test('6_05 异客 S2 聚焦指令: range +1, ATK +, base attack time ×0.7/×0.6 (akdata 乘算负数), 5 bounces', () => {
for (const id of both('chess_char_6_05')) {
const sid = 'skchr_pasngr_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 4, { carryState: READY })],
enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }, { key: 'e', pos: [11, 7] }, { key: 'e', pos: [11, 8] }, { key: 'e', pos: [12, 8] }, { key: 'e', pos: [12, 9] }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time ×(1 − 0.3 / 0.4), not a flat −0.3 / −0.4 s');
approx(skillBuff(u).mods.atkPct, bb.atk);
assert.ok(u.rangeKeySet.has(10 * COLS + 8) && !u.baseRangeKeys.includes(10 * COLS + 8), 'range +1');
h.run(3);
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
const aid = hits[0].dmg.attackId;
assert.equal(hits.filter((c) => c.dmg.attackId === aid).length, bb['attack@max_target'], '5 targets');
done(h);
}
});
// =================================================================================================================
// 6_06 佩佩
test('6_06 佩佩 S1 盖戳!: next attack atk_scale × ATK; cast while stunned, cleansing the stun', () => {
for (const id of both('chess_char_6_06')) {
const sid = 'skchr_pepe_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5));
const main = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill && !c.dmg.isSplash)[0];
approx(main.amount, u.s.atk * bb.atk_scale, 'atk_scale × ATK');
done(h);
const g = run({ units: [U(id, sid, 10, 4, { carryState: READY })] });
const v = g.unit(id);
g.step();
g.b.applyStatus(v, 'stun', { duration: 10, force: true });
assert.ok(v.findBuff('stun'));
g.step(2);
assert.ok(!v.findBuff('stun'), 'cleansed');
assert.equal(started(g, v)[0]?.reason, 'abnormal');
assert.ok(v.skill.active && v.skill.pending, 'the stamp waits for her next attack');
done(g);
}
});
test('6_06 佩佩 S2 阻遏混乱锤: skill range, ATK/ASPD +, random targets; every use adds ASPD +attack_speed_extra to later casts (2 stacks)', () => {
for (const id of both('chess_char_6_06')) {
const sid = 'skchr_pepe_2', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4)], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
usesSkill(u, sid);
h.step();
const as0 = u.s.aspd;
const aspdAt = () => { u.markDirty(); return u.s.aspd; };
assert.ok(u.skill.activate('test', { free: true }));
approx(aspdAt(), as0 + bb.attack_speed, '1st use');
approx(skillBuff(u).mods.atkPct, bb.atk);
assert.ok(u.rangeKeySet.has(10 * COLS + 6), 'skill range reaches 2 tiles');
h.run(2.5);
assert.ok(dealt(h, u, (c) => c.dmg.isAttack).some((c) => c.target.x > 5.5), 'hits the enemy only the skill range reaches');
u.skill.end('test');
assert.ok(u.skill.activate('test', { free: true }));
approx(aspdAt(), as0 + bb.attack_speed + bb.attack_speed_extra, '2nd use');
u.skill.end('test');
assert.ok(u.skill.activate('test', { free: true }));
approx(aspdAt(), as0 + bb.attack_speed + bb.attack_speed_extra * bb.max_stack_cnt, '3rd use (max stacks)');
u.skill.end('test');
u.skill.activate('test', { free: true });
approx(aspdAt(), as0 + bb.attack_speed + bb.attack_speed_extra * bb.max_stack_cnt, 'capped');
done(h);
}
});
// =================================================================================================================
// 6_07 维娜·维多利亚
test('6_07 维娜 S1 重铸晖光: next attack adds atk_scale × ATK true damage to every ground enemy around her', () => {
for (const id of both('chess_char_6_07')) {
const sid = 'skchr_siege2_1', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [9, 4] }, { key: 'e', pos: [11, 4] }, { key: 'e', pos: [10, 3] }, { key: 'e', pos: [11, 5] }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5));
const tr = dealt(h, u, (c) => c.type === 'true' && c.dmg.isSkill);
assert.equal(tr.length, 4, 'the 4 enemies of the plus shape (not the diagonal one)');
for (const x of tr) approx(x.amount, u.s.atk * bb.atk_scale, 'atk_scale × ATK true');
done(h);
}
});
test('6_07 维娜 S2 进赴故土: passive SP +0.3/0.5 with ≥2 allies around her; toggle: ATK +, range +1, 2 targets', () => {
for (const id of both('chess_char_6_07')) {
const sid = 'skchr_siege2_2', bb = bbOf(id, sid);
const p = run({
defs: { chess: { a1: plain('a1'), a2: plain('a2') } },
units: [U(id, sid, 10, 4, { carryState: IDLE }), { chessId: 'a1', row: 9, col: 4 }, { chessId: 'a2', row: 11, col: 4 }],
});
const pu = p.unit(id);
p.run(0.5);
approx(pu.s.spRecovery, pu.base.spRecovery + bb.sp_recovery_per_sec, 'passive SP');
done(p);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.kind, 'toggle');
approx(skillBuff(u).mods.atkPct, bb.atk);
const t0 = h.b.time;
h.run(3);
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.t > t0);
assert.equal(new Set(hits.map((c) => c.target)).size, 2, 'both enemies (the far one only via range +1)');
done(h);
}
});
// =================================================================================================================
// 6_08 焰影苇草
test('6_08 焰影苇草 S1 迅捷打击·γ型: ATK / ASPD +', () => {
for (const id of both('chess_char_6_08')) {
const sid = 'skcom_quickattack[3]', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(skillBuff(u).mods.atkPct, bb.atk);
approx(skillBuff(u).mods.aspd, bb.attack_speed);
approx(u.skill.duration, rec(id, sid).duration);
done(h);
}
});
test('6_08 焰影苇草 S2 枯荣共息: fireballs on ground operators of her range: every 1.5 s atk_scale × her ATK arts, healing only the carrier', () => {
for (const id of both('chess_char_6_08')) {
const sid = 'skchr_reed2_2', bb = bbOf(id, sid);
const h = run({
defs: { chess: { g1: plain('g1', { stats: { maxHp: 1e5 } }), g2: plain('g2', { stats: { maxHp: 1e5 } }) }, enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 3, { carryState: READY }), { chessId: 'g1', row: 10, col: 5 }, { chessId: 'g2', row: 11, col: 5 }],
enemies: [{ key: 'e', pos: [10, 6] }],
});
const u = h.unit(id), g1 = h.unit('g1'), g2 = h.unit('g2');
usesSkill(u, sid);
h.step();
g1.hp = g1.s.maxHp * 0.5; g2.hp = g2.s.maxHp * 0.5;
assert.ok(h.runUntil(() => u.skill.active, 5));
const carriers = u.mem.reedFire.map((f) => f.a);
assert.equal(carriers.length, bb.max_target, `${bb.max_target} carrier(s)`);
const t0 = h.b.time;
h.run(bb.cooldown * 2 + 0.05);
const fb = dealt(h, u, (c) => (c.dmg.tags || []).includes('fireball'));
assert.equal(fb.length, 2 * carriers.length, 'one shot per carrier every cooldown');
approx(fb[0].t - t0, bb.cooldown, 'cooldown', 0.05);
for (const x of fb) {
const r = x.amount / (u.s.atk * bb.atk_scale);
assert.ok(Math.abs(r - 1) < 1e-6 || Math.abs(r - tal(id).damage_scale) < 1e-6, `atk_scale × ATK (灼痕 fragile allowed): ${r}`);
}
// her trait on each fireball heals only its carrier: trait scale × the damage (× 映耀's boost on the elite)
const want = tbOf(id).scale * (tal(id, null, 1).heal_scale ?? 1);
for (const x of fb) {
const same = fb.filter((y) => Math.abs(y.t - x.t) < 1e-9 && Math.abs(y.amount - x.amount) < 1e-6 * x.amount).length;
const fh = heals(h, u, (c) => c.opts?.tags?.includes('incantation') && Math.abs(c.t - x.t) < 1e-9 && Math.abs(c.amount - x.amount * want) < 1e-6 * x.amount);
assert.equal(fh.length, same, 'one trait heal per fireball');
assert.ok(fh.every((c) => carriers.includes(c.target)), 'on a carrier');
}
done(h);
}
});
// =================================================================================================================
// 6_09 塑心
test('6_09 塑心 S2 “安魂的弥撒”: normal attacks allowed; ASPD +, 2 targets; her and the top-ATK ally\'s damage adds ep_damage_ratio × her ATK 凋亡', () => {
for (const id of both('chess_char_6_09')) {
const sid = 'skchr_cello_2', bb = bbOf(id, sid);
const idle = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: IDLE })], enemies: [{ key: 'e', pos: [10, 6] }] });
const iu = idle.unit(id);
idle.run(3);
assert.ok(idle.hooksOf('attack').some((c) => c.attacker === iu && !c.isSkill), 'attacks without the skill (S1-only rule)');
done(idle);
const h = run({
defs: { chess: { big: plain('big', { stats: { atk: 2000 } }), small: plain('small', { stats: { atk: 100 } }) }, enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 4, { carryState: READY }), { chessId: 'big', row: 10, col: 5 }, { chessId: 'small', row: 11, col: 5 }],
enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 6] }],
});
const u = h.unit(id), big = h.unit('big'), small = h.unit('small');
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(skillBuff(u).mods.aspd, bb.attack_speed);
const t0 = started(h, u)[0].t;
assert.equal(u.mem.celloPartner, big, 'highest-ATK other operator of her range');
h.run(1.5); // (before a 凋亡 burst locks the gauges)
assert.ok(h.enemies().every((e) => !e.findBuff('apoptosisBurst')));
const direct = (c) => c.type !== 'element' && c.type !== 'elemental' && c.amount > 0 && c.target.side === 'enemy' && c.t >= t0 - 1e-9;
const fills = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'apoptosis' && (c.dmg.tags || []).includes('skill'));
const own = h.hooksOf('damaged').filter((c) => (c.source === u || c.source === big) && direct(c));
assert.ok(h.hooksOf('damaged').some((c) => c.source === big && direct(c)) && h.hooksOf('damaged').some((c) => c.source === small && direct(c)), 'both allies hit');
assert.equal(fills.length, own.length, 'one 凋亡 per damage of hers or the partner\'s — none for the other ally');
approx(fills[0].dmg.amount, u.s.atk * bb.ep_damage_ratio, 'ep_damage_ratio × her ATK', 1e-3);
const hits = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.ok(hits.some((c) => c.targets.length === 2), '2 targets');
done(h);
}
});
test('6_09 塑心 S3 “自由的探戈”: no attack, skill range, ATK +; 精神逆构 ×scale_delta_to_one; top max HP / ATK / DEF allies +20/25 %', () => {
for (const id of both('chess_char_6_09')) {
const sid = 'skchr_cello_3', bb = bbOf(id, sid);
const h = run({
defs: { chess: { tank: plain('tank', { stats: { maxHp: 9000, atk: 100, def: 900 } }), dps: plain('dps', { stats: { maxHp: 1000, atk: 1500, def: 50 } }) }, enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 4, { carryState: READY }), { chessId: 'tank', row: 10, col: 5 }, { chessId: 'dps', row: 11, col: 5 }],
enemies: [{ key: 'e', pos: [10, 6] }],
});
const u = h.unit(id), tank = h.unit('tank'), dps = h.unit('dps');
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(skillBuff(u).mods.atkPct, bb.atk);
assert.ok(u.rangeKeySet.has(10 * COLS + 7) && !u.baseRangeKeys.includes(10 * COLS + 7), 'range expanded');
const t0 = h.b.time;
h.run(3);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length, 0, 'stops attacking');
approx(tank.findBuff('cello:tango:hp').mods.hpPct, bb['cello_s_3[max_hp].max_hp']);
approx(tank.findBuff('cello:tango:def').mods.defPct, bb['cello_s_3[def].def']);
approx(dps.findBuff('cello:tango:atk').mods.atkPct, bb['cello_s_3[atk].atk']);
assert.ok(!dps.findBuff('cello:tango:hp') && !tank.findBuff('cello:tango:atk'));
const fills = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'apoptosis' && c.t > t0);
assert.ok(fills.length > 0);
approx(fills[0].dmg.mul, 1 + (tal(id, null, 1).ep_damage_scale - 1) * bb.scale_delta_to_one, '精神逆构 × N', 1e-9);
done(h);
}
});
// =================================================================================================================
// 6_11 缪尔赛思
function mlyssBattle(id, sid, extra = {}) {
const log = [];
const h = run({
defs: { chess: { guard: plain('guard', { profession: 'WARRIOR', stats: { maxHp: 3000, atk: 800, def: 100 } }), ...(extra.chess || {}) }, enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 3, extra.unit ?? { carryState: READY }), { chessId: extra.copyId ?? 'guard', row: 11, col: 5 }, { kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 10, col: 5, ownerUid: 1 }],
enemies: [{ key: 'e', pos: [10, 6], time: extra.enemyAt ?? 7 }],
flags: { dpInit: 0, dpPerSec: 0 },
setup(b) { const add = b.addDp.bind(b); b.addDp = (pid, n) => { log.push({ t: b.time, n }); return add(pid, n); }; },
});
h.dpLog = log;
return h;
}
test('6_11 缪尔赛思 S1 渐进性润化: fake_cost DP gradually over the skill; she and the 流形 ATK / ASPD +', () => {
for (const id of both('chess_char_6_11')) {
const sid = 'skchr_mlyss_1', bb = bbOf(id, sid);
const h = mlyssBattle(id, sid);
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 20));
const t0 = h.b.time;
approx(skillBuff(u).mods.atkPct, bb.atk);
approx(skillBuff(u).mods.aspd, bb.attack_speed);
const tok = h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(tok.findBuff('mlyss:s3') && tok.findBuff('mlyss:s3').mods.aspd === bb.attack_speed, '流形 buffed too');
h.run(bb['mlyss_s_1[cost].interval'] * 3 + 0.05);
const gains = h.dpLog.filter((x) => x.t >= t0 - 1e-9);
assert.equal(gains.length, 3, 'one DP per interval');
assert.ok(h.runUntil(() => !u.skill.active, 20));
const total = h.dpLog.filter((x) => x.t >= t0 - 1e-9).reduce((s, x) => s + x.n, 0);
assert.equal(total, bb.fake_cost, `${bb.fake_cost} DP in all`);
done(h);
}
});
test('6_11 缪尔赛思 S2 生态耦合: +cost DP; melee copies regenerate and take 庇护, ranged copies double-hit random targets', () => {
for (const id of both('chess_char_6_11')) {
const sid = 'skchr_mlyss_2', bb = bbOf(id, sid);
const h = mlyssBattle(id, sid);
const u = h.unit(id);
usesSkill(u, sid);
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => u.skill.active, 20));
assert.ok(h.dpLog.some((x) => x.n === bb.cost), `+${bb.cost} DP`);
assert.equal(tok().mem.mlyss?.ranged, false, 'melee copy of the guard');
h.run(0.3);
const eco = tok().findBuff('mlyss:eco');
assert.ok(eco, 'melee copy buffed');
approx(eco.mods.hpRegenRatio, bb.hp_recovery_per_sec_by_max_hp_ratio);
// 庇护: the shared effect of every source (同名效果取最高 — kits/shared/tier1.js holdProtect)
const protect = tok().findBuff('protect');
assert.ok(protect && protect.source === u, 'the 庇护 she grants');
approx(protect.mods.physTakenMul, 1 - bb.damage_resistance);
approx(protect.mods.artsTakenMul, 1 - bb.damage_resistance);
done(h);
// ranged copy: a sniper to copy
const r = mlyssBattle(id, sid, { copyId: 'chess_char_1_01_a' });
const ru = r.unit(id);
assert.ok(r.runUntil(() => ru.skill.active, 20));
const rt = r.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive && x.mem.mlyss);
assert.equal(rt.mem.mlyss.ranged, true);
r.run(0.3);
assert.equal(rt.profile.hits, 2 * rt.mem.mlyssHits, '二连击');
assert.ok(r.runUntil(() => !ru.skill.active, 20));
r.run(0.3);
assert.equal(rt.profile.hits, rt.mem.mlyssHits, 'back to one hit');
done(r);
}
});
// =================================================================================================================
// 6_12 迷迭香
test('6_12 迷迭香 S1 思维膨大: the next attack adds extra_atk_scale × ATK arts to every enemy it hits', () => {
for (const id of both('chess_char_6_12')) {
const sid = 'skchr_rosmon_1', bb = bbOf(id, sid);
// the second enemy inside the 投掷手 splash (PRTS 溅射半径一览 0.9) of the first
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 7.6] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.type === 'arts').length >= 2, 6));
const extra = dealt(h, u, (c) => c.type === 'arts' && c.dmg.isSkill);
assert.equal(new Set(extra.map((c) => c.target)).size, 2, 'main + splash');
for (const x of extra) approx(x.amount, u.s.atk * bb.extra_atk_scale, 'extra arts');
done(h);
}
});
test('6_12 迷迭香 S3 “如你所愿”: two 战术装备 in range, base attack time ×0.5 (akdata 乘算负数), ATK +, 2 targets — blocked enemies only', () => {
for (const id of both('chess_char_6_12')) {
const sid = 'skchr_rosmon_3', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e'), f: dummy('f', { motion: 'FLY' }) } },
units: [U(id, sid, 10, 3, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 7] }, { key: 'e', pos: [9, 8] }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
const gears = h.b.allyUnits.filter((t) => t.defId === 'token_10012_rosmon_shield' && t.alive);
assert.equal(gears.length, 2, 'two gears');
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time ×0.5, not a flat −0.5 s');
approx(skillBuff(u).mods.atkPct, bb.atk);
const t0 = h.b.time;
h.run(6);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0);
assert.ok(atks.length > 0);
for (const a of atks) for (const e of a.targets) assert.ok(e.blockedBy, 'blocked targets only');
assert.ok(atks.some((a) => a.targets.length === 2) || h.enemies().filter((e) => e.blockedBy).length < 2, '2 targets');
assert.ok(statuses(h, 'stun', (c) => c.source?.defId === 'token_10012_rosmon_shield').length > 0, 'gear appear stun');
done(h);
}
});
// =================================================================================================================
// 6_13 新约能天使
test('6_13 新约能天使 S1 天空大扫除: 8 bullets at attack@atk_scale × ATK, flyers first; stopped by hand ⇒ the rest is fired at random', () => {
for (const id of both('chess_char_6_13')) {
const sid = 'skchr_angel2_1', bb = bbOf(id, sid);
const mk = () => run({
defs: { enemies: { g: dummy('g'), f: dummy('f', { motion: 'FLY' }) } },
units: [U(id, sid, 10, 4, { carryState: { sp: rec(id, sid).spCost - 1 } })], // (铳弹协约's aura settled first)
enemies: [{ key: 'g', pos: [10, 5] }, { key: 'f', pos: [10, 7] }],
});
const h = mk();
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.ammoLeft + h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length, bb['attack@trigger_time']);
const t0 = h.b.time;
assert.ok(h.runUntil(() => !u.skill.active, 60));
h.run(1); // shots in flight
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill && c.t >= t0 - 1e-9);
assert.equal(hits.length, bb['attack@trigger_time']);
assert.ok(hits.every((c) => isKey(c.target, 'f')), 'the flyer first');
for (const x of hits) approx(x.amount, u.s.atk * bb['attack@atk_scale'], 'atk_scale');
done(h);
const g = mk();
const v = g.unit(id);
assert.ok(g.runUntil(() => g.hooksOf('attack').filter((c) => c.attacker === v && c.isSkill).length >= 2, 12));
const left = v.mem.angelLeft;
assert.equal(left, bb['attack@trigger_time'] - 2);
const used0 = g.hooksOf('ammoUsed').filter((c) => c.unit === v).length;
v.skill.stop();
const volley = dealt(g, v, tagged('volley'));
assert.equal(volley.length, left, 'every remaining bullet fired');
assert.equal(g.hooksOf('ammoUsed').filter((c) => c.unit === v).length - used0, left);
done(g);
}
});
test('6_13 新约能天使 S3 使命必达!: 5 hits per attack spending 5 bullets; 投递坐标 after deploy: bombardment + a knocked-out ground operator delivered with 6 SP', () => {
for (const id of both('chess_char_6_13')) {
const sid = 'skchr_angel2_3', bb = bbOf(id, sid);
const h = run({
defs: { chess: { vg: plain('vg', { skill: { skillId: 'sk_vg', spCost: 30, initSp: 0 }, stats: { respawnTime: 60 } }) }, enemies: { e: dummy('e') } },
units: [U(id, sid, 10, 3, { carryState: { sp: rec(id, sid).spCost - 2 } }), { chessId: 'vg', row: 12, col: 3 }], enemies: [{ key: 'e', pos: [10, 6] }],
});
const u = h.unit(id), vg = h.unit('vg');
usesSkill(u, sid);
h.step(2);
const coord = h.b.allyUnits.find((t) => t.defId === 'token_10056_angel2_target' && t.alive);
assert.ok(coord, '投递坐标 placed after deployment (next to the enemy entering her range)');
assert.ok(Math.hypot(coord.x - 6, coord.y - 10) <= 1 + 1e-9, 'on the enemy side');
const at = [coord.tileR, coord.tileC];
h.b.dealDamage(null, vg, { amount: 1e9, type: 'true' });
assert.ok(!vg.alive);
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.ok(h.b.time > 1, 'cast after the knock-out');
approx(skillBuff(u).mods.atkPct, bb.atk);
assert.ok(vg.alive, 'delivered');
assert.deepEqual([vg.tileR, vg.tileC], at, 'onto the coordinate');
assert.ok(vg.skill.sp >= bb['attack@sp'] - 1e-9 && vg.skill.sp < bb['attack@sp'] + 0.2, `+${bb['attack@sp']} SP (${vg.skill.sp})`);
const boom = dealt(h, u, tagged('delivery'));
assert.equal(boom.length, 1);
approx(boom[0].amount, u.s.atk * bb['attack@cannon_atk_scale'], 'cannon');
h.run(4);
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
const aid = hits[0].dmg.attackId;
assert.equal(hits.filter((c) => c.dmg.attackId === aid).length, 5, '5 连击');
for (const x of hits) approx(x.amount, u.s.atk * bb['attack@atk_scale'], 'atk_scale per hit');
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length;
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 5 * atks, '5 bullets per attack');
done(h);
}
});
// =================================================================================================================
// 6_14 流明
test('6_14 流明 S1 沐雨: the next heal gives its target and the allies around it aura.heal_scale × ATK per second', () => {
for (const id of both('chess_char_6_14')) {
const sid = 'skchr_lumen_1', bb = bbOf(id, sid);
const h = run({
defs: { chess: { a1: plain('a1', { stats: { maxHp: 1e5 } }), a2: plain('a2', { stats: { maxHp: 1e5 } }), far: plain('far', { stats: { maxHp: 1e5 } }) } },
units: [U(id, sid, 10, 3, { carryState: READY }), { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 5 }, { chessId: 'far', row: 9, col: 7 }],
});
const u = h.unit(id), a1 = h.unit('a1'), a2 = h.unit('a2'), far = h.unit('far');
usesSkill(u, sid);
h.step();
a1.hp = a1.s.maxHp * 0.3; a2.hp = a2.s.maxHp * 0.9; far.hp = far.s.maxHp * 0.9;
assert.ok(h.runUntil(() => started(h, u).length > 0, 5));
h.run(0.5);
const key = `lumen:rain:${u.id}`;
assert.ok(a1.findBuff(key) && a2.findBuff(key), 'target and its neighbour');
assert.ok(!far.findBuff(key), 'not a distant ally');
approx(a1.findBuff(key).duration, bb['aura.projectile_life_time']);
h.run(1.1);
const hot = heals(h, u, (c) => c.target === a2 && c.opts?.hot);
assert.ok(hot.length >= 1);
approx(hot[0].amount, u.s.atk * bb['aura.heal_scale'], 'per second');
done(h);
}
});
test('6_14 流明 S2 沛霖: 2 charges; heals 2 allies for heal_scale × ATK; with full charges also cleanses their 异常状态', () => {
for (const id of both('chess_char_6_14')) {
const sid = 'skchr_lumen_2', bb = bbOf(id, sid);
const h = run({
defs: { chess: { a1: plain('a1', { stats: { maxHp: 1e5 } }), a2: plain('a2', { stats: { maxHp: 1e5 } }), a3: plain('a3', { stats: { maxHp: 1e5 } }) } },
units: [U(id, sid, 10, 3, { carryState: READY }), { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 5 }, { chessId: 'a3', row: 9, col: 5 }],
});
const u = h.unit(id), a1 = h.unit('a1'), a2 = h.unit('a2'), a3 = h.unit('a3');
usesSkill(u, sid);
assert.equal(u.skill.maxCharges, 2);
h.step();
a1.hp = a1.s.maxHp * 0.3; a2.hp = a2.s.maxHp * 0.5; a3.hp = a3.s.maxHp * 0.7;
h.b.applyStatus(a1, 'stun', { duration: 30, force: true });
assert.ok(h.runUntil(() => started(h, u).length > 0, 5));
const t0 = started(h, u)[0].t;
const hh = heals(h, u, (c) => c.t === t0 && c.opts?.skillHeal);
assert.equal(hh.length, bb.max_target, 'two allies');
assert.deepEqual(hh.map((c) => c.target).sort((a, b) => a.id - b.id), [a1, a2].sort((a, b) => a.id - b.id), 'the most injured');
for (const c of hh) approx(c.amount, u.s.atk * bb.heal_scale, 'heal_scale × ATK');
assert.ok(!a1.findBuff('stun'), 'full charges ⇒ cleansed');
done(h);
}
});
// =================================================================================================================
// 6_15 仇白
test('6_15 仇白 S1 留羽: the next attack binds `duration` s; when it ends the target and its neighbours take aoe_scale × ATK arts', () => {
for (const id of both('chess_char_6_15')) {
const sid = 'skchr_qiubai_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5));
const a = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
const own = (c) => c.source === u && c.t >= a.t - 1e-9 && Math.abs(c.duration - bb.duration) < 1e-9;
assert.ok(h.runUntil(() => statuses(h, 'bind', own).length > 0, 2));
const bind = statuses(h, 'bind', own);
approx(bind[0].duration, bb.duration, 'bound for `duration`');
const tb0 = bind[0].t;
h.run(bb.duration + 0.1);
const aoe = dealt(h, u, (c) => c.dmg.isSkill && !c.dmg.isAttack && c.type === 'arts');
assert.equal(aoe.length, 2, 'target + neighbour');
approx(aoe[0].t - tb0, bb.duration, 'when the bind ends', 0.02);
void a;
for (const x of aoe) approx(x.amount, u.s.atk * bb.aoe_scale, 'aoe_scale × ATK');
done(h);
}
});
test('6_15 仇白 S2 承影: begin arts burst, skill range + ATK +, ground enemies in range 停顿, end physical burst', () => {
for (const id of both('chess_char_6_15')) {
const sid = 'skchr_qiubai_2', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 8] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 10));
const t0 = started(h, u)[0].t;
approx(skillBuff(u).mods.atkPct, bb.atk);
const begin = dealt(h, u, (c) => c.t === t0 && c.dmg.isSkill && c.type === 'arts');
assert.equal(begin.length, 2, 'both ground enemies of the 5-tile line');
for (const x of begin) approx(x.amount, u.s.atk * bb.sword_begin_atk_scale, 'begin');
h.run(1);
assert.ok(h.enemies().every((e) => e.findBuff('sluggish')), '停顿');
const atkEnd = u.s.atk;
assert.ok(h.runUntil(() => !u.skill.active, 10));
const end = dealt(h, u, (c) => c.dmg.isSkill && c.type === 'phys' && !c.dmg.isAttack);
assert.equal(end.length, 2);
for (const x of end) approx(x.amount, atkEnd * bb.sword_end_atk_scale, 'end');
approx(end[0].t - t0, rec(id, sid).duration, 'at the end', 0.05);
done(h);
}
});
// =================================================================================================================
// 6_16 溯光星源
test('6_16 溯光星源 S1 星图闪烁: ATK +; every attack bounces 3 more times between enemies (back and forth)', () => {
for (const id of both('chess_char_6_16')) {
const sid = 'skchr_halo2_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(skillBuff(u).mods.atkPct, bb.atk);
assert.ok(h.runUntil(() => dealt(h, u, tagged('chain')).length >= bb['attack@chain.max_target'], 5));
const ch = dealt(h, u, tagged('chain')).slice(0, bb['attack@chain.max_target']);
for (let i = 1; i < ch.length; i++) assert.notEqual(ch[i].target, ch[i - 1].target, 'never twice in a row');
assert.equal(ch[0].target, ch[2].target, 'back and forth');
for (const x of ch) approx(x.amount, u.s.atk, 'full hits');
done(h);
}
});
test('6_16 溯光星源 S2 星束引力: the target farthest from its goal, atk_scale × ATK + 3 s 停顿; up to 2 linked neighbours pulled in and hit', () => {
for (const id of both('chess_char_6_16')) {
const sid = 'skchr_halo2_2', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5));
const [n0, f0] = [enemyOf(h, 'e', 0), enemyOf(h, 'e', 1)];
const a = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
const main = n0.x > f0.x ? n0 : f0, side = main === n0 ? f0 : n0;
assert.deepEqual(a.targets, [main], 'the enemy farthest from its goal');
const x0 = 5;
h.run(1);
const hit = dealt(h, u, (c) => c.target === main && c.dmg.isAttack && c.dmg.isSkill)[0];
approx(hit.amount, u.s.atk * bb.atk_scale, 'atk_scale');
assert.ok(statuses(h, 'sluggish', (c) => c.source === u && c.target === main && Math.abs(c.duration - bb.sluggish) < 1e-9).length === 1, '3 s 停顿');
const link = dealt(h, u, (c) => c.target === side && (c.dmg.tags || []).includes('link'));
assert.equal(link.length, 1, 'linked neighbour hit');
approx(link[0].amount, u.s.atk * bb.atk_scale_link, 'link');
assert.ok(side.x > x0 + 0.05 || main.x < side.x, `pulled toward the target (${x0} → ${side.x})`);
done(h);
}
});
// =================================================================================================================
// 6_17 耀骑士临光
test('6_17 耀骑士临光 S1 灿焰长刃: toggle, skill range, ATK / ASPD +', () => {
for (const id of both('chess_char_6_17')) {
const sid = 'skchr_nearl2_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.kind, 'toggle');
approx(skillBuff(u).mods.atkPct, bb.atk);
approx(skillBuff(u).mods.aspd, bb.attack_speed);
assert.ok(u.rangeKeySet.has(10 * COLS + 6));
done(h);
}
});
test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers for the skill time, then she withdraws; redeploy ×1.25 (×1 after a 卡西米尔 deploy)', () => {
for (const id of both('chess_char_6_17')) {
const sid = 'skchr_nearl2_2', bb = bbOf(id, sid), dur = rec(id, sid).duration;
const h = run({
defs: { enemies: { hit: enemyRec({ key: 'hit', hp: 1e7, speed: 0, atk: 500, bat: 1 }) } },
units: [U(id, sid, 10, 4)], enemies: [{ key: 'hit', pos: [10, 4] }],
});
const u = h.unit(id);
usesSkill(u, sid);
h.step();
approx(u.skill.spec.mods.atkPct, bb.atk);
assert.equal(u.skill.kind, 'duration');
assert.equal(u.skill.active, true);
assert.equal(u.skill.ready, false);
assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], dur);
assert.equal(u.findBuff('nearl2:shield').shieldHits, bb.times);
h.run(bb.times * 1.2 + 1);
const taken = h.hooksOf('damaged').filter((c) => c.target === u);
assert.ok(taken.slice(0, bb.times).every((c) => c.amount === 0), 'the first 3 hits are negated');
assert.ok(taken.slice(bb.times).some((c) => c.amount > 0));
for (const e of h.enemies()) h.b.dealDamage(null, e, { amount: 1e9, type: 'true' });
assert.ok(h.runUntil(() => !u.alive, dur + 1));
approx(h.b.time, dur, 'withdraws when it ends', 0.05);
approx(u.respawnAt - h.b.time, u.base.respawnTime * bb.respawn_time, 'this redeploy ×1.25', 1e-3);
assert.equal(u.removeReason, 'retreat');
assert.equal(u.skill.active, false);
assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']);
done(h);
const k = run({ defs: { chess: { kaz: plain('kaz', { bonds: ['kazimierzShip'] }) } }, units: [U(id, sid, 10, 4), { chessId: 'kaz', row: 12, col: 4 }] });
const ku = k.unit(id);
k.step(2);
assert.ok(ku.alive && ku.skill.active);
assert.ok(k.runUntil(() => !ku.alive, dur + 1));
approx(ku.respawnAt - k.b.time, ku.base.respawnTime, '卡西米尔 before her: no extension', 1e-3);
done(k);
const dead = run({ units: [U(id, sid, 10, 4, { moduleId: 'none' })] });
dead.step();
const du = dead.unit(id);
dead.b.kill(du);
const respawnAt = du.respawnAt;
approx(respawnAt - dead.b.time, du.base.respawnTime, 'early death keeps ordinary redeployment');
assert.equal(du.findBuff('nearl2:shield'), null);
dead.run(dur + 1);
assert.equal(du.respawnAt, respawnAt, 'no delayed retreat or multiplier after death');
assert.deepEqual(dead.hooksOf('skillEnd').filter((c) => c.unit === du).map((c) => c.reason), ['death']);
done(dead);
}
});
// =================================================================================================================
// 6_18 荒芜拉普兰德
test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (2 hits per attack); toggled on: ATK + and the drones lock still enemies anywhere', () => {
for (const id of both('chess_char_6_18')) {
const sid = 'skchr_whitw2_1', bb = bbOf(id, sid);
const idle = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: IDLE })], enemies: [{ key: 'e', pos: [10, 6] }] });
const iu = idle.unit(id);
idle.run(3);
const a0 = idle.hooksOf('attack').find((c) => c.attacker === iu);
assert.ok(a0);
idle.run(1);
assert.equal(dealt(idle, iu, (c) => c.dmg.attackId === dealt(idle, iu)[0].dmg.attackId).length, 2, 'drone +1');
done(idle);
const h = run({
defs: { enemies: { far: dummy('far'), w: enemyRec({ key: 'w', hp: 1e7, speed: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'far', pos: [12, 16] }, { key: 'w', route: 0 }],
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 20), 'toggled on when the walker is in range');
assert.equal(u.skill.kind, 'toggle');
approx(skillBuff(u).mods.atkPct, bb.atk);
const t0 = h.b.time;
h.run(4);
const far = enemyOf(h, 'far');
assert.ok(dealt(h, u, (c) => c.target === far && c.t > t0).length > 0, 'hits the still enemy far outside her range');
assert.ok(dealt(h, u, (c) => isKey(c.target, 'w') && c.t > t0 + 1).length === 0 || enemyOf(h, 'w').blockedBy, 'not the walking one');
done(h);
}
});
test('6_18 荒芜拉普兰德 S2 逐猎狂飙: 1 + attack@cnt drones lock random enemies of the skill range, ATK +, each drone ramps on its own target', () => {
for (const id of both('chess_char_6_18')) {
const sid = 'skchr_whitw2_2', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(skillBuff(u).mods.atkPct, bb.atk);
const t0 = h.b.time;
h.run(4);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill && c.t >= t0 - 1e-9);
assert.ok(atks.length >= 2);
for (const a of atks) assert.equal(a.targets.length, 1 + bb['attack@cnt'], '4 drones');
const f = u.profile.funnel;
const first = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill)[0];
approx(first.amount / (u.s.atk * u.s.dmgDealtMul), f.init, 'a fresh lock starts at the initial scale', 1e-3);
const all = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.ok(all.some((c) => c.amount > first.amount * 1.01), 'ramps up');
assert.ok(all.every((c) => c.amount <= u.s.atk * u.s.dmgDealtMul * f.max * 1.0001), 'capped');
done(h);
}
});
// =================================================================================================================
// 6_19 锏
test('6_19 锏 S1 纯粹的武力: the next attack hits up to 5/6 ground enemies around her, twice each, at atk_scale_s1 × ATK', () => {
for (const id of both('chess_char_6_19')) {
const sid = 'skchr_blkkgt_1', bb = bbOf(id, sid);
const pos = [[9, 3], [9, 4], [9, 5], [10, 3], [10, 5], [11, 3], [11, 4], [11, 5]];
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: pos.map((p) => ({ key: 'e', pos: p })) });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill), 5));
const a = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(a.targets.length, bb.max_target, `${bb.max_target} targets`);
const hits = dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.equal(hits.length, 2 * bb.max_target, 'two hits each');
const base = u.s.atk * bb.atk_scale_s1 * (tbOf(id).damage_scale ?? 1), sc = tal(id).atk_scale;
for (const x of hits) assert.ok(Math.abs(x.amount - base) < 1e-6 || Math.abs(x.amount - base * sc) < 1e-6, `atk_scale_s1 (talent proc allowed): ${x.amount} vs ${base}`);
done(h);
}
});
test('6_19 锏 S2 无声的嘲笑: 2 slashes (3 on blocked enemies) of dot_scale × ATK on ≤5 ground enemies in front, talent at 100 %', () => {
for (const id of both('chess_char_6_19')) {
const sid = 'skchr_blkkgt_2', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 4] }, { key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => started(h, u).length > 0, 5));
const t0 = started(h, u)[0].t;
const sl = dealt(h, u, (c) => c.t === t0 && (c.dmg.tags || []).includes('slash'));
const blocked = h.enemies().filter((e) => e.blockedBy === u);
assert.ok(blocked.length >= 1);
for (const e of h.enemies()) {
const n = sl.filter((c) => c.target === e).length;
assert.equal(n, e.blockedBy === u ? bb['blkkgt_s_2[blocked].trig_cnt'] : bb['blkkgt_s_2[not_blocked].trig_cnt'], `slashes on ${e.id}`);
}
const sc = tal(id).atk_scale, mul = tbOf(id).damage_scale ?? 1;
for (const x of sl) approx(x.amount, u.s.atk * bb.dot_scale * sc * mul, 'dot_scale × ATK × talent (100 %)');
assert.ok(statuses(h, 'tremble', (c) => c.source === u && c.t === t0).length > 0, 'talent 战栗');
done(h);
}
});
// =================================================================================================================
// 6_20 纯烬艾雅法拉
test('6_20 纯烬艾雅法拉 S1 无声润物: toggle heal; ATK +, 2 heal targets; allies in range recover ep_heal_ratio × ATK 元素损伤 per second', () => {
for (const id of both('chess_char_6_20')) {
const sid = 'skchr_agoat2_1', bb = bbOf(id, sid);
const h = run({
defs: { chess: { a1: plain('a1', { stats: { maxHp: 1e5 } }), a2: plain('a2', { stats: { maxHp: 1e5 } }), a3: plain('a3') } },
units: [U(id, sid, 10, 3, { carryState: READY }), { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 5 }, { chessId: 'a3', row: 9, col: 5 }],
});
const u = h.unit(id), a1 = h.unit('a1'), a2 = h.unit('a2'), a3 = h.unit('a3');
usesSkill(u, sid);
h.step();
a1.hp = a1.s.maxHp * 0.3; a2.hp = a2.s.maxHp * 0.4;
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(u.skill.kind, 'toggle');
approx(skillBuff(u).mods.atkPct, bb.atk);
h.run(3);
const hh = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.ok(hh.some((c) => c.targets.length === 2), 'two heal targets');
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } }); // (no heals: a3 never gets a 氤氲 HoT)
a3.elem.burn = 800;
h.run(1.05);
approx(800 - a3.elem.burn, u.s.atk * bb['agoat2_s_1[aura].ep_heal_ratio'], 'per second', 1e-3);
done(h);
}
});
test('6_20 纯烬艾雅法拉 S1 无声润物 (自动触发, effects on her allies only): on as soon as its SP is full — nobody injured, no enemy (the owner\'s decision of 2026-10-05)', () => {
for (const id of both('chess_char_6_20')) {
const sid = 'skchr_agoat2_1', bb = bbOf(id, sid);
const h = run({
defs: { chess: { a1: plain('a1', { stats: { maxHp: 1e5 } }) } },
units: [U(id, sid, 10, 3, { carryState: READY }), { chessId: 'a1', row: 10, col: 5 }],
});
const u = h.unit(id), a1 = h.unit('a1');
usesSkill(u, sid);
assert.equal(u.skill.rule, 'SP_FULL');
assert.ok(h.runUntil(() => u.skill.active, 2), 'on with every ally at full HP and no enemy (the data DEFAULT heal rule waited for an injured ally)');
assert.equal(a1.hp, a1.s.maxHp);
h.b.addBuff(u, { key: 'test:disarm', flags: { disarm: true } }); // (no heals: only the skill's 元素损伤 recovery)
a1.elem.burn = 800;
h.run(1.05);
approx(800 - a1.elem.burn, u.s.atk * bb['agoat2_s_1[aura].ep_heal_ratio'], 'its 元素损伤 recovery runs at full HP', 1e-3);
done(h);
}
});
test('6_20 纯烬艾雅法拉 S2 云霭荫佑: one heal on every ally of her range, then a barrier absorbing atk_scale × ATK of 元素损伤 for `duration` s', () => {
for (const id of both('chess_char_6_20')) {
const sid = 'skchr_agoat2_2', bb = bbOf(id, sid);
const h = run({
defs: { chess: { a1: plain('a1', { stats: { maxHp: 1e5 } }), a2: plain('a2', { stats: { maxHp: 1e5 } }) } },
units: [U(id, sid, 10, 3, { carryState: READY }), { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 5 }],
});
const u = h.unit(id), a1 = h.unit('a1'), a2 = h.unit('a2');
usesSkill(u, sid);
h.step();
a1.hp = a1.s.maxHp * 0.3; a2.hp = a2.s.maxHp * 0.4;
assert.ok(h.runUntil(() => started(h, u).length > 0, 5));
const t0 = started(h, u)[0].t;
const hh = heals(h, u, (c) => c.t === t0);
assert.equal(new Set(hh.map((c) => c.target)).size, 2, 'every ally of her range');
const pool = u.s.atk * bb['agoat2_s_2[shield].atk_scale'];
h.b.dealDamage(null, a1, { type: 'element', element: 'burn', amount: pool * 0.6 });
assert.equal(a1.elem.burn, 0, 'absorbed');
h.b.dealDamage(null, a2, { type: 'element', element: 'burn', amount: pool * 0.6 });
approx(a2.elem.burn, pool * 0.2 * (a2.s.elemTakenMul ?? 1), 'the rest of the pool absorbed, the excess lands', 1e-3);
h.run(bb.duration + 0.5);
h.b.dealDamage(null, a1, { type: 'element', element: 'burn', amount: 100 });
assert.ok(a1.elem.burn > 0, 'gone after its duration');
done(h);
}
});
// =================================================================================================================
// default-skill hooks stay with the default skill (kit talents / install run under every selected skill)
test('default-skill-only hooks do not run under an alternate skill (余 / 维娜 / 焰影苇草 / 流明 / 溯光星源 / 耀骑士临光 / 缪尔赛思)', () => {
// 余 S2 active: 闲云隐市 is NOT given to every operator (S3 only)
{
const h = run({
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, // (S2: an enemy on x-1)
units: [U('chess_char_6_03_a', 'skchr_yu_2', 10, 4, { carryState: READY }), { chessId: 'o1', row: 9, col: 6 }, { chessId: 'o2', row: 11, col: 6 }, { chessId: 'o3', row: 12, col: 6 }],
enemies: [{ key: 'e', pos: [10, 4] }],
});
const u = h.unit('chess_char_6_03_a'), o1 = h.unit('o1');
assert.ok(h.runUntil(() => u.skill.active, 5));
o1.hp = o1.s.maxHp * 0.5;
h.run(2.2);
assert.equal(heals(h, u, (c) => c.target === o1).length, 0, 'no regen for the others');
assert.equal(u.mem.yuWall ?? null, null, 'no fire wall');
done(h);
}
// 维娜 S2 (toggle) active: enemies blocked by allies around her are NOT added to her range (S3 only)
{
const h = run({
defs: { chess: { b1: plain('b1') }, enemies: { e: dummy('e') } },
units: [U('chess_char_6_07_a', 'skchr_siege2_2', 10, 4, { carryState: READY }), { chessId: 'b1', row: 11, col: 3 }],
enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 3] }],
});
const u = h.unit('chess_char_6_07_a');
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(1);
assert.equal((u.extraRangeKeys || []).length, 0);
done(h);
}
// 焰影苇草 S1: 灼痕 lasts its own 6 s, not "until the skill ends"
{
const id = 'chess_char_6_08_a';
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 'skcom_quickattack[3]', 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => enemyOf(h, 'e')?.findBuff('reed2:scorch'), 30));
assert.ok(u.skill.active);
approx(enemyOf(h, 'e').findBuff('reed2:scorch').timeLeft, tal(id).duration, '6 s', 0.02);
done(h);
}
// 流明 S1: her heals are not redirected to abnormal allies at full HP (S3 bookkeeping)
{
const id = 'chess_char_6_14_a';
const h = run({
defs: { chess: { a1: plain('a1', { stats: { maxHp: 1e5 } }), a2: plain('a2', { stats: { maxHp: 1e5 } }) } },
units: [U(id, 'skchr_lumen_1', 10, 3, { carryState: READY }), { chessId: 'a1', row: 10, col: 5 }, { chessId: 'a2', row: 11, col: 5 }],
});
const u = h.unit(id), a1 = h.unit('a1'), a2 = h.unit('a2');
h.step();
a1.hp = a1.s.maxHp * 0.5;
h.b.applyStatus(a2, 'stun', { duration: 30, force: true }); // (应急处理 heals it once right now — the talent)
const ts = h.b.time;
h.run(4);
const hh = heals(h, u, (c) => !c.opts?.hot && !c.opts?.aura && c.t > ts + 0.05);
assert.ok(hh.length > 0 && hh.every((c) => c.target !== a2 || c.amount === 0), 'the stunned full-HP ally is not forced a heal');
assert.ok(a2.findBuff('stun'), 'nor cleansed');
done(h);
}
// 溯光星源 S1: no persistent S3 locks / link transfer
{
const id = 'chess_char_6_16_a';
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 'skchr_halo2_1', 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 5] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3);
assert.equal((u.mem.haloLocks || []).length, 0);
assert.equal(dealt(h, u, tagged('link')).length, 0);
done(h);
}
// 耀骑士临光 S1 (toggle): attacks on enemies she blocks stay physical (S3's true damage)
{
const id = 'chess_char_6_17_a';
const h = run({ defs: { enemies: { e: dummy('e', { def: 300 }) } }, units: [U(id, 'skchr_nearl2_1', 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 4] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3);
const atk = dealt(h, u, (c) => c.dmg.isAttack);
assert.ok(atk.length > 0 && atk.every((c) => c.type === 'phys'));
done(h);
}
// 缪尔赛思 S1: ranged copies do not bind (S3 only)
{
const h = mlyssBattle('chess_char_6_11_a', 'skchr_mlyss_1', { copyId: 'chess_char_1_01_a', enemyAt: 0 });
h.run(12);
assert.equal(statuses(h, 'bind', (c) => c.source === h.unit('chess_char_6_11_a')).length, 0);
done(h);
}
});
// =================================================================================================================
// modules (elite): the non-default choices change what the kit does
const ELITE = (base) => `${base}_b`;
test('module 浊心斯卡蒂 新生代: 捕食习性 ATK +9 % / DEF +8 %, +3 SP when an operator deploys in her range; −30 flat damage for allies in range during her skill', () => {
const id = ELITE('chess_char_6_04'), M = 'uniequip_003_skadi2';
const mk = (moduleId, o = {}) => run({
defs: { chess: { ally_a: plain('ally_a', { stats: { def: 0, maxHp: 1e6 } }) }, enemies: { hit: enemyRec({ key: 'hit', hp: 1e7, speed: 0, atk: 500, bat: 2 }) } },
units: [{ chessId: id, row: 11, col: 4, moduleId, ...o }, { chessId: 'ally_a', row: 10, col: 5 }],
enemies: o.enemies ?? [],
});
const y = mk(M), x = mk(null);
y.step(); x.step();
const uy = y.unit(id), ux = x.unit(id);
approx(uy.skill.sp - ux.skill.sp, tal(id, MOD(M), 1).sp, '+3 SP (the ally deployed after her, in her range)');
y.run(1);
const pb = uy.findBuff('skadi2:predator');
approx(pb.mods.atkPct, tal(id, MOD(M), 1)['skadi2_e_003_t_2[atk][1].atk']);
approx(pb.mods.defPct, tal(id, MOD(M), 1)['skadi2_e_003_t_2[def].def']);
done(y); done(x);
for (const moduleId of [M, null]) {
const h = mk(moduleId, { carryState: READY, enemies: [{ key: 'hit', pos: [10, 5], time: 0.5 }] });
const u = h.unit(id), a = h.unit('ally_a');
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === a && isKey(c.source, 'hit')), 5));
const d = h.hooksOf('damaged').find((c) => c.target === a && isKey(c.source, 'hit'));
approx(d.amount, moduleId ? 500 - 30 : 500, `module ${moduleId}: flat reduction`);
done(h);
}
});
test('module 异客 王权金币: bounces lose nothing and 机理分析 is ×1.28 from 70 % HP; 特限证章 keeps the 15 % falloff', () => {
const id = ELITE('chess_char_6_05');
for (const [moduleId, fall] of [['uniequip_003_pasngr', 0], ['uniequip_004_pasngr', 0.15], ['none', 0.15], [null, 0.1]]) {
const h = run({
defs: { enemies: { e: dummy('e') } },
units: [{ chessId: id, row: 10, col: 4, moduleId, carryState: IDLE }],
enemies: [{ key: 'e', pos: [10, 6] }, { key: 'e', pos: [10, 7] }, { key: 'e', pos: [11, 7] }, { key: 'e', pos: [11, 8] }],
});
const u = h.unit(id);
h.run(4);
const hits = dealt(h, u, (c) => c.dmg.isAttack && !c.dmg.isSkill && c.t > 1); // (孤卒's aura settled)
const one = hits.filter((c) => c.dmg.attackId === hits[0].dmg.attackId);
assert.equal(one.length, 4);
for (let k = 1; k < 4; k++) approx(one[k].amount / one[0].amount, Math.pow(1 - fall, k), `module ${moduleId} bounce ${k}`);
const t0 = tal(id, MOD(moduleId));
approx(one[0].amount, u.s.atk * t0['pasngr_t_1[enhance].damage_scale'], `module ${moduleId}: 机理分析`);
done(h);
}
approx(tal(id, MOD('uniequip_003_pasngr')).hp_ratio, 0.7);
});
test('module 佩佩 特限证章: no ×1.15 when 3 enemies stand in the splash (晴雨 only)', () => {
const id = ELITE('chess_char_6_06');
for (const [moduleId, mul] of [[null, tbOf(id).atk_scale_e], ['uniequip_003_pepe', 1], ['none', 1]]) {
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, moduleId, carryState: IDLE }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isAttack && !c.dmg.isSplash).length > 0, 5));
approx(dealt(h, u, (c) => c.dmg.isAttack && !c.dmg.isSplash)[0].amount, u.s.atk * mul, `module ${moduleId}`);
done(h);
}
});
test('module 维娜 秩序圣“球”: 战栗 6 s (elite / leader 12 s), ×1.15 on 战栗 targets, 10 % 脆弱 on enemies she blocks', () => {
const id = ELITE('chess_char_6_07'), M = 'uniequip_003_siege2';
const h = run({
defs: { enemies: { n: dummy('n'), el: dummy('el', { rank: 'ELITE' }) } },
units: [{ chessId: id, row: 10, col: 4, moduleId: M, carryState: IDLE }], enemies: [{ key: 'el', pos: [10, 5] }, { key: 'n', pos: [10, 4], time: 2 }],
});
const u = h.unit(id);
h.run(4);
const tr = statuses(h, 'tremble', (c) => c.source === u);
const t1 = tal(id, MOD(M), 1);
approx(tr.find((c) => isKey(c.target, 'n')).duration, t1.not_combat_normal, 'normal enemy');
approx(tr.find((c) => isKey(c.target, 'el')).duration, t1.not_combat_elite, 'elite enemy');
const n = enemyOf(h, 'n');
assert.ok(statuses(h, 'fragile', (c) => c.target === n && c.source === u && Math.abs(c.value - (tbOf(id, MOD(M)).damage_scale - 1)) < 1e-9).length > 0, 'blocked ⇒ 脆弱');
const onEl = dealt(h, u, (c) => c.dmg.isAttack && isKey(c.target, 'el') && c.t < 2);
assert.ok(onEl.length >= 2);
approx(onEl[1].amount / onEl[0].amount, ds.getChess(id, MOD(M)).talents.find((t) => t.bb.damage_scale && !t.name)?.bb.damage_scale ?? 1.15, '×1.15 once trembling', 1e-3);
done(h);
// the default module: 4 s everywhere, no ×1.15
const d = run({ defs: { enemies: { el: dummy('el', { rank: 'ELITE' }) } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE }], enemies: [{ key: 'el', pos: [10, 5] }] });
const du = d.unit(id);
d.run(4);
approx(statuses(d, 'tremble', (c) => c.source === du)[0].duration, tal(id, null, 1).not_combat);
const de = dealt(d, du, (c) => c.dmg.isAttack);
approx(de[1].amount / de[0].amount, 1, 'no bonus', 1e-3);
done(d);
});
test('module 焰影苇草 “独属自己的一隅”: damage ×1.1 while an operator of her range is injured; 灼痕 40 % / 8 s (full potential)', () => {
const id = ELITE('chess_char_6_08'), M = 'uniequip_003_reed2';
const h = run({ defs: { chess: { a: plain('a') }, enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, moduleId: M, carryState: IDLE }, { chessId: 'a', row: 11, col: 5 }], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(id), a = h.unit('a');
h.run(0.5);
assert.ok(!u.findBuff('reed2:corner'), 'nobody injured');
a.hp = a.s.maxHp * 0.5;
h.run(0.5);
approx(u.findBuff('reed2:corner')?.mods.dmgDealtMul ?? 1, 1.1, 'injured ally ⇒ ×1.1');
approx(tal(id, MOD(M)).damage_scale, 1.4); // 1.38 + the potential step
done(h);
const d = run({ defs: { chess: { a: plain('a') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE }, { chessId: 'a', row: 11, col: 5 }] });
d.step();
d.unit('a').hp = 1;
d.run(0.5);
assert.ok(!d.unit(id).findBuff('reed2:corner'), 'default module: no bonus');
done(d);
});
test('module 塑心 音乐家的旅程: 精神逆构 field-wide ×1.2 and 150 元素伤害/s during 凋亡 bursts; 10 % 元素脆弱 in her range', () => {
const id = ELITE('chess_char_6_09'), M = 'uniequip_003_cello';
for (const moduleId of [M, null]) {
const h = run({ defs: { enemies: { far: dummy('far'), near: dummy('near') } }, units: [{ chessId: id, row: 10, col: 4, moduleId, carryState: IDLE }], enemies: [{ key: 'far', pos: [12, 16] }, { key: 'near', pos: [10, 5] }] });
const u = h.unit(id);
h.run(0.5);
const far = enemyOf(h, 'far');
h.b.dealDamage(null, far, { type: 'element', element: 'apoptosis', amount: 100 });
const fill = h.hooksOf('elementHit').filter((c) => c.target === far).pop();
approx(fill.dmg.mul, moduleId ? tal(id, MOD(M), 1).ep_damage_scale : 1, `module ${moduleId}: far enemy`);
if (moduleId) {
assert.ok(statuses(h, 'elemFragile', (c) => isKey(c.target, 'near') && Math.abs(c.value - 0.1) < 1e-9).length > 0, '10 % 元素脆弱 in range');
h.b.dealDamage(null, far, { type: 'element', element: 'apoptosis', amount: 5000 });
assert.ok(far.findBuff('apoptosisBurst'));
const t0 = h.b.time;
h.run(2.05);
const dot = dealt(h, u, (c) => c.target === far && c.type === 'elemental' && c.t > t0);
assert.equal(dot.length, 2, 'every second');
approx(dot[0].amount, tal(id, MOD(M), 1).damage_value, '150 per second');
}
done(h);
}
});
test('module 缪尔赛思 落叶四季: copying a 莱茵生命 operator gives her 10 SP;援军 bonus ×1.65', () => {
const id = ELITE('chess_char_6_11'), M = 'uniequip_003_mlyss';
const sp = {};
for (const moduleId of [M, 'none']) {
const h = mlyssBattle(id, 'skchr_mlyss_3', { copyId: 'rh', chess: { rh: plain('rh', { charId: 'char_108_silent', profession: 'WARRIOR' }) }, unit: { moduleId, carryState: IDLE }, enemyAt: 60 });
const u = h.unit(id);
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss, 10), 'copied');
sp[moduleId] = u.skill.sp;
if (moduleId === M) approx(u.profile.reinforceScale, 1.65);
done(h);
}
approx(sp[M] - sp.none, 10, '+10 SP', 0.02);
});
test('module 迷迭香 特限证章: two hits (one aftershock) instead of three', () => {
const id = ELITE('chess_char_6_12');
for (const [moduleId, n] of [[null, 3], ['uniequip_003_rosmon', 2], ['none', 2]]) {
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, moduleId, carryState: IDLE }], enemies: [{ key: 'e', pos: [10, 7] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5));
h.run(1.5);
const e = enemyOf(h, 'e');
const first = h.hooksOf('attack').find((c) => c.attacker === u).t;
const hits = dealt(h, u, (c) => c.target === e && c.t < first + 1.4);
assert.equal(hits.length, n, `module ${moduleId}`);
done(h);
}
});
test('module 流明 “幸运”: far heals are not reduced; 应急处理 heals 100 % with an 8 s cooldown (full potential)', () => {
const id = ELITE('chess_char_6_14'), M = 'uniequip_003_lumen';
for (const [moduleId, mul] of [[M, 1], [null, tbOf(id).heal_scale]]) {
const h = run({ defs: { chess: { f: plain('f', { stats: { maxHp: 1e5 } }) } }, units: [{ chessId: id, row: 10, col: 3, moduleId, carryState: IDLE }, { chessId: 'f', row: 10, col: 6 }] });
const u = h.unit(id), f = h.unit('f');
h.step();
f.hp = f.s.maxHp * 0.5;
assert.ok(h.runUntil(() => heals(h, u, (c) => c.target === f).length > 0, 5));
approx(heals(h, u, (c) => c.target === f)[0].amount, u.s.atk * mul, `module ${moduleId}: far heal`);
done(h);
}
approx(tal(id, MOD(M), 1).heal_scale, 1);
approx(tal(id, MOD(M), 1).duration, 8); // 10 s − the potential step
});
test('module 仇白 欲雪时: the first hit on an enemy binds it 3 s; ASPD +12 with ≥2 enemies in range', () => {
const id = ELITE('chess_char_6_15'), M = 'uniequip_003_qiubai';
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, moduleId: M, carryState: IDLE }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => statuses(h, 'bind', (c) => c.source === u).length > 0, 5));
approx(statuses(h, 'bind', (c) => c.source === u)[0].duration, tal(id, MOD(M), 1).duration_advanced, 'first hit ⇒ 3 s');
h.run(0.5);
approx(u.findBuff('qiubai:crowd').mods.aspd, tal(id, MOD(M)).attack_speed, 'ASPD +12');
done(h);
const d = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
d.run(1);
assert.ok(!d.unit(id).findBuff('qiubai:crowd'), 'default module: no crowd ASPD');
done(d);
});
test('module 耀骑士临光 “骑士家族”: the first knock-out of a deployment keeps her up (max HP −60 %, ASPD +30) and bursts 不畏苦暗 again', () => {
const id = ELITE('chess_char_6_17'), M = 'uniequip_003_nearl2';
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, moduleId: M, carryState: IDLE }], enemies: [{ key: 'e', pos: [10, 5], time: 1 }] });
const u = h.unit(id);
h.run(1.5);
const max0 = u.s.maxHp, as0 = u.s.aspd;
const bursts0 = dealt(h, u, tagged('talent')).length;
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive, 'stays');
approx(u.s.maxHp, max0 * (1 - tbOf(id, MOD(M)).value), 'max HP −60 %');
approx(u.hp, u.s.maxHp, 'full (reduced) HP');
approx(u.s.aspd, as0 + tbOf(id, MOD(M)).attack_speed, 'ASPD +30');
assert.ok(dealt(h, u, tagged('talent')).length > bursts0, '不畏苦暗 again');
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(!u.alive, 'only once per deployment');
done(h);
const d = run({ units: [{ chessId: id, row: 10, col: 4, carryState: IDLE }] });
d.step();
d.b.dealDamage(null, d.unit(id), { amount: 1e9, type: 'true' });
assert.ok(!d.unit(id).alive, 'default module: knocked out');
});
test('module 锏 新合同: ATK +8 %, ignores 70 DEF, ground enemies entering her range tremble 6 s', () => {
const id = ELITE('chess_char_6_19'), M = 'uniequip_003_blkkgt';
const h = run({ defs: { enemies: { w: enemyRec({ key: 'w', hp: 1e7, speed: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, moduleId: M, carryState: IDLE }], enemies: [{ key: 'w', route: 0 }] });
const u = h.unit(id);
h.step();
const b = u.findBuff('blkkgt:contract');
approx(b.mods.atkPct, tal(id, MOD(M), 1).atk);
approx(b.mods.defIgnoreFlat, tbOf(id, MOD(M)).def_penetrate_fixed);
const entry = (c) => c.source === u && Math.abs(c.duration - tal(id, MOD(M), 1).not_combat) < 1e-9;
assert.ok(h.runUntil(() => statuses(h, 'tremble', entry).length > 0, 20), '6 s 战栗 on entering her range');
done(h);
const d = run({ units: [{ chessId: id, row: 9, col: 5, carryState: IDLE }] });
d.step();
assert.ok(!d.unit(id).findBuff('blkkgt:contract'), 'default module: none');
});
test('module 纯烬艾雅法拉 想要留下的生命: ASPD +8 while an ally of her range carries 元素损伤', () => {
const id = ELITE('chess_char_6_20'), M = 'uniequip_003_agoat2';
const h = run({ defs: { chess: { a: plain('a') } }, units: [{ chessId: id, row: 10, col: 3, moduleId: M, carryState: IDLE }, { chessId: 'a', row: 10, col: 5 }] });
const u = h.unit(id), a = h.unit('a');
h.run(0.5);
assert.ok(!u.findBuff('agoat2:linger'));
a.elem.burn = 300;
h.run(0.3);
approx(u.findBuff('agoat2:linger').mods.aspd, tbOf(id, MOD(M)).attack_speed, 'ASPD +8');
done(h);
});
test('module none (elites whose only module is the default): the module-only parts are gone', () => {
// 蕾缪安: 攻击的敌人未被击倒时额外获得1点技力 (trait) / 逃犯引渡手续 numbers
const lem = ELITE('chess_char_6_01');
for (const [moduleId, gain] of [[null, true], ['none', false]]) {
const h = run({ hooks: [...HOOKS, 'spGain'], defs: { enemies: { e: dummy('e') } }, units: [{ chessId: lem, row: 10, col: 4, moduleId, carryState: IDLE }], enemies: [{ key: 'e', pos: [10, 6] }] });
const u = h.unit(lem);
h.run(6);
assert.equal(h.hooksOf('attack').some((c) => c.attacker === u), true);
assert.equal(h.hooksOf('spGain').some((c) => c.unit === u && c.reason === 'trait'), gain, `module ${moduleId}: trait SP`);
done(h);
}
// 溯光星源: 数据建模 max stacks 25 + ATK at max (module) vs 18, no ATK
const halo = ELITE('chess_char_6_16');
assert.equal(tal(halo, MOD('none')).max_stack_cnt, 18);
// 荒芜拉普兰德: initial drone scale 0.35 (module) vs 0.2
const w = ELITE('chess_char_6_18');
for (const [moduleId, init] of [[null, 0.35], ['none', 0.2]]) {
const h = run({ units: [{ chessId: w, row: 10, col: 4, moduleId }] });
h.step();
approx(h.unit(w).profile.funnel.init, init, `module ${moduleId}`);
}
// 圣聆初雪: 范围内敌人越多造成的伤害越高 (module) — none: no bonus
const sb = ELITE('chess_char_6_02');
for (const [moduleId, on] of [[null, true], ['none', false]]) {
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: sb, row: 10, col: 4, moduleId }], enemies: [{ key: 'e', pos: [10, 5] }] });
h.run(0.6);
assert.equal(!!h.unit(sb).findBuff('sbell2:heart'), on, `module ${moduleId}`);
}
});
test('module 缪尔赛思 落叶四季: enemies blocked by her 流形 are more likely attacked (token module part taunt_level +1)', () => {
// regression: the trait "援军阻挡的敌人更容易受到我方的攻击" was left unmodelled although the 流形's 落叶四季 variant
// carries the number (token talent taunt_level 1); 梳妆流形 / no module ⇒ nothing
const id = ELITE('chess_char_6_11');
for (const moduleId of ['uniequip_003_mlyss', 'uniequip_002_mlyss', 'none']) {
const h = run({
defs: {
chess: { guard: plain('guard', { profession: 'WARRIOR', stats: { maxHp: 3000, atk: 800, def: 100, blockCnt: 2 } }) },
enemies: { w: enemyRec({ key: 'w', hp: 1e7, speed: 1 }) },
},
units: [U(id, 'skchr_mlyss_3', 10, 3, { moduleId, carryState: IDLE }), { chessId: 'guard', row: 11, col: 5 }, { kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1 }],
enemies: [{ key: 'w', route: 0, time: 12 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss, 12), 'copied');
assert.ok(h.runUntil(() => h.enemies().some((e) => e.blockedBy === tok()), 30), `${moduleId}: blocked by the 流形`);
h.run(0.5);
const e = h.enemies().find((x) => x.blockedBy === tok());
const want = moduleId === 'uniequip_003_mlyss' ? 1 : 0;
assert.equal(e.s.taunt, want, `${moduleId}: blocked enemy taunt`);
if (want) { // released: the mark fades once it is no longer blocked
tok().hp = 0; h.b.kill(tok());
h.run(1);
assert.equal(e.s.taunt, 0, 'no longer blocked ⇒ no taunt');
}
done(h);
}
});
test('6_02 圣聆初雪 S1: the charges are not dumped on an enemy far outside her range (SEARCH ⇒ enemy in range)', () => {
// regression: the data trigger SEARCH (engine: any enemy on the field) fired both charges the tick an enemy spawned at
// the gate 7 tiles away — "立即对范围内所有敌人…" hit nothing
for (const id of both('chess_char_6_02')) {
const sid = 'skchr_sbell2_1';
const h = run({ defs: { enemies: { w: enemyRec({ key: 'w', hp: 1e7, speed: 0.6 }) } }, units: [U(id, sid, 10, 3, { carryState: READY })], enemies: [{ key: 'w', route: 0, time: 1 }] });
const u = h.unit(id);
usesSkill(u, sid);
h.run(4);
assert.equal(started(h, u).length, 0, `${id}: no cast while the enemy is far away`);
assert.equal(u.skill.charges, u.skill.maxCharges, 'charges kept');
assert.ok(h.runUntil(() => started(h, u).length > 0, 40), 'casts once it walks into range');
const t0 = started(h, u)[0].t;
assert.ok(dealt(h, u, (c) => c.t === t0 && c.dmg.isSkill && c.type === 'arts').length >= 1, 'the burst hits it');
done(h);
}
});