Files
ark-stronghold-protocol/test/content/op_nian.test.js
T
sganggs 57d4d8476a feedback5 O14: 自选 operator kits 年, 斩业星熊 and 珊比 — every skill, talent, trait and module (stage 1 / 3) at every form
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.
2026-10-06 01:21:14 +08:00

291 lines
19 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_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);
}
});