Files
ark-stronghold-protocol/test/content/op_hsgma2.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

392 lines
26 KiB
JavaScript

// test/content/op_hsgma2.test.js — the 自选 operator kit of 斩业星熊 (char_1044_hsgma2, 6★ 驭法铁卫; kit
// server/sim/content/kits/ops/op-hsgma2.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 AST-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_hsgma2.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 HSG = 'char_1044_hsgma2';
const FORMS = BACKUPS.units[HSG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const ASTX = 'uniequip_002_hsgma2';
const S1 = 'skchr_hsgma2_1', S2 = 'skchr_hsgma2_2', S3 = 'skchr_hsgma2_3';
const TEXAS = 'chess_char_1_08_a', YAK = 'chess_char_1_02_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_shooter: dummy('enemy_shooter', { atk: 300, bat: 1, range: 4 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, ASTX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 斩业星熊 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal', 'death'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HSG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const skillBuff = (u) => u.findBuff(`skill:${u.id}`);
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
const mine = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy');
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('斩业星熊 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 3, melee ground-only, physical attacks while no skill runs (the data\'s arts is the skill-on type), 炎, no 特质', () => {
assert.equal(OPERATOR_KITS[HSG], KITS[HSG]);
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], [HSG, 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, 15], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, 'melee', false, 'phys', 1.6], `${label(f)}: 驭法铁卫`);
assert.equal(form.dmgType, 'phys', 'the data carries the normal attack\'s type (build-data classifyAttack: "技能开启时…法术伤害")');
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 2592 / 533 / 461, E2 Lv60 3228 / 622 / 547; AST-X +190 / +55 → +325 / +80
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], [2592, 533, 3228, 622]);
assert.deepEqual([modOf(5, ASTX).attr, modOf(6, ASTX).attr], [{ maxHp: 190, atk: 55 }, { maxHp: 325, atk: 80 }]);
});
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(HSG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HSG), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: HSG, skillIndex: 2, uniEquipId: ASTX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: HSG, skillIndex: 2, uniEquipId: ASTX } } });
});
test('triggers: S1 (AUTO, self buff) fires at full SP with no enemy (the kit\'s SP_FULL); S2 (AUTO) keeps the data DEFAULT; S3 (MANUAL) the data\'s ACTIVE_RANGE on its x-2 (rawRule TAKE_DAMAGE): an enemy two tiles ahead casts it, a shot from outside the x-2 does not', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const form = formOf(tier, elite);
assert.deepEqual(form.skills.map((s) => [s.skillType, s.trigger.rule, s.trigger.rawRule]), [['AUTO', 'DEFAULT', 'DEFAULT'], ['AUTO', 'DEFAULT', 'DEFAULT'], ['MANUAL', 'ACTIVE_RANGE', 'TAKE_DAMAGE']], `T${tier}: data`);
assert.deepEqual(form.skills[2].trigger.customRangeGrid, form.skills[2].rangeGrid, `T${tier}: the running x-2`);
// S1: no enemy at all
const a = field({ tier, elite, skill: 0 });
assert.deepEqual([a.u.skill.rule, a.u.skill.spType, a.u.skill.kind], ['SP_FULL', 'hurt', 'toggle'], `T${tier} S1`);
a.u.skill.gainSp(999);
assert.ok(a.h.runUntil(() => a.u.skill.active, 0.2), `T${tier} S1: cast at once, no enemy`);
done(a.h);
// S2: waits for an attack
const b = field({ tier, elite, skill: 1 });
assert.deepEqual([b.u.skill.rule, b.u.skill.spType], ['DEFAULT', 'attack'], `T${tier} S2`);
b.u.skill.gainSp(999);
b.h.run(2);
assert.equal(b.u.skill.activations, 0, `T${tier} S2: no target, no cast`);
b.h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(b.h.runUntil(() => b.u.skill.activations === 1, 2), `T${tier} S2: cast on her next attack`);
done(b.h);
// S3: the x-2
const c = field({ tier, elite, skill: 2 });
assert.equal(c.u.skill.rule, 'ACTIVE_RANGE', `T${tier} S3`);
c.h.spawn('enemy_shooter', { pos: [10, 9] });
c.u.skill.gainSp(999);
c.h.run(3);
assert.ok(c.h.hooksOf('damaged').some((x) => x.target === c.u), `T${tier} S3: she is being shot`);
assert.equal(c.u.skill.activations, 0, `T${tier} S3: a hit from outside the x-2 casts nothing`);
c.h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(c.h.runUntil(() => c.u.skill.activations === 1, 1), `T${tier} S3: an enemy two tiles ahead (outside her 1-1) casts it`);
done(c.h);
}
});
test('S1 恶业苦果 (持续时间无限): ATK / DEF +40 % / +55 %, arts attacks, every enemy attack she takes returns 100 % / 130 % ATK arts to the attacker (ranged too); it never ends on its own', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.def, sk.bb.atk_scale], [-1, 15, 0, elite ? 0.55 : 0.4, elite ? 0.55 : 0.4, elite ? 1.3 : 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0 });
const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_shooter', { pos: [10, 8] });
h.run(2);
const b0 = h.hooksOf('damaged').length;
h.b.dealDamage(near, u, { amount: 10, type: 'phys', isAttack: true });
assert.ok(!mine(h, u, b0).some((c) => c.dmg.tags?.includes('counter')), `T${tier}: no counter before the cast`);
assert.ok(mine(h, u).filter((c) => c.dmg.isAttack).every((c) => c.type === 'phys'), `T${tier}: physical attacks before`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { atkPct: sk.bb.atk, defPct: sk.bb.def }, `T${tier}: the skill buff`);
for (const src of [near, far]) {
const n = h.hooksOf('damaged').length;
h.b.dealDamage(src, u, { amount: 10, type: 'phys', isAttack: true });
const back = mine(h, u, n).filter((c) => c.target === src && c.dmg.tags?.includes('counter'));
assert.equal(back.length, 1, `T${tier}: a counter on ${src.defId}`);
assert.equal(back[0].type, 'arts');
approx(back[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
}
const n1 = h.hooksOf('damaged').length;
h.run(60);
assert.ok(u.skill.active, `T${tier}: still on after 60 s`);
const atk = mine(h, u, n1).filter((c) => c.dmg.isAttack && c.target === near);
assert.ok(atk.length > 10 && atk.every((c) => c.type === 'arts'), `T${tier}: arts attacks while it runs`);
done(h);
}
});
test('S2 无始无明 (AUTO, attack SP 9 / 7): the cast\'s attack throws the shield — 3 hits of 55 % / 70 % ATK arts on every blocked enemy; the shield then turns 365° at 90°/s (4.06 s) around her, no SP meanwhile, her attacks of the flight in arts; then plain physical attacks and SP again', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.spCost, sk.initSp, sk.bb['attack@atk_scale'], sk.bb.shield_atk_scale, sk.bb.sluggish, sk.bb.interval, sk.bb.heal_ratio],
[elite ? 7 : 9, 0, elite ? 0.7 : 0.55, elite ? 1.1 : 0.8, 1, 0.5, elite ? 0.15 : 0.1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
// two enemies on her own tile, blocked: the shield (1.2 tiles out, radius 1.0) never reaches her centre
const a = h.spawn('enemy_dummy', { pos: [10, 5] }), b = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.blocking.length === 2, 1), `T${tier}: both blocked`);
u.skill.gainSp(999);
const n0 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
const t0 = h.b.time;
const throwHits = mine(h, u, n0).filter((c) => c.dmg.isAttack);
assert.equal(new Set(throwHits.map((c) => c.dmg.attackId)).size, 1, `T${tier}: one attack`);
for (const e of [a, b]) {
const hits = throwHits.filter((c) => c.target === e);
assert.equal(hits.length, 3, `T${tier}: 3 hits on each blocked enemy`);
for (const c of hits) { assert.equal(c.type, 'arts'); approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); }
}
const sp0 = u.skill.sp;
const n1 = h.hooksOf('damaged').length;
assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: the turn ends it`);
approx(h.b.time - t0, 365 / 90, `T${tier}: 365° at 90°/s`, 0.02);
assert.equal(u.skill.sp, sp0, `T${tier}: no SP during the flight`);
const flight = mine(h, u, n1).filter((c) => c.dmg.isAttack);
assert.ok(flight.length >= 2 && flight.every((c) => c.type === 'arts'), `T${tier}: ${flight.length} arts attacks during it`);
assert.ok(!mine(h, u, n1).some((c) => c.dmg.tags?.includes('hsgma2:shield')), `T${tier}: nothing within 1.0 of the shield`);
const n2 = h.hooksOf('damaged').length;
h.run(4);
const after = mine(h, u, n2).filter((c) => c.dmg.isAttack);
assert.ok(after.length > 0 && after.every((c) => c.type === 'phys' && Math.abs(c.amount - Math.max(u.s.atk * 0.05, u.s.atk)) < 1e-6), `T${tier}: plain physical attacks after`);
assert.ok(u.skill.sp > 0, `T${tier}: attack SP again`);
done(h);
}
});
test('S2 shield: within 1.0 of it a targetable ground enemy slows it to 18°/s; every 0.5 s from the throw each such enemy takes 停顿 1 s and 80 % / 110 % ATK arts, and she heals 10 % / 15 % of the HP it lost (through her 禁疗 too); a flyer is untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
const front = h.spawn('enemy_dummy', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [11, 5] });
h.b.addBuff(u, { key: 'test:healFree', flags: { healFree: true, noHeal: true } }); // her 禁疗 (as in 我执)
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
const t0 = h.b.time;
const atk = u.s.atk; // at full HP (no 坚忍): the hits and heals below use it
h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.tags?.includes('hsgma2:shield')), 1);
u.hp = 100; // from now on her heals show in her HP (坚忍 then raises her ATK: amounts are checked on the first hit)
h.runUntil(() => !u.skill.active, 15);
// slowed while the shield is within 1.0 of the enemy one tile ahead: |θ| ≤ acos(0.6) at both ends of the turn
const slow = (2 * Math.acos(0.6) * 180) / Math.PI - 0;
const startArc = (Math.acos(0.6) * 180) / Math.PI, endArc = 365 - (360 - startArc);
const want = startArc / 18 + endArc / 18 + (365 - startArc - endArc) / 90;
approx(h.b.time - t0, want, `T${tier}: ${want.toFixed(2)} s with the slowed arcs (${slow.toFixed(1)}°)`, 0.02);
const shield = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.tags?.includes('hsgma2:shield'));
assert.ok(shield.length >= 10 && shield.every((c) => c.target === front && c.type === 'arts'), `T${tier}: ${shield.length} shield hits, the front enemy only`);
approx(shield[0].amount, atk * sk.bb.shield_atk_scale, `T${tier}: ${sk.bb.shield_atk_scale * 100} % ATK`);
const sl = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.equal(sl.length, shield.length, `T${tier}: 停顿 with every hit`);
assert.ok(sl.every((c) => c.target === front && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 1 s`);
const heals = h.hooksOf('heal').filter((c) => c.target === u && c.source === u);
assert.ok(heals.length >= shield.length, `T${tier}: a heal per hit`);
approx(heals[0].amount, shield[0].amount * sk.bb.heal_ratio, `T${tier}: ${sk.bb.heal_ratio * 100} % of the HP lost`);
assert.ok(u.hp > 100, `T${tier}: healed through 禁疗`);
assert.ok(!h.hooksOf('damaged').some((c) => c.source === u && c.target === fly), `T${tier}: the flyer untouched`);
done(h);
}
});
test('S3 地狱变相 (32 s): range x-2, max HP +55 % / +70 %, ATK +150 % / +180 %, two arts hits on up to 2 enemies — air units too; back to 1-1 and physical 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.max_hp, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.before_dead_duration],
[32, elite ? 50 : 55, elite ? 31 : 28, 'x-2', elite ? 0.7 : 0.55, elite ? 1.8 : 1.5, 2, 11], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2 });
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
const g1 = h.spawn('enemy_dummy', { pos: [9, 6] }), g2 = h.spawn('enemy_dummy', { pos: [11, 6] }), g3 = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
assert.deepEqual(skillBuff(u).mods, { hpPct: sk.bb.max_hp, atkPct: sk.bb.atk }, `T${tier}: the skill buff`);
approx(u.s.maxHp, u.base.maxHp * (1 + sk.bb.max_hp), `T${tier}: max HP`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2 while it runs`);
const n0 = h.hooksOf('damaged').length;
h.run(6);
const atk = mine(h, u, n0).filter((c) => c.dmg.isAttack);
const byAttack = new Map();
for (const c of atk) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c]);
assert.ok(byAttack.size >= 2, `T${tier}: attacks`);
for (const [, list] of byAttack) {
const targets = new Set(list.map((c) => c.target.id));
assert.equal(targets.size, 2, `T${tier}: two of the four enemies on her x-2 per attack`);
for (const id of targets) assert.equal(list.filter((c) => c.target.id === id).length, 2, `T${tier}: two hits each`);
assert.ok(list.every((c) => c.type === 'arts'), `T${tier}: arts`);
}
// with the ground enemies gone, the flyer on her x-2 is struck (技能期间攻击可对空)
for (const g of [g1, g2, g3]) h.b.kill(g, null);
const n1 = h.hooksOf('damaged').length;
h.run(3);
assert.ok(mine(h, u, n1).some((c) => c.target === fly && c.dmg.isAttack && c.type === 'arts'), `T${tier}: air units too`);
u.skill.extend(-999);
h.step();
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1`);
approx(u.s.maxHp, u.base.maxHp, `T${tier}: max HP back`);
done(h);
}
});
test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes it): 11 s with the bonuses kept, four hits, 阻回 + 沉默 + 不死; an allied operator on her range survives at 1 HP and the excess is her 流失, one outside falls; then she leaves the field; a plain stop or the time running out does nothing of it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 2, others: [{ uid: 2, chessId: TEXAS, row: 11, col: 6 }, { uid: 3, chessId: YAK, row: 12, col: 9 }] });
const tex = h.unit(2), yak = h.unit(3);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
h.run(2);
u.skill.end('manual');
assert.ok(u.skill.active && u.mem.hsDying, `T${tier}: the 临死模式 keeps the skill running`);
approx(u.skill.timeLeft, 11, `T${tier}: 11 s`);
assert.deepEqual(u.findBuff('skill:hsgma2:dying')?.flags, { noSp: true, silence: true }, `T${tier}: 阻回 + 沉默`);
assert.equal(u.skill.spec.attack.hits, 4, `T${tier}: four hits`);
assert.ok(skillBuff(u), `T${tier}: the bonuses stay`);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, `T${tier}: no second cast`);
// 不死 for her …
h.b.dealDamage(e, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive, `T${tier}: she cannot be knocked out`);
// … and for 德克萨斯 on her x-2 (the excess becomes her 流失); 角峰 outside falls
const over = 500;
h.b.dealDamage(e, tex, { amount: tex.hp + over, type: 'true' });
assert.deepEqual([tex.alive, tex.hp], [true, 1], `T${tier}: 德克萨斯 at 1 HP`);
const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hsgma2:shoulder'));
assert.equal(loss.length, 1, `T${tier}: one 流失`);
approx(loss[0].amount, over, `T${tier}: the excess`);
assert.ok(u.alive && u.mem.hsEgo, `T${tier}: (she is in 我执 since the blow above; 不死 holds her)`);
h.b.dealDamage(e, yak, { amount: yak.hp + 10, type: 'true' });
assert.equal(yak.alive, false, `T${tier}: 角峰 (off her range) falls`);
h.run(11.1);
const out = h.hooksOf('death').filter((c) => c.unit === u);
assert.deepEqual(out.map((c) => [c.reason, c.dying]), [['retreat', true]], `T${tier}: she leaves the field after 11 s`);
done(h);
// a plain stop / the duration: no 临死模式
for (const how of ['stop', 'time']) {
const g = field({ tier, elite, skill: 2 });
g.h.spawn('enemy_dummy', { pos: [10, 6] });
g.u.skill.gainSp(999);
assert.ok(g.h.runUntil(() => g.u.skill.active, 1));
if (how === 'stop') g.u.skill.stop(); else g.h.runUntil(() => !g.u.skill.active, 40);
assert.deepEqual([g.u.skill.active, !!g.u.mem.hsDying, g.u.alive], [false, false, true], `T${tier} ${how}: no 临死模式`);
done(g.h);
}
}
});
test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.max_minus_hp_ratio, t0['hsgma2_t_1[heal].interval'], t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']], [2, 4, 0.05], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] });
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step();
const max = u.s.maxHp;
h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' });
assert.ok(u.alive && u.mem.hsEgo, `T${tier}: in 我执`);
assert.equal(u.hp, 1, `T${tier}: the engine's HP floor`);
approx(u.mem.hsEgo.pool, 300, `T${tier}: the excess`);
assert.deepEqual(u.findBuff('talent:hsgma2:ego')?.flags, { noHeal: true, healFree: true }, `T${tier}: 禁疗`);
const sp = u.skill.sp;
h.b.dealDamage(e, u, { amount: 500, type: 'true' });
assert.deepEqual([u.hp, Math.round(u.mem.hsEgo.pool), u.skill.sp], [1, 800, sp + 1], `T${tier}: into the pool, +1 受击回复 SP`);
assert.equal(h.b.heal(h.unit(2), u, 1000), 0, `T${tier}: no heal from others`);
// every HP change goes to the negative HP first: a heal that passes 禁疗 (S2's) lowers it, a 流失 raises it
h.b.heal(u, u, 200, { self: true, ignoreHealFree: true });
h.step();
assert.deepEqual([u.hp, Math.round(u.mem.hsEgo.pool)], [1, 600], `T${tier}: a heal that ignores 禁疗 clears negative HP first`);
h.b.loseHp(u, 200, { source: e });
assert.deepEqual([u.alive, u.hp, Math.round(u.mem.hsEgo.pool)], [true, 1, 800], `T${tier}: a 流失 adds to it`);
// quiet: the regeneration starts at 4 s and clears the pool at 5 % max HP / s
h.run(3.9);
assert.equal(u.findBuff('talent:hsgma2:egoRegen'), null, `T${tier}: not before 4 s`);
h.run(0.2);
assert.deepEqual(u.findBuff('talent:hsgma2:egoRegen')?.mods, { hpRegenRatio: 0.05 }, `T${tier}: 生命回复速度(百分比)`);
const t1 = h.b.time;
assert.ok(h.runUntil(() => !u.mem.hsEgo, 30), `T${tier}: out of 我执`);
approx(h.b.time - t1, 800 / (0.05 * max), `T${tier}: 800 at ${0.05 * max} / s`, 0.03);
assert.ok(!u.findBuff('talent:hsgma2:ego') && !u.findBuff('talent:hsgma2:egoRegen'), `T${tier}: 禁疗 / regeneration gone`);
// again into 我执, then 200 %
h.b.dealDamage(e, u, { amount: u.hp + 100, type: 'true' });
assert.ok(u.mem.hsEgo, `T${tier}: 我执 again`);
h.b.dealDamage(e, u, { amount: 2 * max - 100 - 1, type: 'true' });
assert.ok(u.alive, `T${tier}: just under 200 %`);
h.b.dealDamage(e, u, { amount: 5, type: 'true' });
assert.equal(u.alive, false, `T${tier}: 200 % knocks her out`);
assert.equal(u.removeReason, 'killed');
done(h);
}
});
test('T2 鬼之架势 坚忍: RES up to +50 and ATK up to +35 % by the HP lost, full at 70 % lost; AST-X stage 3: full at 50 % lost and then ATK +15 % more', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x3 = elite && mod === ASTX && tier === 6;
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const tb = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual(tb, x3 ? { ...t1, min_hp_ratio: 0.5, atk: 0.15 } : t1, `${label(f)}: data`);
const lo = x3 ? 0.5 : 0.3;
for (const r of [1, 0.85, lo, 0.1]) {
u.hp = r * u.s.maxHp;
h.step();
const fac = Math.min(1, (1 - r) / (1 - lo));
const b = u.findBuff('talent:hsgma2:berserk');
if (fac === 0) { assert.equal(b, null, `${label(f)} ${r}: nothing at full HP`); continue; }
approx(b.mods.resFlat, 50 * fac, `${label(f)} ${r}: RES`);
approx(b.mods.atkPct, 0.35 * fac + (x3 && fac >= 1 ? 0.15 : 0), `${label(f)} ${r}: ATK`);
}
approx(u.s.res, 15 + 50, `${label(f)}: RES 65 at the most`);
done(h);
}
});
test('AST-X 无迹 trait (stages 1 and 3, skill or not): every damage instance of her attacks adds 10 % ATK arts (no dodge) on that enemy; every enemy attack she takes returns 10 % ATK arts; none without the module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x = elite && mod === ASTX;
if (x) assert.deepEqual(modOf(tier, ASTX).traitOverride.bb, { atk_scale: 0.1 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 2 });
noSp(h, u);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(3.5);
const atk = mine(h, u).filter((c) => c.dmg.isAttack);
const extra = mine(h, u).filter((c) => c.dmg.tags?.includes('hsgma2:astx'));
assert.ok(atk.length >= 2, `${label(f)}: attacks`);
assert.equal(extra.length, x ? atk.length : 0, `${label(f)}: one extra per attack hit`);
if (x) for (const c of extra) { assert.deepEqual([c.type, c.dmg.canDodge, c.dmg.isAttack], ['arts', false, false]); approx(c.amount, 0.1 * u.s.atk, `${label(f)}: 10 % ATK`); }
const n = h.hooksOf('damaged').length;
h.b.dealDamage(e, u, { amount: 10, type: 'phys', isAttack: true });
const back = mine(h, u, n).filter((c) => c.target === e && c.dmg.tags?.includes('hsgma2:astx'));
assert.equal(back.length, x ? 1 : 0, `${label(f)}: returned when attacked`);
if (x) approx(back[0].amount, 0.1 * u.s.atk, `${label(f)}: 10 % ATK back`);
done(h);
}
});