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

315 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_ling.test.js — the 自选 operator kit of 令 (char_2023_ling, 6★ 召唤师; kit
// server/sim/content/kits/ops/op-ling.js, the summon deck of kits/shared/summoner.js) and of her summons “清平” / “逍遥” /
// “弦惊”, fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the summons as the placed hand pieces 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 or
// SUM-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_ling.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 LING = 'char_2023_ling';
const FORMS = BACKUPS.units[LING].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SUMY = 'uniequip_002_ling';
const S1 = 'skchr_ling_1', S2 = 'skchr_ling_2', S3 = 'skchr_ling_3';
const Q = 'token_10020_ling_soul1', X = 'token_10020_ling_soul2', J = 'token_10020_ling_soul3';
const SOUL_OF = [Q, X, J];
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 tokOf = (tok, tier, elite, skill, mod) => {
let v = BACKUPS.tokens[tok].variants[`${LING}@${statusOf(tier, elite)}`];
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
if (mod && v.byModule?.[mod]) v = { ...v, ...v.byModule[mod] };
return v;
};
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, SUMY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 令 as uid 1 at (row, col) facing RIGHT; `souls` = [[tokenId, row, col, dir?]] placed pieces (uid 10 +). */
function field({ tier = 5, elite = false, mod = null, skill = 0, souls = [], row = 10, col = 3, seed = 5, dp = 99 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, captureNoisy: true,
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'dodge'],
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LING, skillIndex: skill, uniEquipId: mod }, elite, row, col },
...souls.map(([tokenId, r, c, dir], i) => ({ uid: 10 + i, kind: 'token', tokenId, ownerUid: 1, row: r, col: c, dir: dir ?? 'RIGHT' }))],
});
h.step();
const u = h.unit(1);
return { h, u, ss: h.b.allyUnits.filter((a) => a.kind === 'token'), deck: u.mem.summonDeck };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('令 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-1, ranged arts, hits air, blocks 1, ground-targetable, the data triggers (DEFAULT), 炎, no 特质', () => {
assert.equal(OPERATOR_KITS[LING], KITS[LING]);
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], [LING, 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.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 召唤师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.skill.rule, form.skills[skill].trigger.rule], ['DEFAULT', 'DEFAULT'], `${label(f)}: the data's trigger`);
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, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 852 / 430 / 114, E2 Lv60 1002 / 474 / 130; SUM-Y +100 / +30 → +150 / +50 HP / ATK
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], [852, 430, 1002, 474]);
assert.deepEqual([modOf(5, SUMY).attr, modOf(6, SUMY).attr], [{ maxHp: 100, atk: 30 }, { maxHp: 150, atk: 50 }]);
});
test('令\'s skills in every form: the data\'s SP (time SP, spCost, initSp, charges) and her 3-1 range while each runs (none changes it)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const sk = formOf(tier, elite).skills[skill];
const { h, u } = field({ tier, elite, mod, skill });
assert.deepEqual([u.skill.spType, u.skill.spCost, u.skill.maxCharges], ['time', sk.spCost, sk.maxChargeTime], `${label(f)} S${skill + 1}: SP`);
assert.ok(Math.abs(u.skill.sp - sk.initSp) < 0.1, `${label(f)} S${skill + 1}: initSp ${sk.initSp} (${u.skill.sp})`);
h.spawn('enemy_dummy', { pos: [10, 4] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), `${label(f)} S${skill + 1}: cast with an enemy in her range`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${label(f)} S${skill + 1}: 3-1 while it runs`);
done(h);
}
}
});
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(LING));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LING), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: LING, skillIndex: 1, uniEquipId: SUMY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: LING, skillIndex: 1, uniEquipId: SUMY } } });
});
test('T1 挑灯问梦: her skill\'s summon is her piece; the deck — 5 held, cap 5 (8 with SUM-Y: +3 on the card), at most 3 standing (4 at SUM-Y stage 3); every summon holds 禁疗 and fights with its data (清平 1-1 melee physical blocks 1, 逍遥 3-1 arts hits air, 弦惊 1-1 blocks 2 strikes all it blocks)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const tok = SOUL_OF[skill];
const { h, u, ss, deck } = field({ tier, elite, mod, skill, souls: [[tok, 10, 6]] });
const s = ss[0], v = tokOf(tok, tier, elite, skill, mod);
const y = elite && mod === SUMY;
assert.ok(h.b.producesToken(u, tok) && SOUL_OF.filter((x) => x !== tok).every((x) => !h.b.producesToken(u, x)), `${label(f)} S${skill + 1}: only ${tok}`);
assert.deepEqual([deck.charge, deck.cap, deck.held, deck.maxDeployed], [5, y ? 8 : 5, y ? 8 : 5, y && tier === 6 ? 4 : 3], `${label(f)} S${skill + 1}: deck`);
assert.deepEqual([s.base.maxHp, s.base.atk, s.base.def, s.base.res, s.base.cost, s.base.respawnTime, s.base.bat], [v.stats.maxHp, v.stats.atk, v.stats.def, v.stats.res, v.stats.cost, v.stats.respawnTime, v.stats.bat], `${label(f)}: summon stats`);
assert.deepEqual([s.alive, !!s.s.flags.noHeal, s.kit.skill], [true, true, null], `${label(f)}: deployed, 禁疗, no skill`);
assert.deepEqual(s.liveRangeGrid, v.rangeGrid, `${label(f)}: its range`);
const want = { [Q]: ['melee', 'phys', false, 1, false], [X]: ['ranged', 'arts', true, 1, false], [J]: ['melee', 'phys', false, 2, true] }[tok];
assert.deepEqual([s.profile.attack, s.profile.dmgType, s.profile.canHitFly, s.s.blockCnt, !!s.profile.hitAllBlocked], want, `${label(f)}: ${tok}`);
done(h);
}
}
// SUM-Y: 清平 / 逍遥 −3 DP, 弦惊 −5 ("部署费用-3 / -5"); stage 3 清平 +150 HP +45 ATK, 逍遥 +100 / +35, 弦惊 +250 / +60
assert.deepEqual(SOUL_OF.map((t, i) => tokOf(t, 6, false, i, null).stats.cost - tokOf(t, 6, true, i, SUMY).stats.cost), [3, 3, 5]);
assert.deepEqual(SOUL_OF.map((t, i) => [tokOf(t, 6, true, i, SUMY).stats.maxHp - tokOf(t, 6, true, i, null).stats.maxHp, tokOf(t, 6, true, i, SUMY).stats.atk - tokOf(t, 6, true, i, null).stats.atk]), [[150, 45], [100, 35], [250, 60]]);
});
test('T2 随付笺咏醉屠苏: a summon knocked out, recalled or absorbed ⇒ +4 SP and a stack of ATK +3 % (at most 5); not when her summons leave with her; no SP while a timed skill of hers runs', () => {
const { h, u, ss } = field({ tier: 6, elite: true, skill: 0, souls: [[Q, 10, 6], [Q, 11, 6], [Q, 9, 6]] });
const t1 = formOf(6, true).talents.find((t) => t.index === 1).bb;
assert.deepEqual(t1, { max_stack_cnt: 5, sp: 4, atk: 0.03 });
const atk0 = u.s.atk;
const sp0 = u.skill.sp;
h.b.kill(ss[0], null);
approx(u.skill.sp - sp0, 4, '+4 SP', 1e-3);
assert.equal(u.findBuff('ling:t2').stacks, 1, 'one stack');
approx(u.s.atk, atk0 * 1.03, 'ATK +3 %');
for (let i = 0; i < 6; i++) { h.b.kill(ss[1 + (i % 2)], null); h.runUntil(() => ss.every((s) => s.alive), 15); }
assert.equal(u.findBuff('ling:t2').stacks, 5, 'at most 5 stacks');
approx(u.s.atk, atk0 * 1.15, 'ATK +15 %');
// no SP while S1 runs
h.spawn('enemy_dummy', { pos: [10, 4] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'S1 cast');
const spOn = u.skill.sp;
h.b.kill(ss[0], null);
assert.equal(u.skill.sp, spOn, 'no SP during a timed skill');
// her leaving: the summons go with her, nothing more
const st = u.findBuff('ling:t2').stacks;
h.b.retreat(u);
assert.equal(u.findBuff('ling:t2'), null, 'the stacks leave with her');
assert.equal(st, 5);
done(h);
});
test('S1 重进酒: +1 held at the cast; she and her summons ATK +29 % / +38 % and ASPD +29 / +38 for 25 s; her summons\' attacks deal arts damage meanwhile, physical before and after', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ss, deck } = field({ tier, elite, mod, skill: 0, souls: [[Q, 10, 6]] });
const s = ss[0];
assert.deepEqual([sk.bb.atk, sk.bb.attack_speed, sk.bb.cnt, sk.duration], [elite ? 0.38 : 0.29, elite ? 38 : 29, 1, 25]);
h.spawn('enemy_dummy', { pos: [10, 7] }); // its 1-1 (outside her 3-1)
h.spawn('enemy_dummy', { pos: [11, 4] }); // hers
h.run(3);
assert.ok(from(h, s).length && from(h, s).every((c) => c.type === 'phys'), 'physical before');
deck.held = 2;
const atk0 = u.s.atk, satk0 = s.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
assert.equal(deck.held, 3, '技能开启时获得1个召唤物');
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
approx(s.s.atk, satk0 * (1 + sk.bb.atk), 'its ATK');
assert.deepEqual([u.s.aspd, s.s.aspd], [100 + sk.bb.attack_speed, 100 + sk.bb.attack_speed], 'ASPD');
const n0 = from(h, s).length;
h.run(4);
assert.ok(from(h, s).slice(n0).length && from(h, s).slice(n0).every((c) => c.type === 'arts'), 'arts during S1');
// knocked out during it: back 10 s later with the skill's bonus and arts
h.b.kill(s, null);
assert.ok(h.runUntil(() => s.alive, 10.5) && u.skill.active, 'back while it runs');
assert.deepEqual(s.findBuff('ling:s1:soul')?.mods, { atkPct: sk.bb.atk, aspd: sk.bb.attack_speed }, 'a summon deployed during it takes the bonus');
h.runUntil(() => !u.skill.active, 30);
approx(h.b.time, h.hooksOf('skillStart').at(-1).skill.lastStart + 25, '25 s', 0.01);
const n1 = from(h, s).length;
h.run(3);
assert.ok(from(h, s).slice(n1).every((c) => c.type === 'phys') && s.s.aspd === 100, 'physical and its ASPD back');
done(h);
}
});
test('S2 笑鸣瑟 (2 charges): her cast-attack strikes 2 enemies for 310 % / 370 % ATK arts and 束缚 2 / 2.5 s each; every summon of hers breaks off and strikes 2 enemies of its range for its 2.atk_scale × its ATK + 束缚 2.duration s (silenced / disarmed ones too); 1.2 s later the summons below half HP are recalled (+1 held, T2)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, ss, deck } = field({ tier, elite, mod, skill: 1, souls: [[X, 10, 5], [X, 11, 5], [X, 9, 5]] });
const [hurt, muted, fine] = ss;
const tv = tokOf(X, tier, elite, 1, mod);
assert.deepEqual([u.skill.maxCharges, sk.bb.atk_scale, sk.bb['ling_s2_unmovable.duration'], sk.bb.value, sk.bb.hp_ratio, tv.skill.bb['2.atk_scale'], tv.skill.bb['2.duration']],
[2, elite ? 3.7 : 3.1, elite ? 2.5 : 2, 2, 0.5, elite ? 3.7 : 3.1, elite ? 2.5 : 2]);
const a = h.spawn('enemy_dummy', { pos: [10, 5] }), b = h.spawn('enemy_dummy', { pos: [10, 6] }), c = h.spawn('enemy_dummy', { pos: [11, 6] });
hurt.hp = hurt.s.maxHp * 0.45;
fine.hp = fine.s.maxHp * 0.55;
h.b.applyStatus(muted, 'silence', { duration: 30, force: true });
h.b.applyStatus(muted, 'disarm', { duration: 30, force: true });
deck.held = 1;
u.skill.gainSp(999);
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'cast on her attack');
const castAt = h.b.time;
h.run(0.6);
const mine = from(h, u).filter((x) => x.dmg.isSkill);
assert.equal(mine.length, 2, 'her two targets');
for (const x of mine) approx(x.amount, u.s.atk * sk.bb.atk_scale, 'her scale');
const binds = h.hooksOf('statusApplied').filter((x) => x.status === 'bind');
assert.ok(binds.some((x) => x.source === u) && binds.filter((x) => x.source === u).every((x) => Math.abs(x.duration - sk.bb['ling_s2_unmovable.duration']) < 1e-9), 'her 束缚');
for (const s of ss) {
const hits = from(h, s).filter((x) => x.dmg.tags?.includes('ling:s2'));
assert.equal(hits.length, 2, `${s.uid}: two targets`);
for (const x of hits) approx(x.amount, s.s.atk * tv.skill.bb['2.atk_scale'] * (1 - x.target.s.res / 100), `${s.uid}: its scale`);
assert.ok(binds.filter((x) => x.source === s).length === 2, `${s.uid}: 束缚 ×2`);
}
const struck = new Set(h.hooksOf('damaged').filter((x) => x.dmg?.isSkill).map((x) => x.target));
assert.ok(struck.size >= 2 && [...struck].every((e) => e.s.flags.bind), 'every struck enemy bound');
void a; void b; void c;
// 1.2 s after the cast: the summon below half HP is recalled (T2: +4 SP, a stack)
h.runUntil(() => h.b.time >= castAt + 1.1, 2);
assert.ok(hurt.alive, 'not before 1.2 s');
const spBefore = u.skill.sp;
assert.ok(h.runUntil(() => !hurt.alive, 0.2), 'recalled at 1.2 s');
assert.deepEqual([hurt.alive, muted.alive, fine.alive], [false, true, true], 'only the one below half');
assert.equal(deck.held, 2, '+1 held');
assert.ok(Math.abs(u.skill.sp - spBefore - 4) < 0.2, `T2: +4 SP (${spBefore} → ${u.skill.sp})`);
assert.equal(u.findBuff('ling:t2').stacks, 1, 'T2 stack');
void sp0;
done(h);
}
});
test('S3 宁作吾: she and her summons ATK +55 % / +70 %, DEF +50 % / +60 % for 25 / 30 s; every 0.5 s each summon\'s x-5 ground enemies take 20 % of 令\'s ATK as arts (her damage, not dodgeable; no air, nothing outside); +1 held at its end', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ss, deck } = field({ tier, elite, mod, skill: 2, souls: [[J, 10, 6, 'RIGHT']] });
const s = ss[0];
assert.deepEqual([sk.bb.atk, sk.bb.def, sk.bb.atk_scale, sk.bb.interval, sk.bb.cnt, sk.duration], [elite ? 0.7 : 0.55, elite ? 0.6 : 0.5, 0.2, 0.5, 1, elite ? 30 : 25]);
const front = h.spawn('enemy_dummy', { pos: [10, 7] }), side = h.spawn('enemy_dummy', { pos: [11, 6] });
const fly = h.spawn('enemy_fly', { pos: [9, 6] }), diag = h.spawn('enemy_dummy', { pos: [11, 7] });
h.spawn('enemy_dummy', { pos: [10, 4] });
deck.held = 0;
const atk0 = u.s.atk, satk0 = s.s.atk, sdef0 = s.s.def;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast');
approx(u.s.atk, atk0 * (1 + sk.bb.atk), 'her ATK');
approx(s.s.atk, satk0 * (1 + sk.bb.atk), 'its ATK');
approx(s.s.def, sdef0 * (1 + sk.bb.def), 'its DEF');
h.run(3.05);
const pulses = h.hooksOf('damaged').filter((x) => x.dmg?.tags?.includes('ling:s3'));
assert.ok(pulses.length > 0, 'pulses');
assert.ok(pulses.every((x) => x.source === u), '令\'s damage');
assert.deepEqual(new Set(pulses.map((x) => x.target)), new Set([front, side]), 'its x-5 ground enemies only');
for (const x of pulses) { approx(x.amount, u.s.atk * 0.2, '20 % of her ATK'); assert.deepEqual([x.type, x.dmg.canDodge, x.dmg.isSplash], ['arts', false, true]); }
assert.equal(pulses.filter((x) => x.target === front).length, 6, 'every 0.5 s (6 in 3 s)');
void fly; void diag;
h.runUntil(() => !u.skill.active, 40);
assert.equal(deck.held, 1, '技能结束时获得1个召唤物');
done(h);
}
});
test('the 弦惊 merge: another basic 弦惊 on a new one\'s range ⇒ that one turns 高级形态 and the new one is absorbed (T2); a new one on the range of basic ones ⇒ it turns 高级形态 and the latest of them is absorbed; 高级形态 = HP / ATK / DEF ×(1 + 1 / 0.8 / 0.8), RES ×2, interval +0.8 s, block +2, arts, full HP; never with a 高级形态 one', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SUMY]]) {
const tb = tokOf(J, tier, elite, 2, mod).skill.bb;
assert.deepEqual(tb, { '2.max_hp': 1, '2.atk': 0.8, '2.def': 0.8, '2.magic_resistance': 1, '2.base_attack_time': 0.8, '2.block_cnt': 2 });
// case 1: B (deployed second) faces A — A on B's range ⇒ A evolves, B absorbed
const { h, u, ss } = field({ tier, elite, mod, skill: 2, souls: [[J, 9, 5, 'RIGHT'], [J, 9, 6, 'LEFT']] });
const [a, b] = ss;
assert.deepEqual([a.alive, a.mem.lingAdvanced, b.alive], [true, true, false], 'case 1');
const base = tokOf(J, tier, elite, 2, mod).stats;
approx(a.s.maxHp, base.maxHp * 2, 'HP ×2');
approx(a.hp, a.s.maxHp, 'full HP');
approx(a.s.atk, base.atk * 1.8, 'ATK ×1.8');
approx(a.s.def, base.def * 1.8, 'DEF ×1.8');
approx(a.s.res, base.res * 2, 'RES ×2');
approx(a.s.interval, base.bat + 0.8, 'interval +0.8 s');
assert.equal(a.s.blockCnt, base.blockCnt + 2, 'block +2');
assert.equal(u.findBuff('ling:t2')?.stacks, 1, 'T2: absorbed');
// its attacks are arts
h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => from(h, a).some((x) => x.dmg?.isAttack), 60), 'it fights');
assert.ok(from(h, a).filter((x) => x.dmg?.isAttack).every((x) => x.type === 'arts'), 'arts');
// the absorbed one comes back basic beside it (no merge with a 高级形态 one)
assert.ok(h.runUntil(() => b.alive, 25), 'the absorbed piece comes back');
assert.deepEqual([b.mem.lingAdvanced, a.mem.lingAdvanced, a.alive], [false, true, true], 'no second merge');
done(h);
// case 2: A faces B and B deploys later — B on A's range ⇒ B evolves, A (the latest such) absorbed
const r2 = field({ tier, elite, mod, skill: 2, souls: [[J, 10, 5, 'RIGHT'], [J, 10, 6, 'RIGHT']] });
const [a2, b2] = r2.ss;
assert.deepEqual([a2.alive, b2.alive, b2.mem.lingAdvanced], [false, true, true], 'case 2');
// not facing each other: no merge
const r3 = field({ tier, elite, mod, skill: 2, souls: [[J, 10, 5, 'LEFT'], [J, 10, 6, 'RIGHT']] });
assert.ok(r3.ss.every((x) => x.alive && !x.mem.lingAdvanced), 'apart: none');
done(r2.h);
done(r3.h);
}
});
test('SUM-Y 诗短梦长: +3 held on the card and cap 8 (5 + 3) at both stages, 4 standing at stage 3; the module\'s talent text is the stage-3 T1', () => {
const { h, deck } = field({ tier: 5, elite: true, mod: SUMY, skill: 0 });
assert.deepEqual([deck.held, deck.cap, deck.maxDeployed], [8, 8, 3], 'stage 1');
done(h);
const { h: h2, deck: d2 } = field({ tier: 6, elite: true, mod: SUMY, skill: 0 });
assert.deepEqual([d2.held, d2.cap, d2.maxDeployed], [8, 8, 4], 'stage 3');
assert.match(modOf(6, SUMY).talentChanges[0].desc, /最多同时部署4个/);
done(h2);
});