// Tier-4 operator loadouts (DESIGN §16): every selectable NON-default skill of every visible tier-4 chess has a // hand-authored spec in its kit file (server/sim/content/kits/ops/, `skills[skillId]`), proven here by its signature effect for // the normal (Lv4) and the elite (Lv7) chess; non-default modules (and 'none') change what their text says. Numbers // are read back from the data blackboards (the SELECTED skill's `bb`), real battles through the harness. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; import { getDefaultSource } from '../../server/sim/simdata.js'; import { loadoutOptions } from '../../shared/protocol.js'; import { skillSpecSource } from '../../server/sim/content/index.js'; import { kitCoverage } from '../../tools/kit-coverage.mjs'; import { canTargetAlly } from '../../server/sim/targeting.js'; import { aggregateMods } from '../../server/sim/buffs.js'; const ds = getDefaultSource(); const C = ds.raw.chess; const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`); const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, speed: 0, ...o }); const gold = (id) => id.replace(/_a$/, '_b'); /** Both chess of a base id: [normal, elite]. */ const pair = (base) => [base, gold(base)]; /** Resolved def of a chess for a loadout. */ const D = (id, skillIndex, moduleId) => ds.getChess(id, { skillIndex, ...(moduleId ? { moduleId } : {}) }); /** Harness unit entry with a loadout. */ const U = (chessId, row, col, skillIndex = null, moduleId = null, extra = {}) => ({ chessId, row, col, ...(skillIndex != null ? { skillIndex } : {}), ...(moduleId ? { moduleId } : {}), ...extra }); const dmgBy = (h, u, pred = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && pred(c)); const tagged = (tag) => (c) => (c.dmg?.tags || []).includes(tag); const T4 = Object.values(C).filter((c) => c.tier === 4 && !c.isGolden && c.visible).map((c) => c.chessId).sort(); function battle(units, o = {}) { return makeBattle({ defs: { enemies: { enemy_dummy: dummy(), ...(o.enemies || {}) } }, units, timeLimit: o.timeLimit ?? 300, hooks: o.hooks ?? ['damaged', 'attack', 'skillStart', 'skillEnd', 'heal', 'death', 'deploy', 'dodge', 'spGain'], captureNoisy: true, seed: o.seed ?? 3, flags: o.flags, ...(o.extra || {}), }); } // --------------------------------------------------------------------------------------------------------------- // coverage + smoke test('tier4 loadouts: every selectable skill of every visible chess is hand-authored (normal + elite)', () => { const rep = kitCoverage({ tier: 4 }); assert.equal(rep.summary.chess, 22); assert.equal(rep.summary.covered, rep.summary.skills, JSON.stringify(rep.chess.flatMap((r) => r.skills.filter((s) => !s.covered).map((s) => `${r.name} S${s.index + 1}`)))); for (const base of T4) { const { skills } = loadoutOptions(C[base], C[gold(base)]); for (const i of skills) { if (C[base].skills.find((s) => s.index === i).isDefault) continue; for (const id of pair(base)) assert.equal(skillSpecSource(D(id, i)), 'skills', `${id} S${i + 1}`); } } }); test('tier4 loadouts: every chess × selectable skill × module fights a mixed wave without content errors', () => { const runs = []; for (const base of T4) { const { skills } = loadoutOptions(C[base], C[gold(base)]); const mods = [null, 'none', ...(C[gold(base)].modules || []).filter((m) => !m.isDefault).map((m) => m.uniEquipId)]; for (const i of skills) { runs.push([base, i, null]); for (const m of mods) runs.push([gold(base), i, m]); } } for (const [id, i, m] of runs) { const h = makeBattle({ defs: { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 6000, atk: 300, def: 100, res: 20, speed: 1, bat: 1.5 }), e_fly: enemyRec({ key: 'e_fly', hp: 3000, atk: 150, motion: 'FLY', speed: 1, range: 1.5 }), e_elite: enemyRec({ key: 'e_elite', rank: 'ELITE', hp: 9000, atk: 400, def: 200, speed: 0.8, bat: 2 }), } }, units: [U(id, 9, 6, i, m), U('chess_char_1_02_a', 10, 6), U('chess_char_1_01_a', 11, 5), U('chess_char_4_17_a', 12, 7)], enemies: [{ key: 'e_walk', route: 0, count: 6, interval: 3 }, { key: 'e_walk', route: 1, count: 4, interval: 4 }, { key: 'e_fly', route: 2, count: 3, interval: 5 }, { key: 'e_elite', route: 0, time: 8 }], timeLimit: 70, seed: 5, hooks: [], }); const u = h.unit(id); assert.equal(u.def.loadout?.skillIndex ?? u.def.skill.index, i, `${id} S${i + 1} selected`); u.skill.gainSp?.(1000, 'test'); h.runToEnd(80); assert.equal(h.b.errorCount, 0, `${id} S${i + 1} ${m}: ${JSON.stringify(h.b.errors.map((e) => e.label + ' ' + e.message))}`); checkInvariants(h.b); } }); // --------------------------------------------------------------------------------------------------------------- // 信仰搅拌机 test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fires with the next attack); next attack = 3 hits × atk_scale; reloads one adjacent 拉特兰 ammo skill', () => { for (const id of pair('chess_char_4_01_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 9, 5, 0, gold(id) === id ? 'none' : null), U('chess_char_1_01_a', 10, 4)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 50, bat: 1 }) }, }); h.step(); const u = h.unit(id), ins = h.unit('chess_char_1_01_a'); assert.equal(u.skill.id, 'skchr_rmixer_1'); assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)'); assert.ok(ins.skill.activate('test', { free: true })); u.skill.gainSp(1000); const ammo0 = ins.skill.ammoLeft; h.hooksOf('attack').length = 0; h.spawn('enemy_hitter', { pos: [9, 5] }); assert.ok(h.runUntil(() => h.hooksOf('skillStart').some((c) => c.unit === u), 10), `${id} S1 cast with the next attack`); assert.ok(h.runUntil(() => h.hooksOf('skillEnd').some((c) => c.unit === u), 10), 'the armed attack happened'); const skillHits = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill); assert.equal(skillHits.length, 3, `${id}: triple hit`); assert.equal(new Set(skillHits.map((c) => c.dmg.attackId)).size, 1, 'one attack'); for (const c of skillHits) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, `${id} hit`); const shots = h.hooksOf('attack').filter((c) => c.attacker === ins).length; assert.equal(ins.skill.ammoLeft, ammo0 - shots + bb.charge, `${id} reloaded ${bb.charge} bullet(s)`); checkInvariants(h.b); } }); test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit is negated for ammo_cost bullets; with fewer it is still negated, every bullet goes and the skill ends (PRTS 备注)', () => { for (const id of pair('chess_char_4_01_a')) { const bb = D(id, 1).skill.bb; for (const enough of [true, false]) { const h = battle([U(id, 9, 5, 1)], { enemies: { enemy_killer: dummy({ key: 'enemy_killer', atk: 1e6, bat: 2 }) } }); h.step(); const u = h.unit(id); assert.ok(u.skill.activate('test', { free: true })); assert.equal(u.skill.kind, 'ammo'); assert.equal(u.skill.ammoLeft, bb['attack@trigger_time']); approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, 'ATK'); approx(u.s.def, u.base.def * (1 + bb.def), 1e-6, 'DEF'); if (!enough) u.skill.ammoLeft = bb.ammo_cost - 1; u.hp = 500; const before = u.skill.ammoLeft; h.spawn('enemy_killer', { pos: [9, 5] }); h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === u) || !u.alive, 10); if (enough) { assert.ok(u.alive, `${id}: lethal hit negated`); approx(u.hp, 500, 1e-9, 'HP kept'); const shots = h.hooksOf('attack').filter((c) => c.attacker === u).length; assert.equal(u.skill.ammoLeft, before - bb.ammo_cost - shots, `${bb.ammo_cost} bullets spent`); } else { // PRTS 备注 "弹药量不足时仍可抵挡致命伤害,此时将消耗所有剩余弹药并退出技能状态" (0.1.0: [ASSUMED] no guard) assert.ok(u.alive, `${id}: fewer than ${bb.ammo_cost} bullets still block the lethal hit`); approx(u.hp, 500, 1e-9, 'HP kept'); assert.equal(u.skill.ammoLeft, 0, 'every bullet spent'); assert.equal(u.skill.active, false, 'the skill ends'); } } } }); test('信仰搅拌机 modules: SPT-Y 老朋友 = range +1 and no reveal; SPT-X (default) reveals', () => { const size = (m) => { const h = battle([U('chess_char_4_01_b', 10, 5, null, m)]); h.step(); const u = h.unit('chess_char_4_01_b'); const e = h.spawn('enemy_dummy', { pos: [10, 6] }); h.b.applyStatus(e, 'stealth', { duration: 30 }); h.run(0.5); return { n: u.rangeKeys.length, reveal: !!e.s.flags.reveal }; }; const def = size(null), y = size('uniequip_003_rmixer'), none = size('none'); assert.ok(y.n > def.n, `range ${def.n} → ${y.n}`); assert.equal(def.n, none.n); assert.deepEqual([def.reveal, y.reveal, none.reveal], [true, false, false]); }); // --------------------------------------------------------------------------------------------------------------- // 莫斯提马 test('莫斯提马 S1 攻击力强化·γ型: ATK +45 %/+60 %; talent 2 slow is NOT tripled (S3 only)', () => { for (const id of pair('chess_char_4_02_a')) { const bb = D(id, 0).skill.bb, t1 = D(id, 0).talents[1].bb; const h = battle([U(id, 10, 3, 0)]); h.step(); const u = h.unit(id); const e = h.spawn('enemy_dummy', { pos: [10, 4] }); assert.ok(u.skill.activate('test', { free: true })); h.run(0.5); approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, `${id} ATK`); approx(e.findBuff(`mostma:slow:${u.id}`).mods.moveMul, 1 + t1.move_speed, 1e-9, 'plain talent slow'); approx(u.skill.timeLeft, D(id, 0).skill.duration - 0.5, 0.05); } }); test('莫斯提马 S2 荒时之锁: every enemy in range stunned until the skill ends, atk_scale arts per second', () => { for (const id of pair('chess_char_4_02_a')) { const sk = D(id, 1).skill, bb = sk.bb; const h = battle([U(id, 10, 3, 1)]); h.step(); const u = h.unit(id); const es = [h.spawn('enemy_dummy', { pos: [10, 4] }), h.spawn('enemy_dummy', { pos: [9, 5] }), h.spawn('enemy_dummy', { pos: [11, 5] })]; const far = h.spawn('enemy_dummy', { pos: [10, 9] }); assert.ok(u.skill.activate('test', { free: true })); h.run(0.2); for (const e of es) assert.ok(e.s.flags.stun, `${id}: enemy in range stunned`); assert.ok(!far.s.flags.stun, 'enemy out of range not stunned'); h.runUntil(() => !u.skill.active, 20); const lock = dmgBy(h, u, tagged('timeLock')); for (const e of es) { const mine = lock.filter((c) => c.target === e); assert.equal(mine.length, Math.floor(sk.duration + 1e-6), `${id}: one tick per second (${sk.duration} s)`); approx(mine[0].amount, u.s.atk * bb.atk_scale, 1e-6, 'arts tick'); } assert.ok(!lock.some((c) => c.target === far)); h.run(0.2); for (const e of es) assert.ok(!e.s.flags.stun, 'stun ends with the skill'); checkInvariants(h.b); } }); test('莫斯提马 module SPC-X 资深万国信使定制斗篷: 攻击范围扩大 (default SPC-Y and none do not)', () => { const n = (m) => { const h = battle([U('chess_char_4_02_b', 10, 3, null, m)]); h.step(); return h.unit('chess_char_4_02_b').rangeKeys.length; }; assert.ok(n('uniequip_003_mostma') > n(null)); assert.equal(n(null), n('none')); }); test('莫斯提马 SPC-X: the range becomes the module grid (only the centre tile [0,3] added, no rangeExtend); S3 keeps its own range', () => { const id = 'chess_char_4_02_b'; const modGrid = C[id].modules.find((m) => m.uniEquipId === 'uniequip_003_mostma').talentChanges.find((t) => t.rangeGrid).rangeGrid; const rel = (u) => u.rangeKeys.map((k) => `${Math.floor(k / 21) - u.tileR},${(k % 21) - u.tileC}`).sort(); const run = (m) => { const h = battle([U(id, 10, 3, null, m)]); h.step(); const u = h.unit(id); const base = rel(u), initial = u.baseRangeKeys.length, ext = u.s.rangeExtend; assert.ok(u.skill.activate('test', { free: true })); h.step(); return { base, initial, ext, skill: rel(u) }; }; const x = run('uniequip_003_mostma'), def = run(null); assert.deepEqual(x.base, modGrid.map(([r, c]) => `${r},${c}`).sort(), 'own range = module grid'); assert.equal(x.base.length, C[id].rangeGrid.length + 1, 'exactly one tile more'); assert.ok(x.base.includes('0,3') && !x.base.includes('1,3') && !x.base.includes('-1,3')); assert.equal(x.initial, x.base.length, 'DEFAULT trigger range follows'); assert.equal(x.ext, 0, 'no forward extension'); assert.deepEqual(x.skill, def.skill, 'S3 range unchanged by the module'); assert.equal(x.skill.length, D(id, 2).skill.rangeGrid.length); }); // --------------------------------------------------------------------------------------------------------------- // 伊内丝 test('伊内丝 S1 淬影突袭: next attack +2 DP and a 3 s non-stacking arts DoT of 40 %/55 % ATK per second', () => { for (const id of pair('chess_char_4_04_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 } }); h.step(); const u = h.unit(id); const e = h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(1.5); // first damage: 影织 binds + steals ATK (her ATK is stable afterwards) const dp0 = h.b.getPlayer('p1').dp; assert.ok(u.skill.activate('test', { free: true })); assert.ok(h.runUntil(() => !u.skill.active, 10)); h.run(0.4); // the bolt lands assert.equal(h.b.getPlayer('p1').dp, dp0 + bb.cost, `${id}: +${bb.cost} DP`); assert.ok(e.findBuff(`ines:bleed:${u.id}`), 'DoT applied'); h.run(bb.bleed_duration + 0.5); const dot = dmgBy(h, u, tagged('dot')); assert.equal(dot.length, bb.bleed_duration, `${id}: ${bb.bleed_duration} ticks`); for (const c of dot) approx(c.amount, u.s.atk * bb.bleed_atk_scale, 1e-6, 'arts per second'); assert.ok(e.buffs.filter((b) => b.key === `ines:bleed:${u.id}`).length <= 1, '不叠加'); } }); test('伊内丝 S3 独影归途: first deployment leaves a sentry and retreats; the redeploy recalls it through ≤ 4/5 enemies; ATK up; +1 DP per damage', () => { for (const id of pair('chess_char_4_04_a')) { const sk = D(id, 2).skill, bb = sk.bb; const h = battle([U(id, 10, 5, 2)], { flags: { dpInit: 99, dpPerSec: 0 } }); const u = h.unit(id); h.b.start(); assert.equal(u.skill.kind, 'duration'); assert.equal(u.skill.active, false, 'first deployment does not activate the skill'); assert.equal(u.skill.activations, 0); assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 0, 'placing a sentry emits no skillStart'); assert.equal(u.skill.timeLeft, 0, 'first deployment places the sentry without a full effect window'); assert.equal(u.findBuff('ines:s3'), null); const near = []; for (let i = 0; i < 3; i++) near.push(h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 4] })); const far = h.spawn('enemy_dummy', { pos: [10, 8] }); h.step(3); const left = h.hooksOf('death').find((c) => c.unit === u); assert.ok(left && left.reason === 'retreat', `${id}: leaves right after the first deployment`); assert.ok(h.runUntil(() => u.alive && u.deployed, 2), 'redeploy timer refreshed: back at once'); const buff = u.findBuff('ines:s3'); assert.ok(buff, 'ATK effect on the redeploy'); approx(buff.mods.atkPct, bb.atk, 1e-9); approx(u.skill.timeLeft, sk.duration, 0.1); assert.equal(u.skill.active, true); assert.equal(u.skill.activations, 1); assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'the redeployment starts the skill once'); assert.equal(u.skill.ready, false); assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration); const recall = dmgBy(h, u, tagged('sentryRecall')); assert.equal(recall.length, bb.max_target, `${id}: recall hits ≤ ${bb.max_target} of 6`); assert.ok(recall.every((c) => near.includes(c.target)) && !recall.some((c) => c.target === far)); for (const c of recall) approx(c.amount, u.base.atk * (1 + bb.atk) * bb.atk_scale, 1e-6, 'recall damage'); assert.equal(u.mem.sentry, null, 'sentry recalled'); const dpAt = h.b.getPlayer('p1').dp, tAt = h.b.time; h.run(3); const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.t >= tAt - 1e-9 && c.amount > 0 && c.target.side === 'enemy').length; assert.ok(hits > 0); assert.equal(h.b.getPlayer('p1').dp, dpAt + hits * bb.cost, 'one DP per damage while the passive lasts'); assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'only the first deployment leaves'); h.run(sk.duration); assert.equal(u.skill.active, false); assert.equal(u.skill.ready, false); assert.equal(u.findBuff('ines:s3'), null); const dpEnd = h.b.getPlayer('p1').dp; h.run(2); assert.equal(h.b.getPlayer('p1').dp, dpEnd, 'no skill DP after the actual window ends'); assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 1, 'stays deployed after the window'); assert.deepEqual(h.hooksOf('skillEnd').filter((c) => c.unit === u).map((c) => c.reason), ['duration']); checkInvariants(h.b); } }); test('伊内丝 module none: the first retreat keeps its full redeploy time (AGE-Y shortens it by 35 %)', () => { const wait = (m) => { const h = battle([U('chess_char_4_04_b', 10, 5, null, m)]); h.step(); const u = h.unit('chess_char_4_04_b'); h.b.retreat(u); return u.respawnAt - u.deathAt; }; approx(wait(null), wait('none') * (1 + C.chess_char_4_04_b.modules[0].traitOverride.bb.respawn_time), 1e-6); }); // --------------------------------------------------------------------------------------------------------------- // 寒芒克洛丝 / 风笛 test('寒芒克洛丝 S1 无痕: ATK up, every attack is a double shot, 迷彩 (ranged enemies cannot target her); no S2 hit counting', () => { for (const id of pair('chess_char_4_06_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 10, 4, 0)]); h.step(); const u = h.unit(id); const e = h.spawn('enemy_dummy', { pos: [10, 6] }); assert.ok(u.skill.activate('test', { free: true })); assert.equal(u.skill.kind, 'duration'); approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, `${id} ATK`); assert.ok(u.s.flags.camou && !u.s.flags.stealth, '迷彩 (not 隐匿)'); assert.equal(canTargetAlly(e, u, true), false, 'not a ranged target'); h.run(3); const shots = dmgBy(h, u, (c) => c.dmg.isAttack); const per = new Map(); for (const c of shots) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1); assert.ok(per.size >= 2 && [...per.values()].every((n) => n === 2), `${id}: 2 shots per attack`); assert.equal(u.mem.kroosHits ?? 0, 0, 'S2 hit counter untouched'); h.runUntil(() => !u.skill.active, 30); assert.ok(!u.s.flags.camou); } }); test('风笛 S1 迅捷打击·γ型 (ATK/ASPD) and S3 闭膛连发 (BAT +0.7 s, block +1, ATK/DEF up, triple hits)', () => { for (const id of pair('chess_char_4_07_a')) { const b1 = D(id, 0).skill.bb, b3 = D(id, 2).skill.bb; let h = battle([U(id, 10, 4, 0)]); h.step(); let u = h.unit(id); assert.ok(u.skill.activate('test', { free: true })); approx(u.s.atk, u.base.atk * (1 + b1.atk), 1e-6, 'S1 ATK'); assert.equal(u.s.aspd, u.base.aspd + b1.attack_speed); h = battle([U(id, 10, 4, 2)]); h.step(); u = h.unit(id); h.spawn('enemy_dummy', { pos: [10, 5] }); assert.ok(u.skill.activate('test', { free: true })); approx(u.s.atk, u.base.atk * (1 + b3.atk), 1e-6, 'S3 ATK'); approx(u.s.def, u.base.def * (1 + b3.def), 1e-6, 'S3 DEF'); assert.equal(u.s.blockCnt, u.base.blockCnt + b3.block_cnt); approx(u.s.interval, (u.base.bat + b3.base_attack_time) * 100 / u.s.aspd, 1e-6, 'BAT +0.7 s'); h.run(4); const per = new Map(); for (const c of dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill)) per.set(`${c.dmg.attackId}:${c.target.id}`, (per.get(`${c.dmg.attackId}:${c.target.id}`) ?? 0) + 1); assert.ok(per.size >= 2 && [...per.values()].every((n) => n === 3), `${id}: three hits per target per attack`); } }); test('风笛 module CHG-Y “棍棒与口袋”: attacks on enemies below 40 % HP use 115 % ATK (default / none do not)', () => { const minHit = (m, low) => { const h = battle([U('chess_char_4_07_b', 10, 4, 0, m)]); h.step(); const u = h.unit('chess_char_4_07_b'); const e = h.spawn('enemy_dummy', { pos: [10, 5] }); if (low) e.hp = e.s.maxHp * 0.3; h.run(6); const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e).map((c) => c.amount / u.s.atk); assert.ok(hits.length >= 3); return Math.min(...hits); }; const tb = C.chess_char_4_07_b.modules.find((m) => m.uniEquipId === 'uniequip_003_bpipe').traitOverride.bb; approx(minHit('uniequip_003_bpipe', true), tb.atk_scale, 1e-6, 'low HP ×1.15'); approx(minHit('uniequip_003_bpipe', false), 1, 1e-6, 'healthy target ×1'); approx(minHit(null, true), 1, 1e-6, 'default module: no bonus'); }); // --------------------------------------------------------------------------------------------------------------- // 水月 / 阿罗玛 / 凯瑟琳 test('水月 S1 唤醒: charges; the next attack hits every enemy in range for atk_scale and talent 1 deals ×talent_scale', () => { for (const id of pair('chess_char_4_09_a')) { const d = D(id, 0), bb = d.skill.bb, t0 = d.talents[0].bb; const h = battle([U(id, 10, 4, 0)]); h.step(); const u = h.unit(id); assert.equal(u.skill.maxCharges, bb.cnt); const es = [h.spawn('enemy_dummy', { pos: [10, 5] }), h.spawn('enemy_dummy', { pos: [9, 5] })]; assert.ok(u.skill.activate('test', { free: true })); assert.ok(h.runUntil(() => !u.skill.active, 10)); const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill); assert.deepEqual(new Set(sk.map((c) => c.target)), new Set(es), `${id}: every enemy in range`); for (const c of sk) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, 'skill hit'); const tal = dmgBy(h, u, tagged('talent')); assert.equal(tal.length, 1); approx(tal[0].amount, u.s.atk * t0['attack@mizuki_t_1.atk_scale'] * bb.talent_scale, 1e-6, 'talent ×2'); h.run(8); for (const c of dmgBy(h, u, tagged('talent')).slice(1)) approx(c.amount, u.s.atk * t0['attack@mizuki_t_1.atk_scale'], 1e-6, 'plain talent afterwards'); } }); test('水月 S3 镜花水月: wider range, ATK up, talent 1 +2 targets with stun; an attack hitting < 3 enemies costs 15 % max HP', () => { for (const id of pair('chess_char_4_09_a')) { const bb = D(id, 2).skill.bb; for (const n of [1, 3]) { const h = battle([U(id, 10, 4, 2)]); h.step(); const u = h.unit(id); const base = u.rangeKeys.length; const es = []; for (let i = 0; i < n; i++) es.push(h.spawn('enemy_dummy', { pos: [10 + (i === 1 ? -1 : i === 2 ? 1 : 0), 5] })); assert.ok(u.skill.activate('test', { free: true })); assert.ok(u.rangeKeys.length > base, 'range widened'); approx(u.s.atk, u.base.atk * (1 + bb.atk) * (u.findBuff('mizuki:t2') ? 1 + D(id, 2).talents[1].bb.atk : 1), 1e-6); const hp0 = u.hp; assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 10)); const tal = dmgBy(h, u, tagged('talent')); assert.equal(tal.length, Math.min(n, 1 + bb['attack@max_target']), `${id}: talent hits ${Math.min(n, 3)}`); for (const c of tal) assert.ok(c.target.s.flags.stun, 'talent stun'); if (n < 3) approx(hp0 - u.hp, u.s.maxHp * bb['attack@hp_ratio'], 1e-6, 'HP loss'); else assert.equal(u.hp, hp0, 'no loss with ≥ 3 enemies hit'); } } }); test('水月 module AMB-Y 深蓝之籽: 65 % physical and arts dodge (default AMB-X: 50 % + slow aura)', () => { const dodge = (m) => { const h = battle([U('chess_char_4_09_b', 10, 4, null, m)]); h.step(); return h.unit('chess_char_4_09_b').s.dodgePhys; }; approx(dodge('uniequip_003_mizuki'), 0.65, 1e-9); approx(dodge(null), 0.5, 1e-9); }); test('阿罗玛 S1 强效清洁: charges; next blast atk_scale arts, flying victims take +atk_scale_to_fly', () => { for (const id of pair('chess_char_4_10_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 10, 3, 0, gold(id) === id ? 'none' : null)], { enemies: { enemy_flyer: dummy({ key: 'enemy_flyer', motion: 'FLY' }) } }); h.step(); const u = h.unit(id); const g = h.spawn('enemy_dummy', { pos: [10, 6] }), f = h.spawn('enemy_flyer', { pos: [10, 6] }); h.run(4); // the talent's first-hit bonus / levitation are spent assert.ok(u.skill.activate('test', { free: true })); assert.ok(h.runUntil(() => !u.skill.active, 10)); h.run(1); const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill); assert.deepEqual(new Set(sk.map((c) => c.target)), new Set([g, f])); for (const c of sk) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6, `${id} blast`); const aa = dmgBy(h, u, tagged('antiAir')); assert.equal(aa.length, 1); assert.equal(aa[0].target, f, 'only the flyer'); approx(aa[0].amount, u.s.atk * bb.atk_scale_to_fly, 1e-6, 'anti-air bonus'); } }); test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator holding a device shield; the hand card is her deploy limit', () => { for (const id of pair('chess_char_4_11_a')) { const bb = D(id, 0).skill.bb; // her device is a placed piece (user playtest #6): facing 4_17 on its right const dev = { kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 5, dir: 'RIGHT' }; const h = battle([U(id, 10, 4, 0, null, { uid: 1 }), U('chess_char_4_17_a', 10, 6), U('chess_char_1_01_a', 12, 3), dev]); h.run(1); const u = h.unit(id), hs = h.unit('chess_char_4_17_a'), ins = h.unit('chess_char_1_01_a'); assert.equal(u.skill.kind, 'passive'); approx(u.s.atk, u.base.atk * (1 + bb.s1_atk), 1e-6, `${id} own ATK`); approx(u.s.def, u.base.def * (1 + bb.s1_def), 1e-6, 'own DEF'); for (const a of [hs, ins]) { const f = a.findBuff(`cathy:forge:${u.id}`); assert.equal(!!f, !!a.findBuff('cathy:shield'), `${a.name}: forge buff iff device shield`); if (f) approx(f.mods.atkPct, bb.s1_atk, 1e-9); } assert.ok(hs.findBuff(`cathy:forge:${u.id}`), 'the shielded blocker gets it'); } // the module changes how many devices she carries (none 3, CRA-X 4); the hand gets the deploy limit (2) either way // (PRTS 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区") assert.deepEqual([C.chess_char_4_11_b.talentsBase[0].bb.cnt, C.chess_char_4_11_b.talents[0].bb.cnt], [3, 4]); for (const id of pair('chess_char_4_11_a')) assert.equal(ds.raw.tokens.token_10041_cathy_catsld.variants[id].stats.deployLimit, 2, `${id}: deploy limit`); }); // --------------------------------------------------------------------------------------------------------------- // 歌蕾蒂娅 / 灵知 / 莱恩哈特 test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1.3 vs weight ≤ 3) and pulls the target to her front', () => { for (const id of pair('chess_char_4_12_a')) { const d = D(id, 0), bb = d.skill.bb, t1 = d.talents[1].bb; const h = battle([U(id, 10, 3, 0)]); h.step(); const u = h.unit(id); const e = h.spawn('enemy_dummy', { pos: [10, 6] }); const x0 = e.x; assert.ok(u.skill.activate('test', { free: true })); assert.ok(h.runUntil(() => !u.skill.active, 10)); h.run(0.5); const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill); assert.equal(sk.length, 1); approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`); assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`); // 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`); } }); test('歌蕾蒂娅 S2 缺水的掌握怒海: BAT +0.5 s, wider range, 2 targets at attack@atk_scale, both pulled to her front', () => { for (const id of pair('chess_char_4_12_a')) { const d = D(id, 1), bb = d.skill.bb, t1 = d.talents[1].bb; const h = battle([U(id, 10, 3, 1)]); h.step(); const u = h.unit(id); const es = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [11, 6] }), h.spawn('enemy_dummy', { pos: [9, 6] })]; assert.ok(u.skill.activate('test', { free: true })); approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 1e-6, 'BAT +0.5 s'); assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 10)); const atk = h.hooksOf('attack').find((c) => c.attacker === u); assert.equal(atk.targets.length, bb['attack@max_target'], `${id}: two targets`); h.run(0.6); const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.attackId === dmgBy(h, u)[0].dmg.attackId); assert.equal(hits.length, bb['attack@max_target']); for (const c of hits) approx(c.amount, u.s.atk * bb['attack@atk_scale'] * t1.atk_scale, 1e-6); for (const e of atk.targets) assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`); } }); test('歌蕾蒂娅 module HOK-Y 淡金坠饰: pulling a far enemy (> 2.5 tiles) to herself is one force level stronger', () => { // a weight-2 enemy: 中力 (1) − 2 = 受力等级 −1 ⇒ 35 % of the way; +1 level (HOK-Y) ⇒ 0 ⇒ all the way (PRTS 推与拉) const moved = (m) => { const h = battle([U('chess_char_4_12_b', 10, 3, 0, m)], { enemies: { enemy_heavy: dummy({ key: 'enemy_heavy', mass: 2 }) } }); h.step(); const u = h.unit('chess_char_4_12_b'); const e = h.spawn('enemy_heavy', { pos: [10, 6] }); assert.ok(u.skill.activate('test', { free: true })); h.runUntil(() => !u.skill.active, 10); h.run(0.5); return 6 - e.x; }; const y = moved('uniequip_003_glady'), x = moved(null); assert.ok(y > x + 0.2, `HOK-Y ${y} > HOK-X ${x}`); approx(x, moved('none'), 1e-9); }); test('灵知 S1 高速思考: next attack hits twice for atk_scale arts', () => { for (const id of pair('chess_char_4_13_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 10, 4, 0)]); h.step(); const u = h.unit(id); h.spawn('enemy_dummy', { pos: [10, 5] }); assert.ok(u.skill.activate('test', { free: true })); assert.ok(h.runUntil(() => !u.skill.active, 10)); h.run(0.5); const sk = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill); assert.equal(sk.length, 2, `${id}: two hits`); assert.equal(sk[0].dmg.attackId, sk[1].dmg.attackId); for (const c of sk) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * bb.atk_scale, 1e-6); } } }); test('灵知 S3 失温症: ASPD up, 2 targets (unfrozen first), freezes held until the end, then atk_scale arts on the frozen and thaw', () => { for (const id of pair('chess_char_4_13_a')) { const sk = D(id, 2).skill, bb = sk.bb; const h = battle([U(id, 10, 4, 2)]); h.step(); const u = h.unit(id); const frozenOne = h.spawn('enemy_dummy', { pos: [10, 5] }); const others = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [9, 5] })]; h.b.applyStatus(frozenOne, 'freeze', { duration: 1 }); assert.ok(u.skill.activate('test', { free: true })); assert.equal(u.s.aspd, u.base.aspd + bb.attack_speed); assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5)); const first = h.hooksOf('attack').find((c) => c.attacker === u); assert.equal(first.targets.length, bb.max_target); assert.ok(!first.targets.includes(frozenOne), 'unfrozen targets first'); h.run(2); assert.ok(frozenOne.findBuff('freeze'), `${id}: freeze held past its own 1 s`); approx(frozenOne.findBuff('freeze').timeLeft, u.skill.timeLeft, 0.1, 'until the skill ends'); const atkEnd = u.s.atk; assert.ok(h.runUntil(() => !u.skill.active, 20)); const shatter = dmgBy(h, u, tagged('shatter')); assert.ok(shatter.some((c) => c.target === frozenOne), 'end burst on the frozen enemy'); for (const c of shatter) assert.ok(c.amount >= atkEnd * bb.atk_scale - 1e-6, 'atk_scale arts (× fragile)'); for (const c of shatter) assert.ok(!c.target.findBuff('freeze'), 'thawed'); } }); test('灵知 module UMD-Y 一号项目模型: +0.25 SP/s while an elite enemy is in range (default UMD-X: weaken on hit)', () => { const rate = (m, rank) => { const h = battle([U('chess_char_4_13_b', 10, 4, null, m)], { enemies: { enemy_r: dummy({ key: 'enemy_r', rank }) } }); h.step(); h.spawn('enemy_r', { pos: [10, 6] }); h.run(0.5); return h.unit('chess_char_4_13_b').s.spRecovery; }; const tb = C.chess_char_4_13_b.modules.find((m) => m.uniEquipId === 'uniequip_003_gnosis').traitOverride.bb; approx(rate('uniequip_003_gnosis', 'ELITE'), 1 + tb.sp_recovery_per_sec, 1e-9); approx(rate('uniequip_003_gnosis', 'NORMAL'), 1, 1e-9); approx(rate(null, 'ELITE'), 1, 1e-9); }); test('莱恩哈特 S1 攻击力强化·γ型: ATK up for 30 s; module none: no range bonus', () => { for (const id of pair('chess_char_4_14_a')) { const d = D(id, 0); const h = battle([U(id, 10, 4, 0)]); h.step(); const u = h.unit(id); assert.ok(u.skill.activate('test', { free: true })); approx(u.s.atk, u.base.atk * (1 + d.skill.bb.atk), 1e-6); approx(u.skill.timeLeft, d.skill.duration, 1e-6); } const n = (m) => { const h = battle([U('chess_char_4_14_b', 10, 4, null, m)]); h.step(); return h.unit('chess_char_4_14_b').rangeKeys.length; }; assert.ok(n(null) > n('none')); }); // --------------------------------------------------------------------------------------------------------------- // 缄默德克萨斯 / 星熊 / 泥岩 test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits silence 5 s/8 s with a fixed arts DoT per second', () => { for (const id of pair('chess_char_4_16_a')) { const sk = D(id, 0).skill, bb = sk.bb; const h = battle([U(id, 10, 4, 0)]); h.step(); const u = h.unit(id); assert.equal(u.skill.kind, 'duration'); assert.equal(u.skill.active, true); assert.equal(u.skill.charges, 0); approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 0).talents[0].bb.atk, 1e-9, 'skill and 德克萨斯传统 ATK'); assert.equal(h.snapshot().units.find((t) => t[0] === u.id)[6], sk.duration); const e = h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(3); assert.ok(e.s.flags.silence, 'hit target silenced (失去特殊能力)'); approx(e.findBuff('silence').timeLeft, bb['attack@silence'], 1.5); const dot = dmgBy(h, u, tagged('dot')); assert.ok(dot.length >= 2, `a DoT tick every second although she re-hits faster (${dot.length})`); for (const c of dot) approx(c.amount, bb['attack@texas2_s_1[dot].dot_damage'], 1e-9, 'fixed arts per second (RES 0)'); h.run(sk.duration); assert.equal(u.skill.active, false, 'ends with the skill duration'); assert.equal(u.skill.ready, false); assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u && c.reason === 'duration').length, 1); assert.ok(e.s.flags.silence && e.findBuff(`texas2:drizzleDot:${u.id}`), 'applied enemy effects keep their own duration'); h.run(bb['attack@silence'] + 1); assert.ok(!e.s.flags.silence && !e.findBuff(`texas2:drizzleDot:${u.id}`), 'no new silence / DoT after the passive'); } }); test('缄默德克萨斯 S2: two copies cut the RES of an enemy around both once (同名效果取最高, DESIGN §20.10)', () => { const id = 'chess_char_4_16_a', bb = D(id, 1).skill.bb; const h = battle([U(id, 10, 4, 1), U(id, 11, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } }); h.step(); const two = h.b.allyUnits.filter((u) => u.def.id === id); assert.equal(two.length, 2); const e = h.spawn('enemy_res', { pos: [10, 5] }); for (const u of two) { h.b.retreat(u); assert.ok(h.b.redeploy(u)); } assert.equal(dmgBy(h, two[0], tagged('burst')).length + dmgBy(h, two[1], tagged('burst')).length, 2, 'both bursts hit it'); assert.equal(e.buffs.filter((b) => String(b.key).startsWith('texas2:resDown')).length, 1, 'one RES cut'); approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'once, not twice'); }); test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down around her; ATK up; attacks become arts double hits', () => { for (const id of pair('chess_char_4_16_a')) { const sk = D(id, 1).skill, bb = sk.bb; const h = battle([U(id, 10, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } }); h.step(); const u = h.unit(id); const e = h.spawn('enemy_res', { pos: [10, 5] }); const far = h.spawn('enemy_res', { pos: [10, 8] }); h.b.retreat(u); assert.ok(h.b.redeploy(u)); const burst = dmgBy(h, u, tagged('burst')); assert.deepEqual(burst.map((c) => c.target), [e], `${id}: only enemies around her`); approx(burst[0].amount, u.s.atk * bb.atk_scale * 0.5, 1e-6, 'arts burst vs RES 50'); approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'RES down'); assert.equal(far.s.res, 50); approx(u.skill.spec.mods.atkPct, bb.atk + D(id, 1).talents[0].bb.atk, 1e-9); approx(u.skill.timeLeft, sk.duration, 1e-6); h.run(2); const hits = dmgBy(h, u, (c) => c.dmg.isAttack); assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), 'arts attacks'); const per = new Map(); for (const c of hits) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1); assert.ok([...per.values()].every((v) => v === 2), 'double hits'); h.run(sk.duration); assert.equal(u.skill.active, false); assert.equal(u.skill.ready, false); const n0 = dmgBy(h, u, (c) => c.dmg.isAttack).length; h.run(3); const late = dmgBy(h, u, (c) => c.dmg.isAttack).slice(n0); assert.ok(late.length >= 1 && late.every((c) => c.type === 'phys'), 'back to single phys hits after the duration'); } }); test('缄默德克萨斯 S1/S2/S3: first kill reopens a full window, including a kill inside the deployment burst', () => { for (const id of pair('chess_char_4_16_a')) for (const index of [0, 1, 2]) { const life = []; const h = battle([U(id, 10, 4, index)], { extra: { setup(b) { for (const name of ['skillStart', 'skillEnd']) b.on(name, (c) => life.push([name, c.reason]), { priority: 1000 }); } } }); h.b.start(); const u = h.unit(id), sk = u.skill; h.run(2); u.hp = u.s.maxHp / 2; const e = h.spawn('enemy_dummy', { pos: [10, 5] }); h.b.kill(e, u); assert.equal(u.mem.texasKilled, true); approx(u.hpRatio, 1); assert.equal(u.findBuff('texas2:swordplay'), null); assert.equal(sk.activations, 2); approx(sk.timeLeft, sk.duration); assert.equal(sk.charges, 0); assert.equal(sk.ready, false); assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast']); h.b.kill(h.spawn('enemy_dummy', { pos: [10, 5] }), u); assert.equal(sk.activations, 2, 'only first kill recasts'); h.run(sk.duration + 0.1); assert.equal(sk.active, false); assert.deepEqual(h.hooksOf('skillEnd').map((c) => c.reason), ['recast', 'duration']); assert.deepEqual(h.hooksOf('skillStart').map((c) => c.reason), ['deploy', 'kill']); h.b.retreat(u); const lifeAt = life.length; const weak = h.spawn('enemy_dummy', { pos: [10, 5] }); weak.hp = 1; assert.ok(h.b.redeploy(u, { free: true })); if (index === 0) h.b.kill(weak, u); // S1 has no deployment burst assert.equal(u.mem.texasKilled, true); assert.equal(sk.activations, 4, 'new deployment and first-kill recast, including synchronous burst reentry'); assert.deepEqual(life.slice(lifeAt), [['skillStart', 'deploy'], ['skillEnd', 'recast'], ['skillStart', 'kill']]); assert.equal(u.findBuff('texas2:swordplay'), null); approx(sk.timeLeft, sk.duration); if (index === 2) { const target = h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(sk.duration + 0.1); const waves = new Set(dmgBy(h, u, tagged('swordRain')).filter((c) => c.target === target).map((c) => c.t)); assert.equal(waves.size, sk.duration, 'reentrant deployment keeps a full set of rain waves'); const n = dmgBy(h, u, tagged('swordRain')).length; h.run(2); assert.equal(dmgBy(h, u, tagged('swordRain')).length, n, 'rain stops at skill end'); assert.ok(target.alive); } checkInvariants(h.b); } }); test('缄默德克萨斯 module none: no ATK +10 % when alone (EXE-Y default has it)', () => { const has = (m) => { const h = battle([U('chess_char_4_16_b', 10, 4, null, m)]); h.run(0.5); return !!h.unit('chess_char_4_16_b').findBuff('texas2:module'); }; assert.equal(has(null), true); assert.equal(has('none'), false); assert.equal(has('uniequip_003_texas2'), false); }); test('星熊 S1 战意 (TAKE_DAMAGE: DEF/ATK up, no thorns) and S3 力之锯 (ATK/DEF up, cuts every enemy in front)', () => { for (const id of pair('chess_char_4_17_a')) { const b1 = D(id, 0).skill.bb, b3 = D(id, 2).skill.bb; let h = battle([U(id, 10, 4, 0)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 1 }) } }); h.step(); let u = h.unit(id); assert.equal(u.skill.rule, 'TAKE_DAMAGE'); u.skill.gainSp(1000); h.spawn('enemy_hitter', { pos: [10, 4] }); assert.ok(h.runUntil(() => u.skill.active, 5), `${id}: armed by a hit`); approx(u.s.def, u.base.def * (1 + b1.def + (u.findBuff('hsguma:def') ? D(id, 0).talents[1].bb.def : 0)), 1e-6, 'DEF'); approx(u.s.atk, u.base.atk * (1 + b1.atk), 1e-6, 'ATK'); h.run(3); assert.equal(dmgBy(h, u, tagged('counter')).length, 0, 'thorns belong to S2 only'); h = battle([U(id, 10, 4, 2)]); h.step(); u = h.unit(id); const es = [h.spawn('enemy_dummy', { pos: [10, 5] }), h.spawn('enemy_dummy', { pos: [10, 5] }), h.spawn('enemy_dummy', { pos: [10, 5] })]; assert.ok(u.skill.activate('test', { free: true })); approx(u.s.atk, u.base.atk * (1 + b3.atk), 1e-6, 'S3 ATK'); assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u), 5)); assert.equal(h.hooksOf('attack').find((c) => c.attacker === u).targets.length, es.length, 'all enemies in front'); } }); test('星熊 module PRO-X 护身符: DEF +20 % while blocking', () => { const def = (m, block) => { const h = battle([U('chess_char_4_17_b', 10, 4, null, m)]); h.step(); const u = h.unit('chess_char_4_17_b'); if (block) h.spawn('enemy_dummy', { pos: [10, 4] }); h.run(0.5); return u.s.def / u.base.def; }; const tb = C.chess_char_4_17_b.modules.find((m) => m.uniEquipId === 'uniequip_003_hsguma').traitOverride.bb; approx(def('uniequip_003_hsguma', true) - def('uniequip_003_hsguma', false), tb.def, 1e-9); approx(def(null, true), def(null, false), 1e-9); }); test('泥岩 S1 防御力强化·γ型 (TAKE_DAMAGE) and S3 秽壤的血脉 (闭锁: invulnerable, no attack, blocks nobody — the enemies she held walk on —, slow; then stun, buffs, hits all blocked)', () => { for (const id of pair('chess_char_4_18_a')) { const b1 = D(id, 0).skill.bb, sk = D(id, 2).skill, b3 = sk.bb; let h = battle([U(id, 10, 4, 0)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 1 }) } }); h.step(); let u = h.unit(id); assert.equal(u.skill.rule, 'TAKE_DAMAGE'); u.skill.gainSp(1000); h.spawn('enemy_hitter', { pos: [10, 4] }); assert.ok(h.runUntil(() => u.skill.active, 5)); approx(u.s.def, u.base.def * (1 + b1.def), 1e-6); h = battle([U(id, 9, 5, 2)], { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e8, atk: 500, speed: 1, bat: 1 }) } }); for (let i = 0; i < 3; i++) h.spawn('e_walk', { routeIndex: 0 }); u = h.unit(id); assert.ok(h.runUntil(() => u.blocking.length === 3, 30), 'blocks three walkers'); const walkers = u.blocking.slice(); const hp0 = u.hp; assert.ok(u.skill.activate('test', { free: true })); const t0 = h.b.time; h.step(); // 闭锁 (PRTS 备注 "实际将会进入闭锁状态"; PRTS 异常效果 闭锁 = 强制缴械 + 无敌 + 不可阻挡, 不可阻挡 "无法阻挡/被阻挡,自动解除阻挡") assert.ok(u.blocking.length === 0 && walkers.every((e) => e.blockedBy !== u), `${id}: 闭锁 lets go of the enemies she held`); assert.ok(walkers.every((e) => e.findBuff(`mudrok:slow:${u.id}`)), 'enemies around slowed'); const x0 = walkers.map((e) => e.x); h.run(1); assert.ok(walkers.every((e, i) => e.removed || e.x < x0[i] - 0.05), `${id}: they walk on`); h.run(b3.sleep - 2.5); assert.equal(u.hp >= hp0 - 1e-6, true, `${id}: no damage while dormant`); assert.equal(u.blocking.length, 0, `${id}: blocks nobody while dormant`); assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length, 0, 'cannot act'); const near = [0, 1, 2].map(() => h.spawn('enemy_dummy', { pos: [9, 5] })); // on her tile before she wakes h.step(); assert.equal(u.blocking.length, 0, `${id}: not blocked before she wakes`); assert.ok(h.runUntil(() => !!u.findBuff('mudrok:awake'), 2), 'awake'); h.run(0.2); for (const e of near) assert.ok(e.s.flags.stun, 'ground enemies around stunned'); approx(u.s.atk, u.base.atk * (1 + b3.atk) * (u.findBuff('mudrok:module') ? 1 + (u.def.traitBb.atk ?? 0) : 1), 1e-6, 'ATK'); approx(u.s.interval, (u.base.bat + b3.base_attack_time) * 100 / u.s.aspd, 1e-6, 'BAT −0.3 s'); assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.t > t0), 5)); assert.equal(h.hooksOf('attack').find((c) => c.attacker === u && c.t > t0).targets.length, 3, 'attacks every blocked enemy'); assert.ok(h.runUntil(() => u.blocking.length === 3, 4), `${id}: she blocks again once awake (their stun over)`); h.runUntil(() => !u.skill.active, sk.duration); assert.ok(!u.findBuff('mudrok:awake')); checkInvariants(h.b); } }); test('泥岩 module UNY-Y 沃土的愿景: ATK/DEF +8 % with no ally on the 8 tiles around', () => { const buffed = (m, near) => { const h = battle([U('chess_char_4_18_b', 10, 4, null, m), ...(near ? [U('chess_char_1_01_a', 11, 5)] : [])]); h.run(0.5); return !!h.unit('chess_char_4_18_b').findBuff('mudrok:module'); }; assert.deepEqual([buffed('uniequip_003_mudrok', false), buffed('uniequip_003_mudrok', true), buffed(null, false)], [true, false, false]); }); // --------------------------------------------------------------------------------------------------------------- // 焰尾 / 远牙 / 白面鸮 / 银灰 test('焰尾 S1 迅敏直觉: +6 DP and the next physical attack on her is dodged (feeding 前锋剑术)', () => { for (const id of pair('chess_char_4_19_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 }, enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 1 }) } }); h.step(); const u = h.unit(id); const dp0 = h.b.getPlayer('p1').dp; assert.ok(u.skill.activate('test', { free: true })); assert.equal(h.b.getPlayer('p1').dp, dp0 + bb.cost, `${id}: +${bb.cost} DP`); assert.ok(u.findBuff('flamtl:evade')); h.spawn('enemy_hitter', { pos: [10, 4] }); assert.ok(h.runUntil(() => h.hooksOf('dodge').some((c) => c.target === u), 5), 'dodged'); assert.ok(!u.findBuff('flamtl:evade'), 'only the next attack'); assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source?.side === 'enemy').length, 0, 'no damage from the dodged hit'); assert.ok(u.mem.riposte || u.mem.riposteNow || dmgBy(h, u, tagged('riposte')).length > 0, '前锋剑术 armed'); } }); 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('chess_char_4_19_a')) { const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 } }); h.step(); const u = h.unit(id); assert.equal(u.skill.rule, 'SP_FULL'); const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery; assert.ok(h.runUntil(() => u.skill.activations === 1, 40), `${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})`); } }); test('焰尾 S2 “红松林”: +11/12 DP; ≤ 6 enemies around: 2 × atk_scale phys + stun; allies around +40 %/45 % phys dodge 10 s', () => { for (const id of pair('chess_char_4_19_a')) { const bb = D(id, 1).skill.bb; const h = battle([U(id, 10, 5, 1), U('chess_char_1_01_a', 10, 4)], { flags: { dpPerSec: 0 } }); h.step(); const u = h.unit(id), ally = h.unit('chess_char_1_01_a'); for (let i = 0; i < 7; i++) h.spawn('enemy_dummy', { pos: [10, 6] }); const dp0 = h.b.getPlayer('p1').dp; assert.ok(u.skill.activate('test', { free: true })); assert.equal(h.b.getPlayer('p1').dp, dp0 + bb.cost); const hits = dmgBy(h, u, tagged('redPine')); const victims = new Set(hits.map((c) => c.target)); assert.equal(victims.size, bb.max_target, `${id}: ≤ ${bb.max_target} enemies`); assert.equal(hits.length, 2 * bb.max_target, 'two hits each'); for (const c of hits) approx(c.amount, u.s.atk * bb.atk_scale, 1e-6); for (const e of victims) assert.ok(e.s.flags.stun); for (const a of [u, ally]) { const b = a.findBuff('flamtl:redPine'); assert.ok(b, `${a.name} dodge buff`); approx(b.mods.dodgePhys, bb['flamtl_s_2.prob'], 1e-9); approx(b.timeLeft, bb['flamtl_s_2.duration'], 1e-6); } } }); test('焰尾 module SOL-X 她们的未来: ATK/DEF +8 % while blocking', () => { const on = (m, block) => { const h = battle([U('chess_char_4_19_b', 10, 4, null, m)]); h.step(); if (block) h.spawn('enemy_dummy', { pos: [10, 4] }); h.run(0.5); return !!h.unit('chess_char_4_19_b').findBuff('flamtl:module'); }; assert.deepEqual([on('uniequip_003_flamtl', true), on('uniequip_003_flamtl', false), on(null, true)], [true, false, false]); }); test('远牙 S1 迅捷打击·γ型 / S2 同盟支援: taunt −1 with any skill (屏息); S2 reaches enemies blocked anywhere on the field', () => { for (const id of pair('chess_char_4_20_a')) { const b1 = D(id, 0).skill.bb, b2 = D(id, 1).skill.bb; let h = battle([U(id, 12, 3, 0)]); h.step(); let u = h.unit(id); assert.ok(u.skill.activate('test', { free: true })); approx(u.s.atk, u.base.atk * (1 + b1.atk + (u.findBuff('fartth:focus') ? D(id, 0).talents[0].bb.atk : 0)), 1e-6, 'S1 ATK (+ 凝神)'); assert.equal(u.s.aspd, u.base.aspd + b1.attack_speed); assert.equal(u.s.taunt, -1, '屏息'); h = battle([U(id, 12, 3, 1), U('chess_char_4_17_a', 9, 9)], { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e8, atk: 10, speed: 1, bat: 5 }) } }); u = h.unit(id); h.spawn('e_walk', { routeIndex: 0 }); const e = h.enemies()[0]; assert.ok(h.runUntil(() => e.blockedBy && e.blockedBy.defId === 'chess_char_4_17_a', 20), 'blocked by 星熊 far away'); h.run(2); assert.equal(dmgBy(h, u, (c) => c.target === e).length, 0, 'out of her range without S2'); assert.ok(u.skill.activate('test', { free: true })); assert.equal(u.s.aspd, u.base.aspd + b2.attack_speed); assert.equal(u.s.taunt, -1); h.run(4); assert.ok(dmgBy(h, u, (c) => c.target === e).length > 0, `${id}: S2 hits the blocked enemy`); h.runUntil(() => !u.skill.active, 40); assert.equal(u.extraRangeKeys, null, 'extra range cleared'); } }); test('远牙 S2 同盟支援: an UNBLOCKED enemy on a blocked enemy\'s tile outside her range is never a target', () => { for (const id of pair('chess_char_4_20_a')) { const h = battle([U(id, 12, 3, 1), U('chess_char_4_17_a', 9, 9)], { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e8, atk: 10, def: 800, speed: 1, bat: 5 }), e_fly: dummy({ key: 'e_fly', motion: 'FLY', def: 0 }), } }); const u = h.unit(id); h.spawn('e_walk', { routeIndex: 0 }); const e = h.enemies()[0]; assert.ok(h.runUntil(() => e.blockedBy && e.blockedBy.defId === 'chess_char_4_17_a', 20), 'blocked by 星熊 far away'); const fly = h.spawn('e_fly', { pos: [Math.round(e.y), Math.round(e.x)] }); assert.ok(fly.isFlying && !fly.blockedBy, 'setup: unblocked flyer (lower DEF: her lowDef priority would pick it)'); assert.ok(u.skill.activate('test', { free: true })); h.run(4); assert.ok(dmgBy(h, u, (c) => c.target === e).length > 0, `${id}: S2 hits the blocked enemy`); assert.equal(dmgBy(h, u, (c) => c.target === fly).length, 0, `${id}: not the unblocked flyer on its tile`); checkInvariants(h.b); } }); test('远牙 module DEA-Y 支持者来信: +1 SP whenever the attacked enemy survives', () => { const gains = (m) => { const h = battle([U('chess_char_4_20_b', 10, 3, null, m)]); h.step(); h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(8); return h.hooksOf('spGain').filter((c) => c.reason === 'module').length; }; assert.ok(gains('uniequip_003_fartth') >= 2); assert.equal(gains(null), 0); }); test('远牙 module DEA-Y: the hit that defeats its target gives no SP (未被击倒时 only; `damaged` precedes the kill)', () => { const id = 'chess_char_4_20_b'; const h = battle([U(id, 10, 3, null, 'uniequip_003_fartth')], { enemies: { enemy_weak: dummy({ key: 'enemy_weak', hp: 1 }) } }); h.step(); const u = h.unit(id); const weak = [h.spawn('enemy_weak', { pos: [10, 5] }), h.spawn('enemy_weak', { pos: [10, 6] })]; assert.ok(h.runUntil(() => weak.every((e) => !e.alive), 10), 'both one-HP enemies fall'); assert.ok(dmgBy(h, u, (c) => c.dmg?.isAttack && weak.includes(c.target)).length >= 2, 'killed by her attacks'); assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module').length, 0, 'no SP for a killing hit'); checkInvariants(h.b); }); test('白面鸮 S1 治疗强化·γ型: ATK up while healing; module none: no range bonus', () => { for (const id of pair('chess_char_4_21_a')) { const d = D(id, 0); const h = battle([U(id, 10, 3, 0), U('chess_char_4_17_a', 10, 4)]); h.step(); const u = h.unit(id), hs = h.unit('chess_char_4_17_a'); hs.hp = hs.s.maxHp * 0.3; u.skill.gainSp(1000); assert.ok(h.runUntil(() => u.skill.active, 5), 'heal skill fires on an injured ally'); approx(u.s.atk, u.base.atk * (1 + d.skill.bb.atk), 1e-6); } const n = (m) => { const h = battle([U('chess_char_4_21_b', 10, 3, null, m)]); h.step(); return h.unit('chess_char_4_21_b').rangeKeys.length; }; assert.ok(n(null) > n('none')); }); test('银灰 S1 强力击·γ型: every 4th attack deals atk_scale; S2 雪境生存法则: toggled on for good — small range, DEF up, HP regen', () => { for (const id of pair('chess_char_4_22_a')) { const b1 = D(id, 0).skill.bb, s2 = D(id, 1).skill; let h = battle([U(id, 10, 4, 0, gold(id) === id ? 'none' : null)]); h.step(); let u = h.unit(id); h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(12); const atks = dmgBy(h, u, (c) => c.dmg.isAttack); const sk = atks.filter((c) => c.dmg.isSkill); assert.ok(sk.length >= 2, `${id}: S1 fired`); for (const c of sk) approx(c.amount, u.s.atk * b1.atk_scale, 1e-6); for (const c of atks.filter((x) => !x.dmg.isSkill)) approx(c.amount, u.s.atk, 1e-6); const i1 = atks.indexOf(sk[0]), i2 = atks.indexOf(sk[1]); assert.equal(i2 - i1, D(id, 0).skill.spCost + 1, 'cost-3 attack SP: every 4th attack'); h = battle([U(id, 10, 4, 1)]); h.step(); u = h.unit(id); const n0 = u.rangeKeys.length; h.spawn('enemy_dummy', { pos: [10, 5] }); u.skill.gainSp(1000); assert.ok(h.runUntil(() => u.skill.active, 5)); assert.equal(u.skill.kind, 'toggle'); assert.equal(u.rangeKeys.length, s2.rangeGrid.length, 'smaller range'); assert.ok(n0 > u.rangeKeys.length); approx(u.s.def, u.base.def * (1 + s2.bb.def), 1e-6); approx(u.s.hpRegen, u.s.maxHp * s2.bb.hp_recovery_per_sec_by_max_hp_ratio, 1e-6); h.run(60); assert.ok(u.skill.active, 'stays on'); } }); test('银灰 module none: no 10 % ATK arts rider on attacks (LOR-X default has it)', () => { const rider = (m) => { const h = battle([U('chess_char_4_22_b', 10, 4, 1, m)]); h.step(); h.spawn('enemy_dummy', { pos: [10, 5] }); h.run(4); return dmgBy(h, h.unit('chess_char_4_22_b'), tagged('module')).length; }; assert.ok(rider(null) > 0); assert.equal(rider('none'), 0); }); // --------------------------------------------------------------------------------------------------------------- // 百炼嘉维尔 / 卡涅利安 / 魔王 / 华法琳 test('百炼嘉维尔 S1 精准痛击: ATK up, heals herself heal_scale of the damage dealt (× 医学背景); no S3 damage deferral', () => { for (const id of pair('chess_char_4_23_a')) { const d = D(id, 0), bb = d.skill.bb, t1 = d.talents[1].bb; const h = battle([U(id, 10, 4, 0)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 400, bat: 1 }) } }); h.step(); const u = h.unit(id); const e = h.spawn('enemy_dummy', { pos: [10, 4] }); assert.ok(h.runUntil(() => e.blockedBy === u, 2)); assert.ok(u.skill.activate('test', { free: true })); u.hp = u.s.maxHp * 0.3; const hp0 = u.hp, t0 = h.b.time; assert.ok(h.runUntil(() => h.hooksOf('attack').some((c) => c.attacker === u && c.t >= t0), 5)); const dealt = dmgBy(h, u, (c) => c.dmg.isAttack && c.t >= t0).reduce((a, c) => a + c.amount, 0); assert.ok(dealt > 0); approx(u.hp - hp0, dealt * bb.heal_scale * t1.heal_scale_2, 1e-6, `${id}: self heal`); const hit = h.spawn('enemy_hitter', { pos: [10, 4] }); h.run(2); assert.equal(u.mem.gvialDebt ?? 0, 0, 'no S3 deferral'); assert.ok(hit.alive); } }); test('百炼嘉维尔 S2 链锯强袭: wider range, ATK/DEF up, unblocked enemies it hits are pulled to her front', () => { for (const id of pair('chess_char_4_23_a')) { const bb = D(id, 1).skill.bb; const h = battle([U(id, 10, 4, 1)]); h.step(); const u = h.unit(id); const e = h.spawn('enemy_dummy', { pos: [10, 6] }); h.run(0.2); assert.equal(dmgBy(h, u).length, 0, 'out of the normal range'); assert.ok(u.skill.activate('test', { free: true })); approx(u.s.atk, u.base.atk * (1 + bb.atk + D(id, 1).talents[0].bb.atk), 1e-6, 'ATK (+ 战地巨斧)'); assert.ok(h.runUntil(() => dmgBy(h, u).length > 0, 5), `${id}: reaches 2 tiles ahead`); assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`); } }); test('百炼嘉维尔 module CEN-Y 好锯多磨: −20 % physical damage taken above 50 % HP', () => { const taken = (m, ratio) => { const h = battle([U('chess_char_4_23_b', 10, 4, 0, m)], { enemies: { enemy_hitter: dummy({ key: 'enemy_hitter', atk: 3000, bat: 1 }) } }); h.step(); const u = h.unit('chess_char_4_23_b'); u.hp = u.s.maxHp * ratio; h.spawn('enemy_hitter', { pos: [10, 4] }); h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === u && c.amount > 0), 5); return h.hooksOf('damaged').find((c) => c.target === u && c.amount > 0).amount; }; const tb = C.chess_char_4_23_b.modules.find((m) => m.uniEquipId === 'uniequip_003_gvial2').traitOverride.bb; approx(taken('uniequip_003_gvial2', 0.9) / taken('uniequip_003_gvial2', 0.4), 1 - tb.damage_resistance, 1e-6, 'reduced above half HP'); approx(taken(null, 0.9), taken(null, 0.4), 1e-6, 'default CEN-X: no reduction'); }); test('卡涅利安 S1 沙暴守卫: SEARCH; ATK/DEF up; charged keeps the trait guard; 生命之餐 heals 40 %/80 %', () => { for (const id of pair('chess_char_4_24_a')) { const d = D(id, 0), bb = d.skill.bb, t0 = d.talents[0].bb; for (const charged of [false, true]) { const h = battle([U(id, 10, 4, 0, gold(id) === id ? 'none' : null)]); h.step(); const u = h.unit(id); assert.equal(u.skill.rule, 'SEARCH'); u.hp = u.s.maxHp * 0.1; u.skill.addCharge(charged ? 2 : 1); assert.ok(u.skill.activate('test')); assert.equal(!!u.findBuff('billro:s1guard'), charged, `${id}: guard kept iff charged`); const guard = charged ? u.profile.guardDef : 0; approx(u.s.def, u.base.def * (1 + bb.def + guard), 1e-6, 'DEF'); approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, 'ATK'); approx(u.hp, u.s.maxHp * (0.1 + (charged ? t0['billro_t_1[enhance].heal_scale'] : t0.heal_scale)), 1e-6, '生命之餐'); h.runUntil(() => !u.skill.active, 30); assert.ok(!u.findBuff('billro:s1guard')); } } }); test('卡涅利安 S3 食噬之印: wider range, ATK ramps to +atk over the skill; charged hits mark (+20 %/mark, ≤ 5) until it ends', () => { for (const id of pair('chess_char_4_24_a')) { const sk = D(id, 2).skill, bb = sk.bb; const h = battle([U(id, 10, 4, 2, gold(id) === id ? 'none' : null)]); h.step(); const u = h.unit(id); const n0 = u.rangeKeys.length; const e = h.spawn('enemy_dummy', { pos: [10, 5] }); u.skill.addCharge(2); assert.ok(u.skill.activate('test')); u.skill.charges = 0; // (the spare charge would re-cast right after the end) u.skill.sp = 0; assert.ok(u.rangeKeys.length > n0); h.run(sk.duration / 2); approx(u.findBuff('billro:s3atk').mods.atkPct, bb.atk * 0.5, 0.02, `${id}: half-way ramp`); const m = e.findBuff('billro:mark'); assert.ok(m && m.stacks === 5 && m.source === u, `${id}: marks capped at 5 (${m?.stacks})`); const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e); const last = hits[hits.length - 1]; approx(last.amount / (u.s.atk), 1 + bb['attack@damage_scale'] * 5, 0.05, '+100 % with 5 marks'); h.runUntil(() => !u.skill.active, sk.duration); assert.ok(!e.findBuff('billro:mark'), 'marks end with the skill'); assert.ok(!u.findBuff('billro:s3atk')); } }); test('卡涅利安 module PLX-Y 乡音: +3 % damage per enemy in range (≤ 5); PLX-X keeps part of the guard during any skill', () => { const ratio = (m) => { const h = battle([U('chess_char_4_24_b', 10, 4, 1, m)]); h.step(); const u = h.unit('chess_char_4_24_b'); for (let i = 0; i < 3; i++) h.spawn('enemy_dummy', { pos: [10, 5] }); u.skill.addCharge(1); assert.ok(u.skill.activate('test')); h.runUntil(() => dmgBy(h, u, (c) => c.dmg.isAttack).length > 0, 5); const c = dmgBy(h, u, (c) => c.dmg.isAttack)[0]; return { r: c.amount / u.s.atk, keep: !!u.findBuff('billro:keep') }; }; const mb = C.chess_char_4_24_b.modules.find((m) => m.uniEquipId === 'uniequip_003_billro').talentChanges[0].bb; const y = ratio('uniequip_003_billro'), x = ratio(null), none = ratio('none'); approx(y.r, 1 + mb.damage_scale * 3, 1e-6, 'three enemies'); approx(none.r, 1, 1e-6); assert.deepEqual([x.keep, y.keep, none.keep], [true, false, false]); const s1keep = (charges) => { const h = battle([U('chess_char_4_24_b', 10, 4, 0)]); h.step(); const u = h.unit('chess_char_4_24_b'); u.skill.addCharge(charges); u.skill.activate('test'); return !!u.findBuff('billro:keep'); }; assert.equal(s1keep(1), true, 'uncharged S1 keeps the PLX-X part'); assert.equal(s1keep(2), false, 'charged S1 keeps the whole guard instead'); }); test('魔王 S1 往昔萦绕身旁: SP_FULL toggle — trait ratio raised for good, motes come back after talent_cool_down', () => { for (const id of pair('chess_char_4_25_a')) { const bb = D(id, 0).skill.bb; const h = battle([U(id, 10, 4, 0), U('chess_char_4_17_a', 10, 5)]); h.step(); const u = h.unit(id), hs = h.unit('chess_char_4_17_a'); assert.equal(u.skill.rule, 'SP_FULL'); u.skill.gainSp(1000); h.step(); assert.ok(u.skill.active && u.skill.kind === 'toggle'); assert.equal(u.profile.auraRatio, bb['attack@atk_to_hp_recovery_ratio']); hs.hp = hs.s.maxHp * 0.5; h.run(0.5); const t = Math.max(...u.mem.motes.filter((x) => Number.isFinite(x))); assert.ok(Number.isFinite(t), 'a mote touched the operator'); assert.ok(t - h.b.time <= bb.talent_cool_down + 1e-6, `${id}: faster mote respawn`); h.run(40); assert.ok(u.skill.active, 'duration unlimited'); } }); test('魔王 S2 明日渺远不及: 6 motes that hit enemies within 2 tiles (true damage + bind), not operators; 鼓舞 ATK to allies in range', () => { for (const id of pair('chess_char_4_25_a')) { const bb = D(id, 1).skill.bb; const h = battle([U(id, 10, 4, 1), U('chess_char_4_17_a', 10, 5)]); h.step(); const u = h.unit(id), hs = h.unit('chess_char_4_17_a'); const es = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [11, 5] })]; assert.ok(u.skill.activate('test', { free: true })); assert.equal(u.mem.motes.length, D(id, 1).talents[0].bb.cnt + 3, '上限+3'); h.run(1); const hits = dmgBy(h, u, tagged('mote')); assert.ok(es.every((e) => hits.some((c) => c.target === e)), `${id}: both enemies hit`); for (const c of hits) { assert.equal(c.type, 'true'); approx(c.amount, u.s.atk * bb.atk_scale, 1e-6); } for (const e of es) assert.ok(e.s.flags.bind, 'bound'); assert.ok(!hs.findBuff(`cetsyr:mote:${u.id}`), 'motes no longer touch operators'); const insp = hs.findBuff('inspire'); assert.ok(insp, '鼓舞'); approx(insp.data.val, u.s.atk * bb['attack@atk'], 1e-6); // 鼓舞 is added on top of the target's ATK multipliers: an ATK +100 % buff does not double it h.b.addBuff(hs, { key: 'test:atkUp', mods: { atkPct: 1 } }); h.step(); const { add, mul } = aggregateMods(hs.buffs.filter((x) => x.key !== 'inspire')); const own = (hs.base.atk + (add.atkFlat ?? 0)) * (1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1); approx(hs.s.atk, own + u.s.atk * bb['attack@atk'], 1e-6, `${id}: final ATK = own ATK + 鼓舞`); h.b.removeBuff(hs, 'test:atkUp'); h.runUntil(() => !u.skill.active, 40); assert.equal(u.mem.motes.length, D(id, 1).talents[0].bb.cnt, 'cap back to normal'); } }); test('魔王 鼓舞: strongest source wins (never summed), a bard never receives it (自身不受鼓舞影响); S3 HP 鼓舞 on top of HP%', () => { const [na, el] = pair('chess_char_4_25_a'); const h = battle([U(na, 10, 4, 1), U(el, 10, 5, 1), U('chess_char_4_17_a', 11, 4)]); h.step(); const a = h.unit(na), b = h.unit(el), hs = h.unit('chess_char_4_17_a'); assert.ok(a.skill.activate('test', { free: true }) && b.skill.activate('test', { free: true })); h.run(0.5); assert.ok(!a.findBuff('inspire') && !b.findBuff('inspire'), 'bards get no 鼓舞 from each other'); const inRangeOfBoth = h.b.alliesInGrid(a).includes(hs) && h.b.alliesInGrid(b).includes(hs); assert.ok(inRangeOfBoth, 'setup: 星熊 inside both ranges'); const va = a.s.atk * D(na, 1).skill.bb['attack@atk'], vb = b.s.atk * D(el, 1).skill.bb['attack@atk']; assert.equal(hs.buffs.filter((x) => x.key === 'inspire' || x.key.startsWith('cetsyr:inspire')).length, 1, 'one 鼓舞 buff'); approx(hs.findBuff('inspire').data.val, Math.max(va, vb), 1e-6, 'the stronger one'); // S3 (default): max-HP 鼓舞 is not scaled by the target's HP% buffs const h3 = battle([U(el, 10, 4), U('chess_char_4_17_a', 10, 5)]); h3.step(); const m = h3.unit(el), t = h3.unit('chess_char_4_17_a'); h3.b.addBuff(t, { key: 'test:hpUp', mods: { hpPct: 0.5 } }); assert.ok(m.skill.activate('test', { free: true })); h3.run(0.3); const { add, mul } = aggregateMods(t.buffs.filter((x) => x.key !== 'inspire:hp')); approx(t.s.maxHp, (t.base.maxHp + (add.hpFlat ?? 0)) * (1 + (add.hpPct ?? 0)) * (mul.hpMul ?? 1) + m.s.maxHp * D(el).skill.bb.max_hp, 1e-6, 'max HP + 鼓舞'); checkInvariants(h.b); }); test('华法琳 S2 不稳定血浆: she and one random ally in range: ATK up, 3 % max HP lost per second for 15 s; no S1 bandage', () => { for (const id of pair('chess_char_4_26_a')) { const bb = D(id, 1).skill.bb; const h = battle([U(id, 10, 3, 1), U('chess_char_4_17_a', 10, 4)]); h.step(); const u = h.unit(id), hs = h.unit('chess_char_4_17_a'); hs.hp = hs.s.maxHp * 0.4; u.skill.gainSp(1000); assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'fires on an injured ally in range'); for (const a of [u, hs]) { const b = a.findBuff('bldsk:plasma'); assert.ok(b, `${a.name}: plasma`); approx(b.mods.atkPct, bb.atk, 1e-9); } h.run(3.05); const loss = h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('plasma')); assert.equal(loss.length, 3, `${id}: one loss per second`); for (const c of loss) approx(c.amount, u.s.maxHp * bb.hp_ratio, 1e-6); assert.equal(u.mem.bandage ?? null, null, 'S1 logic not installed'); } }); test('module none on single-module elites removes the module effect (寒芒克洛丝 / 阿罗玛 / 魔王 / 华法琳)', () => { const unit = (id, m, extra = []) => { const h = battle([U(id, 10, 3, null, m), ...extra]); h.run(0.5); return { h, u: h.unit(id) }; }; // 寒芒克洛丝 MAR-X: 攻击空中单位时攻击力提升至110% assert.equal(unit('chess_char_4_06_b', null).u.profile.flyScale, C.chess_char_4_06_b.trait.bb.atk_scale); assert.equal(unit('chess_char_4_06_b', 'none').u.profile.flyScale, 1); // 阿罗玛 BLA-X: farther targets take more damage const blast = (m) => { const { h, u } = unit('chess_char_4_10_b', m); const e = h.spawn('enemy_dummy', { pos: [10, 7] }); h.run(6); const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e).map((c) => c.amount / u.s.atk); return Math.min(...hits); }; assert.ok(blast(null) > blast('none') + 0.05, 'distance bonus only with the module'); approx(blast('none'), 1, 1e-6); // 魔王 BAR-X: ATK +8 % with ≥ 2 other operators in range const ops = [U('chess_char_4_17_a', 10, 4), U('chess_char_1_01_a', 11, 3)]; assert.ok(unit('chess_char_4_25_b', null, ops).u.findBuff('cetsyr:module')); assert.ok(!unit('chess_char_4_25_b', 'none', ops).u.findBuff('cetsyr:module')); // 华法琳 PHY-X: heals on allies below 50 % HP +15 % const heal = (m) => { const { h, u } = unit('chess_char_4_26_b', m, [U('chess_char_4_17_a', 10, 4)]); const hs = h.unit('chess_char_4_17_a'); hs.hp = hs.s.maxHp * 0.2; u.skill.charges = 0; u.skill.sp = 0; const n0 = h.hooksOf('heal').length; h.runUntil(() => h.hooksOf('heal').slice(n0).some((c) => c.source === u && c.target === hs), 5); return h.hooksOf('heal').slice(n0).find((c) => c.source === u && c.target === hs).amount / u.s.atk; }; approx(heal(null) / heal('none'), C.chess_char_4_26_b.trait.bb.heal_scale, 1e-6); });