Kits server/sim/content/kits/ops/op-nian.js, op-hsgma2.js and op-thumpy.js, appended to OPERATOR_KIT_FILES; tests test/content/op_nian.test.js (10), op_hsgma2.test.js (11), op_thumpy.test.js (12). Kit-local code only: no engine or other shared file changed. - 年: S1 锡灼 (DEF / ATK, arts attacks), S2 铜印 (no attack, DEF, block +1, counter + 3 s silence on every enemy attack), S3 铁御 (ATK; the other allied operators of her x-2: DEF, block +1, 抵抗); 积甲成山 (编入队伍时: her team's 重装 max HP +16 / 21 %), 干明可鉴 (3 护盾 layers per deployment; PRO-X Lv3: ATK / DEF +7 % and +3 SP per broken layer); PRO-X DEF +20 % while blocking, PRO-Y block 4 and at Lv3 max HP / healing received +4 % per 重装 on the field (≤ 3). - 斩业星熊: physical attacks with no skill on, arts while one runs (the data's dmgType is the skill-on one); S1 恶业苦果 (toggle, fired at full SP: an AUTO self buff), S2 无始无明 (the throw, then the 365° shield: PRTS's 1.2 tile start, 90°/s, −80 % near a target, 0.5 s pulses with 停顿 and the heal through 禁疗), S3 地狱变相 (x-2, HP / ATK, 2 hits on ≤ 2, air too; a 主动关闭 keeps it 11 s as the 临死模式 — 4 hits, 阻回 / 沉默 / 不死, allies of her range 不死 with the excess as her 流失, then a withdrawal; no strategy of the mode closes it since 下半); 业火 我执 (negative HP to 200 %, 禁疗, the 5 %/s 生命回复速度 after 5 s quiet), 鬼之架势 坚忍 (AST-X Lv3: full at 50 %, +15 % ATK); AST-X extra / returned 10 % ATK arts. - 珊比: S1 (every blocked enemy struck and pushed radially with 小力), S2 (PRTS's glue order on her tile + 4, 0.7 s pulses of 停顿 then the hit, the 15 % 侵蚀 rider on other operators' physical damage), S3 (block +2, the 4-tile conveyor at 0.8 × 20 / 30 tile/s for unblocked ground enemies of 重量 ≤ 4, 1 s damage on her tile + 4 ahead + blocked, −30 DEF on a 侵蚀 burst there); 探险理论 (元素损伤 taken ×0.85; 10 % ATK 侵蚀 per physical hit, or the burst −1 s), 坚硬脚板 (marks, DEF / ATK stack and one growing barrier ≤ 300 % max HP); PRP-X ×1.15 element damage while blocking. Golden: only test/golden/diy.json moved — diy-049 / 050 now field 年 (tiers 5 / 6), diy-051 斩业星熊 tier 5, diy-052 斩业星熊 tier 6 and 珊比's tier-5 normal entries, diy-053 the rest of 珊比 (every form × module × skill); the prototypes and reserves moved from diy-049 … 052 to diy-054 … 057 (same packing). diy-001 … 048 and the roster, bonds, fields, matches and standins families are unchanged.
291 lines
19 KiB
JavaScript
291 lines
19 KiB
JavaScript
// test/content/op_nian.test.js — the 自选 operator kit of 年 (char_2014_nian, 6★ 铁卫; kit
|
||
// server/sim/content/kits/ops/op-nian.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, PRO-X
|
||
// 混沌阵列 or PRO-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_nian.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 NIAN = 'char_2014_nian';
|
||
const FORMS = BACKUPS.units[NIAN].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const PROX = 'uniequip_002_nian', PROY = 'uniequip_003_nian';
|
||
const S1 = 'skchr_nian_1', S2 = 'skchr_nian_2', S3 = 'skchr_nian_3';
|
||
const YAK = 'chess_char_1_02_a', TEXAS = 'chess_char_1_08_a'; // 角峰 (重装), 德克萨斯 (先锋)
|
||
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: 2000, bat: 1 }), enemy_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 3 }),
|
||
};
|
||
/** Every 自选 form: [tier, elite, module]. */
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PROX, PROY].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'], captureNoisy: true,
|
||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NIAN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||
});
|
||
h.step();
|
||
return { h, u: h.unit(1) };
|
||
}
|
||
const skillBuff = (u) => u.findBuff(`skill:${u.id}`);
|
||
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 (PRO-Y: 4), physical melee ground-only, 炎, no 特质', () => {
|
||
assert.equal(OPERATOR_KITS[NIAN], KITS[NIAN]);
|
||
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], [NIAN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.blockCnt],
|
||
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 3 + (m?.attr.blockCnt ?? 0)], `${label(f)}: stats`);
|
||
assert.equal(u.s.blockCnt, mod === PROY ? 4 : 3, `${label(f)}: 铁卫 blocks 3, “请勿叩门” 4`);
|
||
assert.deepEqual([u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], ['melee', false, 'phys', 1.5], `${label(f)}: 铁卫`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
|
||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
|
||
done(h);
|
||
}
|
||
}
|
||
// the numbers of the forms (zh_CN): E2 Lv1 2737 / 513 / 529, E2 Lv60 3375 / 583 / 660; PRO-X +200 HP / +60 DEF →
|
||
// +375 / +50 / +80, PRO-Y +320 / +35 / +21 / block +1 → +550 / +70 / +30 / block +1
|
||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/1/4/0'].stats.def, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.def], [2737, 513, 529, 3375, 660]);
|
||
assert.deepEqual([modOf(5, PROX).attr, modOf(6, PROX).attr, modOf(5, PROY).attr, modOf(6, PROY).attr],
|
||
[{ maxHp: 200, def: 60 }, { maxHp: 375, atk: 50, def: 80 }, { maxHp: 320, atk: 35, def: 21, blockCnt: 1 }, { maxHp: 550, atk: 70, def: 30, blockCnt: 1 }]);
|
||
});
|
||
|
||
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(NIAN));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NIAN), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: NIAN, skillIndex: 2, uniEquipId: PROY } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: NIAN, skillIndex: 2, uniEquipId: PROY } } });
|
||
});
|
||
|
||
test('triggers: every skill is MANUAL with the data\'s DEFAULT (the owner\'s 重装 cast-in-range exception; rawRule TAKE_DAMAGE) — a ranged hit from outside her 1-1 casts nothing, an enemy on her front tile casts; a flyer there neither draws an attack nor a cast', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
for (const skill of [0, 1, 2]) {
|
||
const sk = formOf(tier, elite).skills[skill];
|
||
assert.deepEqual([sk.skillType, sk.trigger.rule, sk.trigger.rawRule], ['MANUAL', 'DEFAULT', 'TAKE_DAMAGE'], `T${tier} S${skill + 1}: data`);
|
||
const { h, u } = field({ tier, elite, skill });
|
||
assert.equal(u.skill.rule, 'DEFAULT', `T${tier} S${skill + 1}`);
|
||
h.spawn('enemy_shooter', { pos: [10, 8] });
|
||
h.spawn('enemy_fly', { pos: [10, 6] });
|
||
u.skill.gainSp(999);
|
||
h.run(3);
|
||
assert.ok(h.hooksOf('damaged').some((c) => c.target === u && c.source?.defId === 'enemy_shooter'), `T${tier} S${skill + 1}: she is being shot`);
|
||
assert.equal(u.skill.activations, 0, `T${tier} S${skill + 1}: no cast on a hit (no TAKE_DAMAGE) nor for the flyer in her range`);
|
||
assert.equal(u.stats.attacks, 0, `T${tier} S${skill + 1}: ground-only — the flyer on her front tile is no target`);
|
||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier} S${skill + 1}: cast with a ground enemy in her range`);
|
||
done(h);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('S1 锡灼 (23 / 26 s): DEF +40 % / +55 %, ATK +20 % / +30 %, her normal attacks deal arts damage; physical again after', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S1);
|
||
const { h, u } = field({ tier, elite, skill: 0 });
|
||
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.def, sk.bb.atk], [elite ? 26 : 23, 30, 10, elite ? 0.55 : 0.4, elite ? 0.3 : 0.2], `T${tier}`);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||
assert.deepEqual(skillBuff(u).mods, { defPct: sk.bb.def, atkPct: sk.bb.atk }, `T${tier}: the skill buff`);
|
||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
|
||
const n0 = u.stats.attacks;
|
||
h.run(4);
|
||
const mine = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.isAttack);
|
||
assert.ok(u.stats.attacks > n0 && mine.length > 0 && mine.every((c) => c.type === 'arts'), `T${tier}: arts attacks during the skill`);
|
||
approx(mine[mine.length - 1].amount, u.s.atk, `T${tier}: 100 % ATK (RES 0)`);
|
||
u.skill.extend(-999);
|
||
h.run(3);
|
||
assert.ok(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack).slice(-1)[0].type === 'phys', `T${tier}: physical once it ends`);
|
||
assert.equal(skillBuff(u), null, `T${tier}: buff gone`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 铜印 (31 / 32 s): stops attacking; DEF +80 % / +100 %, block +1; every enemy attack she takes returns 60 % / 70 % ATK arts to the attacker (ranged ones too, a direct pick) and silences it 3 s; nothing for a non-attack hit, nothing once it ends', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, PROY]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||
assert.deepEqual([sk.duration, sk.spCost, sk.bb.def, sk.bb.block_cnt, sk.bb.atk_scale, sk.bb.silence], [elite ? 32 : 31, 50, elite ? 1 : 0.8, 1, elite ? 0.7 : 0.6, 3], `T${tier}`);
|
||
const near = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
const far = h.spawn('enemy_shooter', { pos: [10, 8] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||
assert.deepEqual(skillBuff(u).mods, { defPct: sk.bb.def, blockCnt: 1 }, `T${tier}: the skill buff`);
|
||
assert.equal(u.s.blockCnt, (mod === PROY ? 4 : 3) + 1, `T${tier}: block +1`);
|
||
const n0 = u.stats.attacks;
|
||
h.run(3);
|
||
assert.equal(u.stats.attacks, n0, `T${tier}: no attack while it runs`);
|
||
for (const src of [near, far]) {
|
||
const before = h.hooksOf('damaged').length;
|
||
h.b.dealDamage(src, u, { amount: 100, type: 'phys', isAttack: true });
|
||
const back = h.hooksOf('damaged').slice(before).filter((c) => c.source === u && c.target === src);
|
||
assert.equal(back.length, 1, `T${tier}: one counter on ${src.defId}`);
|
||
assert.equal(back[0].type, 'arts', `T${tier}: arts`);
|
||
approx(back[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||
const sil = h.hooksOf('statusApplied').filter((c) => c.source === u && c.target === src && c.status === 'silence').slice(-1)[0];
|
||
assert.ok(sil && Math.abs(sil.duration - 3) < 1e-9, `T${tier}: ${src.defId} silenced 3 s`);
|
||
}
|
||
const before = h.hooksOf('damaged').length;
|
||
h.b.dealDamage(near, u, { amount: 100, type: 'phys' });
|
||
assert.ok(!h.hooksOf('damaged').slice(before).some((c) => c.source === u), `T${tier}: a non-attack hit returns nothing`);
|
||
u.skill.extend(-999);
|
||
h.run(0.1);
|
||
const after = h.hooksOf('damaged').length;
|
||
h.b.dealDamage(near, u, { amount: 100, type: 'phys', isAttack: true });
|
||
assert.ok(!h.hooksOf('damaged').slice(after).some((c) => c.source === u), `T${tier}: no counter once it ended`);
|
||
assert.equal(u.s.blockCnt, mod === PROY ? 4 : 3, `T${tier}: block back`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S3 铁御 (40 s): ATK +65 % / +80 % (her block +0); every other allied operator on her x-2 gets DEF +40 % / +50 %, block +1 and 抵抗 (0.5) while it runs, one outside nothing; all gone after', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.rangeId, sk.bb['nian_s_3[self].atk'], sk.bb['nian_s_3[self].block_cnt'], sk.bb['nian_s_3[ally].def'], sk.bb['nian_s_3[ally].block_cnt'], sk.bb.one_minus_status_resistance],
|
||
[40, 85, elite ? 56 : 48, 'x-2', elite ? 0.8 : 0.65, 0, elite ? 0.5 : 0.4, 1, -0.5], `T${tier}`);
|
||
// 德克萨斯 one row up and one tile ahead (inside x-2), 角峰 two rows up and three tiles ahead (outside)
|
||
const { h, u } = field({ tier, elite, skill: 2, others: [{ uid: 2, chessId: TEXAS, row: 11, col: 6 }, { uid: 3, chessId: YAK, row: 12, col: 8 }] });
|
||
const tex = h.unit(2), yak = h.unit(3);
|
||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb['nian_s_3[self].atk'] }, `T${tier}: her ATK`);
|
||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her attack range stays 1-1 (x-2 is the skill's area)`);
|
||
h.run(1);
|
||
assert.deepEqual(tex.findBuff('skill:nian:ironGuard')?.mods, { defPct: sk.bb['nian_s_3[ally].def'], blockCnt: 1 }, `T${tier}: 德克萨斯 inside x-2`);
|
||
assert.equal(tex.s.blockCnt, tex.base.blockCnt + 1, `T${tier}: his block +1`);
|
||
assert.equal(h.b.resistOf(tex), 0.5, `T${tier}: 抵抗`);
|
||
assert.deepEqual([yak.findBuff('skill:nian:ironGuard'), h.b.resistOf(yak)], [null, 0], `T${tier}: 角峰 outside x-2`);
|
||
assert.equal(u.findBuff('skill:nian:ironGuard'), null, `T${tier}: not herself ("其他")`);
|
||
h.runUntil(() => !u.skill.active, 60);
|
||
assert.equal(tex.findBuff('skill:nian:ironGuard'), null, `T${tier}: the DEF / block buff ends with the skill`);
|
||
h.run(0.3);
|
||
assert.equal(h.b.resistOf(tex), 0, `T${tier}: the 抵抗 lapses`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T1 积甲成山: every 重装 operator of her team (her included) max HP +16 % for the whole battle — +21 % with PRO-Y stage 3; a 先锋 nothing', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 2, chessId: YAK, row: 11, col: 3 }, { uid: 3, chessId: TEXAS, row: 11, col: 7 }] });
|
||
const yak = h.unit(2), tex = h.unit(3);
|
||
const want = elite && mod === PROY && tier === 6 ? 0.21 : 0.16;
|
||
assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.max_hp, want, `${label(f)}: data`);
|
||
for (const a of [u, yak]) assert.deepEqual(a.findBuff('talent:nian:armor')?.mods, { hpPct: want }, `${label(f)}: ${a.defId}`);
|
||
assert.equal(tex.findBuff('talent:nian:armor'), null, `${label(f)}: 德克萨斯 (先锋)`);
|
||
assert.ok(yak.findBuff('talent:nian:armor').persist, `${label(f)}: kept through a knock-out (编入队伍时)`);
|
||
if (!(elite && mod === PROY && tier === 6)) approx(yak.s.maxHp, yak.base.maxHp * (1 + want), `${label(f)}: 角峰's max HP`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('T2 干明可鉴: 3 护盾 layers at every deployment, each negating one damage instance; PRO-X stage 3: each broken layer ATK / DEF +7 % (≤ 3 stacks) and +3 SP; 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, row: 9, col: 5 });
|
||
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
|
||
const x3 = elite && mod === PROX && tier === 6;
|
||
assert.deepEqual(t1, x3 ? { times: 3, atk: 0.07, def: 0.07, sp: 3, max_stack_cnt: 3 } : { times: 3 }, `${label(f)}: data`);
|
||
assert.equal(u.findBuff('talent:nian:shield')?.shieldHits, 3, `${label(f)}: 3 layers`);
|
||
const e = h.spawn('enemy_dummy', { pos: [9, 8] });
|
||
assert.ok(u.s.maxHp > 0); // (stats are lazy: settle the HP ratio before reading the HP)
|
||
const hp0 = u.hp;
|
||
u.skill.sp = 0;
|
||
for (let i = 1; i <= 3; i++) {
|
||
const sp = u.skill.sp;
|
||
h.b.dealDamage(e, u, { amount: 1500, type: 'phys', isAttack: true });
|
||
assert.equal(u.hp, hp0, `${label(f)}: hit ${i} negated`);
|
||
assert.equal(u.findBuff('talent:nian:shield')?.shieldHits ?? 0, 3 - i, `${label(f)}: ${3 - i} layers left`);
|
||
assert.equal(u.findBuff('talent:nian:shieldBreak')?.stacks ?? 0, x3 ? i : 0, `${label(f)}: stacks`);
|
||
approx(u.skill.sp - sp, x3 ? 3 : 0, `${label(f)}: SP of layer ${i}`);
|
||
}
|
||
if (x3) assert.deepEqual(u.findBuff('talent:nian:shieldBreak').mods, { atkPct: 0.07, defPct: 0.07 }, 'per stack (×3)');
|
||
if (x3) approx(u.s.atk, u.base.atk * (1 + 0.21), `${label(f)}: ATK +21 %`);
|
||
h.b.dealDamage(e, u, { amount: 1500, type: 'phys', isAttack: true });
|
||
assert.ok(u.hp < hp0, `${label(f)}: the 4th hit lands`);
|
||
assert.equal(u.findBuff('talent:nian:shieldBreak')?.stacks ?? 0, x3 ? 3 : 0, `${label(f)}: no 4th stack`);
|
||
// a new deployment: 3 new layers, the stacks of the last one are gone
|
||
h.b.retreat(u);
|
||
h.b.redeploy(u);
|
||
h.step();
|
||
assert.equal(u.findBuff('talent:nian:shield')?.shieldHits, 3, `${label(f)}: 3 layers again`);
|
||
assert.equal(u.findBuff('talent:nian:shieldBreak'), null, `${label(f)}: stacks reset`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('PRO-X 混沌阵列 trait: DEF +20 % while she blocks an enemy (stages 1 and 3); none without it', () => {
|
||
for (const f of FORMS_ALL) {
|
||
const [tier, elite, mod] = f;
|
||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||
const x = elite && mod === PROX;
|
||
if (x) assert.deepEqual(u.def.raw.trait.bb, { def: 0.2 }, label(f));
|
||
h.run(0.5);
|
||
assert.equal(u.findBuff('trait:nian:block'), null, `${label(f)}: nothing blocked`);
|
||
h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
assert.ok(h.runUntil(() => u.blocking.length > 0, 2), `${label(f)}: blocking`);
|
||
h.step();
|
||
assert.deepEqual(u.findBuff('trait:nian:block')?.mods ?? null, x ? { defPct: 0.2 } : null, `${label(f)}: DEF +20 %`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('PRO-Y “请勿叩门”: blocks 4 at both stages; stage 3 only: per 重装 operator on the field (her included, ≤ 3) max HP +4 % and healing received ×(1 + 0.04 n)', () => {
|
||
for (const [tier, n] of [[5, 4], [6, 1], [6, 2], [6, 3], [6, 4]]) {
|
||
const yaks = Array.from({ length: n - 1 }, (_, i) => ({ uid: 2 + i, chessId: YAK, row: 12, col: 3 + 2 * i }));
|
||
const { h, u } = field({ tier, elite: true, mod: PROY, skill: 0, others: [...yaks, { uid: 9, chessId: TEXAS, row: 9, col: 9 }] });
|
||
h.step();
|
||
assert.equal(u.s.blockCnt, 4, `T${tier}: blocks 4`);
|
||
const tb = u.def.raw.trait.bb;
|
||
if (tier === 5) {
|
||
assert.deepEqual(tb, {}, 'stage 1: no trait blackboard');
|
||
assert.equal(u.findBuff('trait:nian:fortify'), null, 'stage 1: nothing per 重装');
|
||
done(h);
|
||
continue;
|
||
}
|
||
assert.deepEqual(tb, { max_hp: 0.04, heal_scale: 1, max_stack_cnt: 3, heal_scale_addition: 0.04, heal_scale_max_value: 1.12 }, 'stage 3');
|
||
const k = Math.min(3, n);
|
||
const fort = u.findBuff('trait:nian:fortify');
|
||
approx(fort.mods.hpPct, 0.04 * k, `${n} 重装: max HP`);
|
||
approx(fort.mods.healingTakenMul, 1 + 0.04 * k, `${n} 重装: healing received`);
|
||
approx(u.s.maxHp, u.base.maxHp * (1 + 0.21 + 0.04 * k), `${n} 重装: with 积甲成山's 21 %`);
|
||
// a heal of 1000 lands as 1000 × (1 + 0.04 k)
|
||
u.hp = 1;
|
||
const got = h.b.heal(h.unit(9), u, 1000);
|
||
approx(got, 1000 * (1 + 0.04 * k), `${n} 重装: heal`);
|
||
if (n === 2) { // a 重装 leaving the field takes its stack away
|
||
h.b.retreat(h.unit(2));
|
||
h.step();
|
||
approx(u.findBuff('trait:nian:fortify').mods.hpPct, 0.04, 'back to 1 stack');
|
||
}
|
||
done(h);
|
||
}
|
||
});
|