359 lines
22 KiB
JavaScript
359 lines
22 KiB
JavaScript
// 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, full potential): E2 Lv1 2115 / 609 / 303, E2 Lv60 2583 / 731 / 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, 609, 2583, 731]);
|
||
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 7 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, 7], 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 7 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(7 - (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 严酷训练: 12 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(11.8);
|
||
assert.equal(h.b.resistOf(u), 0, `${label(f)}: no 抵抗 before 12 s`);
|
||
h.run(0.4);
|
||
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 at 12 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(18);
|
||
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(12.2);
|
||
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 again 12 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 不死, 9 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 % (9 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, 9, 150, 0.25, 9]);
|
||
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(9.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 9 s lock holds 50 %');
|
||
h.run(9.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);
|
||
});
|