Files
ark-stronghold-protocol/test/content/op_lessng.test.js
T
sganggs a3a0ddd2be feedback5 CB1: a 复活 (埃芒加德's 命结之秘, M3茧甲) is a knock-out and an immediate free redeploy — 砾's and 瑕光's 被击倒时 / 部署时 layers fire on every revive (community report 「卡西米尔和艾芒加德策略似乎有BUG:像砾和瑕光这种死亡和部署的叠层效果…无法触发」, items 9 / 37)
PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 埃芒加德 and M3茧甲 备注: "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的
再部署时间和费用归零". Both revived in place as `fatal` savers, so the operator was never knocked out nor deployed again:
砾 (<部署时> 卡西米尔 +1, <被击倒时> 不屈 +2), 瑕光 (<部署时> +4, 12 per battle), the 卡西米尔 bond's per-deployment ATK,
克莱门莎 / 卡莱莎 and every other 被击倒时 / 部署时 effect skipped the first 3 knock-outs (measured: only the 4th, real
death stacked). Now items/battle.js reviveNow, a `death` hook (M3茧甲 PRIO_RESPAWN 13, 埃芒加德 PRIO_BAND_REVIVE 12,
ahead of 阿戈尔 5 at 11 and 不屈 at 10): the knock-out stands and the operator redeploys at once, free, where it lies
(full HP, SP reset, a new deployment for 坚固维式重锤's lock — revivedInPlace and the `revives` counter are gone). The
ctx carries `revivedBy`, so 阿戈尔 5 still does not count such a knock-out as the member's first (the owner's decision
of 2026-10-05: an operator's own revive uses no slot; 埃芒加德 [ASSUMED] alike, unchanged). 淬羽赫默 S3 keeps its place
right after the hammer's lock (PRIO_REVIVE − 0.5). Tests: test/content/feedback5-revive-redeploy.test.js (fails on the
parent); bands / bonds_core / feedback1f-hammer / op_lessng / op_zuole / docs-consistency updated to the redeploy. Docs:
SIM.md (fatal, death), design/engine.md (fatal, death), PLAYING.md (复活), research 04 (M3茧甲).

Golden: the battles with M3茧甲 or 埃芒加德 whose operators fell — a revive is now a knock-out (deaths count, kill events,
redeploy, SP reset, 被击倒时 / 部署时 effects): roster-012 / 026 / 033 / 040 / 047, boss-boss_6-pair, hidden-boss_8-pair /
boss_9-pair, standin-07; matches solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1.
2026-10-07 01:56:43 +08:00

365 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_lessng.test.js — the 自选 operator kit of 止颂 (char_4011_lessng, 6★ 无畏者; kit
// server/sim/content/kits/ops/op-lessng.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, DRE-X
// “沉锋之束” or DRE-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_lessng.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { deploymentOf } from '../../server/sim/content/items/battle.js';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { DUEL_KEY, OATH_KEY, PAIN_KEY, REBORN_KEY } from '../../server/sim/content/kits/ops/op-lessng.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 LESSNG = 'char_4011_lessng';
const FORMS = BACKUPS.units[LESSNG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const DREX = 'uniequip_002_lessng', DREY = 'uniequip_003_lessng';
const S1 = 'skchr_lessng_1', S2 = 'skchr_lessng_2', S3 = 'skchr_lessng_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 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 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, DREX, DREY].map((m) => [t, true, m]))];
/** A plain ally (no kit, no damage modifiers) for friendly-damage checks. */
const PLAIN = chessRec({ id: 'test_plain_a', name: '测试', skill: null });
/** A battle with 止颂 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: { test_plain_a: PLAIN } }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LESSNG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
/** 止颂 on row 9 with a walker blocked by her. */
function blocked(o = {}) {
const r = field({ row: 9, ...o });
const w = r.h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(r.h.runUntil(() => w.blockedBy === r.u, 30), 'the walker reaches her');
return { ...r, w };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } }); // 阻回: the MANUAL skill stays off
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'}`;
test('止颂 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 1, melee ground-only, 1.5 s, 莱塔尼亚, no 特质', () => {
assert.equal(OPERATOR_KITS[LESSNG], KITS[LESSNG]);
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], [LESSNG, 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.base.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.sub, u.base.bat, u.dmgType], [1, 'melee', false, 'fearless', 1.5, 'phys'], `${label(f)}: 无畏者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.nationId], [['emptyShip'], [], 'leithanien'], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
done(h);
}
}
// E2 Lv1 2798 / 866 / 232, E2 Lv60 3384 / 984 / 262; DRE-X +230 / +17 / +17 → +400 / +30 / +30; DRE-Y +300 / +55 → +450 / +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], [2798, 866, 3384, 984]);
assert.deepEqual([modOf(5, DREX).attr, modOf(6, DREX).attr, modOf(5, DREY).attr, modOf(6, DREY).attr],
[{ maxHp: 230, atk: 17, def: 17 }, { maxHp: 400, atk: 30, def: 30 }, { maxHp: 300, atk: 55 }, { maxHp: 450, atk: 80 }]);
});
test('a 自选 pick: 止颂 is offered at tiers 5 and 6 and a roster with him passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(LESSNG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LESSNG), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LESSNG, skillIndex: 1, uniEquipId: DREY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: LESSNG, skillIndex: 1, uniEquipId: DREY } } });
});
test('T1 苦痛专注: while he blocks, physical / arts damage from anyone but an enemy he blocks — 无来源, allies, himself included — ×0.65 (DRE-Y stage 3: ×0.6); none while not blocking, none on true damage', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const y3 = elite && mod === DREY && tier === 6;
const cut = talentOf(field({ tier, elite, mod }).u, 0).damage_resistance;
assert.equal(cut, y3 ? 0.4 : 0.35, `${label(f)}: the talent of the form`);
const { h, u, w } = blocked({ tier, elite, mod, others: [{ uid: 2, chessId: 'test_plain_a', row: 11, col: 3 }] });
const ally = h.unit(2);
const other = h.spawn('enemy_dummy', { pos: [11, 8] });
const hit = (src, d) => { u.hp = u.s.maxHp; return h.b.dealDamage(src, u, d); }; // (full HP: the return value is the HP removed)
const arts = (src) => hit(src, { amount: 1000, type: 'arts' });
approx(arts(other), 1000 * (1 - cut), `${label(f)}: an enemy he does not block`);
approx(arts(w), 1000, `${label(f)}: the enemy he blocks`);
approx(arts(null), 1000 * (1 - cut), `${label(f)}: 无来源`);
approx(arts(ally), 1000 * (1 - cut), `${label(f)}: an ally`);
approx(arts(u), 1000 * (1 - cut), `${label(f)}: himself`);
approx(hit(other, { amount: 1000, type: 'true' }), 1000, `${label(f)}: true damage`);
approx(hit(other, { amount: 2000, type: 'phys' }), (2000 - u.s.def) * (1 - cut), `${label(f)}: physical`);
u.hp = u.s.maxHp;
h.b.releaseBlocked(u);
h.b.kill(w, null);
h.step();
assert.equal(u.blocking.length, 0);
approx(arts(other), 1000, `${label(f)}: not while he blocks nobody`);
done(h);
}
});
test('T2 痛楚砺刃: any damage aimed at him (a dodged one too) ⇒ ATK +12 % (DRE-X stage 3: +20 %) for 15 s, refreshed, never stacked', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod });
const t = talentOf(u, 1);
const x3 = elite && mod === DREX && tier === 6;
assert.deepEqual([t.atk, t.add_atk_duration], [x3 ? 0.2 : 0.12, 15], `${label(f)}: the talent of the form`);
const e = h.spawn('enemy_dummy', { pos: [11, 8] });
assert.equal(u.findBuff(PAIN_KEY), null);
h.b.addBuff(u, { key: 'test:dodge', mods: { dodgePhys: 1 } });
assert.equal(h.b.dealDamage(e, u, { amount: 500, type: 'phys' }), 0, `${label(f)}: dodged`);
assert.deepEqual(u.findBuff(PAIN_KEY)?.mods, { atkPct: t.atk }, `${label(f)}: still triggered`);
approx(u.s.atk, u.base.atk * (1 + t.atk), `${label(f)}: ATK +${t.atk * 100} %`);
h.b.removeBuff(u, 'test:dodge');
h.run(10);
h.b.dealDamage(e, u, { amount: 100, type: 'arts' });
h.b.dealDamage(null, u, { amount: 100, type: 'true' });
approx(u.findBuff(PAIN_KEY).timeLeft, 15, `${label(f)}: refreshed to 15 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + t.atk), `${label(f)}: not stacked`);
h.run(15.1);
assert.equal(u.findBuff(PAIN_KEY), null, `${label(f)}: gone 15 s after the last hit`);
done(h);
}
});
test('S1 强力击·γ型 (AUTO, attack SP 3, data DEFAULT): the next attack at 205 % / 225 % ATK (× DRE-X 115 % on a blocked target)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, DREX], [5, true, DREY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, w } = blocked({ tier, elite, mod, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.skillType, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 'AUTO', elite ? 2.25 : 2.05], `T${tier}`);
const scale = mod === DREX ? modOf(tier, DREX).traitOverride.bb.atk_scale : 1;
const t0 = h.b.time;
assert.ok(h.runUntil(() => atkHits(h, u).some((c) => c.dmg.isSkill && c.t > t0), 20), `T${tier}: cast`);
const hits = atkHits(h, u).filter((c) => c.t > t0 && c.target === w);
const sk1 = hits.find((c) => c.dmg.isSkill);
approx(sk1.amount, u.s.atk * sk.bb.atk_scale * scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
for (const c of hits.filter((x) => !x.dmg.isSkill)) approx(c.amount, u.s.atk * scale, `T${tier}: plain attacks`);
done(h);
}
});
test('S2 虔修对决 (PASSIVE, 部署后): for 18 / 21 s from every deployment ATK +25 / 35 %, two strikes per attack and 苦痛专注 ×2; then single strikes', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, DREY], [6, true, DREX]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.skillType, sk.spType, sk.duration, sk.bb.atk, sk.bb.talent_scale], ['PASSIVE', 'ON_DEPLOY', elite ? 21 : 18, elite ? 0.35 : 0.25, 2], `T${tier}`);
const { h, u, w } = blocked({ tier, elite, mod, skill: 1 });
const cut = talentOf(u, 0).damage_resistance;
assert.ok(h.b.time < sk.duration, 'the walker arrived inside the window');
assert.deepEqual(u.findBuff(DUEL_KEY)?.mods, { atkPct: sk.bb.atk }, `T${tier}: ATK +${sk.bb.atk * 100} %`);
const other = h.spawn('enemy_dummy', { pos: [11, 8] });
approx(h.b.dealDamage(other, u, { amount: 1000, type: 'arts' }), 1000 * (1 - cut * 2), `T${tier}: 苦痛专注 ×2`);
const t0 = h.b.time;
h.runUntil(() => !u.findBuff(DUEL_KEY), 30);
approx(h.b.time, sk.duration, `T${tier}: the window ends ${sk.duration} s after the deployment`, 0.05);
const inWin = new Map();
for (const c of atkHits(h, u).filter((x) => x.t > t0)) inWin.set(c.dmg.attackId, (inWin.get(c.dmg.attackId) ?? 0) + 1);
assert.ok(inWin.size >= 2 && [...inWin.values()].every((n) => n === 2), `T${tier}: two strikes per attack (${[...inWin.values()]})`);
const t1 = h.b.time;
h.run(5);
const after = new Map();
for (const c of atkHits(h, u).filter((x) => x.t > t1)) after.set(c.dmg.attackId, (after.get(c.dmg.attackId) ?? 0) + 1);
assert.ok(after.size >= 2 && [...after.values()].every((n) => n === 1), `T${tier}: single strikes after`);
approx(u.s.atk, u.base.atk * (1 + (u.findBuff(PAIN_KEY) ? talentOf(u, 1).atk : 0)), `T${tier}: ATK back`);
approx(h.b.dealDamage(other, u, { amount: 1000, type: 'arts' }), 1000 * (1 - cut), `T${tier}: 苦痛专注 ×1`);
// a redeploy restarts the window
h.b.retreat(u);
h.b.redeploy(u);
h.step();
approx(u.findBuff(DUEL_KEY)?.timeLeft ?? 0, sk.duration, `T${tier}: again for ${sk.duration} s`, 0.05);
assert.equal(w.alive, true);
done(h);
}
});
test('S3 苦修破誓 (MANUAL, data DEFAULT): 20 s, max HP +65 / 80 %, a blocked target at 160 % / 180 % ATK (× DRE-X), no 异常状态 while it runs; then all back', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, DREX], [5, true, DREY]]) {
const sk = skillOf(tier, elite, S3);
const b = sk.bb;
assert.deepEqual([sk.duration, b.max_hp, b['lessng_s3[atk_scale].atk_scale'], b.magical_value, sk.skillType], [20, elite ? 0.8 : 0.65, elite ? 1.8 : 1.6, 600, 'MANUAL'], `T${tier}`);
const { h, u, w } = blocked({ tier, elite, mod, skill: 2 });
assert.equal(u.skill.rule, 'DEFAULT');
const unb = h.spawn('enemy_dummy', { pos: [9, 5] }); // on his tile beside the walker: he blocks only one
const hp0 = u.hp, max0 = u.s.maxHp;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.05);
approx(u.s.maxHp, max0 * (1 + b.max_hp), `T${tier}: max HP +${b.max_hp * 100} %`);
approx(u.hp / u.s.maxHp, hp0 / max0, `T${tier}: ratio kept`);
for (const st of ['stun', 'cold', 'freeze', 'sleep', 'silence', 'fear']) assert.equal(h.b.applyStatus(u, st, { duration: 3, source: w }), false, `T${tier}: immune to ${st}`);
const scale = mod === DREX ? modOf(tier, DREX).traitOverride.bb.atk_scale : 1;
const t0 = h.b.time;
h.run(6);
const hits = atkHits(h, u).filter((c) => c.t > t0);
assert.ok(hits.length >= 3);
for (const c of hits) {
if (c.target === w) approx(c.amount, u.s.atk * b['lessng_s3[atk_scale].atk_scale'] * scale, `T${tier}: blocked ×${b['lessng_s3[atk_scale].atk_scale']}`);
else approx(c.amount, unb.blockedBy ? u.s.atk * b['lessng_s3[atk_scale].atk_scale'] * scale : u.s.atk, `T${tier}: the other target`);
}
h.runUntil(() => !u.skill.active, 20);
h.step();
assert.equal(u.findBuff(OATH_KEY), null);
approx(u.s.maxHp, max0, `T${tier}: max HP back`);
assert.equal(h.b.applyStatus(u, 'stun', { duration: 0.5, source: w }), true, `T${tier}: stuns land again`);
done(h);
}
});
test('S3 苦修破誓 cast under a 异常状态: 600 arts to himself first (苦痛专注 applies while he blocks; may knock him out), every 异常状态 cleansed, then the HP; 沉默 does not hold the cast back, 晕眩 does (no attack)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, DREX]]) {
const cut = 0.35;
// cold: he still attacks, the basic strategy casts; he blocks ⇒ 600 × 0.65
{
const { h, u, w } = blocked({ tier, elite, mod, skill: 2 });
h.b.applyStatus(u, 'cold', { duration: 20, source: w });
const hp0 = u.hp, max0 = u.s.maxHp;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast while cold`);
const self = h.hooksOf('damaged').filter((c) => c.source === u && c.target === u);
assert.equal(self.length, 1, `T${tier}: one self hit`);
assert.deepEqual([self[0].type, Math.round(self[0].amount)], ['arts', Math.round(600 * (1 - cut))], `T${tier}: 600 arts ×0.65`);
assert.ok(!u.buffs.some((x) => x.status === 'cold'), `T${tier}: cold cleansed`);
approx(u.hp, (hp0 - 600 * (1 - cut)) / max0 * u.s.maxHp, `T${tier}: the HP buff after the damage (ratio of the hurt HP)`);
assert.ok(u.findBuff(PAIN_KEY), `T${tier}: 痛楚砺刃 saw it`);
done(h);
}
// 沉默: the engine's basic strategy refuses a silenced cast — this skill "可以无视异常状态开启"
{
const { h, u } = field({ tier, elite, mod, skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.applyStatus(u, 'silence', { duration: 20, source: null });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast while silenced`);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u)?.reason, 'abnormal');
assert.equal(Math.round(h.hooksOf('damaged').find((c) => c.source === u && c.target === u)?.amount), 600, `T${tier}: 600 arts (he blocks nobody)`);
assert.ok(!u.s.flags.silence, `T${tier}: silence cleansed`);
done(h);
}
// 晕眩: no attack ⇒ no cast until it ends (then no abnormal state, no self damage)
{
const { h, u } = field({ tier, elite, mod, skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.applyStatus(u, 'stun', { duration: 3, source: null });
u.skill.gainSp(999);
h.run(2.5);
assert.equal(u.skill.activations, 0, `T${tier}: not while stunned`);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: once the stun ends`);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.target === u).length, 0, `T${tier}: no self damage`);
done(h);
}
// the self damage may knock him out (可致死)
{
const { h, u } = field({ tier, elite, mod, skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.applyStatus(u, 'cold', { duration: 20, source: null });
u.hp = 300;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => !u.alive, 5), `T${tier}: knocked out by his own 600`);
assert.equal(u.findBuff(OATH_KEY), null);
done(h);
}
}
});
test('DRE-X “沉锋之束”: his attacks ×115 % on a target blocked by anyone (another operator\'s too), ×1 on an unblocked one — stages 1 and 3; none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }] });
noSp(h, u);
const yak = h.unit(2);
const e = h.spawn('enemy_dummy', { pos: [10, 6] }); // blocked by 角峰, on his front tile
h.step();
assert.equal(e.blockedBy, yak);
const x = elite && mod === DREX;
if (x) assert.equal(u.def.raw.trait.bb.atk_scale, 1.15, label(f));
h.run(6);
const hits = atkHits(h, u).filter((c) => c.target === e);
assert.ok(hits.length >= 3, label(f));
for (const c of hits) approx(c.amount, u.s.atk * (x ? 1.15 : 1), `${label(f)}: ×${x ? 1.15 : 1}`);
h.b.releaseBlocked(yak);
h.b.retreat(yak);
h.step();
assert.ok(!e.blockedBy);
const t0 = h.b.time;
h.run(4);
for (const c of atkHits(h, u).filter((y) => y.t > t0)) approx(c.amount, u.s.atk, `${label(f)}: unblocked ×1`);
done(h);
}
});
test('DRE-Y “苦修者的抗压训练”: the first lethal hit of a deployment ⇒ max HP ×0.4, ASPD +30, full HP (once; again after a redeploy; not while a 不死 holds him); stage 3: 12 % of DEF ignored on the enemy he blocks', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: DREY });
const tb = u.def.raw.trait.bb;
assert.deepEqual([tb.attack_speed, tb.max_hp, tb.hp_ratio, tb.value], [30, 0.4, 1, 0.6], `T${tier}: trait bb`);
const e = h.spawn('enemy_dummy', { pos: [11, 8] });
const max0 = u.s.maxHp, aspd0 = u.s.aspd;
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.ok(u.alive, `T${tier}: not knocked out`);
approx(u.s.maxHp, max0 * 0.4, `T${tier}: max HP ×0.4`);
approx(u.hp, u.s.maxHp, `T${tier}: full HP of the new max`);
assert.equal(u.s.aspd, aspd0 + 30, `T${tier}: ASPD +30`);
assert.ok(u.findBuff(REBORN_KEY));
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.ok(!u.alive, `T${tier}: once per deployment`);
h.b.redeploy(u);
h.step();
approx(u.s.maxHp, max0, `T${tier}: a new deployment: max HP back`);
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.ok(u.alive, `T${tier}: again after the redeploy`);
done(h);
// a running 不死 (坚固维式重锤's window) holds him instead: the module is not used
const r = field({ tier, elite: true, mod: DREY });
r.u.mem.undyingAt = deploymentOf(r.u);
r.u.mem.undyingUntil = r.h.b.time + 5;
const hammer = r.h.b.on('fatal', (c) => { if (c.unit === r.u && !c.prevented) c.prevented = true; }, { priority: -99 });
const f = r.h.spawn('enemy_dummy', { pos: [11, 8] });
r.h.b.dealDamage(f, r.u, { amount: 1e7, type: 'true' });
assert.ok(r.u.alive && r.u.hp < 2 && !r.u.findBuff(REBORN_KEY), `T${tier}: held by the 不死, the module untouched`);
r.h.b.off(hammer);
r.u.mem.undyingUntil = 0;
r.h.b.dealDamage(f, r.u, { amount: 1e7, type: 'true' });
assert.ok(r.u.alive && r.u.findBuff(REBORN_KEY), `T${tier}: the module afterwards`);
done(r.h);
}
// stage 3's hidden part: 无视目标12%的防御力 on the enemy he blocks (none at stage 1, none on an unblocked enemy)
for (const tier of [5, 6]) {
const ENEMY_DEF = 1000;
const h = makeBattle({
defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0, def: ENEMY_DEF }), enemy_dummy: dummy('enemy_dummy', { def: ENEMY_DEF }) } },
timeLimit: 600, autoFinish: false, seed: 5, flags: { dpPerSec: 0 }, hooks: ['damaged'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LESSNG, skillIndex: 2, uniEquipId: DREY }, elite: true, row: 9, col: 5 }],
});
h.step();
const u = h.unit(1);
noSp(h, u);
const w = h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => w.blockedBy === u, 30));
const t0 = h.b.time;
h.run(6);
const pen = tier === 6 ? 0.12 : 0;
const hits = atkHits(h, u).filter((c) => c.t > t0 && c.target === w);
assert.ok(hits.length >= 3);
for (const c of hits) approx(c.amount, Math.max(u.s.atk - ENEMY_DEF * (1 - pen), u.s.atk * 0.05), `T${tier}: DEF ×${1 - pen}`);
done(h);
}
});