// Tier 5 alternate skills & modules (DESIGN §16 operator loadouts; the `skills` maps of the tier-5 kits, server/sim/content/kits/ops/). // Every selectable non-default skill of every visible tier-5 chess runs a real battle — normal (Lv4) and elite (Lv7) — // through the harness with its loadout (`skillIndex` / `moduleId` on the board entry, as a BattleSpec carries them), // and its signature effect is asserted with numbers from that skill's own blackboard (data/chess.json skills[]). // Non-default modules are checked where the module changes behaviour (REA-Y, PRI-Y, AFT-Y, FOR-Y, GUA-Y, PUM-Y, FGT-X, // DEC-X/DEC-Y, SPC-Y, 'none'). 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 { COLS } from '../../server/sim/constants.js'; import { loadoutOptions } from '../../shared/protocol.js'; import { kitCoverage } from '../../tools/kit-coverage.mjs'; const ds = getDefaultSource(); const raw = (id) => ds.rawChess(id); /** SkillRecord `skillId` of chess `id` (its level's bb / duration / grid). */ const rec = (id, skillId) => { const r = raw(id).skills.find((s) => s.skillId === skillId); assert.ok(r, `${id} has ${skillId}`); return r; }; 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 ally = (id, o = {}) => chessRec({ id, skill: null, ...o, stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0, respawnTime: 5, ...(o.stats || {}) } }); const HOOKS = ['damaged', 'heal', 'hit', 'skillStart', 'skillEnd', 'statusApplied', 'ammoUsed', 'attack', 'death', 'deploy', 'kill']; /** Battle + one tick (the spawns of t = 0 exist afterwards). */ const run = (o) => makeBattle({ seed: 7, autoFinish: false, timeLimit: 400, hooks: HOOKS, captureNoisy: true, ...o }).step(); /** No more casts (a silence buff): lets a test watch the end of an effect without a recast. */ const mute = (h, u) => h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } }); /** Board entry of chess `id` carrying skill `skillId` (+ extra fields: module, tile). */ const entry = (id, skillId, o = {}) => ({ chessId: id, skillIndex: rec(id, skillId).index, ...o }); /** The unit of chess `id`, asserted to run the selected skill with its hand-authored spec. */ function sel(h, id, skillId) { const u = h.unit(id); assert.ok(u, `${id} on the board`); assert.equal(u.skill.id, skillId, `${id} carries ${skillId}`); assert.equal(u.kit.skillSource, 'skills', `${id} ${skillId}: hand-authored spec`); return u; } const bbOf = (u) => u.def.skill.bb; const tal = (u, i) => (u.def.raw.talents || []).find((t) => t.index === i)?.bb ?? {}; const dealt = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && f(c)); const tagged = (h, u, tag, target = null) => dealt(h, u, (c) => (c.dmg?.tags || []).includes(tag) && (!target || c.target === target)); const heals = (h, u, f = () => true) => h.hooksOf('heal').filter((c) => c.source === u && f(c)); const attacks = (h, u, f = () => true) => h.hooksOf('attack').filter((c) => c.attacker === u && f(c)); const statuses = (h, key, f = () => true) => h.hooksOf('statusApplied').filter((c) => c.status === key && f(c)); /** Events strictly inside the (first) run of u's skill. */ const during = (h, u, list) => { const s0 = h.hooksOf('skillStart').find((c) => c.unit === u)?.t ?? Infinity, s1 = h.hooksOf('skillEnd').find((c) => c.unit === u)?.t ?? Infinity; return list.filter((c) => c.t > s0 + 1e-9 && c.t < s1 - 1e-9); }; const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); }; const pair = (n) => [`chess_char_5_${n}_a`, `chess_char_5_${n}_b`]; /** Fill SP and run until the skill has been cast. */ function cast(h, u, max = 15) { u.skill.gainSp(1000); assert.ok(h.runUntil(() => u.skill.activations > 0, max), `${u.defId} ${u.skill.id} cast`); } // ================================================================================================================= // coverage & smoke test('tier 5: every selectable skill of every visible chess has a hand-authored spec (normal + elite)', () => { const rep = kitCoverage({ tier: 5 }); assert.equal(rep.summary.chess, 19); assert.equal(rep.summary.covered, rep.summary.skills, rep.chess.flatMap((r) => r.skills.filter((s) => !s.covered).map((s) => `${r.name} S${s.index + 1}`)).join(', ')); assert.equal(rep.summary.chessFullyCovered, rep.summary.chess); }); test('tier 5: every chess × every legal skill × every module fights with its authored spec and casts, no content errors', () => { const bases = ds.chessIds().filter((id) => /^chess_char_5_\d+_a$/.test(id) && raw(id).visible); assert.equal(bases.length, 19); let n = 0; for (const base of bases) { const gold = base.replace(/_a$/, '_b'); const opt = loadoutOptions(raw(base), raw(gold)); const mods = [undefined, 'none', ...(raw(gold).modules || []).map((m) => m.uniEquipId)]; for (const skillIndex of opt.skills) { for (const [id, moduleId] of [[base, undefined], ...mods.map((m) => [gold, m])]) { const h = makeBattle({ seed: 5, hooks: [], defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 30000, atk: 400, bat: 2, speed: 0.6 }) }, chess: { t_a: ally('t_a', { stats: { maxHp: 5000, atk: 100, blockCnt: 1 } }) } }, units: [{ chessId: id, row: 10, col: 5, skillIndex, ...(moduleId ? { moduleId } : {}) }, { chessId: 't_a', row: 11, col: 5 }, { chessId: 'chess_char_5_11_a', row: 9, col: 4 }], enemies: [{ key: 'enemy_dummy', count: 6, interval: 2 }, { key: 'enemy_dummy', route: 1, count: 6, interval: 2 }], timeLimit: 90, }); h.step(); const u = h.b.allyUnits[0]; const tag = `${id} S${skillIndex + 1} ${moduleId ?? 'default module'}`; assert.equal(u.def.loadout.skillIndex, skillIndex, tag); assert.ok(['skills', undefined].includes(u.kit.skillSource), `${tag}: ${u.kit.skillSource}`); const mate = h.b.allyUnits[1]; // (塞雷娅 S1 急救 casts only for an ally of its area below half HP) for (let t = 0; t < 60 && !h.b.finished; t += 5) { if (mate.alive) mate.hp = Math.min(mate.hp, mate.s.maxHp * 0.3); u.skill.gainSp(1000); h.run(5); } assert.ok(u.skill.activations > 0, `${tag}: cast`); assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), [], tag); checkInvariants(h.b); n++; } } } assert.ok(n > 200, `${n} loadouts`); }); // ================================================================================================================= // 圣约送葬人 test('圣约送葬人 S1 遗嘱执行: 8-round ammo (+1 Laterano: himself), skill range, ATK +, ignores def_penetrate_fixed DEF', () => { for (const id of pair('01')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy'), enemy_armour: dummy('enemy_armour', { def: 400 }) } }, units: [entry(id, 'skchr_excu2_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_armour', pos: [10, 6] }], }); const u = sel(h, id, 'skchr_excu2_1'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk; const e = h.enemy('enemy_armour'); assert.ok(!u.rangeKeySet.has(10 * COLS + 6), 'the base range does not reach 2 tiles ahead'); cast(h, u); assert.equal(u.skill.kind, 'ammo'); assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@trigger_time'] + 1, 'ammo + 铳弹共感 (he is Laterano)'); approx(u.s.atk, atk0 * (1 + bb.atk), id); assert.equal(u.s.defIgnoreFlat, bb.def_penetrate_fixed); assert.ok(u.rangeKeySet.has(10 * COLS + 6), 'the skill range does'); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.target === e && c.dmg.isAttack).length > 0, 5)); const d = dealt(h, u, (c) => c.target === e && c.dmg.isAttack)[0]; approx(d.amount, u.s.atk - (400 - bb.def_penetrate_fixed), `${id} DEF 400 − ${bb.def_penetrate_fixed}`); done(h); } }); test('圣约送葬人 S3 圣约决裁: BAT +0.5 s, ATK + and +attack@atk per bullet, reaper heal ×2, the end hits every attacked enemy for 200 %', () => { for (const id of pair('01')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_excu2_3', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [11, 5] }], }); const u = sel(h, id, 'skchr_excu2_3'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk, iv0 = u.s.interval, heal0 = u.profile.selfHeal; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk + bb['attack@atk'] * (u.findBuff('excu2:verdict')?.stacks ?? 0)), 'ATK'); approx(u.s.interval, iv0 * (u.base.bat + bb.base_attack_time) / u.base.bat, 'BAT +0.5 s'); assert.equal(u.profile.selfHeal, heal0 * bb.trait_ratio, 'trait heal ×2'); assert.ok(h.runUntil(() => h.hooksOf('ammoUsed').filter((c) => c.unit === u).length >= 3, 20)); const spent = h.hooksOf('ammoUsed').filter((c) => c.unit === u).length; assert.equal(u.findBuff('excu2:verdict').stacks, spent); approx(u.s.atk, atk0 * (1 + bb.atk + bb['attack@atk'] * spent), 'per-bullet ATK'); let endAtk = 0; h.b.on('ammoUsed', (c) => { if (c.unit === u && c.left <= 0) endAtk = u.s.atk; }, { priority: -50 }); assert.ok(h.runUntil(() => !u.skill.active, 60)); const fin = tagged(h, u, 'verdict'); assert.equal(new Set(fin.map((c) => c.target)).size, 2, 'both attacked enemies'); for (const c of fin) approx(c.amount, endAtk * bb['attack@final_atk_scale'], 'final 200 %'); assert.equal(u.profile.selfHeal, heal0, 'heal restored'); assert.equal(u.findBuff('excu2:verdict'), null, 'stacks reset'); done(h); } }); test('圣约送葬人 module REA-Y (已知悉): ASPD +12 with ≥ 2 enemies in range (the default REA-X has no such rider)', () => { for (const [moduleId, want] of [['uniequip_003_excu2', 12], ['uniequip_002_excu2', 0]]) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [{ chessId: 'chess_char_5_01_b', row: 10, col: 4, moduleId }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], }); const u = h.unit('chess_char_5_01_b'); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd, 'one enemy'); h.spawn('enemy_dummy', { pos: [11, 5] }); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd + want, moduleId); done(h); } }); // ================================================================================================================= // 缇缇 test('缇缇 S1 缓蚀: ATK +, each attack sleeps the target attack@sleep s with attack@prob', () => { for (const id of pair('02')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_titi_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], }); const u = sel(h, id, 'skchr_titi_1'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.ok(h.runUntil(() => !u.skill.active, 40)); const n = attacks(h, u, (c) => c.isSkill).length; const slept = statuses(h, 'sleep', (c) => c.source === u); assert.ok(slept.length > 0 && slept.length < n, `${slept.length} sleeps in ${n} attacks`); for (const c of slept) approx(c.duration, bb['attack@sleep']); done(h); } }); test('缇缇 S2 封护: no attacks; she and the lowest-HP op in range sleep (invulnerable) until the end; enemies around them sleep; T1 ×talent_scale', () => { for (const id of pair('02')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_hurt: ally('t_hurt'), t_fine: ally('t_fine') } }, units: [entry(id, 'skchr_titi_2', { row: 10, col: 4 }), { chessId: 't_hurt', row: 10, col: 6 }, { chessId: 't_fine', row: 11, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_dummy', pos: [12, 8] }], }); const u = sel(h, id, 'skchr_titi_2'); const bb = bbOf(u), t0 = tal(u, 0); const hurt = h.unit('t_hurt'), fine = h.unit('t_fine'); const [near, far] = h.b.enemies; h.step(); hurt.hp = hurt.s.maxHp * 0.3; fine.hp = fine.s.maxHp * 0.6; const atk0 = u.s.atk; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.ok(u.findBuff('titi:ward') && u.s.flags.sleep, 'she sleeps'); assert.ok(hurt.findBuff('titi:ward') && hurt.s.flags.sleep, 'the lowest-HP op sleeps'); assert.ok(!fine.findBuff('titi:ward')); h.b.dealDamage(null, hurt, { amount: 1e9, type: 'true' }); assert.ok(hurt.alive, 'asleep = invulnerable'); h.run(1.2); assert.ok(near.s.flags.sleep, 'the enemy next to the ward sleeps'); assert.ok(!far.s.flags.sleep, 'an enemy far away does not'); const dream = tagged(h, u, 'titiDream', near); assert.ok(dream.length > 0); approx(dream.at(-1).amount, u.s.atk * t0.damage_atk_scale * bb.talent_scale, 'T1 ×talent_scale'); assert.ok(h.runUntil(() => !u.skill.active, 30)); assert.equal(during(h, u, attacks(h, u)).length, 0, 'no attacks during the skill'); // …and every 凝固的时光 tick healed a 咒愈师 ally: the official trait buff (`titi_tr`) is ON_AFTER_OUTPUT_DAMAGE, so // the heal follows ANY damage she deals. The sim ran the trait from the attack path only and she does not attack at // all while this skill runs, so those ticks healed nothing (the same hook gap that left 隐德来希 S2 without heals). const dreams = during(h, u, dealt(h, u, (c) => (c.dmg?.tags || []).includes('titiDream'))); const incHeals = during(h, u, heals(h, u, (c) => (c.opts?.tags || []).includes('incantation'))); assert.ok(dreams.length > 0, `${dreams.length} 凝固的时光 ticks`); assert.equal(incHeals.length, dreams.length, 'one trait heal per tick'); for (const c of incHeals) { approx(c.amount, dreams[0].amount * u.profile.healRatio, 'heal = 50 % of the tick'); assert.ok([hurt, fine, u].includes(c.target), 'on an ally in her range'); } assert.ok(!u.findBuff('titi:ward') && !hurt.findBuff('titi:ward'), 'both wake at the end'); done(h); } }); // ================================================================================================================= // 烛煌 test('烛煌 S1 炙手之援: the highest-max-HP op in range carries a fire aura: atk_scale × ATK arts/s + element_multiplier 灼燃 for 20 s', () => { for (const id of pair('03')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_big: ally('t_big', { stats: { maxHp: 30000 } }), t_small: ally('t_small') } }, units: [entry(id, 'skchr_blaze2_1', { row: 10, col: 3 }), { chessId: 't_big', row: 10, col: 5 }, { chessId: 't_small', row: 11, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], }); const u = sel(h, id, 'skchr_blaze2_1'); const bb = bbOf(u); const big = h.unit('t_big'), small = h.unit('t_small'); h.step(); cast(h, u); const aura = big.findBuff(`blaze2:aid:${u.id}`); assert.ok(aura, 'on the highest max HP op'); approx(aura.timeLeft, bb.max_duration, 'duration', 0.02); assert.ok(!small.findBuff(`blaze2:aid:${u.id}`)); const e = h.b.enemies[0]; assert.ok(h.runUntil(() => tagged(h, u, 'blazeAid', e).length >= 4, 5)); const arts = tagged(h, u, 'blazeAid', e).filter((c) => c.type === 'arts'); const burn = tagged(h, u, 'blazeAid', e).filter((c) => c.type === 'element'); approx(arts[0].amount, u.s.atk * bb.atk_scale, 'arts'); approx(burn[0].amount, arts[0].amount * bb.element_multiplier, '灼燃损伤'); mute(h, u); h.run(bb.max_duration); assert.equal(big.findBuff(`blaze2:aid:${u.id}`), null, 'ends'); done(h); } }); test('烛煌 S2 沸血燎原: BAT +0.9 s, ATK +, 3 targets, −5 % max HP per attack, burning tiles slow −50 % and burn per second', () => { for (const id of pair('03')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_blaze2_2', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [9, 5] }, { key: 'enemy_dummy', pos: [11, 5] }, { key: 'enemy_dummy', pos: [10, 6] }], }); const u = sel(h, id, 'skchr_blaze2_2'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk, iv0 = u.s.interval; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); approx(u.s.interval, iv0 * (u.base.bat + bb.base_attack_time) / u.base.bat, 'BAT +0.9 s'); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 5)); const a = attacks(h, u, (c) => c.isSkill)[0]; assert.equal(a.targets.length, bb['attack@max_target']); approx(u.hp, u.s.maxHp * (1 - attacks(h, u, (c) => c.isSkill).length * bb['attack@hp_ratio']), 'HP loss per attack', 1e-3); const e = a.targets[0]; h.run(1.3); const g = e.findBuff(`blaze2:ground:${u.id}`); assert.ok(g, 'standing on a burning tile'); approx(g.mods.moveMul, 1 + bb.move_speed); const ground = tagged(h, u, 'blazeGround', e); const arts = ground.find((c) => c.type === 'arts'), burn = ground.find((c) => c.type === 'element'); approx(arts.amount, u.s.atk * bb.atk_scale, 'ground arts'); approx(burn.amount, arts.amount * bb.element_damage_scale, 'ground 灼燃'); assert.ok(h.runUntil(() => !u.skill.active, 60)); h.run(0.6); assert.equal(e.findBuff(`blaze2:ground:${u.id}`), null, 'the tiles go out with the skill'); done(h); } }); test('烛煌 / 妮芙 module PRI-Y: SP +0.2/s while an enemy in range is in an element burst, and no ×1.1 burst damage', () => { for (const [id, moduleId] of [['chess_char_5_03_b', 'uniequip_003_blaze2'], ['chess_char_5_22_b', 'uniequip_003_nymph']]) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [{ chessId: id, row: 10, col: 4, moduleId }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], }); const u = h.unit(id); const e = h.b.enemies[0]; h.run(0.5); assert.equal(u.s.spRecovery, u.base.spRecovery, 'no burst yet'); h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 5000 }); h.run(0.5); approx(u.s.spRecovery, u.base.spRecovery + tal(u, -1).sp_recovery_per_sec, moduleId); approx(h.b.dealDamage(u, e, { amount: 1000, type: 'true' }), 1000, 'PRI-Y: no damage bonus'); done(h); } }); // ================================================================================================================= // 乌尔比安 test('乌尔比安 S1 必须促成的接触: the anchor drags up to 2 enemies around the target in front of him, atk_scale × ATK phys each', () => { for (const id of pair('05')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_ulpia_1', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 6] }, { key: 'enemy_dummy', pos: [9, 9] }], }); const u = sel(h, id, 'skchr_ulpia_1'); const bb = bbOf(u); const [front, far, side, away] = h.b.enemies; h.step(); cast(h, u); const pulled = tagged(h, u, 'anchorPull'); assert.equal(pulled.length, bb.max_target, 'max_target enemies'); assert.deepEqual(new Set(pulled.map((c) => c.target)), new Set([far, side]), 'the unblocked target and the one around it'); for (const c of pulled) approx(c.amount, u.s.atk * bb.atk_scale, 'atk_scale'); assert.ok(far.x < 6 - 0.5 && side.x < 6 - 0.5, `dragged towards him (${far.x}, ${side.x})`); assert.equal(away.x, 9); assert.equal(front.x, 4); done(h); } }); test('乌尔比安 S2 必须维系的界限: never ends; ATK +, max HP +, block +1; T1 heals ×talent_scale', () => { for (const id of pair('05')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_ulpia_2', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }], }); const u = sel(h, id, 'skchr_ulpia_2'); const bb = bbOf(u), t0 = tal(u, 0); h.step(); const atk0 = u.s.atk, hp0 = u.s.maxHp, blk0 = u.s.blockCnt; h.b.dealDamage(null, u, { amount: 10, type: 'true' }); const before = heals(h, u, (c) => c.target === u).at(-1).amount; approx(before, t0.value1 * u.s.healingTakenMul, 'T1 before (× CRU-X healing on the elite)'); cast(h, u); assert.equal(u.skill.kind, 'toggle'); approx(u.s.atk, atk0 * (1 + bb.atk)); approx(u.s.maxHp, hp0 * (1 + bb.max_hp)); assert.equal(u.s.blockCnt, blk0 + bb.block_cnt); h.b.dealDamage(null, u, { amount: 10, type: 'true' }); approx(heals(h, u, (c) => c.target === u).at(-1).amount, t0.value1 * bb.talent_scale * u.s.healingTakenMul, 'T1 ×talent_scale'); h.run(120); assert.ok(u.skill.active, 'duration unlimited'); done(h); } }); // ================================================================================================================= // 隐德来希 test('隐德来希 S1 玫影觅迹: the next attack hits twice at atk_scale × ATK', () => { for (const id of pair('06')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_etlchi_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skchr_etlchi_1'); const bb = bbOf(u); h.step(); cast(h, u); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isAttack).length >= 2, 5)); const hits = dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isAttack); assert.equal(new Set(hits.map((c) => c.dmg.attackId)).size, 1, 'one attack'); assert.equal(hits.length, 2, 'two hits'); for (const c of hits) approx(c.amount, u.s.atk * bb.atk_scale); done(h); } }); test('隐德来希 S2 绯红壁合: no attacks; blood sickles on her and on the ground ally with enemies around cut every 0.5 s', () => { for (const id of pair('06')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_g: ally('t_g'), t_idle: ally('t_idle') } }, units: [entry(id, 'skchr_etlchi_2', { row: 10, col: 4 }), { chessId: 't_g', row: 12, col: 7 }, { chessId: 't_idle', row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [12, 8] }, { key: 'enemy_dummy', pos: [9, 9] }], }); const u = sel(h, id, 'skchr_etlchi_2'); const bb = bbOf(u); const [mine, other, none] = h.b.enemies; h.step(); cast(h, u); assert.deepEqual(u.mem.sickles.map((a) => a.defId), [u.defId, 't_g']); assert.ok(h.runUntil(() => !u.skill.active, 30)); assert.equal(during(h, u, attacks(h, u)).length, 0, 'no attacks'); for (const e of [mine, other]) { const cuts = tagged(h, u, 'bloodSickle', e); assert.ok(Math.abs(cuts.length - rec(id, 'skchr_etlchi_2').duration / bb.interval) <= 1, `${cuts.length} cuts`); approx(cuts[0].amount, u.s.atk * bb.atk_scale); } assert.equal(tagged(h, u, 'bloodSickle', none).length, 0); done(h); } }); test('隐德来希 S2 绯红壁合: every 血镰 cut heals the 收割者 trait (每攻击到一个敌人回复自身50生命)', () => { for (const id of pair('06')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_etlchi_2', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], }); const u = sel(h, id, 'skchr_etlchi_2'); h.step(); cast(h, u); u.hp = u.s.maxHp * 0.4; // wounded (and above 重盈's 25 %), so every heal shows const hp0 = u.hp, t0 = h.b.time; h.run(3); const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9); const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9); assert.ok(cuts.length >= 5, `${cuts.length} cuts`); assert.equal(healed.length, cuts.length, 'one trait heal per cut (the trait fires on every damage she outputs)'); for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal = the trait 生命值'); approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored'); done(h); } }); test('隐德来希 S2 绯红壁合: the 收割者 heal is capped by the block count per cut (最大生效数等于阻挡数)', () => { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry('chess_char_5_06_a', 'skchr_etlchi_2', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [11, 4] }], }); const u = sel(h, 'chess_char_5_06_a', 'skchr_etlchi_2'); h.step(); cast(h, u); u.hp = u.s.maxHp * 0.4; const hp0 = u.hp, t0 = h.b.time; h.run(3); const cuts = tagged(h, u, 'bloodSickle').filter((c) => c.t > t0 + 1e-9); const healed = heals(h, u, (c) => c.target === u && c.t > t0 + 1e-9); const ticks = cuts.length / 3; // three enemies stand in the sickle's ring assert.ok(ticks >= 5 && Number.isInteger(ticks), `${cuts.length} cuts over ${ticks} ticks`); assert.equal(healed.length, ticks * Math.max(1, u.s.blockCnt), `${healed.length} heals (阻挡数 ${u.s.blockCnt})`); for (const c of healed) approx(c.amount, u.profile.selfHeal, 'per-hit heal'); approx(u.hp - hp0, healed.length * u.profile.selfHeal, 'HP restored'); done(h); }); test('隐德来希 module REA-Y (玫瑰色故事集): ASPD +12 with ≥ 2 enemies in range', () => { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [{ chessId: 'chess_char_5_06_b', row: 10, col: 4, moduleId: 'uniequip_003_etlchi' }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], }); const u = h.unit('chess_char_5_06_b'); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd); h.spawn('enemy_dummy', { pos: [11, 5] }); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd + u.def.traitBb.attack_speed); assert.equal(u.profile.selfHeal, u.def.traitBb.value, 'reaper heal back to 50'); done(h); }); // ================================================================================================================= // 史尔特尔 test('史尔特尔 S1 烈焰魔剑: next attack atk_scale × ATK; a kill refills the SP at once', () => { for (const id of pair('07')) { const h = run({ defs: { enemies: { enemy_weak: dummy('enemy_weak', { hp: 100 }), enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_surtr_1', { row: 10, col: 4 })], }); const u = sel(h, id, 'skchr_surtr_1'); const bb = bbOf(u); u.skill.gainSp(1000); const w = h.spawn('enemy_weak', { pos: [10, 5] }); assert.ok(h.runUntil(() => !w.alive, 5), 'the skill hit kills'); assert.equal(u.skill.activations, 1); assert.ok(dealt(h, u, (c) => c.target === w)[0].dmg.isSkill, 'by the skill'); assert.ok(u.skill.ready, 'SP refilled'); const e = h.spawn('enemy_dummy', { pos: [10, 5] }); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.target === e && c.dmg.isSkill).length > 0, 5), 'next attack is the skill again'); approx(dealt(h, u, (c) => c.target === e && c.dmg.isSkill)[0].amount, u.s.atk * bb.atk_scale); assert.ok(!u.skill.ready, 'no kill: no refill'); done(h); } }); test('史尔特尔 S2 熔核巨影: ATK +, range +1, 2 targets; a lone target takes ×critical atk_scale', () => { for (const id of pair('07')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_surtr_2', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skchr_surtr_2'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.ok(u.rangeKeySet.has(10 * COLS + 6), 'range +1'); assert.equal(h.b.effectiveProfile(u).maxTargets, bb['attack@max_target']); const [e1] = h.b.enemies; assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isAttack).length > 0, 5)); approx(dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isAttack)[0].amount, u.s.atk * bb['attack@surtr_s_2[critical].atk_scale'], 'lone target'); const e2 = h.spawn('enemy_dummy', { pos: [10, 6] }); const k = dealt(h, u).length; assert.ok(h.runUntil(() => dealt(h, u, (c) => c.target === e2 && c.dmg.isAttack).length > 0, 5)); for (const c of dealt(h, u).slice(k).filter((c) => c.dmg.isAttack)) approx(c.amount, u.s.atk, 'two targets: plain ATK'); assert.ok(e1.alive); done(h); } }); test('史尔特尔 module AFT-Y (旅游必需品): blocked enemies are 法术脆弱 +10 %; no ASPD +8 unblocking', () => { const h = run({ defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e7, speed: 1 }) } }, units: [{ chessId: 'chess_char_5_07_b', row: 9, col: 5, moduleId: 'uniequip_003_surtr' }], enemies: [{ key: 'enemy_walk', route: 0 }], }); const u = h.unit('chess_char_5_07_b'); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd, 'no AFT-X rider'); const e = h.b.enemies[0]; assert.ok(h.runUntil(() => e.blockedBy === u, 20)); h.run(0.5); const f = e.buffs.find((b) => (b.status ?? b.key) === 'artsFragile'); assert.ok(f, '法术脆弱'); approx(e.s.artsTakenMul, u.def.traitBb.damage_scale); done(h); }); // ================================================================================================================= // 号角 test('号角 S1 照明榴弹 (自动触发 ⇒ DEFAULT): the next ranged shot deals atk_scale × ATK, splashes projectile_range and lights the impact', () => { for (const id of pair('08')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_horn_1', { row: 10, col: 2 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11.6, 6] }], }); const u = sel(h, id, 'skchr_horn_1'); const bb = bbOf(u); const [e1, e2] = h.b.enemies; h.step(); u.skill.gainSp(1000); assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)'); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.target === e1).length > 0, 5), 'her next shot, no hit needed'); u.skill.charges = 0; // (no further flare in the window below: it would light the targets again) u.skill.spCostMul = 1000; approx(dealt(h, u, (c) => c.dmg.isSkill && c.target === e1)[0].amount, u.s.atk * bb.atk_scale, 'atk_scale'); assert.ok(dealt(h, u, (c) => c.dmg.isSkill && c.target === e2).length > 0, `the splash reaches 1.6 tiles (${bb.projectile_range})`); h.run(0.5); assert.ok(e1.findBuff('reveal') && e2.findBuff('reveal'), 'lit'); h.run(bb.projectile_delay_time + 0.5); assert.ok(!e1.findBuff('reveal'), 'the light fades'); done(h); } }); test('号角 S2 暴风号令: cast with an enemy in range (DEFAULT, DESIGN §21.29); 10 rounds of attack@s2.atk_scale × ATK splash; the second half adds magic_atk_scale × ATK arts (过载)', () => { for (const id of pair('08')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_horn_2', { row: 10, col: 2 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }] }); const u = sel(h, id, 'skchr_horn_2'); const bb = bbOf(u); const e = h.b.enemies[0]; h.step(); // the owner's deliberate deviation from the 重装 TAKE_DAMAGE row (DESIGN §21.29): cast with an enemy in range assert.equal(u.skill.rule, 'DEFAULT'); u.skill.gainSp(1000); assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'cast with the enemy in range'); assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'DEFAULT'); assert.ok(!h.hooksOf('damaged').some((c) => c.target === u), 'no hit needed'); assert.equal(u.skill.kind, 'ammo'); assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@s2.trigger_time']); assert.ok(h.runUntil(() => !u.skill.active, 60)); h.run(1); const shots = dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isAttack && c.target === e && c.type === 'phys'); assert.equal(shots.length, bb['attack@s2.trigger_time']); for (const c of shots) approx(c.amount, u.s.atk * bb['attack@s2.atk_scale']); const over = tagged(h, u, 'hornOverload', e); assert.equal(over.length, bb['attack@s2.trigger_time'] / 2, 'second half'); for (const c of over) approx(c.amount, u.s.atk * bb['attack@s2.magic_atk_scale']); assert.ok(over[0].t > shots[bb['attack@s2.trigger_time'] / 2 - 1].t, 'after the first half'); done(h); } }); test('号角 module FOR-Y (旧日新装): ASPD +10 while not blocking; no ×1.1 vs blocked', () => { const h = run({ defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e7, speed: 1 }) } }, units: [{ chessId: 'chess_char_5_08_b', row: 9, col: 5, moduleId: 'uniequip_003_horn' }], enemies: [{ key: 'enemy_walk', route: 0, time: 3 }], }); const u = h.unit('chess_char_5_08_b'); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd + tal(u, -1).attack_speed); assert.ok(!u.profile.dmgMul, 'no FOR-X blocked bonus'); assert.ok(h.runUntil(() => u.blocking.length > 0, 30)); h.run(0.5); assert.equal(u.s.aspd, u.base.aspd, 'blocking'); done(h); }); // ================================================================================================================= // 铃兰 test('铃兰 S1 全力以赴: ATK +, ASPD + for the duration; S2 儿时的舞乐: toggle ATK +, 2 targets', () => { for (const id of pair('10')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_lisa_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skchr_lisa_1'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk, aspd0 = u.s.aspd; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.equal(u.s.aspd, aspd0 + bb.attack_speed); h.run(rec(id, 'skchr_lisa_1').duration + 0.5); assert.equal(u.s.aspd, aspd0, 'ends'); done(h); const g = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_lisa_2', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [10, 6] }] }); const v = sel(g, id, 'skchr_lisa_2'); const b2 = bbOf(v); g.step(); const a0 = v.s.atk; cast(g, v); assert.equal(v.skill.kind, 'toggle'); approx(v.s.atk, a0 * (1 + b2.atk)); assert.ok(g.runUntil(() => attacks(g, v, (c) => c.isSkill).length > 0, 5)); assert.equal(attacks(g, v, (c) => c.isSkill)[0].targets.length, b2['attack@max_target']); g.run(200); assert.ok(v.skill.active, 'duration unlimited'); done(g); } }); test('铃兰 module DEC-Y (孩子们): longer 停顿 (1.2 s) and no SP rider', () => { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [{ chessId: 'chess_char_5_10_b', row: 10, col: 4, moduleId: 'uniequip_003_lisa' }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = h.unit('chess_char_5_10_b'); assert.ok(h.runUntil(() => statuses(h, 'sluggish', (c) => c.source === u).length > 0, 5)); approx(statuses(h, 'sluggish', (c) => c.source === u)[0].duration, u.def.traitBb.sluggish); assert.equal(u.findBuff('lisa:module'), null); done(h); }); // ================================================================================================================= // 塞雷娅 test('塞雷娅 S1 急救 (自动触发, at her attack: an ally of the area at ≤ half HP): that attack heals the lowest such ally for heal_scale × ATK instead', () => { for (const id of pair('11')) { // PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击"; "血量小于等于一半" (PRTS 修正) const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_low: ally('t_low'), t_mid: ally('t_mid') } }, units: [entry(id, 'skchr_demkni_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 3 }, { chessId: 't_mid', row: 9, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }], }); const u = sel(h, id, 'skchr_demkni_1'); const bb = bbOf(u), tb = u.def.traitBb; const low = h.unit('t_low'), mid = h.unit('t_mid'); const e = h.b.enemies[0]; h.step(); low.hp = low.s.maxHp * 0.3; mid.hp = mid.s.maxHp * 0.45; u.skill.gainSp(1000); assert.ok(h.runUntil(() => heals(h, u, (c) => c.target === low).length > 0, 10)); const first = heals(h, u, (c) => c.target === low)[0]; assert.ok(first.amount > 0 && heals(h, u)[0] === first, 'the first heal goes to the lowest ratio'); approx(first.amount, u.s.atk * bb.heal_scale * (tb.heal_scale && tb.hp_ratio != null ? tb.heal_scale : 1), 'heal_scale (× GUA-X below 50 % on the elite)', 1e-3); const start = h.hooksOf('skillStart').find((x) => x.unit === u); assert.equal(start.reason, 'DEFAULT'); assert.equal(dealt(h, u, (c) => c.target === e && c.t === start.t).length, 0, 'the heal replaced the attack of that moment'); assert.ok(heals(h, u).every((c) => c.t >= start.t), 'only skill heals: she has no heal of her own'); done(h); } }); test('塞雷娅 S3 钙质化: allies in the area heal attack@heal_scale × ATK/s; enemies there take arts ×damage_scale and move −60 %', () => { for (const id of pair('11')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { atk: 100, bat: 1 }) }, chess: { t_a: ally('t_a') } }, // 重装: TAKE_DAMAGE units: [entry(id, 'skchr_demkni_3', { row: 10, col: 4 }), { chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy', pos: [12, 9] }], }); const u = sel(h, id, 'skchr_demkni_3'); const bb = bbOf(u); const a = h.unit('t_a'); const [e1, e2, out] = h.b.enemies; h.step(); a.hp = a.s.maxHp * 0.6; cast(h, u); h.run(1.2); for (const e of [e1, e2]) { const b = e.findBuff('saria:calcify'); assert.ok(b, 'debuffed in the area'); approx(e.s.artsTakenMul, bb['demkni_s_3.damage_scale']); approx(b.mods.moveMul, 1 + bb['demkni_s_3.move_speed']); } assert.equal(out.findBuff('saria:calcify'), null, 'outside the area'); const hh = heals(h, u, (c) => c.target === a); assert.ok(hh.length >= 1); approx(hh[0].amount, u.s.atk * bb['attack@heal_scale']); done(h); } }); test('塞雷娅 module GUA-Y (科技新星): damage taken −15 %; the S2 auto-cast only runs for S2', () => { const h = run({ units: [{ chessId: 'chess_char_5_11_b', row: 10, col: 4, moduleId: 'uniequip_003_demkni' }] }); const u = h.unit('chess_char_5_11_b'); h.step(); approx(h.b.dealDamage(null, u, { amount: 1000, type: 'true' }), 1000 * (1 - u.def.traitBb.damage_resistance)); done(h); const g = run({ defs: { chess: { t_a: ally('t_a') } }, units: [entry('chess_char_5_11_a', 'skchr_demkni_3', { row: 10, col: 4 }), { chessId: 't_a', row: 10, col: 5 }] }); const v = g.unit('chess_char_5_11_a'); g.step(); g.unit('t_a').hp = 1000; v.skill.gainSp(1000); g.run(2); assert.equal(v.skill.activations, 0, 'S3 is not cast by the S2 injured-ally rule (no enemy in range)'); done(g); }); // ================================================================================================================= // 夕 test('夕 S2 泼墨淋漓: skill range, ATK/ASPD +, one hit on every enemy in range (no splash), ×damage_scale below half HP', () => { for (const id of pair('12')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_dusk_2', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_dummy', pos: [11, 5] }], }); const u = sel(h, id, 'skchr_dusk_2'); const bb = bbOf(u); const [far, e2, low] = h.b.enemies; h.step(); low.hp = low.s.maxHp * 0.4; const atk0 = u.s.atk, aspd0 = u.s.aspd; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.equal(u.s.aspd, aspd0 + bb.attack_speed); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 5)); h.run(1); const first = dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isAttack); const id0 = first[0].dmg.attackId; const one = first.filter((c) => c.dmg.attackId === id0); assert.deepEqual(new Set(one.map((c) => c.target)), new Set([far, e2, low]), 'every enemy in range (col 6 = skill range)'); assert.equal(one.length, 3, 'one hit each, no splash'); approx(one.find((c) => c.target === far).amount, u.s.atk); approx(one.find((c) => c.target === low).amount, u.s.atk * bb.damage_scale, 'below half HP'); done(h); } }); test('夕 S3 写意胜形: BAT +0.4 s, ATK +, splash 1.7; every attack summons / moves one 小自在 (refreshed 25 s)', () => { for (const id of pair('12')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_dusk_3', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [11.4, 5] }], }); const u = sel(h, id, 'skchr_dusk_3'); const bb = bbOf(u), t1 = tal(u, 1); const [e1, e2] = h.b.enemies; const atk0 = u.s.atk, iv0 = u.s.interval; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); approx(u.s.interval, iv0 * (u.base.bat + bb.base_attack_time) / u.base.bat, 'BAT +0.4 s'); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.dmg.isSplash && c.target === e2).length > 0, 8), 'splash 1.7 reaches 1.4 tiles'); const toks = () => h.b.allyUnits.filter((x) => x.kind === 'token' && x.alive && x.defId === 'token_10015_dusk_drgn'); assert.equal(toks().length, 1, 'one 小自在'); const tk = toks()[0]; approx(tk.mem.duskUntil - h.b.time, t1['attack@tokenduration'], 'refreshed at every attack', 0.2); // the target leaves: the next attack lands on another tile → the same 小自在 moves there, refreshed h.b.kill(e1, null); h.b.kill(e2, null); const e3 = h.spawn('enemy_dummy', { pos: [9, 6] }); assert.ok(h.runUntil(() => tk.tileR === 9 && tk.tileC === 6, 8), 'moved onto the new target'); assert.equal(toks().length, 1); assert.ok(tk.alive && e3.alive); mute(h, u); assert.ok(h.runUntil(() => !u.skill.active, 70)); const until = tk.mem.duskUntil; h.run(until - h.b.time + 0.2); assert.equal(tk.alive, false, 'expires 25 s after the last refresh'); done(h); } }); test('夕 S3 写意胜形: unblocked enemies first (the blocked one is attacked only without another choice)', () => { const h = run({ defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e7, speed: 1 }), enemy_dummy: dummy('enemy_dummy') }, chess: { t_block: ally('t_block', { stats: { blockCnt: 1 } }) } }, // (the blocker at col 4: the walker stops 0.71 tile in front of it — block radius, user playtest #5 — on col 5, // inside 夕's 3-column range) units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 4 }], enemies: [{ key: 'enemy_walk', route: 0 }, { key: 'enemy_dummy', pos: [10, 6] }], }); const u = h.unit('chess_char_5_12_a'); const [walker, free] = h.b.enemies; assert.ok(h.runUntil(() => walker.blockedBy != null, 20), 'blocked by t_block'); const k = attacks(h, u).length; assert.ok(h.runUntil(() => attacks(h, u).length > k, 8)); assert.equal(attacks(h, u)[k].targets[0], walker, 'default order: the blocked walker is nearer the goal'); cast(h, u); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 8)); assert.equal(attacks(h, u, (c) => c.isSkill)[0].targets[0], free, 'S3: the unblocked enemy'); // (the 小自在 summoned on its tile blocks it from then on) done(h); }); test('夕 modules: SPC-Y (终夜无寐) −8 cost and no range +1; no module: no range +1 either', () => { for (const moduleId of ['uniequip_003_dusk', 'none']) { const h = run({ units: [{ chessId: 'chess_char_5_12_b', row: 10, col: 4, moduleId }] }); const u = h.unit('chess_char_5_12_b'); h.step(); assert.equal(u.s.rangeExtend, 0, moduleId); if (moduleId !== 'none') assert.equal(u.base.cost, raw('chess_char_5_12_b').statsBase.cost - 8); done(h); } }); // ================================================================================================================= // 归溟幽灵鲨 test('归溟幽灵鲨 S1 生存的技巧: swaps HP ratios with the lowest-ratio other op around her; ATK +', () => { for (const id of pair('13')) { const h = run({ defs: { chess: { t_low: ally('t_low'), t_mid: ally('t_mid'), t_far: ally('t_far') }, enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_ghost2_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 4 }, { chessId: 't_mid', row: 9, col: 3 }, { chessId: 't_far', row: 12, col: 8 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], }); const u = sel(h, id, 'skchr_ghost2_1'); const bb = bbOf(u); const low = h.unit('t_low'), mid = h.unit('t_mid'), far = h.unit('t_far'); h.step(); low.hp = low.s.maxHp * 0.2; mid.hp = mid.s.maxHp * 0.5; far.hp = far.s.maxHp * 0.1; u.hp = u.s.maxHp * 0.9; const atk0 = u.s.atk; cast(h, u); approx(u.hpRatio, 0.2, 'she takes 20 %', 1e-3); approx(low.hpRatio, 0.9, 'it takes 90 %', 1e-3); approx(mid.hpRatio, 0.5); approx(far.hpRatio, 0.1, 'outside the area'); approx(u.s.atk, atk0 * (1 + bb.atk)); done(h); } }); test('归溟幽灵鲨 S3 生存的重压: ATK/HP +, BAT +1 s, hits all blocked; enemies at ≥ her HP ratio take +atk_scale_ex, else she loses 3 %', () => { for (const id of pair('13')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_ghost2_3', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skchr_ghost2_3'); const bb = bbOf(u); const e = h.b.enemies[0]; h.step(); const atk0 = u.s.atk, iv0 = u.s.interval; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); approx(u.s.maxHp, u.base.maxHp * (1 + tal(u, 1).max_hp + bb.max_hp), 'max HP + (with her own 阿戈尔的深邃)'); approx(u.s.interval, iv0 * (u.base.bat + bb.base_attack_time) / u.base.bat); assert.equal(h.b.effectiveProfile(u).hitAllBlocked, true); assert.ok(h.runUntil(() => tagged(h, u, 'ghost2Weight', e).length > 0, 5), 'full-HP enemy: extra hit'); approx(tagged(h, u, 'ghost2Weight', e)[0].amount, u.s.atk * bb['attack@atk_scale_ex']); e.hp = e.s.maxHp * 0.05; const n = tagged(h, u, 'ghost2Weight').length; const k = attacks(h, u).length; const hpBefore = u.hp; assert.ok(h.runUntil(() => attacks(h, u).length > k, 5)); approx(u.hp, hpBefore - u.s.maxHp * bb['attack@hp_ratio'], 'HP loss', 1e-3); assert.equal(tagged(h, u, 'ghost2Weight').length, n, 'no extra hit'); done(h); } }); test('归溟幽灵鲨 S1/S3: the S2 "HP never below 1" guard does not run under other skills; module PUM-Y substitute HP +20 %', () => { const h = run({ units: [entry('chess_char_5_13_a', 'skchr_ghost2_3', { row: 10, col: 4 })], defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = h.unit('chess_char_5_13_a'); h.step(); cast(h, u); h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); assert.ok(u.trait.doll, 'lethal damage during S3 → substitute (trait), not a 1-HP guard'); done(h); const hx = run({ units: [{ chessId: 'chess_char_5_13_b', row: 10, col: 4 }] }); const hy = run({ units: [{ chessId: 'chess_char_5_13_b', row: 10, col: 4, moduleId: 'uniequip_003_ghost2' }] }); const x = hx.unit('chess_char_5_13_b'), y = hy.unit('chess_char_5_13_b'); hx.step(); hy.step(); for (const [g, v] of [[hx, x], [hy, y]]) { g.b.dealDamage(null, v, { amount: 1e9, type: 'true' }); g.run(0.5); assert.ok(v.trait.doll); } const t2 = tal(y, 1).max_hp; approx(y.s.maxHp / y.base.maxHp, (x.s.maxHp / x.base.maxHp) * (1 + t2 + y.def.traitBb.max_hp) / (1 + t2), 'PUM-Y +20 % substitute HP', 1e-3); assert.ok(!y.findBuff('ghost2:module'), 'no PUM-X ATK'); approx(y.hpRatio, 1, 'full'); done(hx); done(hy); }); // ================================================================================================================= // 凛御银灰 test('凛御银灰 S1 周旋的谋略: +10 DP; the waiting op nearest to the eye (cost 16) gets −5 cost and a 50 % max-HP barrier on redeploy', () => { for (const id of pair('14')) { const h = run({ defs: { chess: { t_guard: ally('t_guard', { profession: 'WARRIOR', stats: { cost: 20, respawnTime: 999 } }), t_cheap: ally('t_cheap', { stats: { cost: 8, respawnTime: 999 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_svash2_1', { row: 10, col: 4 }), { chessId: 't_guard', row: 12, col: 3 }, { chessId: 't_cheap', row: 12, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], flags: { dpPerSec: 0 }, }); const u = sel(h, id, 'skchr_svash2_1'); const bb = bbOf(u); const g = h.unit('t_guard'), c = h.unit('t_cheap'); h.step(); assert.equal(u.mem.eyeCost, 16, 'the S1 eye'); for (const a of [g, c]) h.b.dealDamage(null, a, { amount: 1e9, type: 'true' }); const dp0 = h.b.getPlayer(u.ownerId).dp; cast(h, u); assert.equal(h.b.getPlayer(u.ownerId).dp, Math.min(h.b.flags.dpMax, dp0 + bb.cost), '+cost DP'); assert.equal(g.base.cost, 20 - bb['svash2_s_1[deck].cost'], 'right of the eye, guard'); assert.equal(c.base.cost, 8); h.b.redeploy(g); const bar = g.findBuff('svash2:barrier'); assert.ok(bar, 'barrier'); approx(g.s.shield ?? bar.shield, u.s.maxHp * bb['svash2_s_1[deck].shield']); assert.equal(g.base.cost, 20, 'the cut applied to that redeploy'); done(h); } }); test('凛御银灰 S1 周旋的谋略 (AUTO): fires as soon as SP is full — no enemy, no attack needed (like 伺夜 S1; playtest #6 review)', () => { for (const id of pair('14')) { const h = run({ units: [entry(id, 'skchr_svash2_1', { row: 10, col: 4 })], flags: { dpPerSec: 0 } }); const u = sel(h, id, 'skchr_svash2_1'); assert.equal(u.skill.rule, 'SP_FULL'); const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery; // 精锐 talents start her with more SP assert.ok(h.runUntil(() => u.skill.activations === 1, 60), `${id}: cast with no enemy on the field`); const st = h.hooksOf('skillStart').find((c) => c.unit === u); assert.ok(Math.abs(st.t - due) <= 0.05, `${id}: at full SP (${st.t} vs ${due})`); done(h); } }); test('凛御银灰 S3 变革已至: skill range, line attacks bird_atk_scale × ATK + 脆弱, DP 5/6 then +1 per 2 s, first cast swaps waiting costs', () => { for (const id of pair('14')) { const h = run({ defs: { chess: { t_hi: ally('t_hi', { profession: 'CASTER', stats: { cost: 30, respawnTime: 999 } }), t_lo: ally('t_lo', { stats: { cost: 6, respawnTime: 999 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_svash2_3', { row: 10, col: 3 }), { chessId: 't_hi', row: 12, col: 3 }, { chessId: 't_lo', row: 12, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [9, 5] }], flags: { dpPerSec: 0 }, }); const u = sel(h, id, 'skchr_svash2_3'); const bb = bbOf(u); const [e1, e2, e3] = h.b.enemies; const hi = h.unit('t_hi'), lo = h.unit('t_lo'); h.step(); assert.equal(u.mem.eyeCost, 19, 'the S3 eye'); for (const a of [hi, lo]) h.b.dealDamage(null, a, { amount: 1e9, type: 'true' }); h.b.getPlayer(u.ownerId).dp = 0; cast(h, u); const t0 = h.b.time; assert.equal(h.b.getPlayer(u.ownerId).dp, bb['svash2_s_3[start_cost].cost']); assert.deepEqual([hi.base.cost, lo.base.cost], [6, 30], 'costs swapped'); assert.ok(u.rangeKeySet.has(10 * COLS + 6), 'skill range'); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isAttack && c.target === e2).length > 0, 5)); const hit = dealt(h, u, (c) => c.dmg.isAttack); const aid = hit.find((c) => c.target === e2).dmg.attackId; assert.deepEqual(new Set(hit.filter((c) => c.dmg.attackId === aid).map((c) => c.target)), new Set([e1, e2]), 'the line only'); for (const c of hit.filter((c) => c.dmg.attackId === aid)) approx(c.amount, u.s.atk * bb.bird_atk_scale); assert.ok(!hit.some((c) => c.target === e3 && c.dmg.attackId === aid)); const f = e2.buffs.find((b) => (b.status ?? b.key) === 'fragile'); assert.ok(f, '脆弱'); approx(e2.s.dmgTakenMul, bb.damage_scale); h.run(Math.max(0, t0 + 4.1 - h.b.time)); assert.equal(h.b.getPlayer(u.ownerId).dp, bb['svash2_s_3[start_cost].cost'] + 2 * bb['svash2_s_3[cost].cost'], '+1 per 2 s'); // second cast: no second swap h.run(60); cast(h, u); assert.deepEqual([hi.base.cost, lo.base.cost], [6, 30], 'first activation only'); done(h); } }); // ================================================================================================================= // 引星棘刺 test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => { for (const id of pair('15')) { const h = run({ defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }), t_near: ally('t_near', { stats: { def: 100 } }), t_far: ally('t_far', { stats: { def: 100 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_thorn2_1', { row: 10, col: 3 }), { chessId: 't_low', row: 10, col: 5 }, { chessId: 't_near', row: 11, col: 6 }, { chessId: 't_far', row: 12, col: 9 }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }], }); const u = sel(h, id, 'skchr_thorn2_1'); const bb = bbOf(u), t0 = tal(u, 0); const low = h.unit('t_low'), near = h.unit('t_near'), far = h.unit('t_far'); h.step(); low.hp = low.s.maxHp * 0.3; near.hp = near.s.maxHp * 0.8; far.hp = far.s.maxHp * 0.8; cast(h, u); const z = u.mem.zones.at(-1); assert.deepEqual([z.type, z.r, z.c], ['guard', 10, 5], 'on the lowest-HP ally'); approx(z.dur, bb.projectile_delay_time + t0.projectile_extend, '+3 s: another op in her range'); h.run(1.1); for (const a of [low, near]) assert.equal(a.s.def, 100 + bb.def, a.defId); assert.equal(far.s.def, 100); approx(heals(h, u, (c) => c.target === near)[0].amount, u.s.atk * bb.hp_recovery_per_sec_ratio); mute(h, u); h.run(z.dur); assert.ok(!u.mem.zones.includes(z), 'expired'); assert.equal(low.s.def, 100, 'no DEF bonus afterwards'); done(h); } }); test('引星棘刺 S1 度算浪波: two alchemy units on one ally add their DEF (PRTS 备注 「效果均可叠加」, GitHub #388)', () => { // two 引星棘刺 (a normal and an elite: +60 and +75) throw at the same lowest-HP ally; one shared buff key used to let the // last unit's DEF replace the other's (+75 instead of +135) while both heals already landed const [na, el] = pair('15'); const h = run({ defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }) } }, units: [entry(na, 'skchr_thorn2_1', { row: 10, col: 3 }), entry(el, 'skchr_thorn2_1', { row: 11, col: 3 }), { chessId: 't_low', row: 10, col: 5 }], enemies: [], }); const a = sel(h, na, 'skchr_thorn2_1'), b = sel(h, el, 'skchr_thorn2_1'); const low = h.unit('t_low'); h.step(); low.hp = low.s.maxHp * 0.3; for (const u of [a, b]) u.skill.gainSp(1000); h.runUntil(() => [a, b].every((u) => (u.mem.zones || []).some((z) => z.type === 'guard')), 10); h.run(1.1); const [za, zb] = [a, b].map((u) => u.mem.zones.find((z) => z.type === 'guard')); assert.deepEqual([za.r, za.c, zb.r, zb.c], [10, 5, 10, 5], 'both on the lowest-HP ally'); assert.equal(low.s.def, 100 + bbOf(a).def + bbOf(b).def, '+60 +75'); assert.ok(low.hp > low.s.maxHp * 0.3, 'and they restore its HP'); // one unit runs out: the other's DEF stays mute(h, a); mute(h, b); h.run(Math.max(0, za.dur - za.t) + 0.6); assert.ok(!a.mem.zones.includes(za) && b.mem.zones.includes(zb), 'the elite\'s unit (7 s) outlasts the normal one (6 s)'); assert.equal(low.s.def, 100 + bbOf(b).def, 'its DEF stays alone'); h.run(zb.dur); assert.equal(low.s.def, 100, 'no DEF bonus afterwards'); done(h); }); test('引星棘刺 S1 度算浪波 (AUTO): fires as soon as its SP is full, no enemy needed (GitHub #124)', () => { for (const id of pair('15')) { const h = run({ defs: { chess: { t_low: ally('t_low') } }, units: [entry(id, 'skchr_thorn2_1', { row: 10, col: 3 }), { chessId: 't_low', row: 10, col: 5 }], enemies: [], }); const u = sel(h, id, 'skchr_thorn2_1'); h.step(); assert.equal(h.b.enemies.length, 0, 'no enemy on the field'); const need = u.skill.spCost - u.skill.sp; h.run(need + 0.5); assert.ok((u.mem.zones || []).some((z) => z.type === 'guard'), `cast within ${need.toFixed(1)} s of SP filling, no enemy around`); done(h); } }); test('引星棘刺 S3 “我的海疆”: passive skill range; alchemy units on the 3 lowest-block ops debuff (不叠加) and burn enemies around them, ramping to the max after 15 s', () => { for (const id of pair('15')) { const h = run({ defs: { chess: { t_b0: ally('t_b0', { stats: { blockCnt: 0 } }), t_b1: ally('t_b1', { stats: { blockCnt: 1 } }), t_b2: ally('t_b2', { stats: { blockCnt: 2 } }), t_b3: ally('t_b3', { stats: { blockCnt: 3 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy', { atk: 100, def: 100 }), enemy_res: dummy('enemy_res', { res: 50 }) }, }, units: [entry(id, 'skchr_thorn2_3', { row: 10, col: 3 }), { chessId: 't_b0', row: 12, col: 8 }, { chessId: 't_b1', row: 12, col: 9 }, { chessId: 't_b2', row: 9, col: 9 }, { chessId: 't_b3', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_dummy', pos: [11, 9] }, { key: 'enemy_dummy', pos: [9, 7] }, { key: 'enemy_res', pos: [12, 7] }], }); const u = sel(h, id, 'skchr_thorn2_3'); const bb = bbOf(u); const [e1, e2, e3, er] = h.b.enemies; h.step(); assert.ok(u.rangeKeySet.has(10 * COLS + 7) && u.baseRangeKeys.includes(10 * COLS + 7), 'passive: the skill range is her range (and trigger range)'); cast(h, u); const anchors = u.mem.zones.map((z) => z.anchor.defId).sort(); const ownBlk = u.s.blockCnt; assert.deepEqual(anchors, (ownBlk <= 1 ? [u.defId, 't_b0', 't_b1'] : ['t_b0', 't_b1', 't_b2']).sort(), 'the 3 lowest block counts'); h.run(0.6); const b = e2.findBuff('thorn2:sea'); assert.ok(b, 'around t_b0 / t_b1'); approx(e2.s.atk, 100 * (1 + bb.atk)); approx(e2.s.def, 100 * (1 + bb.def)); approx(er.s.res, 50 * (1 + bb.magic_resistance), 'RES −%'); assert.equal(e2.buffs.filter((x) => x.key === 'thorn2:sea').length, 1, '不叠加: one debuff under two units'); const first = tagged(h, u, 'mySea', e2); assert.equal(first.length, 2, 'the first burn lands with the units (two units around e2)'); for (const c of first) approx(c.amount, u.s.atk * bb.atk_scale, 'first burn'); h.run(bb.max_stack_cnt * bb.interval + 0.5); approx(e2.s.atk, 100 * (1 + bb.max_atk), 'max ATK cut'); approx(e2.s.def, 100 * (1 + bb.max_def), 'max DEF cut'); approx(er.s.res, 50 * (1 + bb.max_magic_resistance), 'max RES cut'); approx(tagged(h, u, 'mySea', e2).at(-1).amount, u.s.atk * bb.max_atk_scale, 'max scale'); assert.equal(tagged(h, u, 'mySea', e3).length, 0, 'not around an anchor'); h.run(bb.projectile_delay_time); assert.equal(e2.findBuff('thorn2:sea'), null, 'expired'); assert.ok(e1); done(h); } }); // ================================================================================================================= // 山 test('山 S1 左勾扫拳: next attack atk_scale × ATK on 2 enemies (×1.6 巨力重拳 crits allowed)', () => { for (const id of pair('17')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_f12yin_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_dummy', pos: [10, 4] }] }); const u = sel(h, id, 'skchr_f12yin_1'); const bb = bbOf(u), t0 = tal(u, 0); h.step(); cast(h, u); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 5)); const a = attacks(h, u, (c) => c.isSkill)[0]; assert.equal(a.targets.length, bb.max_target); for (const c of dealt(h, u, (c) => c.dmg.isSkill)) { const r = c.amount / (u.s.atk * bb.atk_scale); assert.ok(Math.abs(r - 1) < 1e-6 || Math.abs(r - t0.atk_scale) < 1e-6, `×${r}`); } done(h); } }); test('山 S3 震地碎岩击: BAT +0.7 s, ATK +, double hits on up to 3 enemies, pushes them; 巨力重拳 chance → talent@prob', () => { for (const id of pair('17')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { mass: 0 }) } }, units: [entry(id, 'skchr_f12yin_3', { row: 10, col: 5 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [9, 5] }, { key: 'enemy_dummy', pos: [11, 5] }, { key: 'enemy_dummy', pos: [10, 4] }], }); const u = sel(h, id, 'skchr_f12yin_3'); const bb = bbOf(u), t0 = tal(u, 0); h.step(); const probs = []; const chance = h.b.rng.chance.bind(h.b.rng); h.b.rng.chance = (p) => { probs.push(p); return chance(p); }; h.run(2); const atk0 = u.s.atk, iv0 = u.s.interval; assert.ok(probs.includes(t0.prob), 'plain chance before'); probs.length = 0; const pos0 = h.b.enemies.map((e) => [e.x, e.y]); cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); approx(u.s.interval, iv0 * (u.base.bat + bb.base_attack_time) / u.base.bat); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 5)); const a = attacks(h, u, (c) => c.isSkill)[0]; assert.equal(a.targets.length, bb['attack@max_target']); for (const t of a.targets) assert.equal(dealt(h, u, (c) => c.target === t && c.dmg.isSkill).length, 2, 'double hit'); const moved = h.b.enemies.filter((e, i) => Math.hypot(e.x - pos0[i][0], e.y - pos0[i][1]) > 0.4); assert.equal(moved.length, bb['attack@max_target'], 'pushed'); assert.ok(probs.length && probs.every((p) => p === bb['talent@prob']), `talent@prob ${probs}`); done(h); } }); test('山 module FGT-X (夜深人静): an own 15 % physical dodge on top of 强壮肉体; no ASPD rider', () => { const h = run({ units: [{ chessId: 'chess_char_5_17_b', row: 10, col: 4, moduleId: 'uniequip_003_f12yin' }] }); const u = h.unit('chess_char_5_17_b'); h.step(); approx(u.s.dodgePhys, 1 - (1 - tal(u, 1).prob) * (1 - u.def.traitBb.prob)); assert.equal(u.s.aspd, u.base.aspd); done(h); }); // ================================================================================================================= // 玛恩纳 test('玛恩纳 S1 未声张的怒火 (SEARCH): cast once an enemy is inside his initial range, not before; attacks attack@atk_scale × ATK (×1.1 游侠), DEF +', () => { for (const id of pair('19')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_mlynar_1', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [12, 9] }] }); const u = sel(h, id, 'skchr_mlynar_1'); const bb = bbOf(u), t0 = tal(u, 0); h.step(); const def0 = u.s.def; // research 03 Addendum C1 (BWIKI): "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放" u.skill.gainSp(1000); h.run(2); assert.equal(u.skill.activations, 0, `${id}: an enemy elsewhere on the field does not trigger SEARCH`); const e = h.spawn('enemy_dummy', { pos: [10, 4] }); assert.ok(h.runUntil(() => u.skill.activations > 0, 1), `${id}: cast once an enemy is in range`); assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'SEARCH'); approx(u.s.def, def0 * (1 + bb.def)); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.target === e && c.dmg.isAttack).length > 0, 5)); approx(dealt(h, u, (c) => c.target === e && c.dmg.isAttack)[0].amount, u.s.atk * bb['attack@atk_scale'] * t0.atk_scale_base); done(h); } }); test('玛恩纳 S2 未宽解的悲哀: skill range, BAT +0.3 s, double hits at attack@atk_scale; a kill keeps the trait ramp at the end', () => { for (const id of pair('19')) { for (const kill of [true, false]) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy'), enemy_weak: dummy('enemy_weak', { hp: 10 }) } }, units: [entry(id, 'skchr_mlynar_2', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }], }); const u = sel(h, id, 'skchr_mlynar_2'); const bb = bbOf(u), t0 = tal(u, 0); h.run(10); const iv0 = u.s.interval; cast(h, u); const ramp = u.trait.ramp; assert.ok(ramp > 0); approx(u.s.interval, iv0 * (u.base.bat + bb.base_attack_time) / u.base.bat); assert.ok(u.rangeKeySet.has(10 * COLS + 5), 'skill range (2 ahead)'); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isAttack).length >= 2, 5)); const hits = dealt(h, u, (c) => c.dmg.isAttack); assert.equal(hits.filter((c) => c.dmg.attackId === hits[0].dmg.attackId).length, 2, '二连击'); approx(hits[0].amount, u.s.atk * bb['attack@atk_scale'] * t0.atk_scale_base); if (kill) { const e = h.b.enemies[0]; e.hp = 1; assert.ok(h.runUntil(() => !e.alive, 5), 'killed during the skill'); } assert.ok(h.runUntil(() => !u.skill.active, 30)); if (kill) approx(u.trait.ramp, ramp, 'kept'); else assert.equal(u.trait.ramp, 0, 'reset'); done(h); } } }); // ================================================================================================================= // 安洁莉娜 test('安洁莉娜 S1 秘杖·速充模式: she attacks without the skill (attack SP), then ATK +', () => { for (const id of pair('20')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_aglina_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skchr_aglina_1'); const bb = bbOf(u); h.step(); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isAttack && !c.dmg.isSkill).length > 0, 5), 'plain attacks off-skill'); const atk0 = u.s.atk; assert.ok(h.runUntil(() => u.skill.active, 60), 'attack SP fills'); approx(u.s.atk, atk0 * (1 + bb.atk)); done(h); } }); test('安洁莉娜 S2 秘杖·微粒模式 (SEARCH): no attack off-skill; interval ×base_attack_time, each attack damage_scale × ATK arts', () => { for (const id of pair('20')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_aglina_2', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skchr_aglina_2'); const bb = bbOf(u); h.run(2); assert.equal(dealt(h, u).length, 0, 'no attack off-skill'); const iv0 = u.s.interval; cast(h, u); approx(u.s.interval, iv0 * bb.base_attack_time, '极大幅度缩短'); assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isAttack).length >= 5, 5)); approx(dealt(h, u, (c) => c.dmg.isAttack)[0].amount, u.s.atk * bb.damage_scale); done(h); } }); test('安洁莉娜 module DEC-X (实验用反重力模块): SP +0.2/s with an enemy in range', () => { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [{ chessId: 'chess_char_5_20_b', row: 10, col: 4, moduleId: 'uniequip_003_aglina' }] }); const u = h.unit('chess_char_5_20_b'); h.run(0.5); assert.equal(u.s.spRecovery, u.base.spRecovery); h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(0.5); approx(u.s.spRecovery, u.base.spRecovery + tal(u, -1).sp_recovery_per_sec); done(h); }); // ================================================================================================================= // 寒檀 test('寒檀 S1 迅捷打击·γ型: ATK +, ASPD + for the duration', () => { for (const id of pair('21')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skcom_quickattack[3]', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] }); const u = sel(h, id, 'skcom_quickattack[3]'); const bb = bbOf(u); h.step(); const atk0 = u.s.atk, aspd0 = u.s.aspd; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.equal(u.s.aspd, aspd0 + bb.attack_speed); h.run(rec(id, 'skcom_quickattack[3]').duration + 0.5); assert.equal(u.s.aspd, aspd0); done(h); } }); // ================================================================================================================= // 妮芙 test('妮芙 S1 笞心击: ATK +; attacks add 10 % 凋亡 and, on a bursting target, extra_ep_damage_scale × ATK elemental', () => { for (const id of pair('22')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_nymph_1', { row: 10, col: 4 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }] }); const u = sel(h, id, 'skchr_nymph_1'); const bb = bbOf(u), tb = u.def.traitBb; const e = h.b.enemies[0]; h.step(); const atk0 = u.s.atk; cast(h, u); approx(u.s.atk, atk0 * (1 + bb.atk)); assert.ok(h.runUntil(() => during(h, u, dealt(h, u, (c) => c.dmg.isAttack && c.type === 'arts')).length > 0, 5)); const hit = during(h, u, dealt(h, u, (c) => c.dmg.isAttack && c.type === 'arts'))[0]; const ep = dealt(h, u, (c) => c.type === 'element' && c.t === hit.t); assert.ok(ep.length > 0, '凋亡损伤'); approx(ep[0].amount, hit.amount * bb['attack@ep_damage_ratio']); assert.equal(tagged(h, u, 'nymphLash').length, 0, 'no burst yet'); h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 5000 }); assert.ok(h.runUntil(() => tagged(h, u, 'nymphLash', e).length > 0, 5)); approx(tagged(h, u, 'nymphLash', e)[0].amount, u.s.atk * bb['attack@extra_ep_damage_scale'] * (tb.damage_scale ?? 1), 'extra (× PRI-X on the elite)'); done(h); } }); test('妮芙 S3 心防溃决: skill range, ATK/ASPD +, 2 targets; attacks on a bursting target are elemental damage', () => { for (const id of pair('22')) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { res: 50 }) } }, units: [entry(id, 'skchr_nymph_3', { row: 10, col: 3 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [9, 5] }] }); const u = sel(h, id, 'skchr_nymph_3'); const bb = bbOf(u), tb = u.def.traitBb; const [e1, e2] = h.b.enemies; h.step(); h.b.dealDamage(null, e1, { type: 'element', element: 'apoptosis', amount: 5000 }); const aspd0 = u.s.aspd; cast(h, u); approx(u.s.atk, u.base.atk * (1 + bb.atk + (u.findBuff('nymph:key')?.stacks ?? 0) * tal(u, 1).atk), 'ATK + (with the 窥心钥 stack of that burst)'); assert.equal(u.s.aspd, aspd0 + bb.attack_speed); assert.ok(u.rangeKeySet.has(10 * COLS + 6), 'skill range'); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 5)); h.run(1); assert.equal(attacks(h, u, (c) => c.isSkill)[0].targets.length, bb['attack@max_target']); const b1 = dealt(h, u, (c) => c.target === e1 && c.dmg.isAttack && c.dmg.isSkill)[0]; assert.equal(b1.type, 'elemental', 'burst target: elemental'); const n2 = dealt(h, u, (c) => c.target === e2 && c.dmg.attackId === b1.dmg.attackId)[0]; assert.equal(n2.type, 'arts'); approx(b1.amount, 2 * n2.amount * bb['attack@split_atk_scale'] * (tb.damage_scale ?? 1), 'elemental ignores RES 50 (× PRI-X on the elite)'); done(h); } }); // ================================================================================================================= // 录武官 test('录武官 S1 触类旁通 (charges): the next heal restores heal_scale × ATK to 2 allies', () => { for (const id of pair('23')) { const h = run({ defs: { chess: { t_a: ally('t_a'), t_b: ally('t_b') } }, units: [entry(id, 'skchr_reckpr_1', { row: 10, col: 3 }), { chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 5 }], }); const u = sel(h, id, 'skchr_reckpr_1'); const bb = bbOf(u); const a = h.unit('t_a'), b = h.unit('t_b'); h.step(); assert.equal(u.skill.maxCharges, rec(id, 'skchr_reckpr_1').maxChargeTime); a.hp = a.s.maxHp * 0.6; b.hp = b.s.maxHp * 0.7; u.skill.gainSp(1000); assert.ok(h.runUntil(() => attacks(h, u, (c) => c.isSkill).length > 0, 5)); const at = attacks(h, u, (c) => c.isSkill)[0]; assert.equal(at.targets.length, bb.max_target); for (const t of [a, b]) approx(heals(h, u, (c) => c.target === t)[0].amount, u.s.atk * bb.heal_scale, t.defId); done(h); } }); // ================================================================================================================= // modules: 'none' on the chess whose only module is the default one; default-skill machinery under other skills test('module "none": 缇缇 heals 50 % (not 60 %), 乌尔比安 no healing ×1.2, 引星棘刺 no SP while a unit is out, 玛恩纳 no +100 % at deploy, 寒檀 full cost, 录武官 no ×1.15', () => { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_a: ally('t_a') } }, units: [ { chessId: 'chess_char_5_02_b', row: 9, col: 3, moduleId: 'none' }, { chessId: 'chess_char_5_05_b', row: 9, col: 4, moduleId: 'none' }, { chessId: 'chess_char_5_15_b', row: 10, col: 3, moduleId: 'none' }, { chessId: 'chess_char_5_19_b', row: 10, col: 4, moduleId: 'none' }, { chessId: 'chess_char_5_21_b', row: 11, col: 3, moduleId: 'none' }, { chessId: 'chess_char_5_23_b', row: 11, col: 4, moduleId: 'none' }, { chessId: 't_a', row: 12, col: 9 }, ], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], }); const titi = h.unit('chess_char_5_02_b'), ulpia = h.unit('chess_char_5_05_b'), thorn = h.unit('chess_char_5_15_b'); const mly = h.unit('chess_char_5_19_b'), sntlla = h.unit('chess_char_5_21_b'), reck = h.unit('chess_char_5_23_b'); assert.equal(titi.profile.healRatio, 0.5); assert.equal(ulpia.s.healingTakenMul, 1); assert.equal(mly.trait.ramp, 0, 'no init_atk'); assert.equal(sntlla.base.cost, raw('chess_char_5_21_b').statsBase.cost); const a = h.unit('t_a'); a.hp = 3000; approx(h.b.heal(reck, a, 100), 100, 'no PHY-X'); thorn.skill.gainSp(1000); assert.ok(h.runUntil(() => (thorn.mem.zones || []).length > 0, 5)); h.run(0.5); assert.equal(thorn.findBuff('thorn2:module'), null, 'no ALC-X'); done(h); }); test('default-skill machinery stays with the default skill: 圣约送葬人 S1 never dodges, 缇缇 S1 has no sleep guard, 玛恩纳 S1 adds no Kazimierz mark', () => { const h = run({ defs: { enemies: { enemy_hitter: dummy('enemy_hitter', { atk: 300, bat: 0.5 }) }, chess: { t_a: ally('t_a'), t_kaz: ally('t_kaz', { bonds: ['kazimierzShip'], stats: { atk: 500 } }) } }, units: [entry('chess_char_5_01_a', 'skchr_excu2_1', { row: 9, col: 5 }), entry('chess_char_5_02_a', 'skchr_titi_1', { row: 10, col: 3 }), { chessId: 't_a', row: 10, col: 4 }, entry('chess_char_5_19_a', 'skchr_mlynar_1', { row: 12, col: 5 }), { chessId: 't_kaz', row: 12, col: 4 }], enemies: [{ key: 'enemy_hitter', pos: [9, 5] }, { key: 'enemy_hitter', pos: [12, 5] }], }); const ex = h.unit('chess_char_5_01_a'), ti = h.unit('chess_char_5_02_a'), ml = h.unit('chess_char_5_19_a'); for (const u of [ex, ti, ml]) u.skill.gainSp(1000); h.run(3); assert.ok(ex.skill.activations > 0 && ti.skill.activations > 0 && ml.skill.activations > 0); assert.equal(h.hooksOf('dodge')?.length ?? 0, 0); assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'dodge' && e[4]?.id === ex.id).length, 0, 'no S2 dodge'); const a = h.unit('t_a'); h.b.dealDamage(null, a, { amount: 1e9, type: 'true' }); assert.ok(!a.alive, 'no S3 sleep guard'); assert.equal(tagged(h, ml, 'mlynarMark').length, 0, 'no S3 mark'); done(h); }); // ================================================================================================================= // adversarial review regressions test('号角 S1 / 塞雷娅 S1 (自动触发, elite: 2 charges): hits never arm them; a cast "next attack" still waiting never spends the other charge', () => { // 号角 S1: the data rule DEFAULT — each charge is cast with one of her attacks (hits taken do nothing) { const id = 'chess_char_5_08_b'; const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_horn_1', { row: 10, col: 2 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }] }); const u = sel(h, id, 'skchr_horn_1'); const e = h.b.enemies[0]; const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u); h.step(); u.atkCd = 3; u.skill.gainSp(1000); assert.equal(u.skill.charges, 2, `${id}: 2 charges`); for (let i = 0; i < 3; i++) h.b.dealDamage(e, u, { amount: 10, type: 'phys' }); assert.equal(starts().length, 0, 'hits do not arm it'); assert.ok(h.runUntil(() => starts().length >= 2, 15), 'both charges are cast'); const shots = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill); assert.equal(new Set(shots.slice(0, 2).map((c) => c.t)).size, 2, 'one charge per attack'); done(h); } // 塞雷娅 S1: DEFAULT + an ally of the area at ≤ half HP — each charge replaces one of her attacks (no heal mode waits) { const id = 'chess_char_5_11_b'; const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_low: ally('t_low'), t_low2: ally('t_low2') } }, units: [entry(id, 'skchr_demkni_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 4 }, { chessId: 't_low2', row: 9, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }] }); const u = sel(h, id, 'skchr_demkni_1'); const e = h.b.enemies[0]; const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length; h.step(); u.atkCd = 2; u.skill.gainSp(1000); assert.equal(u.skill.charges, 2, `${id}: 2 charges`); h.unit('t_low').hp = h.unit('t_low').s.maxHp * 0.3; h.unit('t_low2').hp = h.unit('t_low2').s.maxHp * 0.3; for (let i = 0; i < 3; i++) h.b.dealDamage(e, u, { amount: 10, type: 'phys' }); h.run(1); assert.equal(starts(), 0, 'hits do not arm it, nor does the injured ally alone: it waits for her attack'); assert.ok(h.runUntil(() => starts() === 1, 2), 'her attack casts one charge'); assert.ok(!u.skill.pending, 'and is the heal'); assert.equal(u.skill.charges, 1, 'the second charge stays stored'); assert.ok(h.runUntil(() => starts() === 2, 5), 'her next attack heals the other one'); assert.equal(heals(h, u).length, 2); assert.notEqual(heals(h, u)[0].target, heals(h, u)[1].target); assert.ok(!u.skill.pending); mute(h, u); u.skill.gainSp(1000); h.unit('t_low').hp = h.unit('t_low').s.maxHp * 0.3; h.run(3); assert.equal(starts(), 2, `${id}: silenced`); done(h); } }); test('圣约送葬人 module REA-Y: the reaper heal stays the base 50 per enemy hit (the module trait "value" 12 is display-only: the ASPD text); REA-X 60, none 50', () => { for (const [moduleId, want] of [['uniequip_003_excu2', 50], ['uniequip_002_excu2', 60], ['none', 50]]) { const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [{ chessId: 'chess_char_5_01_b', row: 10, col: 4, moduleId }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], }); const u = h.unit('chess_char_5_01_b'); assert.equal(u.profile.selfHeal, want, moduleId); mute(h, u); u.hp = u.s.maxHp / 2; assert.ok(h.runUntil(() => heals(h, u, (c) => c.target === u).length > 0, 5), moduleId); approx(heals(h, u, (c) => c.target === u)[0].amount, want, `${moduleId}: one enemy hit`); done(h); } });