Files
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

293 lines
19 KiB
JavaScript
Raw Permalink 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_lin.test.js — the 自选 operator kit of 林 (char_4080_lin, 6★ 阵法术师; kit
// server/sim/content/kits/ops/op-lin.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, PLX-X
// “七窍玲珑” or PLX-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_lin.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 LIN = 'char_4080_lin';
const FORMS = BACKUPS.units[LIN].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PLXX = 'uniequip_002_lin', PLXY = 'uniequip_003_lin';
const S1 = 'skchr_lin_1', S2 = 'skchr_lin_2', S3 = 'skchr_lin_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 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_weak: dummy('enemy_weak', { hp: 10 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PLXX, PLXY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** Her x-1 (and x-2) range from (10, 5) facing RIGHT, as [row, col]. */
const X1 = [[12, 5], [11, 4], [11, 5], [11, 6], [10, 3], [10, 4], [10, 5], [10, 6], [10, 7], [9, 4], [9, 5], [9, 6], [8, 5]];
/** A battle with 林 as uid 1 at (10, 5) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, others = [], seed = 5, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LIN, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const glassHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('lin:glass'));
const glassOf = (a) => a.findBuff('lin:glass');
/** True damage straight to `t` from `src` (no DEF / RES: the exact amount meets the 琉璃璧). */
const hurt = (h, src, t, n) => h.b.dealDamage(src, t, { amount: n, type: 'true' });
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('林 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, x-1 range, 阵法术师 (no attack and the guard while no skill runs; every enemy of her range, air too, while one does)', () => {
assert.equal(OPERATOR_KITS[LIN], KITS[LIN]);
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], [LIN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.bat], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 15, 2], `${label(f)}: stats`);
assert.deepEqual([u.profile.noAttackUnlessSkill, u.profile.allInRange, u.profile.canHitFly, u.profile.dmgType, u.profile.projectile], [true, true, true, 'arts', 'beam'], `${label(f)}: 阵法术师`);
const plxx = elite && mod === PLXX;
assert.deepEqual([u.profile.guardDef, u.profile.guardRes], plxx ? [2.15, 25] : [2, 20], `${label(f)}: trait tunables`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: x-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[LIN].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
done(h);
}
}
// E2 Lv1 1679 / 764 / 212, E2 Lv60 1924 / 843 / 232; PLX-X +62 / +45 → +77 / +53 ATK / DEF, PLX-Y +50 / +20 → +73 / +40
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], [1679, 764, 1924, 843]);
assert.deepEqual([modOf(5, PLXX).attr, modOf(6, PLXX).attr, modOf(5, PLXY).attr, modOf(6, PLXY).attr], [{ atk: 62, def: 45 }, { atk: 77, def: 53 }, { atk: 50, def: 20 }, { atk: 73, def: 40 }]);
});
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(LIN));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LIN), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } } });
});
test('trait + PLX-X: DEF ×3 / RES +20 while no skill runs (PLX-X ×3.15 / +25); while one runs the guard is off — PLX-X keeps DEF +100 % / RES +10 (stages 1 and 3)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const plxx = elite && mod === PLXX;
if (plxx) assert.deepEqual(u.def.raw.trait.bb, { def: 2.15, magic_resistance: 25, 'lin_e_002[buff].def': 1, 'lin_e_002[buff].magic_resistance': 10 }, label(f));
approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: guard DEF`);
approx(u.s.res, 15 + (plxx ? 25 : 20), `${label(f)}: guard RES`);
h.run(1);
assert.equal(atkHits(h, u).length, 0, `${label(f)}: no attack while no skill runs`);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: 荫庇`);
approx(u.s.def, u.base.def * (1 + (plxx ? 1 : 0)), `${label(f)}: skill DEF`);
approx(u.s.res, 15 + (plxx ? 10 : 0), `${label(f)}: skill RES`);
h.runUntil(() => !u.skill.active, 30);
approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: the guard is back`);
done(h);
}
});
test('S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once and kept): cast as soon as an enemy is in her range; ATK +25 % / +40 %, interval 2 → 3 s, every enemy of her range (air too) 停顿 0.8 / 0.9 s per attack', () => {
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.kind, sk.bb.atk, sk.bb['attack@sluggish'], sk.bb.base_attack_time, u.skill.spCost], ['SEARCH', 'toggle', elite ? 0.4 : 0.25, elite ? 0.9 : 0.8, 1, elite ? 6 : 8], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`);
const a = h.spawn('enemy_dummy', { pos: [10, 7] }), b = h.spawn('enemy_fly', { pos: [11, 5] }), off = h.spawn('enemy_dummy', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast at once (SEARCH)`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 3, `T${tier}: 3 s`);
h.runUntil(() => atkHits(h, u).length >= 2, 4);
const hits = atkHits(h, u);
assert.deepEqual(hits.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: both in range, air too`);
for (const c of hits) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK`); assert.equal(c.type, 'arts', `T${tier}: arts`); }
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 while it runs`);
const slow = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.deepEqual(slow.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: 停顿 on each`);
for (const c of slow) approx(c.duration, sk.bb['attack@sluggish'], `T${tier}: 停顿 ${sk.bb['attack@sluggish']} s`);
assert.ok(!hits.some((c) => c.target === off), `T${tier}: outside x-1`);
h.run(120);
assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: kept on`);
done(h);
}
});
test('S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +50 / +90, taunt −1; every allied unit of her range gets its own 琉璃璧 (threshold 200, Lin\'s ATK, centred on it, its own regrowth), lost at the end; none outside', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, PLXY]]) {
const sk = skillOf(tier, elite, S2);
const t0 = formOf(tier, elite).modules?.find((m) => m.uniEquipId === mod)?.talentChanges.find((t) => t.talentIndex === 0)?.bb ?? formOf(tier, elite).talents[0].bb;
const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 9 }];
const { h, u } = field({ tier, elite, mod, skill: 1, others });
const near = h.unit(2), far = h.unit(3);
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.attack_speed, sk.bb.taunt_level], ['SEARCH', 25, elite ? 90 : 50, -1], `T${tier}`);
assert.equal(glassOf(near), null, `T${tier}: no copy before the skill`);
u.skill.gainSp(999);
const e0 = h.spawn('enemy_dummy', { pos: [11, 5] });
assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast`);
h.run(0.2);
assert.deepEqual([u.s.aspd, u.s.taunt], [100 + sk.bb.attack_speed, -1], `T${tier}: ASPD / taunt`);
assert.deepEqual([glassOf(near)?.shield, glassOf(near)?.source === u, glassOf(far)], [200, true, null], `T${tier}: a copy in range only`);
// the copy breaks on a hit over 200: burst on Lin's range centred on the ally, Lin's ATK, source Lin
const inBurst = h.spawn('enemy_dummy', { pos: [10, 8] }), outBurst = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
const hp0 = near.hp;
hurt(h, e0, near, 300);
approx(hp0 - near.hp, 100, `T${tier}: 300 − 200 through`);
assert.equal(glassOf(near), null, `T${tier}: broken`);
const gh = glassHits(h, u);
assert.ok(gh.some((c) => c.target === inBurst) && !gh.some((c) => c.target === outBurst), `T${tier}: centred on the ally`);
approx(gh.find((c) => c.target === inBurst).amount, u.s.atk * t0.atk_scale * (mod === PLXY ? 1 + 0.03 : 1), `T${tier}: ${t0.atk_scale * 100} % Lin ATK`);
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && c.target === inBurst), `T${tier}: stunned`);
h.run(t0.interval - 0.2);
assert.equal(glassOf(near), null, `T${tier}: not before ${t0.interval} s`);
h.run(0.4);
assert.equal(glassOf(near)?.shield, 200, `T${tier}: back after ${t0.interval} s`);
h.runUntil(() => !u.skill.active, 30);
assert.equal(glassOf(near), null, `T${tier}: gone with the skill`);
assert.ok(glassOf(u), `T${tier}: hers stays`);
done(h);
}
});
test('S3 流光乍裂 (MANUAL, data CUSTOM_RANGE x-2, 26 / 28 s): cast with an enemy in x-2; range x-2 while on (bursts too), back after; ATK +125 % / +160 %; her 琉璃璧 threshold ×2 / ×2.5; a knock-out by her attack breaks it (burst) and raises it at once', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.talent_scale, sk.rangeId], ['CUSTOM_RANGE', elite ? 28 : 26, elite ? 1.6 : 1.25, elite ? 2.5 : 2, 'x-2'], `T${tier}`);
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [11, 7] }); // x-2 [1, 2], not x-1
assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast with an enemy in x-2`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const thr = 200 * sk.bb.talent_scale;
assert.equal(glassOf(u).shield, thr, `T${tier}: threshold ${thr}`);
const hp0 = u.hp;
hurt(h, e, u, 450);
if (thr > 450) { assert.equal(u.hp, hp0, `T${tier}: 450 absorbed`); assert.ok(glassOf(u)); } else { approx(hp0 - u.hp, 450 - thr, `T${tier}: 450 − ${thr}`); assert.equal(glassOf(u), null); }
// a knock-out by her attack: burst (x-2 around her) and the 琉璃璧 standing again at once
if (!glassOf(u)) { h.run(9); assert.ok(glassOf(u), `T${tier}: regrown`); }
const witness = h.spawn('enemy_dummy', { pos: [9, 3] }); // x-2 [−1, −2]
const weak = h.spawn('enemy_weak', { pos: [10, 6] });
h.step();
const g0 = glassHits(h, u).length;
h.runUntil(() => !weak.alive, 4);
h.step();
assert.ok(glassHits(h, u).slice(g0).some((c) => c.target === witness), `T${tier}: the kill's burst reaches x-2`);
assert.equal(glassOf(u)?.shield, thr, `T${tier}: raised again at once`);
h.runUntil(() => !u.skill.active, 40);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 again`);
assert.equal(glassOf(u)?.shield, 200, `T${tier}: plain threshold again`);
done(h);
}
});
test('T1 计出万全 (every form): a 琉璃璧 from deployment — a hit ≤ 200 absorbed whole, a hit over 200 goes through less 200 and breaks it: stun 1 s first, then 110 % / 120 % ATK arts on her range, regrowing after 8 s / 6 s (PLX-Y stage 3); a lethal hit still breaks it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const order = [];
const { h, u } = field({ tier, elite, mod, skill: 2, setup: (b) => {
b.on('statusApplied', (c) => { if (c.status === 'stun' && c.source?.uid === 1) order.push('stun'); });
b.on('damaged', (c) => { if (c.dmg?.tags?.includes('lin:glass')) order.push(c.target.s.flags.stun ? 'hit-stunned' : 'hit'); });
} });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const y3 = elite && tier === 6 && mod === PLXY;
assert.deepEqual([t0.value, t0.atk_scale, t0.stun, t0.interval], [200, y3 ? 1.2 : 1.1, 1, y3 ? 6 : 8], label(f));
assert.equal(glassOf(u)?.shield, 200, `${label(f)}: up from deployment`);
const e = h.spawn('enemy_dummy', { pos: [10, 7] }), out = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
const hp0 = u.hp;
hurt(h, e, u, 150);
hurt(h, e, u, 200);
assert.deepEqual([u.hp, glassOf(u)?.shield], [hp0, 200], `${label(f)}: 150 and 200 absorbed, full again`);
hurt(h, e, u, 300);
approx(hp0 - u.hp, 100, `${label(f)}: 300 − 200`);
assert.equal(glassOf(u), null, `${label(f)}: broken`);
const gh = glassHits(h, u);
assert.deepEqual(gh.map((c) => c.target.id), [e.id], `${label(f)}: her range only`);
const plxy = elite && mod === PLXY ? 1 + 0.03 * 1 : 1;
approx(gh[0].amount, u.s.atk * t0.atk_scale * plxy, `${label(f)}: ${t0.atk_scale * 100} % ATK`);
assert.deepEqual(order, ['stun', 'hit-stunned'], `${label(f)}: stun first`);
assert.ok(!h.hooksOf('statusApplied').some((c) => c.target === out && c.status === 'stun'), `${label(f)}: none outside`);
h.run(t0.interval - 0.1);
assert.equal(glassOf(u), null, `${label(f)}: not yet`);
h.run(0.2);
assert.equal(glassOf(u)?.shield, 200, `${label(f)}: back after ${t0.interval} s`);
// a lethal hit: the burst still comes
const n0 = glassHits(h, u).length;
hurt(h, e, u, u.hp + 1000);
assert.equal(u.alive, false, `${label(f)}: knocked out`);
assert.equal(glassHits(h, u).length, n0 + 1, `${label(f)}: the lethal hit broke it`);
done(h);
}
});
test('T2 韬光: each damage instance she takes rolls 55 % for +1 SP (PLX-X stage 3: 80 %, +2) — one the 琉璃璧 absorbs whole too; a 流失 never', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 33 });
const x3 = elite && tier === 6 && mod === PLXX;
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1.prob, t1.sp], x3 ? [0.8, 2] : [0.55, 1], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
const hp0 = u.hp;
for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 50); }
assert.equal(u.hp, hp0, `${label(f)}: all absorbed`);
const absorbed = gains();
assert.ok(Math.abs(absorbed.length / 200 - t1.prob) < 0.12, `${label(f)}: absorbed hits roll too: ${absorbed.length} / 200 ≈ ${t1.prob * 100} %`);
assert.ok(absorbed.every((c) => c.amount === t1.sp), `${label(f)}: +${t1.sp} SP`);
hurt(h, e, u, 400); // breaks it: 200 through
const n0 = gains().length;
for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 1); }
const g = gains().slice(n0);
assert.ok(Math.abs(g.length / 200 - t1.prob) < 0.12, `${label(f)}: ${g.length} / 200 ≈ ${t1.prob * 100} %`);
const n1 = gains().length;
for (let i = 0; i < 50; i++) { u.skill.sp = 0; h.b.loseHp(u, 1, { source: e }); }
assert.equal(gains().length, n1, `${label(f)}: a 流失 rolls nothing`);
done(h);
}
});
test('PLX-Y “情与义” (stages 1 and 3): +3 % damage per enemy on her range, at most 5 (+15 %); none without it', () => {
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
const [tier, , mod] = f;
const plxy = mod === PLXY;
for (const n of [2, 7]) {
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
if (plxy) assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb), [{ damage_scale: 0.03, max_valid_stack_cnt: 5 }], label(f));
const tiles = [[10, 6], [10, 7], [11, 5], [9, 5], [11, 4], [9, 4], [11, 6]].slice(0, n);
for (const p of tiles) h.spawn('enemy_dummy', { pos: p });
u.skill.gainSp(999);
h.runUntil(() => atkHits(h, u).length >= n, 5);
for (const c of atkHits(h, u).slice(0, n)) approx(c.amount, u.s.atk * (plxy ? 1 + 0.03 * Math.min(5, n) : 1), `${label(f)}: ${n} enemies`);
done(h);
}
}
});
test('the x-1 grid of the tests is her data range (from (10, 5) facing RIGHT)', () => {
const { h, u } = field({});
assert.deepEqual([...u.rangeKeys].sort((a, b) => a - b), X1.map(([r, c]) => r * 21 + c).sort((a, b) => a - b));
done(h);
});