Files
ark-stronghold-protocol/test/content/op_saga.test.js
T
sganggs c1bf395207 feedback5 O8: 自选 operator kits 闪灵, 夜莺 (+ her 幻影) and 嵯峨 — every skill, talent, trait and module at every form
- op-shining.js 闪灵 (医师): S1 信条; S2 自动掩护 — the next heal gives its target a 屏障 of atk_scale × ATK for duration s
  (every damage type) and DEF +def while one of her barriers holds (PRTS 备注: barriers add up, own timers, older first, the
  DEF bonus does not stack); S3 教条力场 — allies in range DEF +def %; T1 黑恶魔的庇护 (allies in range DEF +60; PHY-Y stage
  3: +100, 地面 units +40 more); T2 法典 (ASPD +10; PHY-X stage 3: +15, ATK +25 % with S2, SP +0.6/s with S3); PHY-Y heals on
  地面 units ×1.15, PHY-X heals on allies below 50 % ×1.15.
- op-cgbird.js 夜莺 (群愈师): S1 治疗强化·γ型; S2 法术护盾 — every target of the next heal gets an arts-only 屏障
  (shieldType 'arts') and RES +flat; S3 圣域 — range y-4 (data ACTIVE_RANGE), allies in it RES ×(1 + v) and arts dodge;
  T1 白恶魔的庇护 (RES +15; RIN-X stage 3: healing received ×1.05); RIN-X range = its module grid, RIN-Y 4 heals; T2 幻影:
  its own talent (30 % / RIN-Y stage 3 40 % physical dodge, 3 % max HP 流失 per second), her stock (+2 / RIN-Y stage 3 +3
  per deployment, at most 3 — PRTS 备注), a fallen board 幻影 back on its tile after its redeploy time using one and its DP.
- op-saga.js 嵯峨 (尖兵): S1 冲锋号令·γ型 (SP_FULL); S2 除恶 — +4 DP, up to 6 ground enemies of the x-6 cross (重伤 ones
  included), the ones still 重伤 0.7 s later killed by her (PRTS 备注); S3 怒目 — +1 DP/s (20), +0.5 s interval, range +1,
  every blocked enemy, one more attack on a target below half HP; T1 劝善 — her damage is never lethal, an enemy at ≤ 1 HP
  becomes 重伤 (PRTS 术语释义: move −80 %, 禁用近战, 禁疗, no regeneration, a forced 无来源 death 10 s later, none while 消失),
  its killer +2 SP, she never selects a 重伤 enemy (profile skipEnemy); SOL-X stage 3 +15 % damage below half HP; T2 清明
  (once per deployment below 40 %: 70 % physical dodge, 生命回复速度 5 % / SOL-Y stage 3 6.5 % — its blackboard — for 15 / 18 s);
  SOL-X block ATK / DEF +8 %; SOL-Y's 首次部署 cost cut has no battle effect.
- kits/index.js OPERATOR_KIT_FILES: the three files, one line each.
Golden (diy only; every other family unchanged): diy-003…008 are new — 闪灵, 夜莺 and 嵯峨 in every form × module × skill at
tiers 5 and 6; the prototype battles moved from diy-003…006 to diy-009…012 (same pieces; their seed follows the index);
diy-001 / 002 (推进之王) unchanged.
Tests: test/content/op_shining.test.js (9), op_cgbird.test.js (10), op_saga.test.js (15).
2026-10-05 23:58:00 +08:00

380 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/content/op_saga.test.js — the 自选 operator kit of 嵯峨 (char_362_saga, 6★ 尖兵; kit
// server/sim/content/kits/ops/op-saga.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, SOL-Y 身向云泥 or
// SOL-X 何处寻驮? at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity
// checklist of kits/README.md item by item (劝善's 重伤 per PRTS 术语释义 重伤 / the talent and S2 备注).
// Run: node --test test/content/op_saga.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';
import { DYING, S2_CHECK, isDying } from '../../server/sim/content/kits/ops/op-saga.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 SAGA = 'char_362_saga';
const FORMS = BACKUPS.units[SAGA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SOLY = 'uniequip_002_saga', SOLX = 'uniequip_003_saga';
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_saga_2', S3 = 'skchr_saga_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);
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_weak: dummy('enemy_weak', { hp: 100 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
// a regenerating enemy (data/enemies.json has two: 200 HP/s for enemy_1061_zomshd)
ENEMIES.enemy_regen = { ...dummy('enemy_regen', { hp: 100 }), stats: { ...dummy('enemy_regen', { hp: 100 }).stats, hpRecoveryPerSec: 200 } };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOLY, SOLX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 嵯峨 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, dp = 0 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'skillStart', 'skillEnd', 'kill', 'death', 'spGain', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SAGA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy');
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('嵯峨 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 2, melee ground-only, no 特质', () => {
assert.equal(OPERATOR_KITS[SAGA], KITS[SAGA]);
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], [SAGA, 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 + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, typeof u.profile.skipEnemy], [2, 'melee', 'phys', false, 1.05, 'function'], `${label(f)}: 尖兵`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${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 1609 / 434 / 297, E2 Lv60 2004 / 498 / 347; SOL-Y +75 / +45 / +15 → +125 / +55 / +20, SOL-X +50 / +28 → +70 / +44
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], [1609, 434, 2004, 498]);
assert.deepEqual([modOf(5, SOLY).attr, modOf(6, SOLY).attr, modOf(5, SOLX).attr, modOf(6, SOLX).attr],
[{ maxHp: 75, atk: 45, def: 15 }, { maxHp: 125, atk: 55, def: 20 }, { atk: 50, def: 28 }, { atk: 70, def: 44 }]);
});
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(SAGA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SAGA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } } });
});
test('melee, ground-only: no attack on a flyer in her range; a ground enemy there is attacked (physical, 100 % ATK)', () => {
const { h, u } = field({ tier: 5, skill: 0 });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(3);
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'flyer untouched');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === e), 3));
const c = hitsBy(h, u).find((x) => x.target === e);
assert.deepEqual([c.type, c.dmg.isAttack], ['phys', true]);
approx(c.amount, u.s.atk, '100 % ATK');
done(h);
});
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (SP_FULL); 41 / 38 SP from 12 / 14', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, mod: elite ? SOLX : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.cost], ['SP_FULL', elite ? 38 : 41, elite ? 14 : 12, 12], `T${tier}`);
h.b.players[0].dp = 0;
h.run(sk.spCost - sk.initSp - 0.5);
assert.equal(u.skill.activations, 0, `T${tier}: not before`);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: at full SP, no enemy`);
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
done(h);
}
});
test('S2 除恶 (MANUAL, data SKILL_RANGE on x-6, 2 charges): +4 DP; the cross\'s ground enemies take 280 % / 320 % ATK physical — at most 6, flyers and other tiles untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod: elite ? SOLY : null, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.rangeId, sk.bb.atk_scale, sk.bb.cost, sk.spCost, sk.initSp], ['SKILL_RANGE', 2, 'x-6', elite ? 3.2 : 2.8, 4, elite ? 16 : 18, elite ? 10 : 9], `T${tier}`);
assert.match(sk.desc, /最多6名地面敌人/);
const cross = [[12, 5], [11, 5], [10, 3], [10, 4], [10, 6], [10, 7], [9, 5]];
const ground = cross.map((pos) => h.spawn('enemy_dummy', { pos }));
const fly = h.spawn('enemy_fly', { pos: [8, 5] });
const diag = h.spawn('enemy_dummy', { pos: [11, 6] });
h.b.players[0].dp = 0;
const n0 = hitsBy(h, u).length;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast (enemies in the cross)`);
const hit = hitsBy(h, u, n0).filter((c) => c.dmg.tags?.includes('saga:s2'));
assert.equal(hit.length, 6, `T${tier}: at most 6`);
assert.ok(hit.every((c) => ground.includes(c.target)), `T${tier}: ground enemies of the cross only`);
assert.equal(new Set(hit.map((c) => c.target)).size, 6, `T${tier}: six different`);
for (const c of hit) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.isSkill, c.dmg.isAttack], ['phys', true, false], `T${tier}: a physical skill damage`);
}
assert.ok(!hit.some((c) => c.target === fly || c.target === diag), `T${tier}: flyer / diagonal untouched`);
approx(h.b.players[0].dp, 4, `T${tier}: +4 DP`);
assert.equal(u.skill.charges, 1, `T${tier}: one charge left`);
done(h);
}
});
test('S2 除恶: its trigger counts any enemy in the cross (a flyer too, 无视其不可选中) but hits ground ones only', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const fly = h.spawn('enemy_fly', { pos: [9, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'cast for a flyer alone in the cross');
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'but not hit');
done(h);
});
test('S2 除恶: a 重伤 enemy is selected (可选择重伤单位) and every hit enemy still 重伤 0.7 s later is killed by her (killer 嵯峨, +2 SP each)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const sk = skillOf(6, true, S2);
const weak = h.spawn('enemy_weak', { pos: [10, 7] }); // in the cross, outside her 1-1
const front = h.spawn('enemy_weak', { pos: [10, 6] }); // in her 1-1
assert.ok(h.runUntil(() => isDying(front), 3), 'her attack: 重伤');
assert.deepEqual([front.hp, weak.hp], [1, 100]);
// 除恶 comes by itself once a charge is ready (SKILL_RANGE: enemies in the cross)
assert.ok(h.runUntil(() => u.skill.activations === 1, sk.spCost - sk.initSp + 1), 'cast');
const tc = h.b.time;
assert.ok(hitsBy(h, u).some((c) => c.target === front && c.dmg.tags?.includes('saga:s2')), '可选择重伤单位');
assert.ok(isDying(weak) && weak.hp === 1, 'the weak one: 1 HP, 重伤');
h.run(S2_CHECK - 0.1);
assert.ok(weak.alive && front.alive, 'not before 0.7 s');
assert.ok(h.runUntil(() => !weak.alive && !front.alive, 0.2), 'both killed at 0.7 s');
const kills = h.hooksOf('kill').filter((c) => c.victim === weak || c.victim === front);
assert.ok(kills.length === 2 && kills.every((c) => c.killer === u), 'killer: 嵯峨');
assert.equal(u.stats.kills, 2);
const talentSp = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent' && c.t >= tc).reduce((n, c) => n + c.amount, 0);
approx(talentSp, 4, '2 × 2 SP');
done(h);
});
test('S3 怒目 (MANUAL, data ACTIVE_RANGE on 1-1 + 1): 20 s, ATK +70 % / +100 %, interval 1.05 + 0.5 s, range +1, every blocked enemy at once, +20 DP over the skill; all back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, row: 9, col: 5 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.ability_range_forward_extend, sk.bb.cost, sk.bb.interval, sk.bb.value, sk.bb['attack@hp_ratio']],
['ACTIVE_RANGE', 20, elite ? 1 : 0.7, 0.5, 1, 1, 1, 20, 0.5], `T${tier}`);
assert.deepEqual(sk.trigger.customRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: her 1-1 + 1`);
u.skill.gainSp(999);
const far = h.spawn('enemy_dummy', { pos: [9, 7] }); // two tiles ahead: the running range only
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy 2 tiles ahead`);
h.b.players[0].dp = 0;
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: range +1`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 1.55, `T${tier}: +0.5 s`);
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === far), 3), `T${tier}: reaches 2 tiles ahead`);
h.b.kill(far, null);
// two walkers blocked: one attack hits both
h.spawn('enemy_walk', { routeIndex: 0 });
h.spawn('enemy_walk', { routeIndex: 0, pos: [9, 9] });
assert.ok(h.runUntil(() => u.blocking.length === 2, 30), `T${tier}: blocking two`);
const n0 = h.hooksOf('attack').length;
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 3));
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
assert.equal(at.targets.length, 2, `T${tier}: every blocked enemy`);
h.runUntil(() => !u.skill.active, 25);
approx(h.b.players[0].dp, 20, `T${tier}: +20 DP in all`, 1e-6);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
approx(u.s.interval, 1.05, `T${tier}: interval back`);
done(h);
}
});
test('S3 怒目: DP comes gradually (1 per second) and one more attack follows each attack on a target below half HP (checked before the hit), none above', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
const low = h.spawn('enemy_dummy', { pos: [10, 6] });
low.hp = low.s.maxHp * 0.4;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1));
h.b.players[0].dp = 0;
h.run(5.5);
approx(h.b.players[0].dp, 5, '5 DP after 5.5 s', 1e-6);
// hits on the target, grouped by attack: an extra attack lands one tick after its attack
const groups = (hits) => {
const out = [];
for (const c of hits) { const g = out.at(-1); if (g && c.t - g.at(-1).t < 0.1) g.push(c); else out.push([c]); }
return out.map((g) => g.length);
};
const n0 = hitsBy(h, u).length;
h.run(6);
const low6 = groups(hitsBy(h, u, n0).filter((c) => c.target === low && c.dmg.isAttack));
assert.ok(low6.length >= 3, `${low6.length} attacks in 6 s (1.55 s apart)`);
assert.ok(low6.slice(0, -1).every((n) => n === 2), `every attack followed by one more: ${low6}`);
// above half: no extra attack
h.b.kill(low, null);
const high = h.spawn('enemy_dummy', { pos: [10, 6] });
high.hp = high.s.maxHp * 0.6;
const n1 = hitsBy(h, u).length;
h.run(5);
const high5 = groups(hitsBy(h, u, n1).filter((c) => c.target === high && c.dmg.isAttack));
assert.ok(high5.length >= 2 && high5.every((n) => n === 1), `one hit per attack: ${high5}`);
done(h);
});
test('T1 劝善: her damage leaves an enemy at 1 HP and 重伤 (move ×0.2, unblockable, 禁疗, no regeneration); she never attacks it; it dies 10 s later (无来源, no SP); a 重伤 enemy takes no second 重伤', () => {
for (const f of [[5, false, null], [6, true, SOLY]]) {
const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 2 });
const t0 = BACKUPS.units[SAGA].forms[f[1] ? '2/60/7/3' : '2/1/4/0'].talents[0].bb;
assert.deepEqual(t0, { move_speed: -0.8, sp: 2, interval: 10 });
const e = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(e), 3), `${label(f)}: 重伤`);
const at = h.b.time;
assert.deepEqual([e.alive, e.hp], [true, 1], `${label(f)}: 1 HP left`);
const d = e.findBuff(DYING);
assert.equal(d.source, u);
approx(d.mods.moveMul, 0.2, `${label(f)}: move −80 %`);
assert.equal(d.mods.hpRegen, -1e9, `${label(f)}: regeneration zeroed`);
assert.deepEqual(d.flags, { unblockable: true, healFree: true, noHeal: true }, `${label(f)}: 禁用近战 / 禁疗`);
approx(e.s.moveSpeed, e.base.moveSpeed * 0.2, `${label(f)}: move`);
assert.ok(e.s.hpRegen <= 0, `${label(f)}: no regeneration (a cancelling hpRegen)`);
assert.equal(h.b.heal(e, e, 50, { self: true }), 0, `${label(f)}: 禁疗`);
// she never attacks it again
const n0 = hitsBy(h, u).length, a0 = u.stats.attacks;
h.run(4);
assert.equal(hitsBy(h, u, n0).length, 0, `${label(f)}: no damage of hers`);
assert.equal(u.stats.attacks, a0, `${label(f)}: no attack`);
// no second 重伤
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
assert.equal(e.findBuff(DYING), d, `${label(f)}: the same 重伤`);
assert.equal(e.hp, 1, `${label(f)}: still not lethal`);
// 10 s: a forced, 无来源 death
const sp0 = u.skill.sp;
h.run(at + 10 - h.b.time - 0.1);
assert.ok(e.alive, `${label(f)}: alive before 10 s`);
assert.ok(h.runUntil(() => !e.alive, 0.2), `${label(f)}: dead at 10 s`);
const k = h.hooksOf('kill').find((c) => c.victim === e);
assert.deepEqual([k.killer, e.removeReason], [null, 'killed'], `${label(f)}: 无来源 kill`);
assert.equal(h.b.killed, 1, `${label(f)}: counted`);
assert.equal(h.hooksOf('spGain').filter((c) => c.reason === 'talent').length, 0, `${label(f)}: no SP for it`);
assert.ok(u.skill.sp >= sp0, `${label(f)}: (time SP only)`);
done(h);
}
});
test('T1 劝善: a 重伤 enemy regenerates nothing (生命回复速度归零) — one with 200 HP/s stays at 1 HP', () => {
const { h, u } = field({ tier: 5, skill: 2 });
const e = h.spawn('enemy_regen', { pos: [10, 6] });
assert.equal(e.s.hpRegen, 200, 'it regenerates');
assert.ok(h.runUntil(() => isDying(e), 3));
h.run(3);
assert.deepEqual([e.alive, e.hp], [true, 1], 'no HP back while 重伤');
assert.equal(hitsBy(h, u).filter((c) => c.target === e).length, 1, 'one hit of hers, then none');
done(h);
});
test('T1 劝善: whoever kills a 重伤 enemy gets 2 SP; 重伤 never kills while 消失 and kills at once when it comes back', () => {
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
const { h } = field({ tier: 5, skill: 2, others });
const texas = h.unit(2);
const e = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(e), 3));
const sp0 = texas.skill.sp;
h.b.dealDamage(texas, e, { amount: 1e6, type: 'true' });
assert.ok(!e.alive, 'another operator\'s damage kills it');
approx(texas.skill.sp - sp0, 2, '+2 SP to 德克萨斯');
// 消失
const g = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(g), 3));
h.step();
g.hidden = true;
h.run(12);
assert.ok(g.alive, 'no natural death while 消失');
g.hidden = false;
h.step();
assert.ok(!g.alive, 'dead the moment it appears');
assert.equal(h.hooksOf('kill').find((c) => c.victim === g).killer, null);
done(h);
});
test('T1 劝善 SOL-X stage 3: her damage on a target below half HP ×1.15 (stage 1 and the other forms: none)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const x3 = elite && tier === 6 && mod === SOLX;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const hitAt = (ratio) => {
e.hp = e.s.maxHp * ratio;
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u, n0).length > 0, 3), label(f));
return hitsBy(h, u, n0)[0].amount;
};
const lo = hitAt(0.4), hi = hitAt(0.6);
approx(lo / hi, x3 ? 1.15 : 1, `${label(f)}: below / above half`);
if (x3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /对生命值低于一半的单位造成的伤害提升15%/);
done(h);
}
});
test('T2 清明: once per deployment, the first time she drops below 40 %: 70 % physical dodge and 5 % max HP/s for 15 s; SOL-Y stage 3: 6.5 % (its blackboard) for 18 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 12, col: 9 });
const y3 = elite && tier === 6 && mod === SOLY;
const tb = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual(tb, { hp_ratio: 0.4, hp_recovery_per_sec_by_max_hp_ratio: y3 ? 0.065 : 0.05, prob: 0.7, duration: y3 ? 18 : 15 }, label(f));
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.55, type: 'true' });
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: at 45 % nothing`);
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.1, type: 'true' });
const b = u.findBuff('talent:saga:qingming');
assert.ok(b, `${label(f)}: below 40 %`);
assert.deepEqual(b.mods, { dodgePhys: 0.7, hpRegenRatio: y3 ? 0.065 : 0.05 }, `${label(f)}: mods`);
approx(b.duration, y3 ? 18 : 15, `${label(f)}: duration`);
approx(u.s.dodgePhys, 0.7, `${label(f)}: dodge`);
const hp0 = u.hp;
h.run(2.01);
approx(u.hp - hp0, 2 * (y3 ? 0.065 : 0.05) * u.s.maxHp, `${label(f)}: regeneration`, 0.02);
h.run(b.duration);
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: over`);
u.hp = u.s.maxHp;
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: only once`);
h.b.retreat(u);
h.b.redeploy(u, { free: true });
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
assert.ok(u.findBuff('talent:saga:qingming'), `${label(f)}: again after a new deployment`);
done(h);
}
});
test('SOL-X 何处寻驮? trait: ATK / DEF +8 % while she blocks an enemy (stages 1 and 3); none without it; SOL-Y\'s 首次部署时部署费用-4 changes nothing in battle', () => {
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 solx = elite && mod === SOLX;
if (solx) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
h.run(1);
assert.equal(u.findBuff('trait:saga:block'), null, `${label(f)}: nothing blocked`);
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:saga:block'), solx, `${label(f)}: blocking`);
if (solx) assert.deepEqual(u.findBuff('trait:saga:block').mods, { atkPct: 0.08, defPct: 0.08 });
if (elite && mod === SOLY) {
assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb.runtime_cost).filter((v) => v != null), [-4], `${label(f)}: the data carries it`);
assert.equal(u.base.cost, formOf(tier, true).stats.cost, `${label(f)}: no cost change`);
}
done(h);
}
});