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.
769 lines
39 KiB
JavaScript
769 lines
39 KiB
JavaScript
// Operator loadouts (DESIGN §16) for the tier-2 kits (server/sim/content/kits/ops/): every selectable NON-default
|
||
// skill of every visible tier-2 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 module choice ('none' instead of the default module) changes what the kit / profile 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';
|
||
|
||
const ds = getDefaultSource();
|
||
const raw = (id) => ds.rawChess(id);
|
||
/** The selectable non-default SkillRecord of a chess (tier 2 chess are E1: one alternative). */
|
||
const alt = (id) => raw(id).skills.find((s) => !s.isDefault);
|
||
const bbAlt = (id) => alt(id).bb;
|
||
/** Named talent blackboard / trait blackboard of the loadout-resolved record. */
|
||
const tal = (id, lo = null, i = 0) => ds.getChess(id, lo).raw.talents.filter((t) => t.index !== -1)[i]?.bb ?? {};
|
||
const tbOf = (id, lo = null) => ds.getChess(id, lo).raw.trait?.bb ?? {};
|
||
const both = (base) => [`${base}_a`, `${base}_b`];
|
||
const NONE = { moduleId: 'none' };
|
||
|
||
const approx = (a, b, msg = '', rel = 1e-6) => assert.ok(Math.abs(a - b) <= rel * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||
const READY = { sp: 999 }; // carryState: skill fully charged at deployment
|
||
const IDLE = { sp: 0 }; // carryState: no initial SP
|
||
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'death', 'deploy'];
|
||
|
||
function run(o) {
|
||
return makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o });
|
||
}
|
||
/** Unit entry with the alternate skill selected (plus loadout / carry extras). */
|
||
const U = (id, row, col, o = {}) => ({ chessId: id, row, col, skillIndex: alt(id).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 || c.opts?.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])); };
|
||
/** The selected skill is the alternative one, authored by the kit (not the generic fallback). */
|
||
function usesAlt(u, id) {
|
||
assert.equal(u.skill.id, alt(id).skillId, `${id} runs ${alt(id).name}`);
|
||
assert.equal(u.kit.skillSource, 'skills', `${id}: hand-authored spec`);
|
||
}
|
||
/** defs.chess override: the chess without its 特质 (garrison buffs would blur the ATK numbers). */
|
||
const noGarrison = (id) => ({ [id]: { ...raw(id), garrisonIds: [] } });
|
||
/** Plain operator without skill / talents (heal targets, lock candidates). */
|
||
const plain = (id, o = {}) => chessRec({ id, skill: null, ...o });
|
||
|
||
// =================================================================================================================
|
||
// coverage
|
||
|
||
test('kit coverage: every selectable skill of every visible tier-2 chess is hand-authored (normal + elite)', () => {
|
||
const rep = kitCoverage({ tier: 2 });
|
||
assert.equal(rep.summary.chess, 17);
|
||
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}`);
|
||
// the def of every legal loadout (module choices included) resolves to the authored spec
|
||
for (const id of [r.chessId, raw(r.chessId).goldenId].filter(Boolean)) {
|
||
for (const moduleId of [null, 'none']) {
|
||
const def = ds.getChess(id, { skillIndex: s.index, moduleId });
|
||
assert.equal(def.skill.id, s.skillId);
|
||
assert.equal(skillSpecSource(def, KITS), 'skills', `${id} ${s.skillId} module ${moduleId}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// =================================================================================================================
|
||
// alternate skills (normal Lv4 + elite Lv7)
|
||
|
||
test('2_01 送葬人 S1 铳口收束: ATK +atk; the trait multiplier hits every enemy in range (not only the front row); no double hits', () => {
|
||
for (const id of both('chess_char_2_01')) {
|
||
const bb = bbAlt(id), fs = tbOf(id).atk_scale;
|
||
const setup = (carry) => run({
|
||
defs: { enemies: { e: dummy('e') } },
|
||
units: [U(id, 10, 4, { carryState: carry })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 6] }],
|
||
});
|
||
// control: without the skill the off-row enemy takes ×1
|
||
const c = setup(IDLE);
|
||
c.step();
|
||
const cu = c.unit(id), off0 = c.enemies()[1];
|
||
c.run(1);
|
||
assert.ok(!cu.skill.active);
|
||
approx(dealt(c, cu, (x) => x.target === off0)[0].amount, cu.s.atk, `${id} off-row ×1 without the skill`);
|
||
done(c);
|
||
|
||
const h = setup(READY);
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.run(3);
|
||
assert.ok(u.skill.active);
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk), 'ATK');
|
||
approx(u.s.interval, u.base.bat * 100 / u.s.aspd, 'attack interval unchanged (最终旅程 only)');
|
||
const [front, off] = h.enemies();
|
||
const hits = dealt(h, u, (x) => x.dmg.isAttack);
|
||
const atks = h.hooksOf('attack').filter((x) => x.attacker === u).length;
|
||
assert.equal(hits.length, 2 * atks, 'one hit per enemy and attack');
|
||
for (const x of hits.filter((y) => y.target === off)) approx(x.amount, u.s.atk * fs, `${id} off-row ×${fs}`);
|
||
for (const x of hits.filter((y) => y.target === front)) approx(x.amount, u.s.atk * fs, `${id} front ×${fs}`);
|
||
assert.ok(hits.some((y) => y.target === off));
|
||
assert.equal(u.skill.duration, alt(id).duration);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_02 赫默 S1 治疗强化·γ型: ATK +atk heals for its duration, no drone', () => {
|
||
for (const id of both('chess_char_2_02')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { chess: { t_a: plain('t_a') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_a', row: 9, col: 6 }] });
|
||
const u = h.unit(id), a = h.unit('t_a');
|
||
usesAlt(u, id);
|
||
h.step();
|
||
a.hp = a.s.maxHp * 0.3;
|
||
h.run(1);
|
||
assert.ok(u.skill.active);
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
const hh = heals(h, u, (c) => c.target === a);
|
||
assert.ok(hh.length > 0);
|
||
approx(hh[0].amount, u.s.atk * (tbOf(id).heal_scale ?? 1), 'heal (elite module: ×heal_scale on ground units)');
|
||
assert.ok(h.b.allyUnits.every((x) => x.kind !== 'token'), 'no medical drone');
|
||
const t0 = started(h, u)[0].t;
|
||
h.runUntil(() => !u.skill.active, 40);
|
||
approx(h.b.time - t0, alt(id).duration, 'duration', 0.02);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_04 小满 S1 竹笛飞声: two charges; each next attack deals atk_scale × ATK arts to one extra enemy; no sleep', () => {
|
||
for (const id of both('chess_char_2_04')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
assert.equal(u.skill.maxCharges, bb.ct);
|
||
h.run(u.s.interval * 2.5);
|
||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||
assert.ok(atks.length >= 3);
|
||
assert.deepEqual(atks.slice(0, 3).map((c) => [c.isSkill, c.targets.length]), [[true, 2], [true, 2], [false, 1]], 'two charged attacks on 2 targets, then normal');
|
||
for (const c of dealt(h, u, (x) => x.dmg.isAttack && x.t <= atks[1].t + 1e-9)) {
|
||
assert.equal(c.type, 'arts');
|
||
approx(c.amount, u.s.atk * bb.atk_scale, `${id} ×${bb.atk_scale}`);
|
||
}
|
||
for (const c of dealt(h, u, (x) => x.dmg.isAttack && x.t >= atks[2].t - 1e-9)) approx(c.amount, u.s.atk, 'normal attack ×1');
|
||
assert.equal(statuses(h, 'sleep').length, 0, 'no 乡音沉沉 sleep');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_05 哈洛德 S1 治疗强化·γ型: ATK +atk; the 重症优先 target order is S2-only (lowest HP first)', () => {
|
||
for (const id of both('chess_char_2_05')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { chess: { ...noGarrison(id), t_low: plain('t_low'), t_el: plain('t_el') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_low', row: 10, col: 5 }, { chessId: 't_el', row: 11, col: 5 }] });
|
||
const u = h.unit(id), low = h.unit('t_low'), el = h.unit('t_el');
|
||
usesAlt(u, id);
|
||
h.step();
|
||
low.hp = low.s.maxHp * 0.3;
|
||
el.elem.neural = 900;
|
||
h.step();
|
||
assert.ok(u.skill.active);
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||
assert.equal(first.targets[0], low, 'lowest HP ratio first, not the element-damaged ally');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_06 莎草 S2 临考发挥: locks the highest-max-HP ally as the only main target; ATK +atk, shorter interval, +1 chain jump', () => {
|
||
for (const id of both('chess_char_2_06')) {
|
||
const bb = bbAlt(id);
|
||
const defs = { chess: { t_big: plain('t_big', { stats: { maxHp: 9000 } }), t_1: plain('t_1'), t_2: plain('t_2'), t_3: plain('t_3') } };
|
||
const h = run({ defs, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_big', row: 10, col: 5 }, { chessId: 't_1', row: 9, col: 5 }, { chessId: 't_2', row: 11, col: 5 }, { chessId: 't_3', row: 10, col: 6 }] });
|
||
const u = h.unit(id), big = h.unit('t_big');
|
||
usesAlt(u, id);
|
||
const count0 = u.profile.heal.count;
|
||
h.step();
|
||
for (const k of ['t_1', 't_2', 't_3']) { const a = h.unit(k); a.hp = a.s.maxHp * 0.2; }
|
||
h.runUntil(() => u.skill.active, 5);
|
||
assert.equal(u.mem.papyrsLock, big, 'locked: highest max HP (at full HP)');
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time −1.1 s');
|
||
assert.equal(u.profile.heal.count, count0 + bb['attack@chain.extra_value']);
|
||
h.run(u.s.interval * 2.2);
|
||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||
assert.ok(atks.length >= 2);
|
||
for (const c of atks) assert.equal(c.targets[0], big, 'the locked ally is the only main target');
|
||
const firstHeals = heals(h, u, (c) => Math.abs(c.t - atks[0].t) < 1e-9 && !(c.opts?.tags || []).length);
|
||
assert.equal(firstHeals.length, count0 + bb['attack@chain.extra_value'], 'main target + jumps (one more)');
|
||
// the lock leaves the field ⇒ the skill stops at once; the chain count is restored
|
||
h.b.retreat(big, { reason: 'retreat', permanent: true });
|
||
h.step(2);
|
||
assert.ok(!u.skill.active);
|
||
assert.equal(h.hooksOf('skillEnd').find((c) => c.unit === u).reason, 'target');
|
||
assert.equal(u.profile.heal.count, count0);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_06 莎草 S2 临考发挥: "无法可选单位时无法开启技能" — no lockable ally ⇒ never activates (no skillStart / 萨尔贡 gains, SP kept)', () => {
|
||
for (const id of both('chess_char_2_06')) {
|
||
// alone and injured, then with a 禁疗 ally in range: the DEFAULT heal trigger holds, but nothing can be locked
|
||
const h = run({ defs: { chess: { t_a: plain('t_a') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_a', row: 10, col: 5 }] });
|
||
const u = h.unit(id), a = h.unit('t_a');
|
||
h.step();
|
||
h.b.retreat(a, { reason: 'retreat', permanent: true });
|
||
const hurt = () => { if (u.hp > u.s.maxHp * 0.6) u.hp = u.s.maxHp * 0.3; };
|
||
for (let i = 0; i < 30; i++) { hurt(); h.run(0.2); }
|
||
assert.ok(heals(h, u, (c) => c.target === u).length >= 3, `${id} heals herself`);
|
||
assert.equal(started(h, u).length, 0, 'never started (a phantom start would fire 特质 / 萨尔贡 skillStart gains)');
|
||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u).length, 0);
|
||
assert.equal(u.skill.activations, 0);
|
||
assert.equal(h.eventsOf('skill').filter((e) => e[1] === u.id).length, 0, 'no skill cast on the client');
|
||
assert.ok(!u.skill.active && u.skill.ready && u.skill.charges === 1, 'charge kept');
|
||
for (const c of heals(h, u, (x) => x.target === u)) approx(c.amount, u.s.atk, 'no boosted heal (ATK +atk never applied)');
|
||
done(h);
|
||
|
||
const h2 = run({ defs: { chess: { t_a: plain('t_a') } }, units: [U(id, 10, 4, { carryState: READY }), { chessId: 't_a', row: 10, col: 5 }] });
|
||
const u2 = h2.unit(id), a2 = h2.unit('t_a');
|
||
h2.step();
|
||
h2.b.applyStatus(a2, 'noHeal', { duration: 2, source: u2 });
|
||
assert.ok(a2.s.flags.noHeal);
|
||
u2.hp = u2.s.maxHp * 0.3;
|
||
h2.run(1.5);
|
||
assert.equal(started(h2, u2).length, 0, `${id}: a 禁疗 ally is never locked`);
|
||
assert.ok(heals(h2, u2, (c) => c.target === u2).length > 0);
|
||
u2.hp = u2.s.maxHp * 0.3;
|
||
assert.ok(h2.runUntil(() => u2.skill.active, 5), 'starts once the ally can be locked');
|
||
assert.equal(u2.mem.papyrsLock, a2);
|
||
assert.equal(started(h2, u2).length, 1);
|
||
done(h2);
|
||
}
|
||
});
|
||
|
||
test('2_07 幽灵鲨 S1 攻击力强化·γ型: ATK +atk; no undying and no self-stun (肉斩骨断 only)', () => {
|
||
for (const id of both('chess_char_2_07')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 4000, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
assert.ok(u.skill.active);
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
assert.ok(h.runUntil(() => h.hooksOf('death').some((c) => c.unit === u), 10), 'lethal hits kill her during the skill');
|
||
assert.equal(statuses(h, 'stun', (c) => c.target === u).length, 0);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_08 泡泡 S1 防御力强化·β型: set off by a hit (TAKE_DAMAGE); DEF +def; keeps attacking; no counter, no taunt', () => {
|
||
for (const id of both('chess_char_2_08')) {
|
||
const bb = bbAlt(id), tb = tbOf(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.runUntil(() => u.skill.active, 3);
|
||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||
approx(u.s.def, u.base.def * (1 + bb.def + (tb.def ?? 0)), 'DEF (+ elite module while blocking)');
|
||
assert.equal(u.s.taunt, 0);
|
||
const t0 = started(h, u)[0].t;
|
||
h.run(4);
|
||
assert.ok(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length > 0, 'still attacks');
|
||
assert.equal(dealt(h, u, tagged('counter')).length, 0, 'no “挨打” counter');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_09 休谟斯 S1 固废切割: next attack atk_scale × ATK on every enemy it reaches + `value` HP once; every spCost+1 attacks', () => {
|
||
for (const id of both('chess_char_2_09')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }, { key: 'e', pos: [10, 6] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||
assert.ok(first && first.isSkill && first.targets.length === 2);
|
||
const hits = dealt(h, u, (c) => c.t === first.t && c.dmg.isAttack);
|
||
assert.equal(hits.length, 2);
|
||
for (const c of hits) approx(c.amount, u.s.atk * bb.atk_scale, `${id} ×${bb.atk_scale}`);
|
||
const sh = heals(h, u, (c) => c.target === u && tagged('skill')(c));
|
||
assert.equal(sh.length, 1, 'one extra heal per cast');
|
||
assert.equal(sh[0].amount, bb.value);
|
||
assert.ok(!u.findBuff('humus:peak'), 'no 精力充沛 (S2)');
|
||
h.runUntil(() => started(h, u).length >= 2, 30);
|
||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||
const i2 = atks.findIndex((c, i) => i > 0 && c.isSkill);
|
||
assert.equal(i2, alt(id).spCost + 1, 'the next cast after spCost normal attacks');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_10 洛洛 S1 战术咏唱·γ型: ASPD +attack_speed; no overload, no stun, no target lock (自负此轭 only)', () => {
|
||
for (const id of both('chess_char_2_10')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
assert.ok(u.skill.active);
|
||
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
|
||
h.run(alt(id).duration / 2 + 1);
|
||
assert.ok(!u.findBuff('rockr:overload'));
|
||
assert.equal(u.mem.rockLock ?? null, null);
|
||
h.runUntil(() => !u.skill.active, 40);
|
||
h.run(1);
|
||
assert.equal(statuses(h, 'stun', (c) => c.target === u).length, 0);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_11 风丸 S1 纸艺·迅击: next attack atk_scale × ATK, loses hp_ratio × max HP; the loss can set off the <替身>', () => {
|
||
for (const id of both('chess_char_2_11')) {
|
||
const bb = bbAlt(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 6] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
const first = h.hooksOf('attack').find((c) => c.attacker === u);
|
||
assert.ok(first && first.isSkill);
|
||
approx(dealt(h, u, (c) => c.dmg.isAttack)[0].amount, u.s.atk * bb.atk_scale, `${id} ×${bb.atk_scale}`);
|
||
const loss = h.hooksOf('damaged').filter((c) => c.target === u && tagged('hpLoss')(c));
|
||
assert.equal(loss.length, 1);
|
||
approx(loss[0].amount, u.s.maxHp * bb.hp_ratio, 'max-HP based loss');
|
||
assert.ok(h.b.allyUnits.every((x) => x.kind !== 'token'), 'no <纸偶> summon');
|
||
// a cast at low HP is fatal ⇒ the dollkeeper substitution
|
||
u.hp = u.s.maxHp * bb.hp_ratio * 0.5;
|
||
assert.ok(h.runUntil(() => started(h, u).length >= 2, 15));
|
||
h.step();
|
||
assert.ok(u.alive && u.findBuff('trait:substitute'), 'switched to the <替身>');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_12 砾 S1 影袭: at deployment DEF +def decaying to 0 over `duration` s, updated once per second; no barrier', () => {
|
||
for (const id of both('chess_char_2_12')) {
|
||
const bb = bbAlt(id), aura = tal(id).def ?? 0; // elite 小个子支援 (cost ≤ 10) includes herself
|
||
const h = run({ units: [U(id, 9, 5)] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.run(0.5);
|
||
approx(u.s.def, u.base.def * (1 + aura + bb.def), `${id} full bonus`);
|
||
assert.equal(u.s.shield, 0, 'no 鼠群 barrier');
|
||
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], bb.duration);
|
||
h.run(1);
|
||
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'one step down after 1 s');
|
||
h.run(0.4);
|
||
approx(u.s.def, u.base.def * (1 + aura + bb.def * (bb.duration - 1) / bb.duration), 'steps once per second');
|
||
h.run(bb.duration);
|
||
approx(u.s.def, u.base.def * (1 + aura), 'gone');
|
||
assert.equal(u.skill.active, false);
|
||
assert.equal(u.skill.ready, false);
|
||
assert.equal(u.findBuff('gravel:shadow'), null);
|
||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_13 蒂比 S1 专业喷绘技巧: ACTIVE_RANGE trigger (an enemy in its 2-3 only), takes off (skill range, ATK +atk, blocks flyers), single shots; hits never set it off', () => {
|
||
for (const id of both('chess_char_2_13')) {
|
||
const bb = bbAlt(id);
|
||
// (10,5) = [1,0]: inside S1's 2-3, outside her own 2-2 — the owner's rule of 2026-10-05 (data trigger ACTIVE_RANGE)
|
||
const h = run({ defs: { enemies: { e: dummy('e'), f: dummy('f', { motion: 'FLY' }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
const range0 = u.baseRangeKeys.length;
|
||
assert.ok(!u.baseRangeKeys.includes(h.b.grid.key(10, 5)), 'the enemy is outside her own range');
|
||
h.runUntil(() => u.skill.active, 3);
|
||
assert.equal(started(h, u)[0].reason, 'ACTIVE_RANGE');
|
||
assert.equal(u.s.flags.liftoff, true, 'airborne (起飞)');
|
||
assert.equal(u.ground, true, 'still a ground unit on her low tile');
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
assert.ok(u.rangeKeys.length > range0, 'skill range');
|
||
const fl = h.spawn('f', { pos: [9, 5] });
|
||
h.step();
|
||
assert.equal(fl.blockedBy, u, 'blocks flyers while airborne');
|
||
h.run(3);
|
||
const atk = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||
const hits = dealt(h, u, (c) => c.dmg.isAttack);
|
||
assert.equal(hits.length, atk.reduce((n, c) => n + c.targets.length, 0), 'one shot per target (no 3 连射)');
|
||
h.runUntil(() => !u.skill.active, 40);
|
||
h.step();
|
||
assert.ok(!u.s.flags.liftoff, 'landed');
|
||
done(h);
|
||
// an enemy attack from outside her range never sets S1 off (S2's 受到攻击后触发 is not hers)
|
||
const h2 = run({ defs: { enemies: { r: dummy('r', { atk: 300, bat: 1, range: 3.2 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'r', pos: [9, 8] }] });
|
||
const u2 = h2.unit(id);
|
||
h2.run(4);
|
||
assert.ok(h2.hooksOf('damaged').some((c) => c.target === u2), 'she is being shot');
|
||
assert.equal(started(h2, u2).length, 0, 'not triggered by hits');
|
||
done(h2);
|
||
}
|
||
});
|
||
|
||
test('2_14 调香师 S1 治疗强化·β型: ATK +atk (ASPD unchanged); heals 3 allies (elite module: 4)', () => {
|
||
for (const id of both('chess_char_2_14')) {
|
||
const bb = bbAlt(id), ids = ['t_1', 't_2', 't_3', 't_4', 't_5'];
|
||
const defs = { chess: Object.fromEntries(ids.map((k) => [k, plain(k)])) };
|
||
const h = run({ defs, units: [U(id, 10, 4, { carryState: READY }), ...ids.map((c, i) => ({ chessId: c, row: [9, 10, 11, 9, 11][i], col: [4, 5, 5, 5, 4][i] }))] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
for (const k of ids) { const a = h.unit(k); a.hp = a.s.maxHp * 0.4; }
|
||
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5);
|
||
assert.ok(u.skill.active);
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
approx(u.s.aspd, u.base.aspd, 'no 精调 ASPD −50');
|
||
const n = h.hooksOf('attack').find((c) => c.attacker === u).targets.length;
|
||
assert.equal(n, id.endsWith('_b') ? 4 : 3);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_16 拉普兰德 S1 日晷: toggle (持续时间无限) ATK +atk; blocks enemy physical damage with `prob`; physical normal attacks', () => {
|
||
for (const id of both('chess_char_2_16')) {
|
||
const bb = bbAlt(id);
|
||
for (const roll of [true, false]) {
|
||
const seen = [];
|
||
// 120 hits a minute at bat 0.5, each at the 5 % floor (12.5): 1500 in the 60 s, short of the normal form's 1791 HP
|
||
const h = run({
|
||
defs: { enemies: { e: dummy('e', { atk: 250, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }],
|
||
setup: (b) => { b.rng.chance = (p) => { seen.push(p); return roll; }; },
|
||
});
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.runUntil(() => u.skill.active, 3);
|
||
assert.equal(u.skill.kind, 'toggle');
|
||
approx(u.s.atk, u.base.atk * (1 + bb.atk));
|
||
const taken0 = u.stats.taken;
|
||
h.run(60);
|
||
assert.ok(u.skill.active, 'still active after 60 s');
|
||
assert.ok(seen.includes(bb.prob), 'rolls prob');
|
||
if (roll) assert.equal(u.stats.taken, taken0, 'every blocked roll negates the physical hit');
|
||
else assert.ok(u.stats.taken > taken0, 'a failed roll lands');
|
||
assert.ok(dealt(h, u, (c) => c.dmg.isAttack).every((c) => c.type === 'phys'), 'no 狼魂 arts conversion');
|
||
done(h);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('2_17 折桠 S1 绝境抵抗: TAKE_DAMAGE; DEF +def and 抵抗 (control statuses halved) for its duration; 简易包扎 at the end', () => {
|
||
for (const id of both('chess_char_2_17')) {
|
||
const bb = bbAlt(id), t = tal(id);
|
||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.runUntil(() => u.skill.active, 3);
|
||
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
|
||
approx(u.s.def, u.base.def * (1 + bb.def));
|
||
approx(h.b.resistOf(u), -bb.one_minus_status_resistance, '抵抗');
|
||
h.b.applyStatus(u, 'stun', { duration: 4, source: h.enemies()[0] });
|
||
approx(statuses(h, 'stun', (c) => c.target === u)[0].duration, 4 * (1 + bb.one_minus_status_resistance), 'stun halved');
|
||
assert.equal(statuses(h, 'tremble').length, 0, 'no 生存决心 tremble');
|
||
u.hp = u.s.maxHp * 0.3;
|
||
h.runUntil(() => !u.skill.active, 20);
|
||
const end = heals(h, u, (c) => c.target === u && tagged('talent')(c));
|
||
assert.equal(end.length, 1);
|
||
approx(end[0].amount, u.s.maxHp * t.hp_ratio);
|
||
h.run(0.2);
|
||
assert.equal(h.b.resistOf(u), 0, '抵抗 ends with the skill');
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_18 灰毫 S2 专注轰击: cast with an enemy in range (DEFAULT, the deliberate deviation from the 重装 row — DESIGN §21.29), then blocks nothing, ranged splash bombs only, shorter interval, ATK +atk', () => {
|
||
for (const id of both('chess_char_2_18')) {
|
||
const bb = bbAlt(id);
|
||
const enemies = { w: enemyRec({ key: 'w', hp: 1e7, speed: 2, atk: 50, bat: 1 }) };
|
||
// control: the default skill blocks the walker
|
||
const c = run({ defs: { enemies }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'w', route: 0 }] });
|
||
assert.ok(c.runUntil(() => c.enemies()[0]?.blockedBy === c.unit(id), 10), 'default: blocked');
|
||
done(c);
|
||
|
||
// (a second walker, 2 s behind the first, comes by while the skill runs — 10 s from the cast, made as the first one
|
||
// comes into range: she bombs it from range, unblocked)
|
||
const h = run({ defs: { enemies }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'w', route: 0 }, { key: 'w', route: 0, time: 2 }] });
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
// the official 下半 重装 row (TAKE_DAMAGE) is overridden for this skill by the owner's decision (data rawRule keeps it)
|
||
assert.equal(u.skill.rule, 'DEFAULT', 'an offensive 重装 skill of the deviation: the basic strategy');
|
||
assert.ok(h.runUntil(() => u.skill.active, 8));
|
||
const st = h.b.time;
|
||
const atk0 = h.eventsOf('atk').length;
|
||
assert.equal(started(h, u)[0].reason, 'DEFAULT', 'cast by the basic strategy');
|
||
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u), 'with the walker in range, before anything hit her');
|
||
assert.ok(u.s.flags.noBlock);
|
||
approx(u.s.atk, u.base.atk * (1 + u.findBuff('talent:ashlok').mods.atkPct + bb.atk), 'ATK (+ 炮术研习)');
|
||
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time shortened');
|
||
const w = h.enemies()[0];
|
||
assert.ok(h.runUntil(() => w.x < 4.5, 8), 'walks past her');
|
||
const w2 = h.enemies()[1];
|
||
assert.ok(h.runUntil(() => w2 && w2.x < 4.5, 12), 'the second one walks past her too');
|
||
assert.ok(u.skill.active);
|
||
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u && x.t > st + 0.1), 'released, never blocked again ⇒ not hit again');
|
||
const shots = h.eventsOf('atk').slice(atk0).filter((e) => e[1] === u.id);
|
||
assert.ok(shots.length > 0 && shots.every((e) => e[3] !== 'none'), `ranged shots only ${JSON.stringify(shots)}`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('2_19 锡人 S1 “老科利”: alchemy unit — 8 s of weaken −atk and atk_scale × ATK arts/s on ground enemies within 1 tile', () => {
|
||
for (const id of both('chess_char_2_19')) {
|
||
const bb = bbAlt(id), wither = tal(id, null, 1)['skill@damage_scale'] ?? 1;
|
||
const h = run({
|
||
defs: { enemies: { g: dummy('g', { atk: 100 }), f: dummy('f', { atk: 100, motion: 'FLY' }) } },
|
||
units: [U(id, 10, 4, { carryState: READY })],
|
||
enemies: [{ key: 'g', pos: [10, 6] }, { key: 'f', pos: [10, 6] }, { key: 'g', pos: [11, 6] }, { key: 'g', pos: [10, 8] }],
|
||
});
|
||
const u = h.unit(id);
|
||
usesAlt(u, id);
|
||
h.step();
|
||
assert.equal(started(h, u).length, 1);
|
||
const atk0 = u.s.atk;
|
||
const [g, f, g2, far] = h.enemies();
|
||
h.run(0.5);
|
||
approx(g.s.atk, 100 * (1 + bb.atk), 'weakened');
|
||
approx(g2.s.atk, 100 * (1 + bb.atk), 'neighbour inside the unit radius');
|
||
approx(f.s.atk, 100, 'flyers unaffected');
|
||
approx(far.s.atk, 100, 'outside');
|
||
h.run(bb.projectile_delay_time);
|
||
const zone = dealt(h, u, (c) => c.target === g && (c.dmg.tags || []).includes('zone'));
|
||
assert.equal(zone.length, bb.projectile_delay_time, 'one pulse per second');
|
||
for (const c of zone) { assert.equal(c.type, 'arts'); approx(c.amount, atk0 * bb.atk_scale * wither, `${id} ×${bb.atk_scale}${wither > 1 ? ' ×凋敝魂灵' : ''}`); }
|
||
assert.equal(dealt(h, u, (c) => (c.target === f || c.target === far) && (c.dmg.tags || []).includes('zone')).length, 0);
|
||
approx(g.s.atk, 100, 'weaken over');
|
||
assert.equal(u.mem.tinZones, 0);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
// =================================================================================================================
|
||
// module choice: 'none' instead of the elite's default module (the kit reads the loadout-resolved record)
|
||
|
||
/** Elite (default module) vs the same elite without module: [measure(default), measure(none)]. */
|
||
function withAndWithout(fn) { return [fn(null), fn('none')]; }
|
||
const mod = (moduleId) => (moduleId ? { moduleId } : {});
|
||
|
||
test('modules: removing the elite module changes the trait / talent behaviour the kit and profile implement', async (t) => {
|
||
await t.test('2_01 送葬人 RPR-X: front-row ×1.6 → ×1.5', () => {
|
||
const id = 'chess_char_2_01_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 5] }] });
|
||
h.run(1);
|
||
const u = h.unit(id);
|
||
const d = dealt(h, u)[0].amount / u.s.atk;
|
||
done(h);
|
||
return d;
|
||
});
|
||
approx(a, tbOf(id).atk_scale);
|
||
approx(b, tbOf(id, NONE).atk_scale);
|
||
assert.ok(a > b);
|
||
});
|
||
await t.test('2_02 赫默 PHY-Y: ground heals ×1.15 → ×1', () => {
|
||
const id = 'chess_char_2_02_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { chess: { t_a: plain('t_a') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, { chessId: 't_a', row: 9, col: 6 }] });
|
||
h.step();
|
||
const x = h.unit('t_a'), u = h.unit(id);
|
||
x.hp = x.s.maxHp * 0.3;
|
||
h.runUntil(() => heals(h, u).length > 0, 10);
|
||
const r = heals(h, u)[0].amount / u.s.atk;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, 1.15);
|
||
approx(b, 1);
|
||
});
|
||
await t.test('2_04 小满 DEC-X: +0.2 SP/s with an enemy in range → none', () => {
|
||
const id = 'chess_char_2_04_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||
h.run(2);
|
||
const sp = h.unit(id).skill.sp;
|
||
done(h);
|
||
return sp;
|
||
});
|
||
approx(a, 2 * (1 + 0.2), 'module', 1e-3);
|
||
approx(b, 2, 'none', 1e-3);
|
||
});
|
||
await t.test('2_05 哈洛德 WDM-X: element recovery 0.6 → 0.5 × ATK', () => {
|
||
const id = 'chess_char_2_05_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { chess: { t_el: plain('t_el') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, { chessId: 't_el', row: 10, col: 5 }] });
|
||
h.step();
|
||
const x = h.unit('t_el'), u = h.unit(id);
|
||
x.elem.neural = 900;
|
||
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5);
|
||
const r = (900 - x.elem.neural) / u.s.atk;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, tbOf(id).ep_heal_ratio);
|
||
approx(b, tbOf(id, NONE).ep_heal_ratio);
|
||
assert.ok(a > b);
|
||
});
|
||
await t.test('2_06 莎草 XAH-X: chain heal falloff −15 % → −25 % per jump', () => {
|
||
const id = 'chess_char_2_06_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { chess: { t_1: plain('t_1'), t_2: plain('t_2') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, { chessId: 't_1', row: 10, col: 5 }, { chessId: 't_2', row: 10, col: 6 }] });
|
||
h.step();
|
||
const u = h.unit(id), x1 = h.unit('t_1'), x2 = h.unit('t_2');
|
||
x1.hp = x1.s.maxHp * 0.2; x2.hp = x2.s.maxHp * 0.3;
|
||
h.runUntil(() => heals(h, u).length >= 2, 5);
|
||
const [m0, j1] = heals(h, u).filter((c) => !(c.opts?.tags || []).length);
|
||
done(h);
|
||
return j1.amount / m0.amount;
|
||
});
|
||
approx(a, 0.85);
|
||
approx(b, 0.75);
|
||
});
|
||
await t.test('2_07 幽灵鲨 CEN-X: vs blocked ×1.1 → ×1', () => {
|
||
const id = 'chess_char_2_07_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
h.run(1);
|
||
const u = h.unit(id);
|
||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, 1.1);
|
||
approx(b, 1);
|
||
});
|
||
await t.test('2_08 泡泡 PRO-X: DEF +20 % while blocking → none', () => {
|
||
const id = 'chess_char_2_08_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
h.run(0.3);
|
||
const u = h.unit(id);
|
||
const r = u.s.def / u.base.def;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, 1.2);
|
||
approx(b, 1);
|
||
});
|
||
await t.test('2_09 休谟斯 REA-X: trait self heal 60 → 50 per enemy hit', () => {
|
||
const id = 'chess_char_2_09_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
h.step();
|
||
const u = h.unit(id);
|
||
u.hp = u.s.maxHp * 0.5;
|
||
h.runUntil(() => heals(h, u, (c) => c.target === u).length > 0, 3);
|
||
const r = heals(h, u, (c) => c.target === u)[0].amount;
|
||
done(h);
|
||
return r;
|
||
});
|
||
assert.equal(a, tbOf(id).value);
|
||
assert.equal(b, tbOf(id, NONE).value);
|
||
assert.ok(a > b);
|
||
});
|
||
await t.test('2_10 洛洛 FUN-X: drone first hit 0.35 → 0.2 × ATK', () => {
|
||
const id = 'chess_char_2_10_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 6] }] });
|
||
h.runUntil(() => dealt(h, h.unit(id)).length > 0, 5);
|
||
const u = h.unit(id);
|
||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, tbOf(id).init_atk_scale);
|
||
approx(b, tbOf(id, NONE).init_atk_scale);
|
||
assert.ok(a > b);
|
||
});
|
||
await t.test('2_11 风丸 PUM-X: substituted ATK +15 % → +0 %', () => {
|
||
const id = 'chess_char_2_11_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 8000, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
const u = h.unit(id);
|
||
h.runUntil(() => u.findBuff('trait:substitute'), 20);
|
||
h.step();
|
||
const tokAtk = h.b.data.getToken('token_10022_kazema_shadow', id).stats.atk;
|
||
const r = u.s.atk / tokAtk;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, 1 + tbOf(id).atk);
|
||
approx(b, 1);
|
||
});
|
||
await t.test('2_14 调香师 RIN-Y: heals 4 → 3 allies', () => {
|
||
const id = 'chess_char_2_14_b', ids = ['t_1', 't_2', 't_3', 't_4', 't_5'];
|
||
const defs = { chess: Object.fromEntries(ids.map((k) => [k, plain(k)])) };
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }, ...ids.map((c, i) => ({ chessId: c, row: [9, 10, 11, 9, 11][i], col: [4, 5, 5, 5, 4][i] }))] });
|
||
h.step();
|
||
for (const k of ids) { const x = h.unit(k); x.hp = x.s.maxHp * 0.4; }
|
||
const u = h.unit(id);
|
||
h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5);
|
||
const n = h.hooksOf('attack').find((c) => c.attacker === u).targets.length;
|
||
done(h);
|
||
return n;
|
||
});
|
||
assert.deepEqual([a, b], [4, 3]);
|
||
});
|
||
await t.test('2_16 拉普兰德 LOR-X: +10 % ATK arts on hit → none', () => {
|
||
const id = 'chess_char_2_16_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 6] }] });
|
||
h.run(1);
|
||
const n = dealt(h, h.unit(id), (c) => (c.dmg.tags || []).includes('module')).length;
|
||
done(h);
|
||
return n;
|
||
});
|
||
assert.ok(a > 0);
|
||
assert.equal(b, 0);
|
||
});
|
||
await t.test('2_17 折桠 UNY-X: damage from blocked attackers ×0.85 → ×1', () => {
|
||
const id = 'chess_char_2_17_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e', { atk: 1500, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [9, 5] }] });
|
||
const u = h.unit(id);
|
||
h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === u), 3);
|
||
const r = h.hooksOf('damaged').find((c) => c.target === u).amount / (1500 - u.s.def);
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, 0.85);
|
||
approx(b, 1);
|
||
});
|
||
await t.test('2_18 灰毫 FOR-X: vs blocked ×1.1 → ×1', () => {
|
||
const id = 'chess_char_2_18_b';
|
||
const [a, b] = withAndWithout((m) => {
|
||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 5, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 5] }] });
|
||
h.run(4);
|
||
const u = h.unit(id);
|
||
const r = dealt(h, u)[0].amount / u.s.atk;
|
||
done(h);
|
||
return r;
|
||
});
|
||
approx(a, 1.1);
|
||
approx(b, 1);
|
||
});
|
||
});
|
||
|
||
// =================================================================================================================
|
||
// smoke: every loadout of every tier-2 chess fights without content errors
|
||
|
||
test('every tier-2 loadout (skill × module, normal & elite) fights 30 s with its authored kit and no content errors', () => {
|
||
const bases = Object.values(ds.raw.chess).filter((c) => c.tier === 2 && c.visible && !c.isGolden);
|
||
for (const base of bases) {
|
||
for (const id of [base.chessId, base.goldenId].filter(Boolean)) {
|
||
const r = raw(id);
|
||
const mods = Array.isArray(r.modules) && r.modules.length ? [null, 'none'] : [null];
|
||
for (const s of r.skills) {
|
||
for (const moduleId of mods) {
|
||
const h = makeBattle({
|
||
seed: 11, timeLimit: 30, autoFinish: false,
|
||
units: [{ chessId: id, row: 10, col: 5, skillIndex: s.index, ...(moduleId ? { moduleId } : {}), carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 5 }],
|
||
enemies: [{ key: 'enemy_1007_slime', route: 0, count: 6, interval: 2 }, { key: 'enemy_1007_slime', route: 1, count: 4, interval: 3 }],
|
||
});
|
||
const u = h.unit(id);
|
||
assert.equal(u.skill.id, s.skillId);
|
||
assert.equal(u.kit.skillSource ?? 'kit', s.isDefault ? 'kit' : 'skills', `${id} ${s.skillId}`);
|
||
h.run(30);
|
||
assert.equal(h.b.errors.length, 0, `${id} S${s.index + 1} module ${moduleId}: ${JSON.stringify(h.b.errors[0])}`);
|
||
checkInvariants(h.b);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|