Files
ark-stronghold-protocol/test/content/op_slent2.test.js
T
sganggs a61d82ee4c feedback5 O22: 自选 kits for 淬羽赫默 (with her 夜灯), 遥 and 酒神 (with her 本能的召唤 and 迷狂牢笼)
Three owned-6★ picks get their operator kits (kits/ops/op-slent2.js, op-haruka.js, op-phatm2.js; OPERATOR_KIT_FILES):
every skill at rank 4 / 7, both talents, the trait and every module at stages 1 and 3, from data/backups.json, PRTS and the
client's battle data (charpacks + buff_template_data of the three operators).

- 淬羽赫默: 护佑者 (arts attacks, heals while a skill runs; BLS-X 100 %, BLS-Y 2 targets); 无声砥柱 庇护 10 % → 24 % by the
  lost HP (shared `protect`); 丰润羽翼 as 生命回复速度 (the atk_to_hp_recovery template PRTS documents so for 絮雨 / 琴柳), ×2
  for 莱茵生命; BLS-X Lv3 max HP +10 %, BLS-Y Lv3 the once-per-deployment barrier; S2's placed 夜灯 (T1 ×2.1 / ×2.4 around it,
  withdrawn at the skill's end or when she leaves); S3's 不死 for the first lethal hit in her range, 2 uses a battle.
- 遥: bubbles with each attack (or instead of one), 30 % 庇护 ([bonus], ×1.4 / ×1.7 in S3), breaking 0.5 s after an enemy's
  damage, the break heal (BLS-Y Lv3 +1 SP to the holder); S1 re-bubbles around, S2 heal → arts splash and endless from the
  2nd use, S3 y-8 and the levitation with its damage.
- 酒神: T1 神经损伤 on each hit + 1.3 splash; 堕梦 ASPD cut in 神经 bursts and 70 / 90 on enemy attacks (a burst interrupts
  the attack); RIT-X ×1.18 vs elite / leader; S1 two hits + 束缚 ×1.3 / ×1.5; S2 (SP_FULL toggle) the placed 本能的召唤
  (lure 4, exit 停顿 + DoT, 25 s redeploys while it runs); S3 y-8, per-second 神经损伤 until a burst, 1.5× burst recovery
  and the 迷狂牢笼 (blocks its tile's enemies at spawn / refresh, 1 per hit, 30 s after the skill).

Engine (additive, no handler before): ai.js hook `enemyAttackStart` (an enemy's normal attack, before 麻痹), blocking.js
flag `noNewBlock`, items/battle.js `holdsUndying` also sees a buff flagged `undying`; docs/SIM.md §3 / §5, kits/README.md
(docked skill-summon pieces: their kit without offOwner).

Golden: only diy moved — diy-106 … diy-109 (the prototype configurations shifted behind the new operators) and the new
diy-110 … diy-114 (the three operators' configurations); roster, bonds, fields, matches, standins unchanged.
Tests: test/content/op_slent2.test.js (11), op_haruka.test.js (9), op_phatm2.test.js (13).
2026-10-06 05:14:29 +08:00

367 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_slent2.test.js — the 自选 operator kit of 淬羽赫默 (char_1031_slent2, 6★ 护佑者; kit
// server/sim/content/kits/ops/op-slent2.js) and of her summon 夜灯 (token_10029_slent2_protrb), fielded the production way
// (a DIY slot + its `diy` pick, simdata getDiy; the 夜灯 as the placed hand piece of her player) in every form: tiers 5 / 6,
// normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, BLS-X “难得清醒” or BLS-Y “可控动量”
// at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_slent2.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 { holdsUndying } from '../../server/sim/content/items/battle.js';
import { shelterOf, RHINE } from '../../server/sim/content/kits/ops/op-slent2.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 ID = 'char_1031_slent2';
const LAMP = 'token_10029_slent2_protrb';
const FORMS = BACKUPS.units[ID].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_slent2', Y = 'uniequip_003_slent2';
const S1 = 'skchr_slent2_1', S2 = 'skchr_slent2_2', S3 = 'skchr_slent2_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(tier, elite)];
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' }) };
/** A synthetic 【莱茵生命】 ally (伊芙利特's charId; no skill) and a plain one. */
const RHINE_REC = chessRec({ id: 'test_rhine_a', charId: 'char_134_ifrit', profession: 'CASTER', skill: null, stats: { maxHp: 2000, def: 0, res: 0 } });
const PLAIN_REC = chessRec({ id: 'test_plain_a', charId: 'char_test_plain', profession: 'CASTER', skill: null, stats: { maxHp: 2000, def: 0, res: 0 } });
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 淬羽赫默 as uid 1 at (10, 5) facing RIGHT, plus `others`; `lamp` = the 夜灯 piece's tile (uid 9) or null. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], lamp = null, lampFirst = false, seed = 5, flags = {} } = {}) {
const op = { uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col };
const piece = lamp ? [{ uid: 9, kind: 'token', tokenId: LAMP, ownerUid: 1, row: lamp[0], col: lamp[1] }] : [];
const h = makeBattle({
defs: { enemies: ENEMIES, chess: { test_rhine_a: RHINE_REC, test_plain_a: PLAIN_REC } }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, ...flags }, hooks: ['damaged', 'skillStart', 'skillEnd', 'heal', 'death', 'deploy', 'fatal'], captureNoisy: true,
units: lampFirst ? [...piece, op, ...others] : [op, ...piece, ...others],
});
h.step();
return { h, u: h.unit(1), lampUnit: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === LAMP) ?? null };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const ally = (uid, row, col, id = 'test_plain_a') => ({ uid, chessId: id, row, col });
const protectOf = (a) => a.findBuff('protect')?.data?.value ?? 0;
test('淬羽赫默 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, y-6, 护佑者 (ranged arts, hits air, blocks 1; BLS-Y 2 targets), the data DEFAULT triggers, ground-targetable, 空构 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
// BLS-X stage 3 gives her (a 莱茵生命 operator in her own range) max HP +10 % ×2
const selfHp = m?.uniEquipId === X && tier === 6 ? 1.2 : 1;
approx(u.s.maxHp, (form.stats.maxHp + (m?.attr.maxHp ?? 0)) * selfHp, `${label(f)}: max HP`);
assert.deepEqual([u.base.atk, u.base.def, u.base.res], [form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 护佑者`);
assert.equal(u.profile.maxTargets, mod === Y && elite ? 2 : 1, `${label(f)}: targets`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-6`);
assert.equal(u.skill.rule, 'DEFAULT', `${label(f)}: trigger`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
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], [1445, 387, 1765, 440]);
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr], [{ maxHp: 100, atk: 45 }, { maxHp: 150, atk: 70 }, { maxHp: 150, atk: 40 }, { maxHp: 200, atk: 62 }]);
});
test('a 自选 pick: 淬羽赫默 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ID));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: Y } } });
});
test('Trait: arts attacks on enemies (BLS-Y: two targets, flyers too); while a skill runs every attack heals the most injured ally of her range for heal_scale × ATK (0.75; BLS-X 1.0; BLS-Y two allies) instead', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, X], [6, true, Y]]) {
const tb = (elite ? modOf(tier, mod).traitOverride : formOf(tier, elite).trait).bb;
const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 11, 6), ally(3, 9, 6), ally(4, 10, 6)] });
const e1 = h.spawn('enemy_dummy', { pos: [10, 7] }), e2 = h.spawn('enemy_fly', { pos: [11, 7] });
h.run(4);
assert.equal(u.skill.activations, 0, 'no SP yet');
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
assert.ok(hits.length >= 2 && hits.every((c) => c.type === 'arts'), `${tier}${mod}: arts hits`);
assert.equal(new Set(hits.map((c) => c.target)).size, mod === Y ? 2 : 1, `${tier}${mod}: targets`);
if (mod === Y) assert.ok(hits.some((c) => c.target === e2), 'the flyer too');
void e1;
const [a, b, c] = [h.unit(2), h.unit(3), h.unit(4)];
a.hp = a.s.maxHp * 0.3; b.hp = b.s.maxHp * 0.5; c.hp = c.s.maxHp * 0.9;
const n0 = h.hooksOf('heal').length;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast with an enemy in range');
assert.ok(h.runUntil(() => h.hooksOf('heal').slice(n0).some((x) => x.source === u && !x.opts?.regen), 3), 'heals');
const heals = h.hooksOf('heal').slice(n0).filter((x) => x.source === u && !x.opts?.regen);
const first = heals.filter((x) => Math.abs(x.t - heals[0].t) < 1e-9);
assert.deepEqual(first.map((x) => x.target.uid).sort(), (mod === Y ? [2, 3] : [2]).sort(), `${tier}${mod}: the most injured`);
for (const x of first) approx(x.amount, u.s.atk * tb.heal_scale, `${tier}${mod}: ${tb.heal_scale * 100} % ATK`);
const atk0 = h.hooksOf('damaged').filter((x) => x.source === u && x.dmg?.isAttack && x.t > heals[0].t - 1e-9);
assert.equal(atk0.length, 0, 'no attack on enemies while the skill heals');
done(h);
}
});
test('S1 进取之心 (MANUAL, DEFAULT): ATK +35 % / +50 % for 25 s, SP 35 / 30 from 10; cast on an enemy in her range, not on an injured ally alone', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0, others: [ally(2, 10, 6)] });
assert.deepEqual([u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk], [elite ? 30 : 35, 10, 25, elite ? 0.5 : 0.35]);
h.unit(2).hp = 100;
u.skill.gainSp(999);
h.run(3);
assert.equal(u.skill.activations, 0, 'an injured ally alone casts nothing (a 辅助, not a 医疗)');
h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.skill.timeLeft, 25, 'duration', 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK');
h.runUntil(() => !u.skill.active, 30);
approx(u.s.atk, u.base.atk, 'back');
done(h);
}
});
test('S2 俯瞰视界 + 夜灯: ASPD +20 / +30 for 12 s; the placed 夜灯 deploys at the battle start, leaves when the skill ends, comes back on its tile with the next cast and leaves when she does; the allies of its x-4 hold her T1 庇护 ×2.1 / ×2.4; untargetable, no attack', () => {
for (const [tier, elite, lampFirst] of [[5, false, false], [6, true, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, lampUnit } = field({ tier, elite, skill: 1, lamp: [12, 8], lampFirst, others: [ally(2, 12, 9), ally(3, 9, 9)] });
assert.deepEqual([sk.duration, sk.bb.attack_speed, sk.bb.damage_resistance_scale], [12, elite ? 30 : 20, elite ? 2.4 : 2.1]);
const lamp = lampUnit;
assert.ok(lamp && lamp.alive, 'deployed with the board (the battle-start deploy of a placed skill summon)');
assert.ok(lamp.s.flags.untargetable && lamp.profile.noAttack && lamp.s.blockCnt === 0, 'untargetable, no attack, no block');
const a = h.unit(2), far = h.unit(3);
a.hp = a.s.maxHp * 0.5;
h.run(0.3);
// (12, 9) is next to the 夜灯 and outside her y-6: 20 % × 2.1 / 2.4; (9, 9) outside both
const t1 = formOf(tier, elite).talents[0].bb;
approx(protectOf(a), shelterOf(t1, a, sk.bb.damage_resistance_scale), 'the 夜灯\'s 庇护', 1e-9);
approx(protectOf(a), 0.2 * sk.bb.damage_resistance_scale, '50 % HP: 20 % × scale', 1e-9);
assert.equal(protectOf(far), 0, 'outside');
h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.aspd, 100 + sk.bb.attack_speed, 'ASPD');
assert.ok(lamp.alive, 'the start 夜灯 is this skill\'s drone');
assert.ok(h.runUntil(() => !u.skill.active, 13));
h.step();
assert.ok(!lamp.alive, 'withdrawn at the skill end');
h.run(0.3);
assert.equal(protectOf(a), 0, 'its 庇护 is gone');
// next cast (after its 5 s redeploy time): back on its tile
h.run(6);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.step();
assert.ok(lamp.alive && lamp.tileR === 12 && lamp.tileC === 8, 'back on its tile');
h.b.retreat(u);
h.step();
assert.ok(!lamp.alive, 'withdrawn when she leaves');
h.run(8);
assert.ok(!lamp.alive, 'not back without her skill');
done(h);
}
});
test('S2 without a placed 夜灯: nothing deploys; S1 / S3 picks never field the piece', () => {
const { h, u } = field({ skill: 1 });
h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(h.b.allyUnits.filter((a) => a.defId === LAMP).length, 0);
done(h);
for (const skill of [0, 2]) {
const r = field({ skill, lamp: [12, 8] });
r.h.run(2);
assert.ok(!r.lampUnit.alive, `skill ${skill}: no 夜灯`);
done(r.h);
}
});
test('T1 无声砥柱: every ally of her range (herself too) holds 庇护 base × (1 + 2 % per 1 % HP lost, at most 70 steps): 10 % → 20 % at half HP → 24 % at ≤ 30 %; it cuts physical and arts damage, not true; gone once the ally leaves her range; S3 ×1.5 / ×1.8', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, X]]) {
const { h, u } = field({ tier, elite, mod, skill: 2, others: [ally(2, 10, 6), ally(3, 10, 9)] });
const a = h.unit(2), out = h.unit(3);
const t1 = formOf(tier, elite).talents[0].bb;
assert.deepEqual(t1, { damage_resistance_base: 0.1, resistance_scale: 0.02, hp_ratio: 0.01, min_hp_ratio: 0.3 });
h.run(0.2);
approx(protectOf(a), 0.1, 'full HP: 10 %', 1e-9);
approx(protectOf(u), 0.1, 'herself', 1e-9);
assert.equal(protectOf(out), 0, '(10, 9) is outside her y-6');
for (const [ratio, want] of [[0.5, 0.2], [0.555, 0.1 * (1 + 0.02 * 44)], [0.3, 0.24], [0.1, 0.24]]) {
a.hp = a.s.maxHp * ratio;
h.run(0.25);
approx(protectOf(a), want, `HP ${ratio}`, 1e-9);
}
// the cut: physical and arts ×(1 − 24 %), true untouched
const e = h.spawn('enemy_dummy', { pos: [11, 9] });
for (const type of ['phys', 'arts', 'true']) {
const hp0 = a.hp;
h.b.dealDamage(e, a, { amount: 100, type, canDodge: false });
approx(hp0 - a.hp, type === 'true' ? 100 : 76, `${type}`, 1e-6);
a.hp = a.s.maxHp * 0.1;
}
// S3: ×talent_scale
const sk3 = skillOf(tier, elite, S3);
h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
a.hp = a.s.maxHp * 0.1;
h.run(0.25);
approx(protectOf(a), 0.24 * sk3.bb.talent_scale, `S3 ×${sk3.bb.talent_scale}`, 1e-9);
// leaving the range: gone within one refresh
assert.ok(h.b.relocate(a, 12, 9));
h.run(0.3);
assert.equal(protectOf(a), 0, 'gone outside her range');
done(h);
}
});
test('T2 丰润羽翼: an ally of her range below 50 % HP gets 生命回复速度 5 % of her ATK (hpRegen, from 1 s after; 禁疗 does not stop it), a 【莱茵生命】 operator twice; none at ≥ 50 % or outside; BLS-X stage 3: 7 % and max HP +10 % (×2 莱茵生命) in her range; stage 1: neither', () => {
for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, X]]) {
const x3 = mod === X && tier === 6;
const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 10, 6), ally(3, 11, 6, 'test_rhine_a'), ally(4, 9, 6)] });
const a = h.unit(2), r = h.unit(3), full = h.unit(4);
assert.ok(RHINE.has(r.def.charId) && !RHINE.has(a.def.charId));
const ratio = x3 ? 0.07 : 0.05;
assert.equal(formOf(tier, elite).talents[1].bb.atk_to_hp_recovery_ratio, 0.05);
approx(a.s.maxHp, 2000 * (x3 ? 1.1 : 1), 'max HP');
approx(r.s.maxHp, 2000 * (x3 ? 1.2 : 1), '莱茵生命 max HP');
h.b.addBuff(a, { key: 'test:healFree', flags: { noHeal: true, healFree: true } });
a.hp = a.s.maxHp * 0.4; r.hp = r.s.maxHp * 0.4;
h.run(0.95);
assert.equal(a.s.hpRegen, 0, 'not before 1 s');
h.run(0.2);
approx(a.s.hpRegen, u.s.atk * ratio, `${ratio * 100} % ATK`);
approx(r.s.hpRegen, u.s.atk * ratio * 2, '莱茵生命 ×2');
assert.equal(full.s.hpRegen, 0, 'full HP: none');
const hp0 = a.hp;
h.run(2);
approx(a.hp - hp0, u.s.atk * ratio * 2, '2 s of regeneration through 禁疗', 0.02);
a.hp = a.s.maxHp * 0.6;
h.run(0.15);
assert.equal(a.s.hpRegen, 0, 'stops at ≥ 50 %');
h.b.relocate(r, 10, 9);
h.run(0.15);
assert.equal(r.s.hpRegen, 0, 'stops outside her range');
done(h);
}
});
test('BLS-Y stage 3 (丰润羽翼): a hit that would leave an operator of her range below 50 % meets a 屏障 of 50 % of her max HP first (莱茵生命 ×2) — once per deployment of hers; entering her range below 50 % gives it too; stage 1 / no module: none', () => {
for (const [tier, elite, mod] of [[6, true, Y], [5, true, Y], [6, true, null]]) {
const on = mod === Y && tier === 6;
const { h, u } = field({ tier, elite, mod, skill: 0, others: [ally(2, 10, 6), ally(3, 11, 6, 'test_rhine_a'), ally(4, 10, 9)] });
const a = h.unit(2), r = h.unit(3), out = h.unit(4);
const e = h.spawn('enemy_dummy', { pos: [11, 9] });
h.run(0.2);
const cap = u.s.maxHp * 0.5;
// 2000 HP: 900 leaves 1100 (55 %): no barrier; then 200 would leave 900 (45 %)
h.b.dealDamage(e, a, { amount: 900, type: 'true' });
assert.equal(a.hp, 1100, 'above 50 %: no barrier');
h.b.dealDamage(e, a, { amount: 200, type: 'true' });
if (on) {
assert.equal(a.hp, 1100, 'the barrier took the hit');
approx(a.findBuff(`slent2:barrier#${u.id}`)?.shield ?? 0, cap - 200, 'what is left of it');
} else assert.equal(a.hp, 900, 'no barrier');
// once: a later drop gives none
a.hp = 1100;
for (const b of a.buffs.filter((x) => x.key.startsWith('slent2:barrier'))) h.b.removeBuff(a, b);
h.b.dealDamage(e, a, { amount: 200, type: 'true' });
assert.equal(a.hp, 900, 'once per deployment');
h.b.dealDamage(e, r, { amount: 1200, type: 'true' });
if (on) approx(r.hp, 2000 - Math.max(0, 1200 - 2 * cap), '莱茵生命: twice the barrier');
// entering her range below 50 %
out.hp = 500;
h.b.relocate(out, 11, 5);
h.run(0.2);
assert.equal(!!out.findBuff(`slent2:barrier#${u.id}`), on, 'on entering below 50 %');
// a new deployment of hers clears the marks
if (on) {
h.b.retreat(u);
h.b.redeploy(u);
h.run(0.2);
a.hp = 1100;
h.b.dealDamage(e, a, { amount: 200, type: 'true' });
assert.equal(a.hp, 1100, 'again after her redeploy');
}
done(h);
}
});
test('S3 无畏者协议 (60 s, at most 2 casts per battle): ATK +20 % / +30 %; the first operator of her range taking lethal damage keeps 1 HP and holds 不死 for 6 / 7 s (outlasting her) — once per cast, not a summon, not one outside her range, not one already holding 不死; no third cast', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, others: [ally(2, 10, 6), ally(3, 11, 6), ally(4, 10, 9)] });
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.grave_duration, sk.bb.skill_max_trigger_time, u.skill.spCost, sk.initSp], [60, elite ? 0.3 : 0.2, elite ? 7 : 6, 2, elite ? 42 : 46, 20]);
const a = h.unit(2), b = h.unit(3), out = h.unit(4);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), 'ATK');
h.b.dealDamage(e, out, { amount: 1e6, type: 'true' });
assert.ok(!out.alive, 'outside her range: no 不死');
h.b.dealDamage(e, a, { amount: 1e6, type: 'true' });
assert.ok(a.alive && a.hp === 1, 'kept at 1 HP');
assert.ok(a.findBuff('slent2:grave') && holdsUndying(h.b, a), '不死 (holdsUndying sees it)');
approx(a.findBuff('slent2:grave').timeLeft, sk.bb.grave_duration, 'duration', 0.01);
h.b.retreat(u); // the 不死 outlasts her
h.run(sk.bb.grave_duration - 0.5);
h.b.dealDamage(e, a, { amount: 1e6, type: 'true' });
assert.ok(a.alive, 'held during the window');
h.b.dealDamage(e, b, { amount: 1e6, type: 'true' });
assert.ok(!b.alive, 'once per cast');
h.run(1);
h.b.dealDamage(e, a, { amount: 1e6, type: 'true' });
assert.ok(!a.alive, 'the window is over');
done(h);
}
// two casts per battle, then no SP
const { h, u } = field({ skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 7] });
for (let i = 1; i <= 2; i++) {
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === i, 5), `cast ${i}`);
h.runUntil(() => !u.skill.active, 61);
}
h.run(100);
assert.equal(u.skill.activations, 2, 'no third cast');
assert.equal(u.skill.charges, 0);
assert.equal(u.skill.gainSp(50), 0, 'no SP after the last use');
done(h);
});
test('S3: a summon is no 干员 (no 不死); a lethal hit on an operator already holding 不死 leaves the cast\'s 不死 for the next one', () => {
const { h, u, lampUnit } = field({ skill: 2, others: [ally(2, 10, 6), ally(3, 11, 6)] });
void lampUnit;
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const a = h.unit(2), b = h.unit(3);
// a summon of her range (a 夜灯 spawned for the test) takes no 不死
const tok = h.b.spawnToken(u, LAMP, 11, 5, { anySource: true });
assert.ok(tok && tok.alive && u.rangeKeySet.has(11 * 21 + 5));
h.b.dealDamage(e, tok, { amount: 1e6, type: 'true' });
assert.ok(!tok.alive && !tok.findBuff('slent2:grave'), 'a summon is no 干员');
h.b.addBuff(a, { key: 'test:undying', flags: { undying: true }, duration: 5 });
h.b.on('fatal', (c) => { if (c.unit === a && a.findBuff('test:undying')) c.prevented = true; }, { priority: -99 });
h.b.dealDamage(e, a, { amount: 1e6, type: 'true' });
assert.ok(a.alive && !a.findBuff('slent2:grave'), 'its own 不死 held it: the cast keeps its charge');
h.b.dealDamage(e, b, { amount: 1e6, type: 'true' });
assert.ok(b.alive && b.findBuff('slent2:grave'), 'the next operator gets it');
done(h);
});