feedback5 O15: 自选 operator kits 森蚺, 斥罪 and 黍 — every skill, talent, trait and module (stage 1 / 3) at every form

- kits/ops/op-zumama.js (森蚺, 决战者): trait 阻回 unless blocking (charpack SpRecoveryStopped / SpRecovery); HES-X / HES-Y /
  RA-A turn it into an SP-recovery ratio (−0.8 / −0.999 / −1) while unblocked, HES-Y ATK / DEF +15 % blocking; RA-A's
  生息演算-only parts N/A (愈战愈勇 read from talentsBase); 勇冠三军 polled every 0.1 s (攻击力倍率 / 庇护); 愈战愈勇; S1 passive;
  S2 blockee stun past 抵抗; S3 hpRegenRatio + 5 s self-stun.
- kits/ops/op-judge.js (斥罪, 不屈者): 律法卫士 barrier (deploy / kill / S3, live 300 % cap, S2 ×(1 + shield_scale)); 荆棘环身
  thorns while barrier holds (the breaking hit too); UNY-X ×0.85 from blocked units, UNY-Y +8 % alone; S1 蓄力 (all SP per
  cast, ×1.8 + 3 s stun, attack-cycle reset); S2 no attack, 庇护, x-4 pulses at 0.9 + n s; S3 barrier / ATK / taunt.
- kits/ops/op-shu.js (黍, 守护者): 百谷长青 sown tiles (hpRegen + 庇护, S2 ×scale, GUA-X stage 3 born tile, cleared when she
  leaves); 天有四时 (professions, 4 【岁】 squad ATK + SP / 4 s); S1 as 塞雷娅 S1 (DEFAULT + an ally of x-4 at ≤ half); S2 heals
  of 2 on x-1; S3 range x-2 with Attack-else-Heal, the harvest aura and the 播种标记 teleport; GUA-X ×1.15 at ≤ 50 %. Her S3
  data rule TRY_SEARCH_ALLY_SKILL (no engine rule) is mapped onto SKILL_RANGE + allies on x-2 (reported).
- 庇护 in all three through battle.applyStrongest(unit, 'protect') (同名效果取最高 across these kits).
- kits/index.js: the three files appended to OPERATOR_KIT_FILES.
- test/content/op_zumama.test.js (8), op_judge.test.js (8), op_shu.test.js (9).
- docs/research/03-operators.md: 黍's trigger row (a 自选 pick now, the kit's mapping).
- golden: only diy moved — diy-049 … diy-052 now field 森蚺 / 斥罪 / 黍 and diy-053 … diy-058 are new (the 12-per-battle
  packing appends the new configurations; the prototype / reserve battles of 049 … 052 move to 055 … 058); diy-001 … 048
  and every other family unchanged.
This commit is contained in:
sganggs committed 2026-10-06 01:30:05 +08:00
1 parent fef4ea3d4b
commit 10bb226382
9 files changed
+1926 -99

No files matched your search

+314
View File
@@ -0,0 +1,314 @@
// test/content/op_judge.test.js — the 自选 operator kit of 斥罪 (char_4065_judge, 6★ 不屈者; kit
// server/sim/content/kits/ops/op-judge.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, UNY-X
// “通往未来的荆棘之路” or UNY-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_judge.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, 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 JUDGE = 'char_4065_judge';
const FORMS = BACKUPS.units[JUDGE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const UNYX = 'uniequip_002_judge', UNYY = 'uniequip_003_judge';
const S1 = 'skchr_judge_1', S2 = 'skchr_judge_2', S3 = 'skchr_judge_3';
const BARRIER = 'talent:judge:barrier';
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_frail: dummy('enemy_frail', { hp: 10 }),
};
const ALLIES = { t_mate: chessRec({ id: 't_mate', skill: null, stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0 } }),
t_medic: chessRec({ id: 't_medic', profession: 'MEDIC', skill: null, stats: { maxHp: 10000, atk: 500, def: 0, blockCnt: 0 } }) };
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, UNYX, UNYY].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, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed, setup,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'kill'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: JUDGE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const barrier = (u) => u.findBuff(BARRIER)?.shield ?? 0;
const t0Of = (u) => u.def.raw.talents.find((t) => t.index === 0).bb;
const t1Of = (u) => u.def.raw.talents.find((t) => t.index === 1).bb;
const thorns = (h, u, target = null) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags ?? []).includes('judge:thorns') && (!target || c.target === target));
/** Knock an enemy out with damage credited to her. */
function killOne(h, u) {
const e = h.spawn('enemy_frail', { pos: [12, 9] });
h.b.dealDamage(u, e, { amount: 1e6, type: 'true' });
assert.ok(!e.alive, 'knocked out');
}
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, melee ground-only, 叙拉古, no 特质; 不屈者: no heal from others; triggers from the data', () => {
assert.equal(OPERATOR_KITS[JUDGE], KITS[JUDGE]);
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, others: [{ uid: 2, chessId: 't_medic', row: 11, col: 5 }] });
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], [JUDGE, 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.profile.noHeal, u.base.bat], [3, 'melee', 'phys', false, true, 1.6], `${label(f)}: 不屈者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['siracusaShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
const sk = form.skills[skill];
if (skill === 0) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.maxCharges], ['DEFAULT', 'DEFAULT', 'AUTO', 2], `${label(f)}: S1 AUTO`);
else assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType], ['DEFAULT', 'TAKE_DAMAGE', 'MANUAL'], `${label(f)}: S${skill + 1} trigger (the 重装 exception)`);
assert.equal(u.skill.spType, skill === 2 ? 'hurt' : 'time', `${label(f)}: SP type`);
// 无法被友方角色治疗: a heal from another unit does nothing
u.hp = u.s.maxHp * 0.5;
assert.equal(h.b.heal(h.unit(2), u, 500), 0, `${label(f)}: no heal from others`);
done(h);
}
}
// zh_CN numbers: E2 Lv1 2960 / 683 / 474, E2 Lv60 3686 / 811 / 568; UNY-X +150/+35/+15 → +300/+50/+33, UNY-Y +60/+48 → +85/+70
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], [2960, 683, 3686, 811]);
assert.deepEqual([modOf(5, UNYX).attr, modOf(6, UNYX).attr, modOf(5, UNYY).attr, modOf(6, UNYY).attr],
[{ maxHp: 150, atk: 35, def: 15 }, { maxHp: 300, atk: 50, def: 33 }, { atk: 60, def: 48 }, { atk: 85, def: 70 }]);
});
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(JUDGE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(JUDGE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: JUDGE, skillIndex: 0, uniEquipId: UNYY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: JUDGE, skillIndex: 0, uniEquipId: UNYY } } });
});
test('T1 律法卫士: barrier 50 % max HP at every deployment (70 % UNY-X stage 3), +10 % per enemy she knocks out (12 %), never past 300 % of the current max HP; it absorbs before HP', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const t0 = t0Of(u);
const x3 = elite && mod === UNYX && tier === 6;
assert.deepEqual([t0.born_hp_ratio, t0.kill_hp_ratio, t0.max_hp_ratio], x3 ? [0.7, 0.12, 3] : [0.5, 0.1, 3], label(f));
approx(barrier(u), u.s.maxHp * t0.born_hp_ratio, `${label(f)}: at the deployment`);
killOne(h, u);
approx(barrier(u), u.s.maxHp * (t0.born_hp_ratio + t0.kill_hp_ratio), `${label(f)}: +1 kill`);
// it absorbs: HP untouched until it runs out
const hp0 = u.hp, e = h.spawn('enemy_dummy', { pos: [12, 9] });
h.b.dealDamage(e, u, { amount: 300, type: 'true' });
assert.equal(u.hp, hp0, `${label(f)}: absorbed`);
approx(barrier(u), u.s.maxHp * (t0.born_hp_ratio + t0.kill_hp_ratio) - 300, `${label(f)}: −300`);
// the cap: 300 % of the current max HP
u.findBuff(BARRIER).shield = u.s.maxHp * 2.95;
killOne(h, u);
approx(barrier(u), u.s.maxHp * 3, `${label(f)}: capped`);
killOne(h, u);
approx(barrier(u), u.s.maxHp * 3, `${label(f)}: no gain at the cap`);
// a max-HP change keeps the barrier; the cap follows the current max HP
h.b.addBuff(u, { key: 'test:hp', mods: { hpPct: -0.5 } });
const kept = barrier(u);
killOne(h, u);
assert.equal(barrier(u), kept, `${label(f)}: above the new cap: no gain, no cut`);
h.b.removeBuff(u, 'test:hp');
// a new deployment: a new barrier
h.b.retreat(u);
h.b.redeploy(u);
h.step();
approx(barrier(u), u.s.maxHp * t0.born_hp_ratio, `${label(f)}: again at the redeployment`);
done(h);
}
});
test('T2 荆棘环身: while she holds barrier, every damage from an enemy deals 50 % ATK arts back (58 % UNY-Y stage 3) — the hit that breaks it too; none without barrier, for a 流失, a 无来源 hit or an ally', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 't_mate', row: 12, col: 3 }] });
const t1 = t1Of(u);
assert.equal(t1.atk_scale, elite && mod === UNYY && tier === 6 ? 0.58 : 0.5, label(f));
const e = h.spawn('enemy_dummy', { pos: [12, 9] });
h.b.dealDamage(e, u, { amount: 100, type: 'phys' });
const t = thorns(h, u, e);
assert.equal(t.length, 1, `${label(f)}: one reflection`);
approx(t[0].amount, u.s.atk * t1.atk_scale, `${label(f)}: ${t1.atk_scale * 100} % ATK`);
assert.deepEqual([t[0].type, t[0].dmg.isAttack, t[0].dmg.canDodge], ['arts', false, false], `${label(f)}: arts, not an attack, undodgeable`);
h.b.loseHp(u, 50, { source: e });
h.b.dealDamage(e, u, { amount: 50, type: 'true', sourceless: true });
h.b.dealDamage(h.unit(2), u, { amount: 50, type: 'true' });
assert.equal(thorns(h, u).length, 1, `${label(f)}: no reflection for a 流失, a 无来源 hit or an ally`);
// the breaking hit still reflects; then none
u.findBuff(BARRIER).shield = 10;
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
assert.equal(barrier(u), 0, `${label(f)}: broken`);
assert.equal(thorns(h, u).length, 2, `${label(f)}: the breaking hit reflects`);
h.b.dealDamage(e, u, { amount: 50, type: 'true' });
assert.equal(thorns(h, u).length, 2, `${label(f)}: no barrier, no thorns`);
done(h);
}
});
test('S1 一锤定音 (AUTO, data DEFAULT): the next attack adds 160 % / 180 % ATK arts; every cast spends all the SP; 蓄力 (twice the cost) also stuns 3 s and raises all her damage ×1.8 (thorns included), and reaching it restarts her attack cycle', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
let probe = null;
const { h, u } = field({ tier, elite, skill: 0, setup: (b) => {
// during her 蓄力 attack an enemy hits her: the reflection carries the ×1.8
b.on('hit', (c) => {
if (!probe || probe.done || c.source !== probe.u || !c.dmg?.isAttack) return;
probe.done = true;
b.dealDamage(probe.e, probe.u, { amount: 10, type: 'true' });
});
} });
assert.deepEqual([sk.spCost, sk.initSp, sk.maxChargeTime, sk.bb.atk_scale_2, sk.bb['judge_s_1_enhance_checker.atk_scale'], sk.bb.stun], elite ? [4, 0, 2, 1.8, 1.8, 3] : [5, 0, 2, 1.6, 1.8, 3], `T${tier}`);
// one charge: a plain cast
u.skill.gainSp(sk.spCost, 'init');
assert.equal(u.skill.charges, 1);
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast at her attack`);
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: all SP spent`);
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
h.step(2);
const gavel = hits().filter((c) => (c.dmg?.tags ?? []).includes('judge:gavel'));
assert.equal(gavel.length, 1, `T${tier}: one arts rider`);
approx(gavel[0].amount, u.s.atk * sk.bb.atk_scale_2, `T${tier}: ${sk.bb.atk_scale_2 * 100} % ATK arts`);
assert.deepEqual([gavel[0].type, gavel[0].dmg.isSkill], ['arts', true]);
assert.ok(!h.hooksOf('statusApplied').some((c) => c.target === e && c.status === 'stun'), `T${tier}: no stun without 蓄力`);
// 蓄力: the SP runs on to twice the cost; the attack cycle restarts the moment it gets there
h.b.kill(e, u);
h.step(2);
u.atkCd = 1.2;
u.skill.setSpTotal(2 * sk.spCost - 0.02);
assert.equal(u.skill.charges, 1);
assert.ok(h.runUntil(() => u.skill.charges === 2, 1), `T${tier}: the stack fills`);
h.step();
assert.equal(u.atkCd, 0, `T${tier}: attack cycle restarted`);
const e2 = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] });
probe = { u, e: e2, done: false };
assert.ok(h.runUntil(() => u.skill.activations === 2, 1), `T${tier}: 蓄力 cast`);
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: all SP spent`);
h.step(2);
const all = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e2);
const phys = all.find((c) => c.dmg?.isAttack);
const rider = all.find((c) => (c.dmg?.tags ?? []).includes('judge:gavel'));
approx(phys.amount, u.s.atk * 1.8, `T${tier}: the attack ×1.8`);
approx(rider.amount, u.s.atk * sk.bb.atk_scale_2 * 1.8, `T${tier}: the rider ×1.8`);
const th = thorns(h, u, e2);
assert.ok(th.length >= 1, `T${tier}: a reflection during the attack`);
approx(th[0].amount, u.s.atk * t1Of(u).atk_scale * 1.8, `T${tier}: thorns ×1.8`);
const st = h.hooksOf('statusApplied').find((c) => c.target === e2 && c.status === 'stun' && c.source === u);
assert.ok(st, `T${tier}: stun`);
approx(st.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
assert.equal(u.s.atkScaleMul, 1, `T${tier}: ×1.8 gone after the attack`);
done(h);
}
});
test('S2 坚心苦修 (data DEFAULT): 20 s without attacks, 40 % / 50 % 庇护, 90 % / 110 % ATK arts to every ground enemy on her x-4 at 0.9 s and every second after (20 pulses), barrier gains ×1.6 / ×1.8 meanwhile', () => {
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([sk.duration, sk.bb.damage_resistance, sk.bb.atk_scale, sk.bb.shield_scale, sk.rangeId], elite ? [20, 0.5, 1.1, 0.8, 'x-4'] : [20, 0.4, 0.9, 0.6, 'x-4'], `T${tier}`);
u.skill.gainSp(999, 'init');
const inside = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [11, 4] }), h.spawn('enemy_dummy', { pos: [9, 5] })];
const fly = h.spawn('enemy_fly', { pos: [9, 6] }), out = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`);
const t0 = h.hooksOf('skillStart').find((c) => c.unit === u).t;
const pb = u.findBuff('protect');
approx(pb.data.value, sk.bb.damage_resistance, `T${tier}: 庇护`);
const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).length;
// the barrier gain of a kill ×(1 + shield_scale)
const b0 = barrier(u);
killOne(h, u);
approx(barrier(u) - b0, u.s.maxHp * t0Of(u).kill_hp_ratio * (1 + sk.bb.shield_scale), `T${tier}: kill barrier ×${1 + sk.bb.shield_scale}`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
const pulses = h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags ?? []).includes('judge:atonement'));
const times = [...new Set(pulses.map((c) => Math.round((c.t - t0) * 100) / 100))];
assert.equal(times.length, 20, `T${tier}: 20 pulses`);
approx(times[0], 0.9, `T${tier}: first at 0.9 s`, 0.05);
approx(times[1] - times[0], 1, `T${tier}: then every second`, 0.05);
for (const e of inside) assert.equal(pulses.filter((c) => c.target === e).length, 20, `T${tier}: ${e.tileR},${e.tileC} hit 20 times`);
for (const c of pulses.slice(0, 3)) {
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], ['arts', true, false]);
}
assert.ok(!pulses.some((c) => c.target === fly || c.target === out), `T${tier}: no flyer, nothing outside x-4`);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && c.t > t0 + 0.01 && c.t < t0 + sk.duration - 0.01).length - n0, 0, `T${tier}: no attack while it runs`);
assert.equal(u.findBuff('protect'), null, `T${tier}: 庇护 gone`);
done(h);
}
});
test('S3 披荆斩棘 (data DEFAULT, 受击回复 27 / 24 SP — absorbed hits count): barrier +70 % / +100 % max HP (to the cap), 30 s ATK +260 % / +320 %, interval 1.6 + 0.9 s, taunt +1', () => {
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([sk.duration, sk.bb.hp_ratio, sk.bb.atk, sk.bb.base_attack_time, sk.bb.taunt_level, sk.spCost, sk.initSp, sk.spType], elite ? [30, 1, 3.2, 0.9, 1, 24, 0, 'INCREASE_WHEN_TAKEN_DAMAGE'] : [30, 0.7, 2.6, 0.9, 1, 27, 0, 'INCREASE_WHEN_TAKEN_DAMAGE'], `T${tier}`);
const src = h.spawn('enemy_dummy', { pos: [12, 9] });
for (let i = 0; i < sk.spCost - 1; i++) h.b.dealDamage(src, u, { amount: 1, type: 'true' });
assert.equal(u.skill.sp, sk.spCost - 1, `T${tier}: one SP per hit, the barrier absorbing`);
assert.equal(u.hp, u.s.maxHp, `T${tier}: HP untouched`);
h.b.dealDamage(src, u, { amount: 1, type: 'true' });
assert.ok(u.skill.ready, `T${tier}: ready`);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy in range, no cast`);
const b0 = barrier(u);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`);
approx(barrier(u), Math.min(u.s.maxHp * 3, b0 + u.s.maxHp * sk.bb.hp_ratio), `T${tier}: +${sk.bb.hp_ratio * 100} % max HP`, 1e-3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 2.5, `T${tier}: 1.6 + 0.9 s`);
assert.equal(u.s.taunt, 1, `T${tier}: taunt +1`);
// the cap holds for the skill's barrier too
h.b.retreat(u);
h.b.redeploy(u);
h.step();
u.findBuff(BARRIER).shield = u.s.maxHp * 2.5;
u.skill.gainSp(999, 'init');
assert.ok(h.runUntil(() => u.skill.active, 5));
approx(barrier(u), u.s.maxHp * 3, `T${tier}: capped at 300 %`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1));
approx(u.s.interval, 1.6, `T${tier}: interval back`);
assert.equal(u.s.taunt, 0, `T${tier}: taunt back`);
done(h);
}
});
test('UNY-X “通往未来的荆棘之路”: damage from an enemy she blocks ×0.85 (stages 1 and 3); UNY-Y “无罪”: ATK / DEF +8 % with no allied operator on the 8 tiles around her (a summon does not count)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 't_mate', row: 12, col: 8 }, { uid: 3, chessId: 'chess_char_3_19_a', row: 12, col: 3 },
{ uid: 4, kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 3, row: 11, col: 6 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
const x = elite && mod === UNYX, y = elite && mod === UNYY;
if (x) assert.equal(u.def.raw.trait.bb.damage_scale, 0.85, label(f));
if (y) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
u.findBuff(BARRIER).shield = 0;
const blocked = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => blocked.blockedBy === u, 1));
const free = h.spawn('enemy_dummy', { pos: [12, 9] });
const take = (src) => { u.hp = u.s.maxHp; return h.b.dealDamage(src, u, { amount: 500, type: 'true' }); };
approx(take(blocked), x ? 425 : 500, `${label(f)}: from the blocked enemy`);
approx(take(free), 500, `${label(f)}: from another`);
// the wolf (a summon) is on the 8 around her: still alone
h.step(2);
assert.equal(!!u.findBuff('trait:judge:alone'), y, `${label(f)}: alone (a summon next to her)`);
if (y) approx(u.s.atk, u.base.atk * 1.08, `${label(f)}: ATK +8 %`);
h.b.relocate(h.unit(2), 9, 4); // an operator on her diagonal
h.step(2);
assert.equal(u.findBuff('trait:judge:alone'), null, `${label(f)}: not alone`);
done(h);
}
});
+370
View File
@@ -0,0 +1,370 @@
// test/content/op_shu.test.js — the 自选 operator kit of 黍 (char_2025_shu, 6★ 守护者; kit
// server/sim/content/kits/ops/op-shu.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 or GUA-X
// “钦天司时” 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_shu.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, 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 { COLS } from '../../server/sim/constants.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 SHU = 'char_2025_shu';
const FORMS = BACKUPS.units[SHU].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const GUAX = 'uniequip_002_shu';
const S1 = 'skchr_shu_1', S2 = 'skchr_shu_2', S3 = 'skchr_shu_3';
const FARM = 'talent:shu:farm';
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_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 1, mass: 0 }),
};
const ally = (id, o = {}) => chessRec({ id, skill: null, ...o, stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0, respawnTime: 5, ...(o.stats || {}) } });
const ALLIES = {
t_a: ally('t_a'), t_b: ally('t_b'), t_c: ally('t_c'), t_d: ally('t_d'),
t_war: ally('t_war', { profession: 'WARRIOR' }), t_war2: ally('t_war2', { profession: 'WARRIOR' }), t_war3: ally('t_war3', { profession: 'WARRIOR' }),
t_med: ally('t_med', { profession: 'MEDIC' }),
t_sp: chessRec({ id: 't_sp', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 0, def: 0, blockCnt: 0 },
skill: { skillId: 'sk_t_sp', spType: 'INCREASE_WHEN_ATTACK', spCost: 99, initSp: 0, duration: 5 } }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, GUAX].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, enemies = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed, enemies,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SHU, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const healsBy = (h, u, f = () => true) => h.hooksOf('heal').filter((c) => c.source === u && !c.opts?.regen && f(c));
const sownOf = (u) => u.mem.shuSown ?? new Set();
const key = (r, c) => r * COLS + c;
/** GUA-X on this form: ×heal_scale on a target at or below hp_ratio. */
const guax = (u, ratio) => (u.def.raw.trait.bb.heal_scale && ratio <= u.def.raw.trait.bb.hp_ratio + 1e-9 ? u.def.raw.trait.bb.heal_scale : 1);
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, range 0-1, blocks 3, melee ground-only, 炎, no 特质; triggers (S3: TRY_SEARCH_ALLY_SKILL → SKILL_RANGE + allies on x-2)', () => {
assert.equal(OPERATOR_KITS[SHU], KITS[SHU]);
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], [SHU, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 10], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [3, 'melee', 'phys', false, 1.2], `${label(f)}: 守护者`);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `${label(f)}: 0-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, `${label(f)}: ground enemies target her`);
const sk = form.skills[skill];
if (skill === 0) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies, u.skill.triggerHpAtMost], ['DEFAULT', 'DEFAULT', 'AUTO', true, 0.5], `${label(f)}: S1`);
if (skill === 1) assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.skillType, u.skill.triggerAllies], ['DEFAULT', 'TAKE_DAMAGE', 'MANUAL', false], `${label(f)}: S2 (the 重装 exception)`);
if (skill === 2) {
assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['TRY_SEARCH_ALLY_SKILL', 'TRY_SEARCH_ALLY_SKILL', 'MANUAL'], `${label(f)}: S3 data rule`);
assert.deepEqual([u.skill.rule, u.skill.triggerAllies, u.skill.triggerGrid], ['SKILL_RANGE', true, sk.rangeGrid], `${label(f)}: S3 engine rule`);
}
done(h);
}
}
// zh_CN numbers: E2 Lv1 2313 / 392 / 493, E2 Lv60 2910 / 450 / 565; GUA-X +160/+30/+30 → +270/+50/+50
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], [2313, 392, 2910, 450]);
assert.deepEqual([modOf(5, GUAX).attr, modOf(6, GUAX).attr], [{ maxHp: 160, atk: 30, def: 30 }, { maxHp: 270, atk: 50, def: 50 }]);
});
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(SHU));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHU), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SHU, skillIndex: 2, uniEquipId: GUAX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: SHU, skillIndex: 2, uniEquipId: GUAX } } });
});
test('S1 化被草木 (AUTO, DEFAULT + an ally of x-4 at ≤ half HP): her attack heals the lowest such ally for 130 % / 150 % ATK instead (×1.15 GUA-X); 1 / 2 charges; nothing for allies above half or outside x-4', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, GUAX]]) {
const sk = skillOf(tier, elite, S1);
const others = [{ uid: 2, chessId: 't_a', row: 11, col: 4 }, { uid: 3, chessId: 't_b', row: 9, col: 5 }, { uid: 4, chessId: 't_c', row: 12, col: 5 }];
const { h, u } = field({ tier, elite, mod, skill: 0, others });
assert.deepEqual([sk.bb.heal_scale, sk.maxChargeTime, sk.spCost, sk.rangeId], elite ? [1.5, 2, 5, 'x-4'] : [1.3, 1, 5, 'x-4'], `T${tier}`);
const [a, b, c] = [h.unit(2), h.unit(3), h.unit(4)];
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
u.skill.gainSp(999, 'init');
a.hp = a.s.maxHp * 0.6; b.hp = b.s.maxHp * 0.55; c.hp = c.s.maxHp * 0.1;
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: nobody of x-4 at ≤ half (the one at 10 % is outside)`);
assert.ok(h.hooksOf('damaged').some((x) => x.source === u && x.target === e), `T${tier}: she attacks meanwhile`);
a.hp = a.s.maxHp * 0.3; b.hp = b.s.maxHp * 0.45;
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast at her attack`);
const first = healsBy(h, u)[0];
assert.equal(first.target, a, `T${tier}: the lowest`);
approx(first.amount, u.s.atk * sk.bb.heal_scale * guax(u, 0.3), `T${tier}: ${sk.bb.heal_scale * 100} % ATK`, 1e-3);
const t0 = h.hooksOf('skillStart').find((x) => x.unit === u).t;
assert.ok(!h.hooksOf('damaged').some((x) => x.source === u && x.target === e && Math.abs(x.t - t0) < 1e-9), `T${tier}: that attack was the heal`);
assert.ok(sownOf(u).has(key(a.tileR, a.tileC)), `T${tier}: the heal sowed`);
if (elite) {
a.hp = a.s.maxHp;
assert.ok(h.runUntil(() => u.skill.activations === 2, 5), 'the second charge on the next attack');
assert.equal(healsBy(h, u)[1].target, b, 'the ally still at ≤ half');
}
done(h);
}
});
test('S2 嘉禾盈仓 (data DEFAULT: casts at her attack on an enemy): 25 s, her attacks heal up to 2 injured allies of x-1 for ATK (+60 % / +90 %), block 3 + 1, interval 1.2 + 1.3 s, range x-1 meanwhile; T1 ×1.3 / ×1.4', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, GUAX]]) {
const sk = skillOf(tier, elite, S2);
const others = [{ uid: 2, chessId: 't_a', row: 10, col: 7 }, { uid: 3, chessId: 't_b', row: 12, col: 5 }, { uid: 4, chessId: 't_c', row: 9, col: 4 }, { uid: 5, chessId: 't_d', row: 12, col: 7 }];
const { h, u } = field({ tier, elite, mod, skill: 1, others });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.block_cnt, sk.bb.base_attack_time, sk.bb['attack@max_target'], sk.bb.extra_extend_scale, sk.rangeId], elite ? [25, 0.9, 1, 1.3, 2, 1.4, 'x-1'] : [25, 0.6, 1, 1.3, 2, 1.3, 'x-1'], `T${tier}`);
const [a, b, c, d] = [h.unit(2), h.unit(3), h.unit(4), h.unit(5)];
u.skill.gainSp(999, 'init');
a.hp = a.s.maxHp * 0.4; b.hp = b.s.maxHp * 0.7; c.hp = c.s.maxHp * 0.9; d.hp = d.s.maxHp * 0.2;
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: injured allies alone do not cast it`);
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast at her attack`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-1 while it runs`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.blockCnt, 4, `T${tier}: block 3 + 1`);
approx(u.s.bat, 2.5, `T${tier}: base attack time 1.2 + 1.3 s`); // (T2: four WARRIOR here ⇒ ASPD +12 on top)
const t0 = h.hooksOf('skillStart').find((x) => x.unit === u).t;
assert.ok(h.runUntil(() => healsBy(h, u, (x) => x.t >= t0 - 1e-9).length >= 2, 4), `T${tier}: a heal attack (the cast's own attack)`);
const hs = healsBy(h, u, (x) => x.t >= t0 - 1e-9);
assert.deepEqual(hs.slice(0, 2).map((x) => x.target.id).sort(), [a.id, b.id].sort(), `T${tier}: the two lowest of x-1 (the one at 20 % is outside)`);
for (const x of hs.slice(0, 2)) approx(x.amount, u.s.atk * guax(u, x.target === a ? 0.4 : 0.7), `T${tier}: ATK`, 1e-3);
assert.ok(!h.hooksOf('damaged').some((x) => x.source === u && x.target === e && x.t > t0 + 0.01), `T${tier}: no attack on the enemy`);
// T1 on her sown tiles ×extra_extend_scale while it runs
h.run(0.3);
const fb = a.findBuff(FARM);
assert.ok(fb, `T${tier}: the healed ally stands on a sown tile`);
const t0b = u.def.raw.talents.find((t) => t.index === 0).bb;
approx(fb.mods.hpRegen, t0b.hp_recovery_per_sec * sk.bb.extra_extend_scale, `T${tier}: regeneration ×${sk.bb.extra_extend_scale}`);
approx(a.findBuff('protect').data.value, t0b.damage_resistance * sk.bb.extra_extend_scale, `T${tier}: 庇护 ×${sk.bb.extra_extend_scale}`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
h.run(0.6);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `T${tier}: back to 0-1`);
approx(a.findBuff(FARM).mods.hpRegen, t0b.hp_recovery_per_sec, `T${tier}: regeneration back to ×1`);
approx(u.s.bat, 1.2, `T${tier}: attack time back`);
done(h);
}
});
test('S3 离离枯荣 (SKILL_RANGE + an injured ally of x-2 — no enemy needed, an enemy alone does not cast it): range x-2, ATK +22 % / +34 %, every attack on an enemy also heals the most injured other ally for ATK; with no enemy her attack heals (herself included)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, GUAX]]) {
const sk = skillOf(tier, elite, S3);
const others = [{ uid: 2, chessId: 't_a', row: 12, col: 5 }, { uid: 3, chessId: 't_b', row: 12, col: 9 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.e_atk, sk.bb.e_attack_speed, sk.bb.max_distance, sk.rangeId], elite ? [30, 0.34, 0.2, 20, 3, 'x-2'] : [30, 0.22, 0.15, 15, 4, 'x-2'], `T${tier}`);
const [a, far] = [h.unit(2), h.unit(3)];
u.skill.gainSp(999, 'init');
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
far.hp = far.s.maxHp * 0.2;
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: an enemy and an injured ally outside x-2 do not cast it`);
a.hp = a.s.maxHp * 0.6;
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for the injured ally of x-2`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2 while it runs`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const t0 = h.hooksOf('skillStart').find((x) => x.unit === u).t;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((x) => x.source === u && x.target === e && x.t > t0), 3), `T${tier}: she strikes the enemy 2 tiles ahead`);
h.step();
const hit = h.hooksOf('damaged').find((x) => x.source === u && x.target === e && x.t > t0);
assert.deepEqual([hit.type, hit.dmg.isAttack], ['phys', true]);
const hh = healsBy(h, u, (x) => x.t >= hit.t - 1e-9);
assert.equal(hh[0]?.target, a, `T${tier}: the attack heals the injured ally`);
approx(hh[0].amount, u.s.atk, `T${tier}: for ATK`, 1e-3);
// she alone injured: an enemy attack heals nobody; with no enemy her attack heals her
a.hp = a.s.maxHp;
u.hp = u.s.maxHp * 0.7;
const n0 = healsBy(h, u).length;
h.run(3);
assert.equal(healsBy(h, u).length, n0, `T${tier}: the attack-heal is for the others`);
h.b.kill(e, u);
assert.ok(h.runUntil(() => healsBy(h, u).length > n0, 3), `T${tier}: no enemy: a heal attack`);
assert.equal(healsBy(h, u)[n0].target, u, `T${tier}: on herself`);
assert.ok(h.hooksOf('attack').some((x) => x.attacker === u && x.targets[0] === u), `T${tier}: an attack (ASPD / 缴械 count)`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration), `T${tier}: ends`);
assert.deepEqual(u.liveRangeGrid, [[0, 0]], `T${tier}: back to 0-1`);
done(h);
}
});
test('S3 离离枯荣: a ground enemy on a sown tile ⇒ the operators of her range ATK +15 % / +20 % and ASPD +15 / +20 (no summon); a ground enemy entering a sown tile is put back there once it is max_distance (4 / 3) tiles away — until the skill ends', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const others = [{ uid: 2, chessId: 't_a', row: 11, col: 7 }, { uid: 3, chessId: 'chess_char_3_19_a', row: 12, col: 3 },
{ uid: 4, kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 3, row: 11, col: 4 }];
const { h, u } = field({ tier, elite, skill: 2, others });
const a = h.unit(2), wolf = h.unit(4);
// sow row 9 cols 6-8 around (9, 7) with a heal of hers (an ally there for the moment)
h.b.relocate(a, 9, 7);
h.b.heal(u, a, 1);
h.b.relocate(a, 11, 7);
for (const [r, c] of [[9, 6], [9, 7], [9, 8], [10, 7]]) assert.ok(sownOf(u).has(key(r, c)), `T${tier}: ${r},${c} sown`);
a.hp = a.s.maxHp * 0.5;
u.skill.gainSp(999, 'init');
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
h.step();
assert.equal(a.findBuff('skill:shu:harvest'), null, `T${tier}: no enemy on a sown tile yet`);
const w = h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => sownOf(u).has(key(Math.round(w.y), Math.round(w.x))), 15), `T${tier}: it reaches the sown tiles`);
h.step(2);
const hb = a.findBuff('skill:shu:harvest');
assert.ok(hb, `T${tier}: an operator of her range`);
assert.deepEqual(hb.mods, { atkPct: sk.bb.e_atk, aspd: sk.bb.e_attack_speed }, `T${tier}: +${sk.bb.e_atk * 100} % / +${sk.bb.e_attack_speed}`);
assert.ok(u.findBuff('skill:shu:harvest'), `T${tier}: herself`);
assert.equal(wolf.findBuff('skill:shu:harvest'), null, `T${tier}: not a summon`);
const mark = u.mem.shuMarks.get(w.id);
assert.deepEqual([mark.r, mark.c], [9, 8], `T${tier}: marked with the first sown tile it entered`);
let farthest = 8, ported = false;
for (let i = 0; i < 600 && !ported; i++) {
h.step();
const c = Math.round(w.x);
if (c < farthest) farthest = c;
if (c === 8 && farthest < 8) ported = true;
}
assert.ok(ported, `T${tier}: put back on its sown tile`);
// the tile at max_distance is never seen after a tick: it is put back the tick it enters it
assert.equal(farthest, 8 - sk.bb.max_distance + 1, `T${tier}: on entering the tile ${sk.bb.max_distance} tiles away`);
assert.ok(h.eventsOf('fx').some((x) => x[1] === 'teleport'), `T${tier}: teleport fx`);
// after the skill: no more teleports
u.skill.end('test');
h.step();
assert.equal(u.mem.shuMarks, null, `T${tier}: the marks end with the skill`);
let reachedGoal = false;
for (let i = 0; i < 900 && !reachedGoal; i++) { h.step(); if (!w.alive || Math.round(w.x) <= 8 - sk.bb.max_distance - 1) reachedGoal = true; }
assert.ok(reachedGoal, `T${tier}: it walks on`);
done(h);
}
});
test('T1 百谷长青: her heals sow the healed unit\'s tile and the 4 next to it (walkable / deployable tiles only); units on them regenerate 70 / s (80 GUA-X stage 3) and take 10 % (15 %) 庇护 — a 无法被治疗 unit and summons too; all gone when she leaves; GUA-X stage 3 sows her tile at the deployment', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 't_a', row: 9, col: 5 }, { uid: 3, chessId: 't_b', row: 12, col: 9 }, { uid: 4, chessId: 'chess_char_3_19_a', row: 12, col: 3 },
{ uid: 5, kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 4, row: 10, col: 6 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
const t0 = u.def.raw.talents.find((t) => t.index === 0);
const x3 = elite && mod === GUAX && tier === 6;
assert.deepEqual([t0.bb.hp_recovery_per_sec, t0.bb.damage_resistance, t0.bbStr?.born_range_id ?? null], x3 ? [80, 0.15, '0-1'] : [70, 0.1, null], label(f));
assert.equal(sownOf(u).has(key(10, 5)), x3, `${label(f)}: her own tile sown at the deployment (GUA-X stage 3 only)`);
const [a, b, wolf] = [h.unit(2), h.unit(3), h.unit(5)];
a.hp = a.s.maxHp * 0.5;
h.b.heal(u, a, 100);
const s = sownOf(u);
for (const [r, c] of [[9, 5], [10, 5], [9, 4], [9, 6]]) assert.ok(s.has(key(r, c)), `${label(f)}: ${r},${c}`);
assert.ok(!s.has(key(8, 5)), `${label(f)}: 8,5 is no walkable / deployable tile (A)`);
h.run(0.3);
assert.deepEqual(a.findBuff(FARM)?.mods, { hpRegen: t0.bb.hp_recovery_per_sec }, `${label(f)}: 生命回复速度`);
approx(a.findBuff('protect').data.value, t0.bb.damage_resistance, `${label(f)}: 庇护`);
approx(a.s.physTakenMul, 1 - t0.bb.damage_resistance, `${label(f)}: physical ×${1 - t0.bb.damage_resistance}`);
// a 无法被治疗 / 禁疗 unit still regenerates (an hpRegen attribute, no heal); a unit off the tiles gets nothing
h.b.addBuff(a, { key: 'test:noheal', flags: { noHeal: true, healFree: true } });
a.hp = a.s.maxHp * 0.5;
const hp0 = a.hp;
h.run(1);
approx(a.hp - hp0, t0.bb.hp_recovery_per_sec, `${label(f)}: +${t0.bb.hp_recovery_per_sec} HP in 1 s`, 0.05);
assert.equal(b.findBuff(FARM), null, `${label(f)}: off the tiles`);
// a summon (the 禁疗 wolf: no heal of hers reaches it) on a tile her heal of a neighbour sowed
h.b.relocate(b, 10, 7);
b.hp = b.s.maxHp * 0.5;
h.b.heal(u, b, 1);
assert.ok(sownOf(u).has(key(wolf.tileR, wolf.tileC)), `${label(f)}: the wolf's tile sown`);
h.run(0.3);
assert.ok(wolf.findBuff(FARM), `${label(f)}: a summon on a sown tile`);
// she leaves: every tile is cleared
h.b.retreat(u);
h.step();
assert.equal(a.findBuff(FARM), null, `${label(f)}: cleared at once`);
assert.equal(sownOf(u).size, 0, `${label(f)}: no sown tile left`);
h.run(0.5);
assert.equal(a.findBuff('protect'), null, `${label(f)}: 庇护 gone`);
done(h);
}
});
test('T2 天有四时: with her deployed, 3 professions on the field ⇒ every operator max HP +12 %, 3 operators of one profession ⇒ ASPD +12; 4 【岁】 in her squad ⇒ its operators ATK +12 % and 1 SP every 4 s, deployed or not', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
assert.deepEqual(t1, { max_hp: 0.12, attack_speed: 12, atk: 0.12, interval: 4, sp: 1 });
// 黍 (TANK) + 2 WARRIOR + 1 MEDIC: three professions; no three of one
{
const others = [{ uid: 2, chessId: 't_war', row: 12, col: 3 }, { uid: 3, chessId: 't_war2', row: 12, col: 5 }, { uid: 4, chessId: 't_med', row: 12, col: 7 }];
const { h, u } = field({ tier, elite, skill: 1, others });
h.run(0.3);
for (const x of [u, h.unit(2), h.unit(3), h.unit(4)]) {
assert.deepEqual(x.findBuff('talent:shu:seasons:hp')?.mods, { hpPct: t1.max_hp }, `T${tier}: max HP +12 % (${x.defId})`);
assert.equal(x.findBuff('talent:shu:seasons:aspd'), null, `T${tier}: no three of one`);
}
assert.equal(u.findBuff('talent:shu:sui'), null, `T${tier}: one 【岁】 only`);
h.b.retreat(u);
h.run(0.5);
assert.equal(h.unit(2).findBuff('talent:shu:seasons:hp'), null, `T${tier}: only while she is deployed`);
done(h);
}
// three WARRIOR: ASPD +12 (two professions only)
{
const others = [{ uid: 2, chessId: 't_war', row: 12, col: 3 }, { uid: 3, chessId: 't_war2', row: 12, col: 5 }, { uid: 4, chessId: 't_war3', row: 12, col: 7 }];
const { h, u } = field({ tier, elite, skill: 1, others });
h.run(0.3);
for (const x of [u, h.unit(2)]) {
assert.deepEqual(x.findBuff('talent:shu:seasons:aspd')?.mods, { aspd: t1.attack_speed }, `T${tier}: ASPD +12`);
assert.equal(x.findBuff('talent:shu:seasons:hp'), null, `T${tier}: two professions`);
}
done(h);
}
// four 【岁】: 黍, 重岳 (自选), 夕, 余 — and an operator whose SP comes only from gifts
{
const others = [{ uid: 2, diy: { slot: 6, charId: 'char_2024_chyue', skillIndex: 0 }, row: 12, col: 3 },
{ uid: 3, chessId: 'chess_char_5_12_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_6_03_a', row: 11, col: 3 },
{ uid: 5, chessId: 't_sp', row: 12, col: 8 }];
const { h, u } = field({ tier, elite, skill: 1, others });
h.step();
const sp = h.unit(5);
for (const x of [u, h.unit(2), h.unit(3), h.unit(4), sp]) assert.deepEqual(x.findBuff('talent:shu:sui')?.mods, { atkPct: t1.atk }, `T${tier}: ATK +12 % (${x.defId})`);
assert.equal(sp.skill.sp, 0);
h.run(3.9);
assert.equal(sp.skill.sp, 0, `T${tier}: nothing before 4 s`);
h.run(0.2);
assert.equal(sp.skill.sp, t1.sp, `T${tier}: +1 SP at 4 s`);
h.b.retreat(u);
h.run(4);
assert.equal(sp.skill.sp, 2 * t1.sp, `T${tier}: on without her deployed`);
done(h);
}
}
});
test('GUA-X “钦天司时”: her heals ×1.15 on a target at or below 50 % HP (stages 1 and 3), never above, never a regen tick; none without the module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 't_a', row: 12, col: 9 }] });
const a = h.unit(2);
const on = elite && mod === GUAX;
if (on) assert.deepEqual(u.def.raw.trait.bb, { heal_scale: 1.15, hp_ratio: 0.5 }, label(f));
a.hp = a.s.maxHp * 0.5;
approx(h.b.heal(u, a, 1000), on ? 1150 : 1000, `${label(f)}: at 50 %`);
a.hp = a.s.maxHp * 0.51;
approx(h.b.heal(u, a, 1000), 1000, `${label(f)}: above 50 %`);
done(h);
}
});
+283
View File
@@ -0,0 +1,283 @@
// test/content/op_zumama.test.js — the 自选 operator kit of 森蚺 (char_416_zumama, 6★ 决战者; kit
// server/sim/content/kits/ops/op-zumama.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, HES-X
// “祖玛玛的工具箱”, HES-Y “小小的丑东西” or RA-A 森蚺特限证章 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_zumama.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 ZUMAMA = 'char_416_zumama';
const FORMS = BACKUPS.units[ZUMAMA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const HESX = 'uniequip_002_zumama', HESY = 'uniequip_003_zumama', RAA = 'uniequip_004_zumama';
const S1 = 'skchr_zumama_1', S2 = 'skchr_zumama_2', S3 = 'skchr_zumama_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_tough: dummy('enemy_tough', { immunities: { stun: true } }),
enemy_frail: dummy('enemy_frail', { hp: 10 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, HESX, HESY, RAA].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 = 1, 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: ZUMAMA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
/** An enemy she blocks: a still dummy put on her own tile (blocked on the next tick). */
function blockOne(h, u, key = 'enemy_dummy') {
const e = h.spawn(key, { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => e.blockedBy === u, 1), 'blocked');
return e;
}
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 1, melee ground-only, 萨尔贡, no 特质; triggers from the data', () => {
assert.equal(OPERATOR_KITS[ZUMAMA], KITS[ZUMAMA]);
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], [ZUMAMA, 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], [1, 'melee', 'phys', false, 1.6], `${label(f)}: 决战者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
// the 重装 exception (the owner's decision of 2026-10-05): S2 / S3 cast in range (DEFAULT), the official TAKE_DAMAGE kept
const sk = form.skills[skill];
if (skill === 0) assert.deepEqual([u.skill.kind, sk.skillType, u.skill.spCost], ['passive', 'PASSIVE', 0], `${label(f)}: S1 passive`);
else assert.deepEqual([u.skill.rule, sk.trigger.rule, sk.trigger.rawRule, sk.skillType], ['DEFAULT', 'DEFAULT', 'TAKE_DAMAGE', 'MANUAL'], `${label(f)}: S${skill + 1} trigger`);
done(h);
}
}
// zh_CN numbers: E2 Lv1 3440 / 835 / 492, E2 Lv60 4121 / 949 / 574; HES-X +80/+70 → +105/+85 ATK/DEF, HES-Y +70/+80 →
// +85/+110, RA-A +280/+80 → +380/+130 HP/ATK
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], [3440, 835, 4121, 949]);
assert.deepEqual([modOf(5, HESX).attr, modOf(6, HESX).attr, modOf(5, HESY).attr, modOf(6, HESY).attr, modOf(5, RAA).attr, modOf(6, RAA).attr],
[{ atk: 80, def: 70 }, { atk: 105, def: 85 }, { atk: 70, def: 80 }, { atk: 85, def: 110 }, { maxHp: 280, atk: 80 }, { maxHp: 380, atk: 130 }]);
});
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(ZUMAMA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ZUMAMA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ZUMAMA, skillIndex: 2, uniEquipId: HESX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: ZUMAMA, skillIndex: 2, uniEquipId: HESX } } });
});
test('S1 轻型挂斧 (PASSIVE): ATK / DEF +14 % / +18 % from every deployment, no SP', () => {
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.bb.atk, sk.bb.def], elite ? [0.18, 0.18] : [0.14, 0.14], `T${tier}`);
assert.ok(u.skill.active, `T${tier}: on from the deployment`);
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`);
h.b.retreat(u);
h.b.redeploy(u);
h.step();
assert.ok(u.skill.active, `T${tier}: on again after a redeploy`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK again`);
done(h);
}
});
test('S2 震慑劈砍 (data DEFAULT: casts with the enemy she blocks): 16 / 17 s, ATK +100 % / +130 %, interval 1.6 + 0.4 s, every enemy she blocks stunned past 抵抗 while it lasts; a stun immunity holds; all gone after', () => {
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([sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.spCost, sk.initSp], elite ? [17, 1.3, 0.4, 32, 12] : [16, 1, 0.4, 35, 11], `T${tier}`);
u.skill.gainSp(999, 'init');
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
h.b.applyStatus(e, 'resist', { duration: 999, value: 0.5 });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast once she blocks it`);
assert.equal(e.blockedBy, u);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 2.0, `T${tier}: 1.6 + 0.4 s`);
h.step(2);
assert.ok(e.s.flags.stun, `T${tier}: the blocked enemy is stunned`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.status === 'stun' && c.target === e && c.source === u);
assert.ok(stuns.length > 0 && stuns.every((c) => Math.abs(c.duration - 0.1) < 1e-9), `T${tier}: 抵抗 does not shorten it`);
h.run(5);
assert.ok(e.s.flags.stun && u.skill.active, `T${tier}: still stunned while blocked`);
// a new blockee mid-skill: the first one falls, a stun-immune one and a plain one come in turn
h.b.kill(e, u);
const tough = h.spawn('enemy_tough', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => tough.blockedBy === u, 1));
h.step(3);
assert.ok(!tough.s.flags.stun, `T${tier}: 晕眩 immunity`);
h.b.kill(tough, u);
const late = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC] });
assert.ok(h.runUntil(() => late.blockedBy === u, 1));
h.step(2);
assert.ok(late.s.flags.stun, `T${tier}: an enemy blocked mid-skill is stunned too`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration), `T${tier}: ends`);
h.run(0.15);
assert.ok(!late.s.flags.stun, `T${tier}: the stun ends with the skill`);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
// the stunned enemies fought nobody meanwhile: her normal attacks hit them (physical, ground)
assert.ok(atkHits(h, u).some((c) => c.target === late && c.type === 'phys'), `T${tier}: she keeps attacking`);
done(h);
}
});
test('S3 钢铁意志 (data DEFAULT): 31 / 32 s, ATK +140 % / +170 %, DEF +90 % / +120 %, block 1 + 2, 生命回复速度 3 % max HP / s (禁疗-proof); 5 s self-stun after, blocking nobody meanwhile', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, HESX]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.def, sk.bb.block_cnt, sk.bb.hp_recovery_per_sec_by_max_hp_ratio, sk.bb.stun], elite ? [32, 1.7, 1.2, 2, 0.03, 5] : [31, 1.4, 0.9, 2, 0.03, 5], `T${tier}`);
u.skill.gainSp(999, 'init');
const e = blockOne(h, u);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with the enemy she blocks`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.05);
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.equal(u.s.blockCnt, 3, `T${tier}: block 1 + 2`);
approx(u.s.hpRegen, u.s.maxHp * 0.03, `T${tier}: 3 % max HP / s`);
// 生命回复速度, no heal: 禁疗 and 无法被治疗 do not stop it
h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } });
u.hp = u.s.maxHp * 0.5;
const hp0 = u.hp;
h.run(1);
approx(u.hp - hp0, u.s.maxHp * 0.03, `T${tier}: +3 % in 1 s under 禁疗`, 0.05);
h.b.removeBuff(u, 'test:healFree');
assert.ok(h.runUntil(() => !u.skill.active, sk.duration + 1), `T${tier}: ends`);
const st = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === u);
assert.ok(st, `T${tier}: self-stun`);
approx(st.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
h.step(2);
assert.ok(u.s.flags.stun && u.blocking.length === 0 && e.blockedBy !== u, `T${tier}: stunned, blocking nobody`);
assert.equal(u.s.blockCnt, 1, `T${tier}: block back to 1`);
h.run(sk.bb.stun + 0.2);
assert.ok(!u.s.flags.stun, `T${tier}: the stun is over`);
done(h);
}
});
test('T1 勇冠三军: above half HP every hit ×1.15 as an 攻击力倍率 (×1.23 with HES-Y stage 3), at or below half 20 % 庇护 (28 %) on physical / arts damage only — checked every 0.1 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const y3 = elite && mod === HESY && tier === 6;
assert.deepEqual([t0.hp_ratio, t0.atk_scale, t0.damage_resistance], y3 ? [0.5, 1.23, 0.28] : [0.5, 1.15, 0.2], label(f));
const e = h.spawn('enemy_dummy', { pos: [u.tileR, u.tileC + 1] }); // in her 1-1, not blocked: no SP, no cast
h.run(0.2);
assert.equal(u.s.atkScaleMul, t0.atk_scale, `${label(f)}: above half`);
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 3));
const hit = atkHits(h, u)[n0];
approx(hit.amount, u.s.atk * t0.atk_scale, `${label(f)}: ×${t0.atk_scale} on a 0-DEF target`);
// half HP: 庇护, no ×1.15
u.hp = u.s.maxHp * 0.5;
h.run(0.14);
assert.equal(u.s.atkScaleMul, 1, `${label(f)}: at half: no bonus`);
const pb = u.findBuff('protect');
assert.ok(pb, `${label(f)}: 庇护`);
approx(pb.data.value, t0.damage_resistance, `${label(f)}: ${t0.damage_resistance * 100} %`);
const hit1 = (amount, type) => { u.hp = u.s.maxHp * 0.5; return h.b.dealDamage(e, u, { amount, type }); };
approx(hit1(400, 'arts'), 400 * (1 - t0.damage_resistance), `${label(f)}: arts`);
approx(hit1(300, 'true'), 300, `${label(f)}: true untouched`);
approx(hit1(u.s.def + 400, 'phys'), 400 * (1 - t0.damage_resistance), `${label(f)}: physical`);
// back above half: the bonus within one check, the 庇护 gone
u.hp = u.s.maxHp * 0.9;
h.run(0.25);
assert.equal(u.s.atkScaleMul, t0.atk_scale, `${label(f)}: above half again`);
assert.equal(u.findBuff('protect'), null, `${label(f)}: no 庇护`);
done(h);
}
});
test('trait 决战者: no module ⇒ 阻回 (no SP of any kind, gifts too) unless she blocks; HES-X ×0.2 / HES-Y ×0.001 / RA-A ×0 SP recovery unblocked (gifts land); T2 愈战愈勇 +0.2 / s blocking (HES-X stage 3 +0.55; RA-A stays +0.2); HES-Y ATK / DEF +15 % blocking', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const form = formOf(tier, elite);
const m = elite ? modOf(tier, mod) : null;
const ratio = m ? m.traitOverride.bb.sp_recover_ratio : null;
const t2 = elite && mod === HESX && tier === 6 ? 0.55 : 0.2;
assert.equal(ratio, { [HESX]: -0.8, [HESY]: -0.999, [RAA]: -1 }[mod] ?? null, label(f));
const init = skillOf(tier, elite, S2).initSp;
assert.ok(u.skill.sp >= init && u.skill.sp < init + 0.05, `${label(f)}: initSp at the deployment despite 阻回 (${u.skill.sp})`);
// unblocked
h.run(0.2);
const sp0 = u.skill.sp;
h.run(5);
const unblocked = u.skill.sp - sp0;
if (ratio == null) {
assert.ok(u.s.flags.noSp, `${label(f)}: 阻回`);
approx(unblocked, 0, `${label(f)}: no SP`);
assert.equal(u.skill.gainSp(3, 'talent'), 0, `${label(f)}: a gift refused`);
} else {
assert.ok(!u.s.flags.noSp, `${label(f)}: no 阻回 with a module`);
approx(u.s.spRecovery, 1 + ratio, `${label(f)}: ×${1 + ratio}`);
approx(unblocked, 5 * (1 + ratio), `${label(f)}: ${5 * (1 + ratio)} SP in 5 s`, 1e-3);
approx(u.skill.gainSp(3, 'talent'), 3, `${label(f)}: a gift lands`);
}
assert.equal(u.findBuff('trait:zumama:block'), null, `${label(f)}: nothing blocked`);
// blocking: full SP recovery + T2
u.skill.sp = 0;
blockOne(h, u);
h.step();
approx(u.s.spRecovery, 1 + t2, `${label(f)}: 1 + ${t2} / s blocking`);
const sp1 = u.skill.sp;
h.run(5);
approx(u.skill.sp - sp1, 5 * (1 + t2), `${label(f)}: SP while blocking`, 1e-3);
if (elite && mod === HESY) {
assert.deepEqual(u.findBuff('trait:zumama:block')?.mods, { atkPct: 0.15, defPct: 0.15 }, `${label(f)}: HES-Y`);
} else assert.equal(u.findBuff('trait:zumama:block'), null, `${label(f)}: no ATK / DEF trait`);
if (elite && mod === RAA && tier === 6) {
// the composed record carries the 生息演算-only 0.4 / s; the kit reads 愈战愈勇 from talentsBase
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.sp_recovery_per_sec, 0.4, 'the sandbox part is in the record');
assert.equal(u.def.raw.talentsBase.find((t) => t.index === 1).bb.sp_recovery_per_sec, 0.2);
}
assert.equal(form.trait.desc, '只有阻挡敌人时才能够回复技力');
done(h);
}
});
test('RA-A 森蚺特限证章: only its attributes and its trait ratio apply here — no 生息演算 parts (S2 block stays 1, no 物理脆弱, no attraction)', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: RAA, skill: 1, row: 9, col: 5 });
const m = modOf(tier, RAA);
assert.match(m.traitOverride.moduleDesc, /生息演算/);
u.skill.gainSp(999, 'init');
const far = h.spawn('enemy_dummy', { pos: [12, 9] });
const e = blockOne(h, u);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(u.s.blockCnt, 1, `T${tier}: no 技能期间阻挡数+2`);
approx(e.s.physTakenMul, 1, `T${tier}: no 物理脆弱`);
approx(far.x, 9, `T${tier}: nobody pulled`);
done(h);
}
});