Files
ark-stronghold-protocol/test/content/op_chyue.test.js
T
sganggs 29fa988604 feedback5 O5: 自选 operator kits for 左乐, 重岳 and 煌 — every skill at ranks 4 / 7, both talents, the trait and both modules at stages 1 / 3, from the data, PRTS 备注, the client's buff templates and the battle skeletons' clip events
- 左乐 (op-zuole.js): S1 next attack + 1 / 2 extra strikes below 80 / 50 % HP (each a damage instance and a trait heal); S2 流失 50 % current HP (never below 1), a barrier of scale × max HP stacking to 2×, ATK / block +1 / every blocked enemy, −200 per second once over; S3 7 slashes on ≤ 3 enemies of 3-2, the last ×2 + stun, the trait's heal turned into a barrier (70 × 2.5 / 3 per damage instance, ≤ 2× max HP, 15 s); 坚忍 ASPD / SP recovery; 守正自明 per damage instance (SBL-Y Lv3 ×1.2 on a success); SBL-X 庇护 (behind the barrier once 行险 resets it — PRTS S2 备注 — through the hpDamage hook); SBL-Y revive at 30 % once per deployment.
- 重岳 (op-chyue.js): 止戈 marks (normal-attack instances, ×1.65 / ×1.75 on all his damage); 万象为宾 SP at each skill / segment end, 阻回 ignored (FGT-X Lv3 4, else 1); S1 charges with the full-stack burst of 3; S2 x-4 ≤ 4 enemies (air too) + 浮空 2 s on the marked, the second segment 0.5 s later (skeleton events) on every 浮空 enemy; S3 strike + 0.8 splash, the 5th cast transforms him (x-6, double hits, +1 SP per attack, self-cast, the strike twice); FGT-X dodge, FGT-Y ASPD.
- 煌 (op-huang.js): CEN-X ×1.1 vs blocked enemies on every damage; CEN-Y −20 % physical above 50 %; 紧急除颤 不死 + heal + the 50 % HP floor through the hpDamage hook (CEN-Y Lv3: 50 % then 25 %, 8 s, DEF −150 above 50 %); 严酷训练 抵抗 after 15 s (CEN-X Lv3 ATK +6 % / ASPD +12 after 30 / 45 s); S1 next attack; S2 SP_FULL toggle with 2-2; S3 a 10 s 维持技能状态: cuts each second for 8 s, ramp to full at 8.95 s, at 9 s 流失 25 % max HP and the 3-6 burst (air too).
- kits/index.js OPERATOR_KIT_FILES lists the three files; test/content/diy.test.js's "owned 6★ without a kit" case takes the first kitless operator of diy.ownedPool (it named 煌).
- Golden: only test/golden/diy.json moved — the diy family packs the new kits: diy-003 / 004 field 左乐, diy-005 / 006 重岳, diy-007 / 008 煌 (tiers 5 / 6, every form × module × skill, no battle error); the prototypes and reserves moved from diy-003 … 006 to diy-009 … 012 (new ids, same packing); diy-001 / 002 (推进之王) and every other family unchanged.
- Tests: test/content/op_zuole.test.js (10), op_chyue.test.js (8), op_huang.test.js (10).
2026-10-06 00:13:05 +08:00

328 lines
20 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_chyue.test.js — the 自选 operator kit of 重岳 (char_2024_chyue, 6★ 斗士; kit
// server/sim/content/kits/ops/op-chyue.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, FGT-X
// “自晦及明” or FGT-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_chyue.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 CHYUE = 'char_2024_chyue';
const FORMS = BACKUPS.units[CHYUE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const FGTX = 'uniequip_002_chyue', FGTY = 'uniequip_003_chyue';
const S1 = 'skchr_chyue_1', S2 = 'skchr_chyue_2', S3 = 'skchr_chyue_3';
const X6 = [[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]];
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_frail: dummy('enemy_frail', { hp: 100 }), enemy_mid: dummy('enemy_mid', { hp: 3000 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, FGTX, FGTY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const markOf = (u, e) => e.findBuff(`talent:chyue:mark#${u.id}`);
/** 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', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHYUE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').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: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 炎, no 特质; FGT-X 15 % physical dodge, FGT-Y +10 ASPD above 50 % HP', () => {
assert.equal(OPERATOR_KITS[CHYUE], KITS[CHYUE]);
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], [CHYUE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'melee', false, 'phys', 0.78], `${label(f)}: 斗士`);
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 him`);
assert.equal(u.s.dodgePhys, elite && mod === FGTX ? 0.15 : 0, `${label(f)}: FGT-X 物理闪避`);
assert.equal(u.s.aspd, elite && mod === FGTY ? 110 : 100, `${label(f)}: FGT-Y at full HP`);
if (elite && mod === FGTY) {
u.hp = u.s.maxHp * 0.5;
h.step();
assert.equal(u.s.aspd, 100, `${label(f)}: FGT-Y off at 50 % (not above)`);
}
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2160 / 477 / 304, E2 Lv60 2475 / 552 / 343; FGT-X +210 / +25 / +20 →
// +260 / +45 / +30, FGT-Y +52 / +25 → +75 / +35 ATK / DEF
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], [2160, 477, 2475, 552]);
assert.deepEqual([modOf(5, FGTX).attr, modOf(6, FGTX).attr, modOf(5, FGTY).attr, modOf(6, FGTY).attr],
[{ maxHp: 210, atk: 25, def: 20 }, { maxHp: 260, atk: 45, def: 30 }, { atk: 52, def: 25 }, { atk: 75, def: 35 }]);
assert.deepEqual([modOf(5, FGTX).traitOverride.bb, modOf(6, FGTY).traitOverride.bb], [{ prob: 0.15 }, { attack_speed: 10, hp_ratio: 0.5 }]);
});
test('a 自选 pick: 重岳 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(CHYUE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHYUE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CHYUE, skillIndex: 0, uniEquipId: FGTX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: CHYUE, skillIndex: 0, uniEquipId: FGTX } } });
});
test('S1 冲盈 (MANUAL, attack SP 5 / 4, 3 charges, data DEFAULT): 250 % / 290 % ATK on the attack target; from a full stack (蓄力) every charge goes and it strikes 3 times', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.maxCharges, sk.initSp, sk.bb.atk_scale, sk.bb.times],
['DEFAULT', 'attack', elite ? 4 : 5, 3, 0, elite ? 2.9 : 2.5, 3], `T${tier}`);
// a full stack: 蓄力
u.skill.gainSp(999);
assert.equal(u.skill.charges, 3);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast`);
h.step();
const strike = hitsBy(h, u).filter((c) => c.dmg.isSkill);
assert.equal(strike.length, 3, `T${tier}: three strikes`);
for (const c of strike) {
assert.equal(c.target, e, `T${tier}: the ground target`);
approx(c.dmg.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.ok(c.dmg.mul === 1 || Math.abs(c.dmg.mul - 1.65) < 1e-9, `T${tier}: ×1 or ×1.65 (止戈)`);
approx(c.amount, c.dmg.amount * c.dmg.mul, `T${tier}: physical, DEF 0`);
}
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: every charge spent`);
assert.ok(!hitsBy(h, u).some((c) => c.target === fly), `T${tier}: never the flyer`);
// one charge: one strike
u.skill.gainSp(u.skill.spCost);
assert.equal(u.skill.charges, 1);
const n0 = hitsBy(h, u).filter((c) => c.dmg.isSkill).length;
assert.ok(h.runUntil(() => u.skill.activations === 2, 4), `T${tier}: second cast`);
h.step();
assert.equal(hitsBy(h, u).filter((c) => c.dmg.isSkill).length - n0, 1, `T${tier}: one strike`);
done(h);
}
});
test('S1 冲盈: exact damage — 290 % ATK per strike, ×1.65 on a 止戈-marked target; the strike rolls no 止戈 and gives no attack SP', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
const sk = skillOf(6, true, S1);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(u.skill.spCost);
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
h.step();
const c = hitsBy(h, u).find((x) => x.dmg.isSkill);
approx(c.amount, u.s.atk * sk.bb.atk_scale, '290 % (no mark yet)');
assert.equal(markOf(u, e), null, 'the skill strike marks nothing');
assert.equal(u.skill.sp, 0, 'no attack SP for the strike');
h.b.addBuff(e, { key: `talent:chyue:mark#${u.id}`, duration: 2.5 });
u.skill.gainSp(u.skill.spCost);
assert.ok(h.runUntil(() => u.skill.activations === 2, 4));
h.step();
const c2 = hitsBy(h, u).filter((x) => x.dmg.isSkill)[1];
approx(c2.amount, u.s.atk * sk.bb.atk_scale * 1.65, '×1.65 on the marked target');
done(h);
});
test('S2 拂尘 (MANUAL, 2 charges, data SKILL_RANGE x-4): ≤ 4 enemies around (air too; his marked ones first, then the one he blocks) take 290 % / 350 %; the marked ground ones 浮空 2 s; 0.5 s later every 浮空 enemy around loses it, takes 410 % / 480 % and is marked', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1, row: 9 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.maxCharges, sk.initSp, sk.rangeId, sk.bb.max_target, sk.bb.atk_scale, sk.bb.atk_scale_down],
['SKILL_RANGE', 'time', elite ? 11 : 12, 2, elite ? 7 : 6, 'x-4', 4, elite ? 3.5 : 2.9, elite ? 4.8 : 4.1], `T${tier}`);
const blocked = h.spawn('enemy_dummy', { pos: [9, 5] });
const m1 = h.spawn('enemy_dummy', { pos: [10, 6] });
const m2 = h.spawn('enemy_dummy', { pos: [10, 4] });
const flyM = h.spawn('enemy_fly', { pos: [8, 6] });
const plain1 = h.spawn('enemy_dummy', { pos: [8, 4] });
const plain2 = h.spawn('enemy_dummy', { pos: [9, 6] });
const outside = h.spawn('enemy_dummy', { pos: [9, 7] });
const lifted = h.spawn('enemy_dummy', { pos: [10, 5] }); // 浮空 by another source, not marked
h.step();
assert.equal(blocked.blockedBy, u);
for (const e of [m1, m2, flyM]) h.b.addBuff(e, { key: `talent:chyue:mark#${u.id}`, duration: 9 });
h.b.applyStatus(lifted, 'levitate', { duration: 5, source: null });
assert.ok(lifted.isFlying);
u.skill.gainSp(sk.spCost);
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), `T${tier}: cast`);
const t0 = h.b.time;
const first = hitsBy(h, u).filter((c) => c.dmg.isSkill);
const hitSet = new Set(first.map((c) => c.target));
assert.equal(first.length, 4, `T${tier}: four targets`);
assert.deepEqual([m1, m2, flyM, blocked].map((e) => hitSet.has(e)), [true, true, true, true], `T${tier}: the marked (flyer included), then the blocked one`);
assert.ok(![plain1, plain2, outside].some((e) => hitSet.has(e)), `T${tier}: no room for the rest`);
for (const c of first) approx(c.amount, u.s.atk * sk.bb.atk_scale * (markOf(u, c.target) ? 1.65 : 1), `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.deepEqual([m1, m2, flyM, blocked].map((e) => !!e.s.flags.levitate), [true, true, false, false], `T${tier}: the marked ground ones 浮空 (not a flyer, not the unmarked)`);
approx(m1.findBuff('levitate').timeLeft, 2, `T${tier}: 2 s`, 0.05);
assert.equal(blocked.blockedBy, u, 'still blocked');
h.run(0.4);
assert.equal(hitsBy(h, u).filter((c) => c.dmg.isSkill).length, 4, `T${tier}: nothing yet`);
h.runUntil(() => h.b.time >= t0 + 0.5 - 1e-9, 0.3);
h.step();
const second = hitsBy(h, u).filter((c) => c.dmg.isSkill).slice(4);
assert.deepEqual(new Set(second.map((c) => c.target)), new Set([m1, m2, lifted]), `T${tier}: every 浮空 enemy around (the other source's too)`);
for (const c of second) approx(c.amount, u.s.atk * sk.bb.atk_scale_down * (c.target === lifted ? 1 : 1.65), `T${tier}: ${sk.bb.atk_scale_down * 100} % (its own mark comes after the damage)`);
for (const e of [m1, m2, lifted]) {
assert.ok(!e.s.flags.levitate, `T${tier}: 浮空 over`);
approx(markOf(u, e).timeLeft, 2.5, `T${tier}: marked 2.5 s`, 0.1);
}
done(h);
}
});
test('S3 我无 (MANUAL, attack SP 11 / 10, data DEFAULT): its strike hits the target and the ground enemies within 0.8 for 240 % / 300 %; the 5th cast turns him — x-6, double normal hits, +1 SP per attack (阻回 ignored), a self-cast the moment SP is full, the strike lands twice; a redeploy undoes it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 9 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.cast_cnt], ['DEFAULT', 'attack', elite ? 10 : 11, 0, elite ? 3 : 2.4, 5], `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const near = h.spawn('enemy_dummy', { pos: [10.6, 6.4] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
const far = h.spawn('enemy_dummy', { pos: [11, 7] });
const strikes = () => hitsBy(h, u).filter((c) => c.dmg.isSkill);
for (let n = 1; n <= 5; n++) {
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === n, 5), `T${tier}: cast ${n} (3 s apart: the automatic-operation cooldown)`);
h.step();
assert.equal(u.mem.chyueAwake, n === 5, `T${tier}: transformed after the 5th cast only`);
}
const pre = strikes();
assert.equal(pre.length, 10, `T${tier}: each of the five strikes hits the target and the one within 0.8`);
assert.deepEqual(new Set(pre.map((c) => c.target)), new Set([e, near]), `T${tier}: not the flyer, not the one 1.4 away`);
for (const c of pre) {
approx(c.dmg.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
approx(c.amount, c.dmg.amount * c.dmg.mul, `T${tier}: physical (×1.65 on a marked one)`);
}
assert.deepEqual(u.liveRangeGrid, X6, `T${tier}: x-6`);
assert.ok(!hitsBy(h, u).some((c) => c.target === fly || c.target === far), `T${tier}: never the flyer / far one before`);
// transformed: two hits per normal attack, 2 SP per attack (阻回 does not stop the extra one)
const n0 = hitsBy(h, u).length;
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: 30 });
const sp0 = u.skill.sp;
h.runUntil(() => hitsBy(h, u).length > n0, 3);
h.step();
const plain = hitsBy(h, u).slice(n0).filter((c) => !c.dmg.isSkill);
assert.ok(plain.length >= 1 && plain.length % 2 === 0, `T${tier}: double hits`);
assert.equal(new Set(plain.map((c) => c.dmg.attackId)).size, plain.length / 2, `T${tier}: two per attack`);
approx(u.skill.sp - sp0, plain.length / 2, `T${tier}: +1 SP per attack under 阻回`);
h.b.removeBuff(u, 'test:noSp');
// the self-cast: it fires the moment its SP is full (no 3 s operation cooldown), the strike twice
const c6 = u.skill.activations;
const s0 = strikes().length;
u.skill.gainSp(999);
h.step();
assert.equal(u.skill.activations, c6 + 1, `T${tier}: cast at once`);
const twice = strikes().slice(s0);
assert.equal(twice.filter((c) => c.target === e).length, 2, `T${tier}: the strike lands twice on its target`);
assert.equal(twice.filter((c) => c.target === near).length, 2, `T${tier}: and twice around it`);
assert.ok(far.alive && !twice.some((c) => c.target === far || c.target === fly));
// a redeploy starts untransformed
h.b.kill(u, e);
h.b.redeploy(u);
h.step();
assert.deepEqual([u.mem.chyueAwake, u.mem.chyueCasts], [false, 0], `T${tier}: reset`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
done(h);
}
});
test('T1 止戈: each normal-attack damage instance rolls 23 % to mark its target for 2.5 s (not that instance), every damage of his to a marked target ×1.65 — FGT-Y stage 3 ×1.75 (stage 1 ×1.65)', () => {
for (const f of [[5, false, null], [5, true, FGTY], [6, true, FGTY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const scale = tier === 6 && mod === FGTY ? 1.75 : 1.65;
assert.deepEqual([t0.prob, t0.damage_scale, t0.up_duration], [0.23, scale, 2.5], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
let marked = 0, plainHits = 0, prevMarked = false;
h.b.on('hit', (c) => { if (c.source === u && c.target === e && c.dmg.isAttack && !c.dmg.isSkill) prevMarked = !!markOf(u, e); }, { priority: 100 });
const amounts = [];
h.b.on('damaged', (c) => {
if (c.source !== u || c.target !== e || c.dmg.isSkill) return;
plainHits++;
amounts.push([c.amount, prevMarked]);
if (!prevMarked && markOf(u, e)) marked++;
});
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity }); // keep S1 off: plain attacks only
h.run(400);
const unmarkedHits = amounts.filter(([, m]) => !m).length;
const rate = marked / unmarkedHits;
assert.ok(rate > 0.18 && rate < 0.28, `${label(f)}: ${marked} / ${unmarkedHits} ≈ 23 %`);
for (const [amt, m] of amounts.slice(0, 50)) approx(amt, u.s.atk * (m ? scale : 1), `${label(f)}: ×${scale} while marked`);
assert.ok(plainHits > 400);
done(h);
}
});
test('T2 万象为宾: a skill kill gives 3 SP when the skill ends (阻回 ignored; FGT-X stage 3: 4, and 1 when it killed nobody — stage 1: 3 / 0); a normal-attack kill gives nothing; S2\'s second segment is judged on its own', () => {
for (const f of [[5, true, null], [5, true, FGTX], [6, true, FGTX]]) {
const [tier, elite, mod] = f;
const x3 = tier === 6 && mod === FGTX;
const { h, u } = field({ tier, elite, mod, skill: 1, row: 9 });
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.equal(t1.sp, x3 ? 4 : 3, label(f));
const s2 = skillOf(tier, elite, S2);
// a cast that kills: +sp at its end; the second segment (no 浮空 enemy) gives the unkill part alone
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
const frail = h.spawn('enemy_frail', { pos: [10, 6] });
u.skill.sp = 0;
u.skill.addCharge(1);
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), label(f));
assert.ok(!frail.alive, `${label(f)}: killed by the skill`);
approx(u.skill.sp - sp0, x3 ? 4 : 3, `${label(f)}: +${x3 ? 4 : 3} under 阻回`);
h.run(1.05);
approx(u.skill.sp - sp0, (x3 ? 4 : 3) + (x3 ? 1 : 0), `${label(f)}: the second segment killed nobody: +${x3 ? 1 : 0}`);
// a cast that kills nobody
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const sp1 = u.skill.sp;
u.skill.addCharge(1);
h.run(3.5);
assert.equal(u.skill.activations, 2, label(f));
approx(u.skill.sp - sp1, x3 ? 2 : 0, `${label(f)}: no kill: ${x3 ? '1 + 1' : 'nothing'}`);
assert.ok(e.alive);
assert.ok(s2.spCost > 0);
done(h);
}
// a normal-attack kill gives nothing; S2's second segment killing gives its own SP
const { h, u } = field({ tier: 6, elite: true, mod: FGTX, skill: 1, row: 9 });
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
u.skill.sp = 0;
const weak = h.spawn('enemy_frail', { pos: [9, 6] });
h.runUntil(() => !weak.alive, 3);
assert.equal(u.skill.sp, 0, 'a normal-attack kill: nothing (and 阻回 blocks the attack SP: time SP anyway)');
const tough = h.spawn('enemy_dummy', { pos: [10, 6] });
const late = h.spawn('enemy_mid', { pos: [10, 5] }); // survives segment 1 (350 %), not segment 2 (+480 %)
h.b.applyStatus(late, 'levitate', { duration: 5, source: null });
h.b.addBuff(tough, { key: `talent:chyue:mark#${u.id}`, duration: 9 });
u.skill.addCharge(1);
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
approx(u.skill.sp - sp0, 1, 'segment 1 killed nobody: +1');
h.run(1.05);
assert.ok(!late.alive, 'the second segment killed the 浮空 one');
approx(u.skill.sp - sp0, 1 + 4, 'segment 2 killed: +4');
done(h);
});