Files
ark-stronghold-protocol/test/content/op_pallas.test.js
T
sganggs c454b61075 feedback5 O9: 自选 operator kits 帕拉斯, 止颂 and 贝洛内 (with his 牵绊) — every skill, talent, trait and module (stage 1 / 3) at every form
- op-pallas.js (char_485_pallas, 教官): 英雄的诞生 精力充沛 on every 米诺斯 operator (one buff per ally, the
  highest active bonus — her S3's too), 女神的振奋 on every damage instance (her 1-1, through 禁疗; INS-Y stage 3 the
  米诺斯 extra), S1 two strikes, S2 range +1 / ATK / stun (data DEFAULT: build-data's ATTACK_RANGE_CHANGE misses
  "攻击范围向前延伸一格" — reported, not worked around), S3 three targets and the front-holder buff (low ground,
  operators only, passed on retreat / deploy). INS-Y's "可以额外部署在远程位" is a prep placement rule (owner's
  decision of 2026-10-04): unchanged, reported.
- op-lessng.js (char_4011_lessng, 无畏者): 苦痛专注 (hit-time cut while blocking, 无来源 / allies / himself
  included), 痛楚砺刃 (any damage aimed at him, dodged too), S2 the deploy window (2 strikes, ATK, talent ×2), S3
  status immunity, blocked-target scale, the cast under a 异常状态 (600 arts first, cleanse, then HP; 沉默 does
  not hold it back), DRE-X ×1.15 on a blocked target, DRE-Y once-per-deployment save and stage 3's 12 % DEF ignore.
- op-demetr.js (char_4037_demetr, 斗士): 【手段】 (one per enemy, stacks before the hit, 8 and 停顿 under S2, back
  to 5 on the 0.1 s update), the HP-ratio damage bonus, 街头直觉 from every deployment (a 【移动】 too), S1 two
  strikes, S2 3-13 / three targets, S3 the 【移动】s (x-2 around his located tile, buildable low-ground tiles,
  in-place move without a target, re-target after 1 s without damage or when the elite he hit falls), 牵绊
  (无敌 / untargetable / 孤立), the fatal-hit 不死 + end, the 【返回】 (SP emptied, a short 不死) and the forced
  retreat; FGT-Y ASPD above half HP.
- kits/index.js: the three files appended to OPERATOR_KIT_FILES.
- Tests: test/content/op_pallas.test.js (9), op_lessng.test.js (10), op_demetr.test.js (10).
- Golden: only diy moved — diy-049 … 054 now field the three operators (every form × module × skill), the
  prototype / reserve battles moved down to diy-054 … 058 (new ids, same packing); diy-001 … 048 and every other
  family unchanged.
2026-10-06 01:30:26 +08:00

325 lines
22 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_pallas.test.js — the 自选 operator kit of 帕拉斯 (char_485_pallas, 6★ 教官; kit
// server/sim/content/kits/ops/op-pallas.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, INS-X
// “故乡的山岩” or INS-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_pallas.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 { PEAK_KEY, holderKey } from '../../server/sim/content/kits/ops/op-pallas.js';
import { diyPool, validateDiyPicks, diyRecord } from '../../shared/diy.js';
import { positionClass } from '../../server/match/board.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 PALLAS = 'char_485_pallas';
const FORMS = BACKUPS.units[PALLAS].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const INSX = 'uniequip_002_pallas', INSY = 'uniequip_003_pallas';
const S1 = 'skchr_pallas_1', S2 = 'skchr_pallas_2', S3 = 'skchr_pallas_3';
/** The unit form of a slot (tier, normal / elite). */
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);
/** A talent of the composed record the unit fights with (module changes applied). */
const talentOf = (u, i) => u.def.raw.talents.find((t) => t.index === i)?.bb ?? {};
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: 0.6, mass: 0 }) };
/** A 米诺斯 operator of no kit (帕拉斯 is the only one of the data): a plain melee record with nationId minos. */
const MINOS_OP = { ...chessRec({ id: 'test_minos_a', name: '米诺斯测试', stats: { maxHp: 2000, atk: 500, def: 200, blockCnt: 1 }, skill: null }), nationId: 'minos' };
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, INSX, INSY].map((m) => [t, true, m]))];
/** 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, rows = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: { test_minos_a: MINOS_OP } }, timeLimit: 600, autoFinish: false, seed,
...(rows ? { stageId: undefined, flat: { rows } } : {}),
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: PALLAS, 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);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** The instructor scale of a form (trait bb atk_scale; INS-X's override). */
const traitScale = (tier, elite, mod) => (elite && mod ? modOf(tier, mod).traitOverride.bb.atk_scale : formOf(tier, elite).trait.bb.atk_scale);
test('帕拉斯 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 2-2, blocks 2, melee ground-only, 教官 ×1.2 (INS-X ×1.3), 米诺斯, no 特质', () => {
assert.equal(OPERATOR_KITS[PALLAS], KITS[PALLAS]);
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], [PALLAS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.sub, u.base.bat, u.dmgType], [2, 'melee', false, 'instructor', 1.05, 'phys'], `${label(f)}: 教官`);
approx(u.profile.unblockedScale, traitScale(tier, elite, mod), `${label(f)}: 教官 scale`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 2-2`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.nationId], [['emptyShip'], [], 'minos'], `${label(f)}: bonds / 特质 / nation`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1413 / 577 / 382, E2 Lv60 1778 / 650 / 430; INS-X +150 HP +55 ATK → +210 / +83,
// trait 130 %; INS-Y +40 ATK +3 ASPD → +60 / +5
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.atk], [1413, 577, 382, 1778, 650]);
assert.deepEqual([modOf(5, INSX).attr, modOf(6, INSX).attr, modOf(5, INSY).attr, modOf(6, INSY).attr], [{ maxHp: 150, atk: 55 }, { maxHp: 210, atk: 83 }, { atk: 40, aspd: 3 }, { atk: 60, aspd: 5 }]);
assert.deepEqual([formOf(5, false).trait.bb.atk_scale, modOf(5, INSX).traitOverride.bb.atk_scale, modOf(6, INSY).traitOverride.bb.atk_scale], [1.2, 1.3, 1.2]);
});
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(PALLAS));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(PALLAS), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: PALLAS, skillIndex: 2, uniEquipId: INSY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: PALLAS, skillIndex: 2, uniEquipId: INSY } } });
});
test('trait 教官: her attack ×1.2 (INS-X ×1.3) on an enemy she does not block, ×1 on the one she blocks; flyers are out of her reach', () => {
for (const f of [[5, false, null], [5, true, INSX], [6, true, INSX], [6, true, INSY]]) {
const [tier, elite, mod] = f;
const scale = traitScale(tier, elite, mod);
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); // (阻回: S3 stays off)
{
const { h, u } = field({ tier, elite, mod, skill: 2 });
noSp(h, u);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(6);
const hits = atkHits(h, u);
assert.ok(hits.length >= 3, `${label(f)}: she attacks (${hits.length})`);
for (const c of hits) {
assert.equal(c.target, e, `${label(f)}: only the ground dummy`);
approx(c.amount, u.s.atk * scale, `${label(f)}: unblocked ×${scale}`);
assert.equal(c.type, 'phys');
}
assert.ok(!h.hooksOf('damaged').some((c) => c.target === fly), `${label(f)}: no anti-air`);
done(h);
}
{
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9 });
noSp(h, u);
const w = h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => w.blockedBy === u, 30), `${label(f)}: the walker reaches her`);
const t0 = h.b.time;
h.run(6);
const hits = atkHits(h, u).filter((c) => c.t > t0);
assert.ok(hits.length >= 3, `${label(f)}: she attacks the enemy she blocks`);
for (const c of hits) approx(c.amount, u.s.atk, `${label(f)}: blocked ×1`);
done(h);
}
}
});
test('T1 英雄的诞生: while she is deployed, every 米诺斯 operator (her too) ATK +25 % above 80 % HP (INS-X stage 3: above 50 %, +30 %) — one 精力充沛, not for others, gone when she leaves', () => {
const others = [{ uid: 2, chessId: 'test_minos_a', row: 11, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 7 }];
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others });
h.step();
const minos = h.unit(2), yak = h.unit(3);
const t = talentOf(u, 0);
const x3 = elite && mod === INSX && tier === 6;
assert.deepEqual([t['peak_performance.hp_ratio'], t['peak_performance.atk']], x3 ? [0.5, 0.3] : [0.8, 0.25], `${label(f)}: the talent of the form`);
const v = t['peak_performance.atk'], thr = t['peak_performance.hp_ratio'];
for (const a of [u, minos]) {
assert.deepEqual(a.findBuff(PEAK_KEY)?.mods, { atkPct: v }, `${label(f)}: ${a.name}`);
approx(a.s.atk, a.base.atk * (1 + v), `${label(f)}: ${a.name} ATK`);
}
assert.equal(yak.findBuff(PEAK_KEY), null, `${label(f)}: 角峰 (谢拉格) gets nothing`);
// the HP condition, re-read every tick: strictly above hp_ratio
minos.hp = minos.s.maxHp * thr;
h.step();
assert.equal(minos.findBuff(PEAK_KEY), null, `${label(f)}: at ${thr * 100} % HP`);
minos.hp = minos.s.maxHp * 0.6;
h.step();
assert.equal(!!minos.findBuff(PEAK_KEY), x3, `${label(f)}: at 60 % HP`);
minos.hp = minos.s.maxHp;
h.step();
assert.ok(minos.findBuff(PEAK_KEY), `${label(f)}: back above`);
h.b.retreat(u);
h.step();
assert.equal(minos.findBuff(PEAK_KEY), null, `${label(f)}: gone once she leaves the field`);
done(h);
}
});
test('T2 女神的振奋: every damage instance she deals heals her and the ally on the tile in front 40 HP (INS-Y stage 3: 60, and every 米诺斯 operator — her too — 40 more), through 禁疗, not a 不成为治疗目标 ally, not the one behind', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { uid: 3, chessId: 'test_minos_a', row: 10, col: 4 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
const front = h.unit(2), behind = h.unit(3);
const t = talentOf(u, 1);
const y3 = elite && mod === INSY && tier === 6;
assert.deepEqual([t.value, t['pallas_e_t_2.value']], y3 ? [60, 40] : [40, undefined], `${label(f)}: the talent of the form`);
for (const a of [u, front, behind]) a.hp = a.s.maxHp * 0.5;
h.b.addBuff(front, { key: 'test:healFree', flags: { healFree: true } }); // 禁疗: the heal ignores it
h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(5);
const out = h.hooksOf('damaged').filter((c) => c.source === u && c.target.side === 'enemy');
assert.ok(out.length >= 3, `${label(f)}: ${out.length} damage instances`);
const heals = (a, amount) => h.hooksOf('heal').filter((c) => c.source === u && c.target === a && Math.abs(c.amount - amount) < 1e-6).length;
assert.equal(heals(u, t.value), out.length, `${label(f)}: herself ${t.value} per instance`);
assert.equal(heals(front, t.value), out.length, `${label(f)}: the ally in front (禁疗 too)`);
assert.equal(heals(behind, t.value), 0, `${label(f)}: not the ally behind`);
const extra = (a) => h.hooksOf('heal').filter((c) => c.source === u && c.target === a && c.opts?.tags?.includes('module')).length;
assert.deepEqual([extra(u), extra(behind), extra(front)], y3 ? [out.length, out.length, 0] : [0, 0, 0], `${label(f)}: the 米诺斯 extra (INS-Y stage 3): her and the 米诺斯 operator behind`);
if (y3) assert.ok(h.hooksOf('heal').filter((c) => c.opts?.tags?.includes('module')).every((c) => Math.abs(c.amount - 40) < 1e-6), `${label(f)}: 40 each`);
assert.ok(front.hp > front.s.maxHp * 0.5, `${label(f)}: the front ally gained HP`);
// 不成为其他角色的治疗目标 (noHeal): not healed
const n = h.hooksOf('heal').length;
h.b.addBuff(front, { key: 'test:noHeal', flags: { noHeal: true } });
const hp = front.hp;
h.run(3);
assert.equal(front.hp, hp, `${label(f)}: no heal reaches a noHeal ally`);
assert.ok(h.hooksOf('heal').length > n, `${label(f)}: she still heals herself`);
done(h);
}
});
test('S1 胜利的连击 (AUTO, attack SP 3, data DEFAULT): the next attack strikes twice for 125 % / 145 % ATK, each ×the 教官 scale, and heals twice', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, INSX], [5, true, INSY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, mod, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.skillType, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, 'AUTO', elite ? 1.45 : 1.25], `T${tier}`);
h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).some((c) => c.dmg.isSkill), 20), `T${tier}: cast on her 4th attack`);
const sk1 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const id = sk1[0].dmg.attackId;
const strikes = sk1.filter((c) => c.dmg.attackId === id);
assert.equal(strikes.length, 2, `T${tier}: two strikes`);
for (const c of strikes) approx(c.amount, u.s.atk * sk.bb.atk_scale * traitScale(tier, elite, mod), `T${tier}: ${sk.bb.atk_scale * 100} % × 教官`);
assert.equal(atkHits(h, u).filter((c) => c.dmg.attackId < id).length, 3, `T${tier}: after 3 plain attacks`);
assert.equal(h.hooksOf('heal').filter((c) => c.source === u && c.target === u).length, atkHits(h, u).length, `T${tier}: one 女神的振奋 per strike`);
done(h);
}
});
test('S2 信念的长鞭 (MANUAL, data DEFAULT): 20 / 22 s, range 2-2 + 1 tile forward, ATK +35 / 50 %, each attack 60 / 70 % to stun 0.2 s; all back after', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, INSY]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 9 });
// the data's DEFAULT: tools/build-data.mjs ATTACK_RANGE_CHANGE misses "攻击范围向前延伸一格" (reported, not worked around)
assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.duration, sk.bb.atk, sk.bb.ability_range_forward_extend, sk.bb['attack@buff_prob'], sk.bb['attack@stun']],
['DEFAULT', 'DEFAULT', elite ? 22 : 20, elite ? 0.5 : 0.35, 1, elite ? 0.7 : 0.6, 0.2], `T${tier}`);
const far = h.spawn('enemy_dummy', { pos: [10, 8] }); // 3 tiles ahead: only in the extended range
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: nothing in her own 2-2: no cast (DEFAULT)`);
const near = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast on her attack`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3]], `T${tier}: one tile more forward`);
const v = talentOf(u, 0)['peak_performance.atk'];
approx(u.s.atk, u.base.atk * (1 + v + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`);
h.b.kill(near, null);
const n0 = atkHits(h, u).length;
u.skill.extend(300);
h.run(150);
const hits = atkHits(h, u).slice(n0);
assert.ok(hits.length > 100 && hits.every((c) => c.target === far), `T${tier}: the far dummy is hit (${hits.length})`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === far);
const r = stuns.length / hits.length;
assert.ok(Math.abs(r - sk.bb['attack@buff_prob']) < 0.08, `T${tier}: ${stuns.length} / ${hits.length} stuns ≈ ${sk.bb['attack@buff_prob']}`);
for (const c of stuns) approx(c.duration, 0.2, `T${tier}: stun 0.2 s`);
h.runUntil(() => !u.skill.active, 200);
h.step();
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 2-2`);
approx(u.s.atk, u.base.atk * (1 + v), `T${tier}: ATK back`);
done(h);
}
});
test('S3 英勇的祝福 (MANUAL, data DEFAULT): 30 s, ATK +55 / 70 %, three targets; the operator on the low-ground tile in front holds 精力充沛 +35 / 40 % (> 80 % HP), DEF +20 / 25 %, block +1 — else she does, and it moves on deploy / retreat', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, INSX], [6, true, INSY]]) {
const sk = skillOf(tier, elite, S3);
const b = sk.bb;
assert.deepEqual([sk.duration, b.atk, b['attack@max_target'], b['attack@peak_performance.atk'], b['attack@peak_performance.hp_ratio'], b['attack@def'], b['attack@block_cnt']],
[30, elite ? 0.7 : 0.55, 3, elite ? 0.4 : 0.35, 0.8, elite ? 0.25 : 0.2, 1], `T${tier}`);
const v1 = (u) => talentOf(u, 0)['peak_performance.atk'];
// (a) 角峰 (an operator on a road tile) in front: it holds
{
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }] });
const yak = h.unit(2);
assert.equal(u.skill.rule, 'DEFAULT', `T${tier}: data DEFAULT`);
for (const pos of [[10, 5], [10, 6], [10, 7]]) h.spawn('enemy_dummy', { pos }); // three enemies on her 2-2
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
h.step();
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.1);
assert.deepEqual(yak.findBuff(holderKey(u))?.mods, { defPct: b['attack@def'], blockCnt: 1 }, `T${tier}: 角峰 holds`);
assert.equal(yak.s.blockCnt, yak.base.blockCnt + 1, `T${tier}: 角峰 block +1`);
approx(yak.s.def, yak.base.def * (1 + b['attack@def']), `T${tier}: 角峰 DEF`);
assert.deepEqual(yak.findBuff(PEAK_KEY)?.mods, { atkPct: b['attack@peak_performance.atk'] }, `T${tier}: 角峰 精力充沛`);
assert.equal(u.findBuff(holderKey(u)), null, `T${tier}: not her`);
assert.equal(u.s.blockCnt, 2);
approx(u.s.atk, u.base.atk * (1 + v1(u) + b.atk), `T${tier}: her ATK +${b.atk * 100} % (+ her own T1)`);
yak.hp = yak.s.maxHp * 0.8;
h.step();
assert.equal(yak.findBuff(PEAK_KEY), null, `T${tier}: 角峰 at 80 % HP: no 精力充沛`);
// three targets per attack
const t0 = h.b.time;
h.run(4);
const per = new Map();
for (const c of atkHits(h, u).filter((x) => x.t > t0)) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? new Set()).add(c.target));
assert.ok(per.size >= 2 && [...per.values()].every((s) => s.size === 3), `T${tier}: three targets per attack`);
// 角峰 leaves ⇒ she holds at once (同类取最高: her 精力充沛 is the higher of T1 and S3); a new operator in front takes it back
h.b.retreat(yak);
h.step();
assert.deepEqual(u.findBuff(holderKey(u))?.mods, { defPct: b['attack@def'], blockCnt: 1 }, `T${tier}: passed to her`);
assert.equal(u.s.blockCnt, 3, `T${tier}: her block +1`);
assert.deepEqual(u.findBuff(PEAK_KEY)?.mods, { atkPct: Math.max(v1(u), b['attack@peak_performance.atk']) }, `T${tier}: one 精力充沛, the highest`);
approx(u.s.atk, u.base.atk * (1 + b.atk + Math.max(v1(u), b['attack@peak_performance.atk'])), `T${tier}: her ATK`);
h.b.redeploy(yak, { free: true });
h.step();
assert.ok(yak.findBuff(holderKey(u)) && !u.findBuff(holderKey(u)), `T${tier}: back to the redeployed 角峰`);
h.runUntil(() => !u.skill.active, 40);
h.step();
assert.ok(!yak.findBuff(holderKey(u)) && !u.findBuff(holderKey(u)) && yak.s.blockCnt === yak.base.blockCnt, `T${tier}: gone at the end`);
assert.deepEqual(u.findBuff(PEAK_KEY)?.mods, { atkPct: v1(u) }, `T${tier}: her T1 only`);
done(h);
}
// (b) the tile in front is high ground (a ranged operator on it): she holds (the empty tile: (a) after the retreat)
{
const rows = { 10: '##hrrrhrrrfrrrrrrrf##' }; // (10,6) = 高台 'h'
const { h, u } = field({ tier, elite, mod, skill: 2, rows, others: [{ uid: 2, chessId: 'chess_char_1_01_a', row: 10, col: 6 }] });
const sniper = h.unit(2);
assert.equal(h.b.grid.isLow(10, 6), false);
h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
h.step();
assert.ok(u.findBuff(holderKey(u)) && !sniper.findBuff(holderKey(u)), `T${tier}: an operator on a 高台 in front does not hold it`);
done(h);
}
}
});
test('INS-Y 「可以额外部署在远程位」: the data carries it (hidden buildable_type 2); a placement rule of the prep, not of the battle — a 帕拉斯 piece stays on melee tiles (owner\'s decision of 2026-10-04, shared/highGround.js)', () => {
for (const tier of [5, 6]) {
const m = modOf(tier, INSY);
assert.equal(m.traitOverride.moduleDesc, '可以额外部署在远程位');
assert.deepEqual(m.talentChanges.find((t) => t.talentIndex === -1)?.bb, { buildable_type: 2 });
const rec = diyRecord(SLOT[tier], { charId: PALLAS, skillIndex: 0, uniEquipId: INSY }, { elite: true, data: { chess: CHESS, backups: BACKUPS } });
assert.equal(positionClass(rec, INSY), 'melee', `T${tier}: placement unchanged`);
const { h, u } = field({ tier, elite: true, mod: INSY, skill: 2 });
assert.equal(u.def.position, 'MELEE');
done(h);
}
});