Files
ark-stronghold-protocol/test/content/op_huang.test.js
T
sganggs 29fa988604 feedback5 O5: 自选 operator kits for 左乐, 重岳 and 煌 — every skill at ranks 4 / 7, both talents, the trait and both modules at stages 1 / 3, from the data, PRTS 备注, the client's buff templates and the battle skeletons' clip events
- 左乐 (op-zuole.js): S1 next attack + 1 / 2 extra strikes below 80 / 50 % HP (each a damage instance and a trait heal); S2 流失 50 % current HP (never below 1), a barrier of scale × max HP stacking to 2×, ATK / block +1 / every blocked enemy, −200 per second once over; S3 7 slashes on ≤ 3 enemies of 3-2, the last ×2 + stun, the trait's heal turned into a barrier (70 × 2.5 / 3 per damage instance, ≤ 2× max HP, 15 s); 坚忍 ASPD / SP recovery; 守正自明 per damage instance (SBL-Y Lv3 ×1.2 on a success); SBL-X 庇护 (behind the barrier once 行险 resets it — PRTS S2 备注 — through the hpDamage hook); SBL-Y revive at 30 % once per deployment.
- 重岳 (op-chyue.js): 止戈 marks (normal-attack instances, ×1.65 / ×1.75 on all his damage); 万象为宾 SP at each skill / segment end, 阻回 ignored (FGT-X Lv3 4, else 1); S1 charges with the full-stack burst of 3; S2 x-4 ≤ 4 enemies (air too) + 浮空 2 s on the marked, the second segment 0.5 s later (skeleton events) on every 浮空 enemy; S3 strike + 0.8 splash, the 5th cast transforms him (x-6, double hits, +1 SP per attack, self-cast, the strike twice); FGT-X dodge, FGT-Y ASPD.
- 煌 (op-huang.js): CEN-X ×1.1 vs blocked enemies on every damage; CEN-Y −20 % physical above 50 %; 紧急除颤 不死 + heal + the 50 % HP floor through the hpDamage hook (CEN-Y Lv3: 50 % then 25 %, 8 s, DEF −150 above 50 %); 严酷训练 抵抗 after 15 s (CEN-X Lv3 ATK +6 % / ASPD +12 after 30 / 45 s); S1 next attack; S2 SP_FULL toggle with 2-2; S3 a 10 s 维持技能状态: cuts each second for 8 s, ramp to full at 8.95 s, at 9 s 流失 25 % max HP and the 3-6 burst (air too).
- kits/index.js OPERATOR_KIT_FILES lists the three files; test/content/diy.test.js's "owned 6★ without a kit" case takes the first kitless operator of diy.ownedPool (it named 煌).
- Golden: only test/golden/diy.json moved — the diy family packs the new kits: diy-003 / 004 field 左乐, diy-005 / 006 重岳, diy-007 / 008 煌 (tiers 5 / 6, every form × module × skill, no battle error); the prototypes and reserves moved from diy-003 … 006 to diy-009 … 012 (new ids, same packing); diy-001 / 002 (推进之王) and every other family unchanged.
- Tests: test/content/op_zuole.test.js (10), op_chyue.test.js (8), op_huang.test.js (10).
2026-10-06 00:13:05 +08:00

359 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_huang.test.js — the 自选 operator kit of 煌 (char_017_huang, 6★ 强攻手; kit
// server/sim/content/kits/ops/op-huang.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, CEN-X
// “过载的武器与过热的心脏” or CEN-Y “稍后回来” 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_huang.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 HUANG = 'char_017_huang';
const FORMS = BACKUPS.units[HUANG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CENX = 'uniequip_002_huang', CENY = 'uniequip_003_huang';
const S1 = 'skchr_huang_1', S2 = 'skchr_huang_2', S3 = 'skchr_huang_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_hitter: dummy('enemy_hitter', { atk: 400, bat: 1 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CENX, CENY].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 = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HUANG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').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 range, blocks 3 and strikes every blocked enemy, melee ground-only, 炎 + 维多利亚, no 特质', () => {
assert.equal(OPERATOR_KITS[HUANG], KITS[HUANG]);
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], [HUANG, 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.hitAllBlocked, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, true, 'melee', false, 'phys', 1.2], `${label(f)}: 强攻手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip', 'victoriaShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2115 / 581 / 303, E2 Lv60 2583 / 703 / 347; CEN-X +240 / +50 → +285 / +86
// HP / ATK, CEN-Y +300 / +40 → +345 / +66
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], [2115, 581, 2583, 703]);
assert.deepEqual([modOf(5, CENX).attr, modOf(6, CENX).attr, modOf(5, CENY).attr, modOf(6, CENY).attr], [{ maxHp: 240, atk: 50 }, { maxHp: 285, atk: 86 }, { maxHp: 300, atk: 40 }, { maxHp: 345, atk: 66 }]);
});
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(HUANG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HUANG), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: HUANG, skillIndex: 2, uniEquipId: CENY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: HUANG, skillIndex: 2, uniEquipId: CENY } } });
});
test('S1 强力击·γ型 (AUTO, attack SP 3, data DEFAULT): her 4th attack is the skill one — 205 % / 225 % ATK on every enemy she blocks', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0, row: 9 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, elite ? 2.25 : 2.05], `T${tier}`);
const foes = [0, 1, 2].map(() => h.spawn('enemy_dummy', { pos: [9, 5] }));
h.step();
assert.equal(u.blocking.length, 3, `T${tier}: blocks all three`);
assert.ok(h.runUntil(() => u.skill.activations === 1, 10), `T${tier}: cast`);
h.step();
const attacks = [...new Set(hitsBy(h, u).map((c) => c.dmg.attackId))];
assert.equal(attacks.length, 4, `T${tier}: the 4th attack (3 SP, then the skill attack)`);
const last = hitsBy(h, u).filter((c) => c.dmg.attackId === attacks[3]);
assert.deepEqual(last.map((c) => c.target).sort((a, b) => a.id - b.id), foes.sort((a, b) => a.id - b.id), `T${tier}: every blocked enemy`);
for (const c of last) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]);
}
for (const c of hitsBy(h, u).filter((c) => c.dmg.attackId !== attacks[3])) approx(c.amount, u.s.atk, `T${tier}: plain attacks`);
done(h);
}
});
test('S2 链锯延伸模块 (AUTO, 持续时间无限): on as soon as its 85 / 80 SP are full — no enemy needed —, ATK +55 % / +70 %, DEF +15 % / +20 %, range 2-2, never ends', () => {
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.kind, u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.atk, sk.bb.def, sk.duration], ['toggle', 'SP_FULL', elite ? 80 : 85, 0, elite ? 0.7 : 0.55, elite ? 0.2 : 0.15, -1], `T${tier}`);
h.run(sk.spCost - 1);
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: on at full SP, no enemy on the field`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 2-2`);
assert.deepEqual(sk.rangeGrid, [[0, 0], [0, 1], [0, 2]]);
const far = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(3);
assert.ok(hitsBy(h, u).some((c) => c.target === far), `T${tier}: strikes two tiles ahead`);
h.run(300);
assert.ok(u.skill.active, `T${tier}: still on (持续时间无限)`);
done(h);
}
});
test('S3 沸腾爆裂 (data DEFAULT, 维持技能状态, 10 s): no normal attack; a cut every second for 8 s on the ground enemies of her 1-1 (not air), ATK / DEF ramp to +45 % / +60 % (full at 8.95 s); at 9 s −25 % max HP (never below 1) and 320 % / 340 % on the 3 × 3 ahead, air too', () => {
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, u.skill.kind, sk.duration, sk.bb.atk, sk.bb.def, sk.bb.hp_ratio, sk.bb.damage_by_atk_scale, sk.rangeGrid],
['DEFAULT', 'duration', 10, elite ? 0.6 : 0.45, elite ? 0.6 : 0.45, 0.25, elite ? 3.4 : 3.2, null], `T${tier}`);
const front = h.spawn('enemy_dummy', { pos: [10, 6] });
const own = h.spawn('enemy_dummy', { pos: [10, 5] });
const flyIn = h.spawn('enemy_fly', { pos: [10, 6] });
const side = h.spawn('enemy_dummy', { pos: [9, 6] }); // 3-6, not 1-1
const ahead2 = h.spawn('enemy_dummy', { pos: [10, 7] }); // 3-6, not 1-1
const flySide = h.spawn('enemy_fly', { pos: [11, 7] }); // 3-6 (air)
const outside = h.spawn('enemy_dummy', { pos: [10, 8] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in her range`);
const t0 = h.b.time;
approx(u.s.atk, u.base.atk, `T${tier}: no bonus before the first update`);
const at = [], hits = [];
let ended = false, lossAt = null;
h.b.on('damaged', (c) => { if (c.target === u && c.dmg?.tags?.includes('hpLoss')) lossAt = h.b.time - t0; });
h.b.on('skillEnd', (c) => { if (c.unit === u) ended = true; });
h.b.on('damaged', (c) => {
if (c.source !== u || c.target?.side !== 'enemy' || ended) return;
hits.push(c);
if (c.target === front) at.push({ t: h.b.time - t0, amt: c.amount, atk: u.s.atk, def: u.s.def });
});
h.runUntil(() => !u.skill.active, 12);
const span = h.b.time - t0;
assert.ok(span > 9.9 && span < 10.1, `T${tier}: the state lasts its 10 s (${span})`);
approx(lossAt, 9, `T${tier}: the finish at 9 s`, 0.05);
approx(at[8].t, 9, `T${tier}: the burst at 9 s`, 0.05);
assert.ok(hits.every((c) => c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: no normal attack while it runs`);
const cutsOn = (e) => hits.filter((c) => c.target === e).length;
assert.deepEqual([cutsOn(front), cutsOn(own)], [9, 9], `T${tier}: 8 cuts + the burst on her 1-1`);
assert.deepEqual([cutsOn(flyIn), cutsOn(side), cutsOn(ahead2), cutsOn(flySide), cutsOn(outside)], [1, 1, 1, 1, 0], `T${tier}: the burst alone on the 3 × 3 (air too); nothing outside`);
const D = sk.duration - 1.05;
for (let k = 1; k <= 8; k++) {
const c = at[k - 1];
approx(c.t, k, `T${tier}: cut ${k} at ${k} s`, 0.05);
approx(c.atk, u.base.atk * (1 + sk.bb.atk * Math.min(1, k / D)), `T${tier}: ATK at cut ${k}`);
approx(c.def, u.base.def * (1 + sk.bb.def * Math.min(1, k / D)), `T${tier}: DEF at cut ${k}`);
approx(c.amt, c.atk, `T${tier}: cut ${k} = 100 % ATK`);
}
approx(at[8].amt, u.base.atk * (1 + sk.bb.atk) * sk.bb.damage_by_atk_scale, `T${tier}: the burst at full ATK`);
const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hpLoss'));
assert.equal(loss.length, 1, `T${tier}: one 流失`);
approx(loss[0].amount, u.s.maxHp * 0.25, `T${tier}: 25 % of MAX HP`);
assert.equal(u.findBuff('skill:huang:ramp'), null, `T${tier}: the bonus goes with the skill`);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her range throughout`);
done(h);
}
});
test('S3 沸腾爆裂: the 流失 never takes her below 1 HP; a 晕眩 ends the 维持技能状态 early — it still finishes (流失 + burst)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'DEFAULT: no enemy, no cast');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3));
h.run(3.2);
const n0 = hitsBy(h, u).filter((c) => c.target === e).length;
assert.equal(n0, 3, 'three cuts');
h.b.applyStatus(u, 'stun', { duration: 2, source: e });
h.step();
assert.ok(!u.skill.active, 'the stun ended it');
const after = hitsBy(h, u).filter((c) => c.target === e);
assert.equal(after.length, 4, 'the burst on its end');
approx(after[3].amount, u.base.atk * (1 + 0.6 * 3 / 8.95) * 3.4, 'the burst at the ATK of that moment (three updates)');
done(h);
// the non-lethal 流失: at 10 % HP the skill end leaves her at 1 HP (her 紧急除颤 then fires: HP ≤ 25 %)
const r = field({ tier: 5, elite: false, skill: 2 });
r.h.spawn('enemy_dummy', { pos: [10, 6] });
r.u.mem.huangDefib.undeadA = false; // talent already spent: only the 流失's own floor
r.u.hp = r.u.s.maxHp * 0.1;
r.u.skill.gainSp(999);
assert.ok(r.h.runUntil(() => r.u.skill.active, 3));
r.h.runUntil(() => !r.u.skill.active, 12);
assert.ok(r.u.alive && Math.abs(r.u.hp - 1) < 1e-6, `left at 1 HP (${r.u.hp})`);
done(r.h);
});
test('T1 紧急除颤: 不死 until her HP first reaches ≤ 25 % — then +50 % max HP and for 6 s no damage takes her below 50 % (a 流失 still does); once per deployment, again after a redeploy', () => {
for (const f of [[5, false, null], [6, true, CENX], [5, true, CENY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.hp_ratio, t0['huang_t_1[heal].hp_ratio'], t0['huang_t_1[lock].min_hp_ratio'], t0['huang_t_1[lock].duration']], [0.25, 0.5, 0.5, 6], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const max = u.s.maxHp;
// a lethal hit from full HP: 不死 keeps her at 1 HP, the talent heals 50 %
h.b.dealDamage(e, u, { amount: max * 5, type: 'true' });
assert.ok(u.alive, `${label(f)}: 不死`);
approx(u.hp, 1 + max * 0.5, `${label(f)}: 1 HP + 50 %`);
const lockStart = h.b.time;
// inside 6 s: a 60 % hit stops at exactly 50 %
h.b.dealDamage(e, u, { amount: max * 0.6, type: 'true' });
approx(u.hp, max * 0.5, `${label(f)}: held at 50 %`);
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' });
approx(u.hp, max * 0.5, `${label(f)}: below 50 % nothing passes`);
h.b.loseHp(u, max * 0.1, { source: e });
approx(u.hp, max * 0.4, `${label(f)}: a 流失 is not held`);
h.run(6 - (h.b.time - lockStart) + 0.1);
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' });
approx(u.hp, max * 0.1, `${label(f)}: the lock is over`);
h.b.dealDamage(e, u, { amount: max, type: 'true' });
assert.ok(!u.alive, `${label(f)}: spent — the next lethal hit kills`);
// a redeploy re-arms it
h.b.redeploy(u);
h.step();
h.b.dealDamage(e, u, { amount: u.s.maxHp * 0.8, type: 'true' });
approx(u.hp, u.s.maxHp * 0.7, `${label(f)}: again after the redeploy (20 % + 50 %)`);
done(h);
}
});
test('T2 严酷训练: 15 s after each deployment 抵抗 (control statuses last half as long); CEN-X stage 3 adds ATK +6 % after 30 s and ASPD +12 after 45 s (stage 1: nothing more)', () => {
for (const f of [[5, false, null], [5, true, CENX], [6, true, CENX], [6, true, CENY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const x3 = tier === 6 && mod === CENX;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(14.8);
assert.equal(h.b.resistOf(u), 0, `${label(f)}: no 抵抗 before 15 s`);
h.run(0.4);
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 at 15 s`);
h.b.applyStatus(u, 'stun', { duration: 2, source: e });
approx(u.findBuff('stun').timeLeft, 1, `${label(f)}: a 2 s stun lasts 1 s`);
h.run(15);
assert.equal(!!u.findBuff('talent:huang:atk'), x3, `${label(f)}: ATK +6 % after 30 s`);
if (x3) approx(u.s.atk, u.base.atk * 1.06, 'ATK +6 %');
h.run(15);
assert.equal(!!u.findBuff('talent:huang:aspd'), x3, `${label(f)}: ASPD +12 after 45 s`);
if (x3) assert.equal(u.s.aspd, 112);
// per deployment: knocked out and back, the timer starts again
h.b.kill(u, e);
assert.ok(!u.alive);
h.b.redeploy(u);
h.step();
assert.deepEqual([h.b.resistOf(u), !!u.findBuff('talent:huang:atk')], [0, false], `${label(f)}: reset by the redeploy`);
h.run(15.2);
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 again 15 s later`);
done(h);
}
});
test('CEN-X (stages 1 and 3): ×1.1 ATK scale on every damage of hers to a blocked enemy (normal attacks and S3 cuts) — none to an unblocked one; none without the module', () => {
for (const f of [[5, true, null], [5, true, CENX], [6, true, CENX], [6, true, CENY]]) {
const [tier, elite, mod] = f;
const mul = mod === CENX ? 1.1 : 1;
if (mod === CENX) assert.deepEqual(formOf(tier, true).modules.find((m) => m.uniEquipId === CENX).traitOverride.bb, { atk_scale: 1.1 });
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9 });
const blocked = h.spawn('enemy_dummy', { pos: [9, 5] });
h.step();
assert.equal(blocked.blockedBy, u, `${label(f)}: blocked`);
h.run(3);
const plain = hitsBy(h, u).filter((c) => c.target === blocked && !c.dmg.isSkill);
assert.ok(plain.length >= 2, label(f));
for (const c of plain) approx(c.amount, u.s.atk * mul, `${label(f)}: blocked target`);
const free = h.spawn('enemy_dummy', { pos: [9, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
h.run(1.1);
const cut = hitsBy(h, u).filter((c) => c.dmg.isSkill);
approx(cut.find((c) => c.target === blocked).amount, u.s.atk * mul, `${label(f)}: S3 cut on the blocked one`);
approx(cut.find((c) => c.target === free).amount, u.s.atk, `${label(f)}: S3 cut on the free one`);
done(h);
}
});
test('CEN-Y stage 1: −20 % physical damage taken above 50 % HP (arts untouched); stage 3: 紧急除颤 at 50 % then a second trigger at 25 % (its own 不死, 8 s), and 150 DEF ignored while above 50 %', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: CENY, skill: 0 });
assert.deepEqual(u.def.raw.trait.bb, { hp_ratio: 0.5, damage_resistance: 0.2 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
assert.equal(u.s.defIgnoreFlat, tier === 6 ? 150 : 0, `T${tier}: 150 DEF ignored above 50 % from stage 2 on`);
const max = u.s.maxHp;
let hp = u.hp;
h.b.dealDamage(e, u, { amount: 1000, type: 'phys' });
approx(hp - u.hp, Math.max(1000 - u.s.def, 50) * 0.8, `T${tier}: physical ×0.8`);
hp = u.hp;
h.b.dealDamage(e, u, { amount: 300, type: 'arts' });
approx(hp - u.hp, 300, `T${tier}: arts untouched`);
hp = u.hp;
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
approx(hp - u.hp, 200, `T${tier}: true untouched`);
u.mem.huangDefib.undeadA = u.mem.huangDefib.undeadB = false; // (stage 3's talent would fire at 50 %)
u.hp = max * 0.5;
hp = u.hp;
h.b.dealDamage(e, u, { amount: 1000, type: 'phys' });
approx(hp - u.hp, Math.max(1000 - u.s.def, 50), `T${tier}: at 50 % (not above) the full physical damage`);
done(h);
}
// stage 3: the talent fires at 50 % (8 s), the hidden part at 25 % — only once the first is spent
const { h, u } = field({ tier: 6, elite: true, mod: CENY, skill: 0 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const hid = Object.assign({}, ...u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb));
assert.deepEqual([t0.hp_ratio, t0['huang_t_1[lock].duration'], hid.def_penetrate_fixed, hid['huang_e_003[lock].check_hp_ratio'], hid['huang_e_003[lock].duration']], [0.5, 8, 150, 0.25, 8]);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
assert.equal(u.s.defIgnoreFlat, 150, 'above 50 %: 150 DEF ignored');
const max = u.s.maxHp;
h.b.dealDamage(e, u, { amount: max * 0.6, type: 'true' }); // 100 % → 40 %: the 50 % trigger
approx(u.hp, max * 0.9, '50 % trigger: +50 %');
h.run(8.2);
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' }); // 90 % → 60 %
h.step();
assert.equal(u.s.defIgnoreFlat, 150);
h.b.dealDamage(e, u, { amount: max * 0.2, type: 'true' }); // 60 % → 40 %: nothing (the first is spent)
approx(u.hp, max * 0.4, 'no second 50 % trigger');
h.step();
assert.equal(u.s.defIgnoreFlat, 0, 'at 40 % the DEF penetration is off');
h.b.dealDamage(e, u, { amount: max * 5, type: 'true' }); // lethal: its own 不死, then the 25 % trigger
assert.ok(u.alive, 'the second 不死');
approx(u.hp, 1 + max * 0.5, '25 % trigger: +50 %');
h.b.dealDamage(e, u, { amount: max * 0.4, type: 'true' });
approx(u.hp, max * 0.5, 'its 8 s lock holds 50 %');
h.run(8.2);
h.b.dealDamage(e, u, { amount: max, type: 'true' });
assert.ok(!u.alive, 'both spent');
done(h);
// a single hit from above 50 % to 10 %: the 50 % trigger first (+50 % ⇒ 60 %), the 25 % one waits for a later drop
const r = field({ tier: 6, elite: true, mod: CENY, skill: 0 });
const e2 = r.h.spawn('enemy_dummy', { pos: [10, 6] });
const m2 = r.u.s.maxHp;
r.h.b.dealDamage(e2, r.u, { amount: m2 * 0.9, type: 'true' });
approx(r.u.hp, m2 * 0.6, 'the 50 % trigger first');
assert.deepEqual([r.u.mem.huangDefib.undeadA, r.u.mem.huangDefib.undeadB], [false, true], 'the 25 % trigger still armed');
done(r.h);
});