// test/content/op_chen3.test.js — the 自选 operator kit of 赤刃明霄陈 (char_1050_chen3, 6★ 术战者; kit // server/sim/content/kits/ops/op-chen3.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in // every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or AFT-X // “记忆残页” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot // status); the fidelity checklist of kits/README.md item by item. // Run: node --test test/content/op_chen3.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js'; import { diyPool, validateDiyPicks } from '../../shared/diy.js'; const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8')); const CHESS = load('chess'); const BACKUPS = load('backups'); const CHEN3 = 'char_1050_chen3'; const FORMS = BACKUPS.units[CHEN3].forms; const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' }; const AFTX = 'uniequip_002_chen3'; const S1 = 'skchr_chen3_1', S2 = 'skchr_chen3_2', S3 = 'skchr_chen3_3'; const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0']; const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id); const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null); /** Talent `i`'s blackboard of a form with its module. */ const tOf = (tier, elite, mod, i) => { const base = formOf(tier, elite).talents.find((t) => t.index === i).bb; const ch = elite && mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === i) : null; return { ...base, ...(ch?.bb ?? {}) }; }; const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o }); const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_frail: dummy('enemy_frail', { hp: 2000 }), enemy_mid: dummy('enemy_mid', { hp: 10000 }), enemy_big: dummy('enemy_big', { hp: 1e6 }), enemy_flymid: dummy('enemy_flymid', { hp: 10000, motion: 'FLY' }), enemy_mage: dummy('enemy_mage', { res: 60 }), enemy_tank: dummy('enemy_tank', { def: 5000 }), enemy_shooter: dummy('enemy_shooter', { atk: 100, range: 2.5, bat: 1 }), enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), }; const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, AFTX].map((m) => [t, true, m]))]; /** A battle with 赤刃明霄陈 as uid 1 at (row, col) facing RIGHT. */ function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, seed = 5, others = [] } = {}) { const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed, flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'heal', 'deploy', 'dodge'], captureNoisy: true, units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHEN3, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others], }); h.step(); return { h, u: h.unit(1) }; } const from = (h, u, pred = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && pred(c)); const tagged = (c, t) => (c.dmg?.tags || []).includes(t); function done(h) { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); } const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`; test('赤刃明霄陈 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee arts ground-only, 炎, no 特质, ground enemies can target her', () => { assert.equal(OPERATOR_KITS[CHEN3], KITS[CHEN3]); for (const f of FORMS_ALL) { const [tier, elite, mod] = f; for (const skill of [0, 1, 2]) { const { h, u } = field({ tier, elite, mod, skill }); const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null; assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CHEN3, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f)); assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def], `${label(f)}: stats`); assert.deepEqual([u.s.blockCnt, u.profile.sub, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'artsfghter', 'melee', false, 'arts', 1.25], `${label(f)}: 术战者`); assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`); assert.deepEqual([form.tokens, form.displayTokens, u.def.raw.tokens], [[], [], []], `${label(f)}: no summons`); assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`); assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`); done(h); } } // E2 Lv1 2211 / 570 / 352, E2 Lv60 2674 / 655 / 400; AFT-X +240 / +30 → +420 / +60 (HP / ATK) assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2211, 570, 2674, 655]); assert.deepEqual([modOf(5, AFTX).attr, modOf(6, AFTX).attr], [{ maxHp: 240, atk: 30 }, { maxHp: 420, atk: 60 }]); }); test('a 自选 pick: 赤刃明霄陈 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => { const data = { chess: CHESS, backups: BACKUPS }; assert.ok(KITTED_CHARS.includes(CHEN3)); for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHEN3), `tier ${t}`); assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: CHEN3, skillIndex: 1, uniEquipId: AFTX } }, { data, kitted: KITTED_CHARS }), { ok: true, picks: { [SLOT[5]]: { charId: CHEN3, skillIndex: 1, uniEquipId: AFTX } } }); }); test('T1 形意洞照: ATK +16 %, ASPD +16; every physical / arts damage of hers is 弱点伤害 — physical on a high-RES enemy, arts on a high-DEF one', () => { for (const f of FORMS_ALL) { const [tier, elite, mod] = f; const t0 = tOf(tier, elite, mod, 0); assert.deepEqual([t0.atk, t0.attack_speed], [0.16, 16]); const { h, u } = field({ tier, elite, mod, skill: 0 }); approx(u.s.atk, u.base.atk * 1.16, `${label(f)}: ATK`); const as = elite && mod === AFTX ? 8 : 0; // she blocks nobody yet: AFT-X's +8 on top assert.equal(u.s.aspd, 100 + 16 + as, `${label(f)}: ASPD`); u.skill.sp = 0; const mage = h.spawn('enemy_mage', { pos: [10, 6] }); h.runUntil(() => from(h, u, (c) => c.target === mage).length > 0, 3); const m = from(h, u, (c) => c.target === mage)[0]; assert.deepEqual([m.type, m.dmg.isAttack], ['phys', true], `${label(f)}: physical on RES 60 / DEF 0`); approx(m.amount, u.s.atk, `${label(f)}: full`); h.b.kill(mage, null); const tank = h.spawn('enemy_tank', { pos: [10, 6] }); h.runUntil(() => from(h, u, (c) => c.target === tank).length > 0, 3); assert.equal(from(h, u, (c) => c.target === tank)[0].type, 'arts', `${label(f)}: arts on DEF 5000 / RES 0`); done(h); } }); test('AFT-X “记忆残页” (stages 1 / 3): ASPD +8 while she blocks nobody — off while she blocks', () => { for (const f of FORMS_ALL) { const [tier, elite, mod] = f; const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 }); const on = elite && mod === AFTX; if (on) assert.deepEqual(u.def.raw.trait.bb, { attack_speed: 8 }, label(f)); h.step(); assert.equal(!!u.findBuff('trait:chen3:unblocked'), on, `${label(f)}: nobody blocked`); u.skill.sp = 0; h.spawn('enemy_walk', { routeIndex: 0 }); assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`); h.step(); assert.equal(u.findBuff('trait:chen3:unblocked'), null, `${label(f)}: blocking`); assert.equal(u.s.aspd, 116, `${label(f)}: ASPD while blocking`); done(h); } }); test('T2 寒暑觉知: every 7 s (AFT-X stage 3: 6 s) without damage a self-heal of ATK × a whole random percent of [35, 166) ([55, 206)) and one held dodge; any damage restarts the count', () => { for (const f of [[5, false, null], [6, true, null], [5, true, AFTX], [6, true, AFTX]]) { const [tier, elite, mod] = f; const t1 = tOf(tier, elite, mod, 1); const st = t1.stack_time, lo = t1.heal_atk_scale_min, hi = t1.heal_atk_scale_max; assert.deepEqual([st, lo, hi], elite && mod === AFTX && tier === 6 ? [6, 55, 206] : [7, 35, 166], label(f)); const { h, u } = field({ tier, elite, mod, skill: 0 }); u.hp = 100; const heals = () => h.hooksOf('heal').filter((c) => c.target === u && c.source === u && !c.opts?.regen); h.run(st - 0.15); assert.equal(heals().length, 0, `${label(f)}: not before ${st} s`); assert.equal(u.mem.chen3T2.evade, false); h.run(0.25); assert.equal(heals().length, 1, `${label(f)}: at ${st} s`); const pct = heals()[0].amount / u.s.atk * 100; assert.ok(Math.abs(pct - Math.round(pct)) < 1e-6 && pct >= lo && pct < hi, `${label(f)}: ${pct} % of ATK`); assert.equal(u.mem.chen3T2.evade, true, `${label(f)}: a dodge held`); // damage restarts the count: no second heal at 2 × st h.run(st - 3); const e = h.spawn('enemy_dummy', { pos: [12, 9] }); h.b.dealDamage(e, u, { amount: 1, type: 'true' }); h.run(3); assert.equal(heals().length, 1, `${label(f)}: the count restarted`); h.run(st - 3 + 0.2); assert.equal(heals().length, 2, `${label(f)}: ${st} s after the damage`); done(h); } }); test('T2: the held dodge cancels the next dodgeable physical / arts damage instance and is spent; true damage passes; a 流失 restarts nothing; her other dodges (S2) are rolled first and keep it', () => { const { h, u } = field({ tier: 5, skill: 0 }); const e = h.spawn('enemy_dummy', { pos: [12, 9] }); h.run(7.2); assert.equal(u.mem.chen3T2.evade, true); const hp0 = u.hp; assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'true' }), 300, 'true damage is not dodged'); assert.equal(u.mem.chen3T2.evade, true, 'still held'); assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'arts' }), 0, 'arts dodged'); assert.equal(u.mem.chen3T2.evade, false, 'spent'); assert.equal(h.hooksOf('dodge').filter((c) => c.target === u).length, 1, 'a dodge event'); assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'phys' }) > 0, true, 'the next one lands'); approx(u.hp, hp0 - 300 - (300 - u.s.def > 300 * 0.05 ? 300 - u.s.def : 15), 'HP'); // a 流失 does not restart the count h.run(5); h.b.loseHp(u, 1, { source: e }); h.run(2.2); assert.equal(u.mem.chen3T2.evade, true, 'a 流失 is no damage taken'); // another dodge first: a certain one (p 1) wins and the held one is kept h.b.addBuff(u, { key: 'test:dodge', mods: { dodgePhys: 1 } }); assert.equal(h.b.dealDamage(e, u, { amount: 300, type: 'phys' }), 0); assert.equal(u.mem.chen3T2.evade, true, 'kept behind the other dodge'); done(h); }); test('S1 赤霄·奔夜 (MANUAL, data DEFAULT): ATK +65 % / +80 % for 18 s, every attack hits twice, 沉默 on its target until the skill ends', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S1); const { h, u } = field({ tier, elite, skill: 0 }); assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk], ['DEFAULT', 18, elite ? 0.8 : 0.65], `T${tier}`); u.skill.gainSp(999); const e = h.spawn('enemy_dummy', { pos: [10, 6] }); assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`); approx(u.s.atk, u.base.atk * (1 + 0.16 + sk.bb.atk), `T${tier}: ATK`); const n0 = from(h, u).length; h.runUntil(() => from(h, u).length >= n0 + 2, 3); const two = from(h, u).slice(n0, n0 + 2); assert.equal(two[0].dmg.attackId, two[1].dmg.attackId, `T${tier}: one attack, two hits`); approx(two[0].amount, u.s.atk, `T${tier}: ATK each`); const sil = h.hooksOf('statusApplied').filter((c) => c.status === 'silence' && c.target === e && c.source === u); assert.ok(sil.length >= 1, `T${tier}: 沉默`); approx(sil[0].duration + sil[0].t, u.mem.chen3S1End, `T${tier}: until the skill ends`, 0.05); h.runUntil(() => !u.skill.active, 20); h.step(); assert.ok(!e.s.flags.silence, `T${tier}: lifted with the skill's end`); done(h); } // knocked out mid-skill: the silences she set end with it const { h, u } = field({ tier: 5, skill: 0 }); u.skill.gainSp(999); const e = h.spawn('enemy_dummy', { pos: [10, 6] }); h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.status === 'silence'), 3); assert.ok(e.s.flags.silence); h.b.kill(u, null); h.step(); assert.ok(!e.s.flags.silence, 'gone with the skill'); done(h); }); test('S2 赤霄·绝影-驰 (MANUAL, 技能范围 x-1, data SKILL_RANGE): 10 slashes on the nearest enemy, 0.4 s apart, 390 % / 430 % ATK arts; 无敌, blocks nobody, no attack, stun immune; then a 【移动】 onto its tile and 6 s of ATK +230 % / +260 % with 40 % / 50 % physical and arts dodge', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S2); const { h, u } = field({ tier, elite, skill: 1 }); assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk_scale, sk.bb['chen3_s2[respawn_buff].atk'], sk.bb['chen3_s2[respawn_buff].prob']], ['SKILL_RANGE', 'x-1', 6, elite ? 4.3 : 3.9, elite ? 2.6 : 2.3, elite ? 0.5 : 0.4], `T${tier}`); u.skill.gainSp(999); const near = h.spawn('enemy_dummy', { pos: [11, 6] }); // √2 away const far = h.spawn('enemy_dummy', { pos: [10, 7] }); // 2 away const shooter = h.spawn('enemy_shooter', { pos: [12, 7] }); assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`); const t0 = h.b.time; h.step(); assert.ok(u.s.flags.invulnerable && u.s.flags.noBlock, `T${tier}: 无敌, 不可阻挡`); assert.equal(h.b.applyStatus(u, 'stun', { duration: 2, source: shooter }), false, `T${tier}: stun immune`); const hp0 = u.hp; h.run(3.5); assert.equal(u.hp, hp0, `T${tier}: nothing hurts her`); h.runUntil(() => u.mem.chen3S2?.phase !== 1, 3); const cuts = from(h, u, (c) => tagged(c, 'chen3:slash')); assert.equal(cuts.length, 10, `T${tier}: 10 slashes`); assert.ok(cuts.every((c) => c.target === near), `T${tier}: on the nearest`); cuts.forEach((c, i) => approx(c.t - cuts[0].t, 0.4 * i, `T${tier}: slash ${i + 1}`, 0.04)); approx(cuts[0].t, t0, `T${tier}: the first at the cast`, 0.05); for (const c of cuts) { approx(c.amount, u.base.atk * 1.16 * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`); assert.deepEqual([c.type, c.dmg.isSkill], ['arts', true]); } // the move onto the target's tile, the 6 s state const moves = h.hooksOf('deploy').filter((c) => c.unit === u && c.move); assert.equal(moves.length, 1, `T${tier}: one 【移动】`); approx(moves[0].t - cuts[0].t, 4, `T${tier}: after 10 × 0.4 s`, 0.05); assert.equal(from(h, u, (c) => c.dmg.isAttack && c.t < moves[0].t).length, 0, `T${tier}: no attack while slashing`); assert.deepEqual([u.tileR, u.tileC], [11, 6], `T${tier}: moved onto its tile`); assert.ok(!u.s.flags.invulnerable && !u.s.flags.noBlock, `T${tier}: the slashing is over`); approx(u.s.atk, u.base.atk * (1 + 0.16 + sk.bb['chen3_s2[respawn_buff].atk']), `T${tier}: ATK`); approx(u.s.dodgePhys, sk.bb['chen3_s2[respawn_buff].prob'], `T${tier}: physical dodge`); approx(u.s.dodgeArts, sk.bb['chen3_s2[respawn_buff].prob'], `T${tier}: arts dodge`); approx(u.skill.timeLeft, 6, `T${tier}: 6 s from the move`, 0.05); h.runUntil(() => !u.skill.active, 7); approx(u.s.atk, u.base.atk * 1.16, `T${tier}: back`); assert.equal(from(h, u, (c) => tagged(c, 'chen3:slash') && c.target === far).length, 0, `T${tier}: the farther one is never slashed`); done(h); } }); test('S2: a slashed enemy that falls hands the slashes to the nearest one within 1.7 (+1 slash); with nobody left the slashing ends and she moves back onto her own tile; damage on a 2000-HP target', () => { { const { h, u } = field({ tier: 5, skill: 1 }); u.skill.gainSp(999); const frail = h.spawn('enemy_frail', { pos: [10, 6] }), next = h.spawn('enemy_dummy', { pos: [11, 7] }); assert.ok(h.runUntil(() => u.skill.active, 2)); h.runUntil(() => u.mem.chen3S2?.phase !== 1, 6); const cuts = from(h, u, (c) => tagged(c, 'chen3:slash')); assert.ok(!frail.alive, 'the first target fell'); const onFrail = cuts.filter((c) => c.target === frail).length; assert.equal(cuts.length, 11, `11 slashes (${onFrail} on the first)`); assert.ok(cuts.slice(onFrail).every((c) => c.target === next), 'the rest on the next'); assert.deepEqual([u.tileR, u.tileC], [11, 7], 'onto the last target\'s tile'); done(h); } { const { h, u } = field({ tier: 5, skill: 1 }); u.skill.gainSp(999); const frail = h.spawn('enemy_frail', { pos: [10, 6] }); assert.ok(h.runUntil(() => u.skill.active, 2)); h.runUntil(() => u.mem.chen3S2?.phase !== 1, 6); assert.ok(!frail.alive); assert.ok(from(h, u, (c) => tagged(c, 'chen3:slash')).length < 10, 'ended early'); assert.deepEqual([u.tileR, u.tileC], [10, 5], 'back on her own tile'); assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && c.move).length, 1, 'a 【移动】 in place'); assert.ok(u.skill.active && u.findBuff('skill:chen3:respawn'), 'the 6 s state still'); done(h); } }); test('S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on 3-12): range 3-12, each attack hits up to 3 ground enemies 3 times for 165 % / 180 % ATK arts; back to 1-1 after 20 s', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S3); const { h, u } = field({ tier, elite, skill: 2 }); assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@max_target'], sk.bb.hp_ratio, sk.bb.projectile_min_atk_scale], ['ACTIVE_RANGE', '3-12', 20, elite ? 1.8 : 1.65, 3, 0.06, elite ? 5.5 : 5.3], `T${tier}`); u.skill.gainSp(999); const ground = [[10, 7], [9, 6], [11, 6], [10, 8]].map((pos) => h.spawn('enemy_dummy', { pos })); const fly = h.spawn('enemy_fly', { pos: [11, 5] }); assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: an enemy on the 3-12`); assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-12`); const n0 = from(h, u, (c) => c.dmg.isAttack).length; h.runUntil(() => from(h, u, (c) => c.dmg.isAttack).length >= n0 + 9, 4); const hits = from(h, u, (c) => c.dmg.isAttack).slice(n0, n0 + 9); assert.equal(new Set(hits.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`); assert.equal(new Set(hits.map((c) => c.target)).size, 3, `T${tier}: 3 targets`); assert.ok(hits.every((c) => c.target !== fly && ground.includes(c.target)), `T${tier}: ground only`); for (const c of hits) { approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); assert.equal(c.type, 'arts'); } h.runUntil(() => !u.skill.active, 21); assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1`); done(h); } }); test('S3: the sword wave leaves her tile forward at 1.5 tiles/s, hits each enemy within 1.3 once per straight run (air too) for max(6 % of its HP, 530 % / 550 % ATK) arts, turns clockwise at the field\'s edge / high ground / gates and forgets whom it hit, and is gone with the skill', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const sk = skillOf(tier, elite, S3); const { h, u } = field({ tier, elite, skill: 2 }); u.skill.gainSp(999); const trigger = h.spawn('enemy_dummy', { pos: [10, 6] }); assert.ok(h.runUntil(() => u.skill.active, 2)); const t0 = h.b.time; const mid = h.spawn('enemy_mid', { pos: [10, 8] }), big = h.spawn('enemy_big', { pos: [9, 8] }); const fly = h.spawn('enemy_flymid', { pos: [11, 9] }); const corner = h.spawn('enemy_dummy', { pos: [10, 10] }); const waveHits = (e) => from(h, u, (c) => tagged(c, 'chen3:wave') && c.target === e); h.run(3); const w = u.mem.chen3Wave; approx(w.x, 5 + 1.5 * (h.b.time - t0), `T${tier}: 1.5 tiles/s along row 10`, 0.02); assert.deepEqual([w.dr, w.dc, w.y], [0, 1, 10], `T${tier}: still heading right`); assert.equal(waveHits(trigger).length, 1, `T${tier}: once per run`); approx(waveHits(trigger)[0].t, t0, `T${tier}: at the cast`, 0.1); // 10 000 HP: 6 % = 600 < 530 % ATK ⇒ the ATK floor; 1 000 000 HP: 6 % = 60 000 wins for (const [e, amt] of [[mid, sk.bb.projectile_min_atk_scale * u.s.atk], [fly, sk.bb.projectile_min_atk_scale * u.s.atk], [big, 0.06 * 1e6]]) { assert.equal(waveHits(e).length, 1, `T${tier}: ${e.defId} once`); approx(waveHits(e)[0].amount, amt, `T${tier}: ${e.defId}`); assert.deepEqual([waveHits(e)[0].type, waveHits(e)[0].dmg.isSkill], ['arts', true]); } // the field ends after column 10: it turns down there (the corner enemy, within 1.3, is hit again), then left before // the 侵入点 at (9, 10) — and runs back along row 10 h.run(1); assert.ok(waveHits(corner).length >= 2, `T${tier}: the corner one again after a turn (${waveHits(corner).length})`); h.run(2); assert.deepEqual([u.mem.chen3Wave.dr, u.mem.chen3Wave.dc], [0, -1], `T${tier}: heading left`); assert.ok(u.mem.chen3Wave.x < 10 && Math.round(u.mem.chen3Wave.y) === 10, `T${tier}: back along row 10`); h.runUntil(() => !u.skill.active, 20); h.step(); assert.equal(u.mem.chen3Wave, null, `T${tier}: gone with the skill`); const n = from(h, u, (c) => tagged(c, 'chen3:wave')).length; h.run(3); assert.equal(from(h, u, (c) => tagged(c, 'chen3:wave')).length, n, `T${tier}: no more wave hits`); done(h); } });