Files
ark-stronghold-protocol/test/content/op_chyue.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

330 lines
21 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, full potential): E2 Lv1 2160 / 501 / 304, E2 Lv60 2475 / 576 / 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, 501, 2475, 576]);
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.7) < 1e-9, `T${tier}: ×1 or ×1.7 (止戈)`);
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.7 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.7, '×1.7 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.7 : 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.7), `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.7 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 25 % to mark its target for 2.5 s (not that instance), every damage of his to a marked target ×1.7 — FGT-Y stage 3 ×1.8 at 23 % (stage 1 ×1.7)', () => {
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;
// full potential: 25 % / ×1.7; FGT-Y stage 3: ×1.8, its blackboard keeps prob 0.23 (its text reads 25%(+2%))
const y3 = tier === 6 && mod === FGTY;
const [prob, scale] = y3 ? [0.23, 1.8] : [0.25, 1.7];
assert.deepEqual([t0.prob, t0.damage_scale, t0.up_duration], [prob, 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 > prob - 0.05 && rate < prob + 0.05, `${label(f)}: ${marked} / ${unmarkedHits} ≈ ${prob * 100} %`);
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);
});