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

348 lines
24 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_closur.test.js — the 自选 operator kit of 可露希尔 (char_4228_closur, 6★ 战术家; kit
// server/sim/content/kits/ops/op-closur.js) and of her 援军 指挥中心 (token_10066_closur_ourbase), 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 or TAC-X 给自己的小奖杯 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_closur.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';
import { BASE, SHIELD_KEY, S2_KEY, RHODES_KEY, SLOWDOWN } from '../../server/sim/content/kits/ops/op-closur.js';
import { COLS } from '../../server/sim/constants.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 CL = 'char_4228_closur';
const FORMS = BACKUPS.units[CL].forms;
const TOKREC = BACKUPS.tokens[BASE];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const TX = 'uniequip_002_closur';
const S1 = 'skchr_closur_1', S2 = 'skchr_closur_2', S3 = 'skchr_closur_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 talentOf = (tier, elite, mod, i) => modOf(tier, elite ? mod : null)?.talentChanges?.find((t) => t.talentIndex === i) ?? formOf(tier, elite).talents.find((t) => t.index === i);
const tokVariant = (tier, elite) => TOKREC.variants[`${CL}@${statusOf(tier, elite)}`];
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_atk: dummy('enemy_atk', { atk: 2000, bat: 1 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, TX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const YAK = 'chess_char_1_02_a', TEXAS = 'chess_char_1_08_a', PODEGO = 'chess_char_1_13_a';
const tk = (r, c) => r * COLS + c;
/**
* 可露希尔 as uid 1 at (row, col) facing RIGHT; `base` = the 指挥中心 piece's tile (uid 2) or null; `others` as given.
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, base = [10, 6], others = [], seed = 5 } = {}) {
const op = { uid: 1, diy: { slot: SLOT[tier], charId: CL, skillIndex: skill, uniEquipId: mod }, elite, row, col };
const piece = base ? [{ uid: 2, kind: 'token', tokenId: BASE, ownerUid: 1, row: base[0], col: base[1] }] : [];
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack'], captureNoisy: true,
units: [op, ...piece, ...others],
});
h.step();
return { h, u: h.unit(1), t: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === BASE && a.alive) ?? null };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const dpOf = (h) => h.b.players[0].dp;
const hitsOn = (h, u, e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
/** Record the DP gains' times. */
function dpLog(h) {
const log = [];
let last = dpOf(h);
h.b.on('tick', () => { const v = dpOf(h); if (v !== last) { log.push([h.b.time, v - last]); last = v; } });
return log;
}
test('可露希尔 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-3, 战术家 (ranged physical, hits air, blocks 1), ground-targetable, the triggers (S1 at full SP); the placed 指挥中心 deploys with its variant\'s stats, no attack, 禁疗, blocks 2, its range by her skill (x-5 / x-4 / x-6); no generic 援军', () => {
assert.equal(OPERATOR_KITS[CL], KITS[CL]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u, t } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null, sk = form.skills[skill];
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CL, SLOT[tier], sk.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.profile.sub, u.base.bat], [1, 'ranged', true, 'phys', 'tactician', 1], `${label(f)}: 战术家`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-3`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
assert.deepEqual([u.skill.rule, u.skill.spCost], [skill === 0 ? 'SP_FULL' : sk.trigger.rule, sk.spCost], `${label(f)}: trigger`);
if (skill > 0) assert.deepEqual([sk.trigger.rule, sk.trigger.rawRule], ['SP_FULL', 'ALWAYS'], `${label(f)}: the 战术家 row`);
else assert.equal(sk.trigger.rule, 'DEFAULT', `${label(f)}: the data's AUTO rule (the kit fires it at full SP)`);
const v = tokVariant(tier, elite);
assert.ok(t && t.uid === 2 && t.tileR === 10 && t.tileC === 6, `${label(f)}: the placed 指挥中心 on its tile`);
assert.deepEqual([t.base.maxHp, t.base.def, t.s.blockCnt, !!t.profile.noAttack, !!t.s.flags.noHeal], [v.stats.maxHp, v.stats.def, 2, true, true], `${label(f)}: 指挥中心`);
const grid = skill === 0 ? v.skill.rangeGrid : v.bySkill[String(skill)].skill.rangeGrid;
assert.deepEqual(t.liveRangeGrid, grid, `${label(f)}: its range by her S${skill + 1}`);
assert.equal(h.b.allyUnits.filter((a) => a.defId === 'token_tactician_reinforce').length, 0, `${label(f)}: no generic 援军`);
assert.equal(u.trait.reinforcement, t, `${label(f)}: her 援军`);
done(h);
}
}
assert.deepEqual([modOf(5, TX).attr, modOf(6, TX).attr], [{ maxHp: 150, atk: 20, def: 15 }, { maxHp: 180, atk: 30, def: 25 }]);
assert.deepEqual([tokVariant(5, false).stats.maxHp, tokVariant(5, true).stats.maxHp, tokVariant(5, true).skill.rangeId, tokVariant(5, true).bySkill['1'].skill.rangeId, tokVariant(5, true).bySkill['2'].skill.rangeId], [2620, 3054, 'x-5', 'x-4', 'x-6']);
});
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(CL));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CL), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: CL, skillIndex: 1, uniEquipId: TX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: CL, skillIndex: 1, uniEquipId: TX } } });
});
test('精准投放 without a placed piece: the 指挥中心 comes on the tactical point (a walkable tile of her range, the enemy path first); her leaving withdraws it for good, her redeploy brings a new one; knocked out it sits in its 战术点形态 for 15 s (off the fight, its tile kept, no 援军) and comes back at full HP', () => {
const { h, u, t } = field({ tier: 6, elite: true, mod: TX, skill: 0, base: null });
assert.ok(t && t.uid == null, 'summoned');
assert.ok(u.baseRangeKeys.includes(tk(t.tileR, t.tileC)) && h.b.grid.groundPassable(t.tileR, t.tileC), 'a walkable tile of her range');
assert.ok(h.b.groundPathTiles().has(tk(t.tileR, t.tileC)), 'on an enemy ground path');
done(h);
// the placed piece: knock-out ⇒ 战术点形态
const r = field({ tier: 5, elite: true, mod: TX, skill: 1, others: [{ uid: 3, chessId: YAK, row: 11, col: 6 }] });
const refresh = tokVariant(5, true).talents.find((x) => x.bb.interval != null).bb.interval;
assert.equal(refresh, 15);
r.h.b.kill(r.t, null);
assert.ok(!r.t.alive && !r.t.removed && r.t.mem.closurTac, 'in its 战术点形态');
assert.ok(r.h.b.isReservedTile(10, 6), 'its tile kept');
r.u.skill.gainSp(999);
r.h.run(0.5);
assert.equal(r.h.unit(3).findBuff(`${S2_KEY}:${r.u.id}`), null, 'no 援军 while it is down (角峰 in its range unmarked)');
r.h.run(refresh - 0.6);
assert.ok(!r.t.alive, 'still down before 15 s');
r.h.run(0.3);
assert.ok(r.t.alive && r.t.tileR === 10 && r.t.tileC === 6, 'back on its tile after 15 s');
approx(r.t.hp, r.t.s.maxHp, 'at full HP');
r.h.run(0.2);
assert.ok(r.h.unit(3).findBuff(`${S2_KEY}:${r.u.id}`), '角峰 marked again (S2 runs)');
// her leaving: withdrawn for good; her redeploy: a new one on the board tile
r.h.b.kill(r.u, null);
assert.ok(!r.t.alive && r.t.removed, 'withdrawn with her (no 战术点形态)');
r.h.b.redeploy(r.u, { free: true });
const n = r.h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === BASE && a.alive);
assert.ok(n && n !== r.t && n.tileR === 10 && n.tileC === 6, 'a new 指挥中心 on the board tile');
done(r.h);
});
test('Trait: her damage on an enemy blocked by her 援军 — the 指挥中心 or an ally of its range — ×atk_scale (1.5); ×1 vs an enemy blocked by an ally outside its range', () => {
for (const f of [[5, false, null], [6, true, TX]]) {
const [tier, elite, mod] = f;
// S1 carried: the 指挥中心's x-5 around (10,6) holds (11,6) (角峰), not (11,7) (德克萨斯)
const { h, u, t } = field({ tier, elite, mod, skill: 0, others: [{ uid: 3, chessId: YAK, row: 11, col: 6 }, { uid: 4, chessId: TEXAS, row: 11, col: 7 }] });
const scale = formOf(tier, elite).trait.bb.atk_scale;
assert.equal(scale, 1.5);
for (const [pos, blocker, mul] of [[[10, 6], t, scale], [[11, 6], h.unit(3), scale], [[11, 7], h.unit(4), 1]]) {
const e = h.spawn('enemy_dummy', { pos });
assert.ok(h.runUntil(() => e.blockedBy === blocker && hitsOn(h, u, e).length > 0, 5), `${label(f)}: ${pos} blocked and hit`);
const c = hitsOn(h, u, e)[0];
approx(c.amount, u.s.atk * mul, `${label(f)}: ×${mul} on the enemy blocked at ${pos}`);
h.b.kill(e, null);
h.step();
}
done(h);
}
});
test('S1 递归策略 (AUTO, at full SP): every 援军 — the 指挥中心 and the allies of its x-5, not one outside, not a 孤立 one — gets one 护盾 layer (不叠加); 3 DP, then 4, 5 … up to 6 (normal) / 7 (elite), one at a time every 8 / n s from half that; the count restarts with a redeploy', () => {
for (const f of [[5, false, null], [6, true, TX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.bb.cost, sk.bb.cost_per_add, sk.bb.cost_add_max, sk.bb.shield_cnt, sk.initSp], [8, 3, 1, elite ? 7 : 6, 1, 0], label(f));
const others = [{ uid: 3, chessId: YAK, row: 11, col: 6 }, { uid: 4, chessId: TEXAS, row: 11, col: 7 }, { uid: 5, chessId: PODEGO, row: 9, col: 6 }];
const { h, u, t } = field({ tier, elite, mod, skill: 0, others });
h.b.addBuff(h.unit(5), { key: 'test:isolated', flags: { isolated: true } });
const log = dpLog(h);
h.b.players[0].dp = 0;
const casts = [];
h.b.on('skillStart', (c) => { if (c.unit === u) casts.push(h.b.time); });
assert.ok(h.runUntil(() => u.skill.activations === 1, sk.spCost + 1), `${label(f)}: cast at full SP, no enemy on the field`);
h.step();
for (const a of [t, h.unit(3)]) assert.equal(a.findBuff(`${SHIELD_KEY}:${u.id}`)?.shieldHits, 1, `${label(f)}: ${a.defId} one 护盾 layer`);
for (const a of [h.unit(4), h.unit(5), u]) assert.equal(a.findBuff(`${SHIELD_KEY}:${u.id}`), null, `${label(f)}: ${a.defId} no 护盾`);
// the 护盾 takes one hit whole
const e = h.spawn('enemy_atk', { pos: [10, 6] });
assert.ok(h.runUntil(() => !t.findBuff(`${SHIELD_KEY}:${u.id}`), 4), `${label(f)}: spent by a hit`);
approx(t.hp, t.s.maxHp, `${label(f)}: that hit blocked whole`);
h.b.kill(e, null);
// the DP of the casts: n = 3, 4, … ≤ max; each over 8 s every 8 / n from half that
const want = [];
for (let k = 0; k < 6; k++) want.push(Math.min(sk.bb.cost_add_max, sk.bb.cost + k));
h.runUntil(() => u.skill.activations >= 6 && !u.skill.active, 6 * (sk.spCost + 9));
for (let k = 0; k < 6; k++) {
const s0 = casts[k], n = want[k];
const gains = log.filter(([tt]) => tt > s0 - 1e-9 && tt < s0 + 8 + 0.05);
assert.equal(gains.reduce((a, [, d]) => a + d, 0), n, `${label(f)}: cast ${k + 1} gives ${n}`);
approx(gains[0][0] - s0, 8 / n / 2, `${label(f)}: cast ${k + 1} first DP after ${8 / n / 2} s`, 0.06);
}
// a redeploy restarts the count
h.b.retreat(u);
h.b.redeploy(u, { free: true });
const before = dpOf(h);
h.runUntil(() => u.skill.activations >= 7 && !u.skill.active, sk.spCost + 10);
assert.equal(dpOf(h) - before, sk.bb.cost, `${label(f)}: back to ${sk.bb.cost} after a redeploy`);
done(h);
}
});
test('S2 模型扩展: +cost DP at once and 15 one per 2 s; ATK +atk, 2 targets; every 援军 (x-4) DEF +def and block +1; the enemies they block are her targets beyond her range; an operator of hers deployed on the 指挥中心\'s range meanwhile gives back ceil(40 % × its cost) — not after a 【移动】, not outside the range', () => {
for (const f of [[5, false, null], [6, true, TX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.duration, sk.bb.cost, sk.bb.cost_period, sk.bb['closur_s_2[add_cost_period].interval'], sk.bb.atk, sk.bb.def, sk.bb.block_cnt, sk.bb['attack@max_target'], sk.bb.cost_return],
[30, elite ? 7 : 6, 15, 2, elite ? 0.55 : 0.4, elite ? 0.45 : 0.3, 1, 2, 0.4], label(f));
// 角峰 (11,7) is in the x-4 of (10,6); 德克萨斯 (12,8) is outside it and outside her 3-3; 波登可 (9,9) far
const others = [{ uid: 3, chessId: YAK, row: 11, col: 7 }, { uid: 4, chessId: TEXAS, row: 12, col: 8 }, { uid: 5, chessId: PODEGO, row: 12, col: 4 }];
const { h, u, t } = field({ tier, elite, mod, skill: 1, others });
const yak = h.unit(3), texas = h.unit(4), podego = h.unit(5);
const log = dpLog(h);
h.b.players[0].dp = 0;
const atk0 = u.s.atk, yakDef = yak.s.def, yakBlock = yak.s.blockCnt;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1));
const t0 = h.b.time;
approx(dpOf(h), sk.bb.cost, `${label(f)}: +${sk.bb.cost} at once`);
approx(u.s.atk, atk0 * (1 + sk.bb.atk + (u.findBuff(RHODES_KEY)?.mods.atkPct ?? 0)) / (1 + (u.findBuff(RHODES_KEY)?.mods.atkPct ?? 0)), `${label(f)}: ATK +${sk.bb.atk * 100} %`, 1e-3);
h.run(0.2);
assert.deepEqual([t.s.blockCnt, yak.s.blockCnt], [3, yakBlock + 1], `${label(f)}: 援军 block +1`);
approx(yak.s.def - yakDef, yak.base.def * sk.bb.def, `${label(f)}: 援军 DEF +${sk.bb.def * 100} %`, 1e-3);
assert.equal(texas.findBuff(`${S2_KEY}:${u.id}`), null, `${label(f)}: not outside the x-4`);
// an enemy blocked by 角峰 outside her 3-3? (11,7) is inside it; one blocked by the 指挥中心 and two more in range: 2 targets
const es = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [9, 5] }), h.spawn('enemy_dummy', { pos: [11, 5] })];
const n0 = h.hooksOf('attack').length;
h.run(3);
const atks = h.hooksOf('attack').slice(n0).filter((c) => c.attacker === u);
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 2), `${label(f)}: 2 targets per attack (${atks.map((c) => c.targets.length)})`);
for (const e of es) h.b.kill(e, null);
// the refund: 角峰 (cost 17) redeployed on the x-4 ⇒ +7; 波登可 outside ⇒ nothing; a 【移动】 ⇒ nothing
h.step();
const d0 = dpOf(h);
h.b.kill(yak, null);
h.b.redeploy(yak, { free: true });
approx(dpOf(h) - d0, Math.ceil(yak.base.cost * 0.4), `${label(f)}: ceil(40 % × ${yak.base.cost})`);
const d1 = dpOf(h);
h.b.kill(podego, null);
h.b.redeploy(podego, { free: true });
approx(dpOf(h) - d1, 0, `${label(f)}: outside the range — nothing`);
assert.ok(h.b.moveRedeploy(yak, 9, 6), '【移动】');
approx(dpOf(h) - d1, 0, `${label(f)}: a 【移动】 — nothing`);
h.runUntil(() => !u.skill.active, 31);
approx(h.b.time - t0, 30, `${label(f)}: 30 s`, 0.01);
const periodic = log.filter(([tt]) => tt > t0 + 0.5 && tt <= t0 + 30.05).filter(([, d]) => d === 1);
assert.equal(periodic.length, 15, `${label(f)}: 15 DP over the skill`);
approx(periodic[0][0] - t0, 2, `${label(f)}: one per 2 s`, 0.06);
assert.equal(t.findBuff(`${S2_KEY}:${u.id}`), null, `${label(f)}: the 援军 buff ends with it`);
assert.equal(u.extraRangeKeys, null, `${label(f)}: no extra range after`);
done(h);
}
// the enemies her 援军 block are her targets beyond her own range: 角峰 at (11,8) — out of her 3-3 ([1, 4]) — is in the
// x-4 of a 指挥中心 at (10,7)
const { h, u } = field({ tier: 6, elite: true, mod: TX, skill: 1, base: [10, 7], others: [{ uid: 3, chessId: YAK, row: 11, col: 8 }] });
const e = h.spawn('enemy_dummy', { pos: [11, 8] });
h.run(0.5);
assert.equal(e.blockedBy, h.unit(3));
assert.equal(hitsOn(h, u, e).length, 0, 'out of her range before');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => hitsOn(h, u, e).length > 0, 4), 'hit while S2 runs');
approx(hitsOn(h, u, e)[0].amount, u.s.atk * 1.5, 'with the trait ×1.5');
done(h);
});
test('S3 Q.E.D.: 18 DP one per 1.66 s; attack interval (1.0 − 0.5 s) / ASPD 1.08 (full potential); hits of attack@atk_scale × ATK with one 迟钝 stack each (4 % / 5 %, ≤ 10 stacks, one 3 s timer); one more target per 9 attacks (≤ 6); her range adds the 指挥中心\'s x-6 and her 援军\'s ranges', () => {
for (const f of [[5, false, null], [6, true, TX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.bb.cost_period, sk.bb['closur_s_3[add_cost_period].interval'], sk.bb.base_attack_time, sk.bb['attack@atk_scale'], sk.bb['attack@slow_down'], sk.bb['attack@slow_down_time'], sk.bb['attack@max_stack_cnt'], sk.bb['attack@slow_down_max'], sk.bb.attack_trigger_cnt, sk.bb.max_trigger_cnt],
[30, 18, 1.66, -0.5, elite ? 1.9 : 1.5, elite ? 0.05 : 0.04, 3, 10, elite ? 0.5 : 0.4, 9, 6], label(f));
const { h, u, t } = field({ tier, elite, mod, skill: 2 });
const log = dpLog(h);
// eight enemies in her 3-3 (a flyer among them): enough for 1 + 6 targets
const near = [[9, 4], [9, 5], [9, 6], [9, 7], [11, 4], [11, 5], [11, 6], [11, 7]].map((p, i) => h.spawn(i === 2 ? 'enemy_fly' : 'enemy_dummy', { pos: p }));
h.run(1);
h.b.players[0].dp = 0;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1));
const t0 = h.b.time;
assert.equal(formOf(tier, elite).stats.aspd, 108, `${label(f)}: ASPD 108 (潜能 攻速 +8)`);
approx(u.s.interval, 0.5 / 1.08, `${label(f)}: interval 0.5 s / 1.08 (ASPD 108)`);
let attacks = 0;
const perAttack = [];
h.b.on('attack', (c) => { if (c.attacker === u && u.skill.active) { attacks++; perAttack.push(c.targets.length); } });
h.runUntil(() => attacks >= 9 + 1, 10);
assert.ok(perAttack.slice(0, 9).every((n) => n === 1) && perAttack[9] === 2, `${label(f)}: one more target after 9 attacks (${perAttack})`);
// the 迟钝 on one enemy: 1 stack per hit, capped
const e0 = [...near].sort((a, b) => hitsOn(h, u, b).filter((c) => c.dmg.isSkill).length - hitsOn(h, u, a).filter((c) => c.dmg.isSkill).length)[0];
const sl = e0.findBuff(SLOWDOWN);
assert.ok(sl, `${label(f)}: 迟钝 on a hit enemy`);
const skillHits = (e) => hitsOn(h, u, e).filter((c) => c.dmg.isSkill);
const stacks = Math.min(10, skillHits(e0).length);
approx(sl.data.value, Math.min(sk.bb['attack@slow_down_max'], sk.bb['attack@slow_down'] * stacks), `${label(f)}: ${stacks} stacks`);
approx(e0.s.moveSpeed, e0.base.moveSpeed * (1 - sl.data.value), `${label(f)}: move speed ×(1 − v)`);
for (const c of skillHits(e0).slice(0, 3)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `${label(f)}: ${sk.bb['attack@atk_scale'] * 100} % ATK`);
h.runUntil(() => !u.skill.active, 31);
approx(h.b.time - t0, 30, `${label(f)}: 30 s`, 0.01);
assert.equal(Math.max(...perAttack), 7, `${label(f)}: at most 1 + 6 targets`);
approx(log.reduce((a, [tt, d]) => a + (tt > t0 - 1e-9 && tt < t0 + 30.05 ? d : 0), 0), 18, `${label(f)}: 18 DP`);
approx(log.find(([tt]) => tt > t0 - 1e-9)[0] - t0, 1.66, `${label(f)}: the first after 1.66 s`, 0.06);
// the cap after 10 hits, one timer: 3 s without a hit clears every stack
const capped = near.find((e) => skillHits(e).length >= 10);
assert.ok(capped);
h.run(0.1);
h.b.kill(u, null);
h.run(3.1);
assert.equal(capped.findBuff(SLOWDOWN), null, `${label(f)}: gone 3 s after the last hit`);
done(h);
void t;
}
// her range adds the 指挥中心's x-6: (10,8) is outside her 3-3 (cols 4–7), inside the x-6 of (10,6) ([0, 2])
const { h, u } = field({ tier: 6, elite: true, mod: TX, skill: 2 });
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
h.run(2);
assert.equal(hitsOn(h, u, far).length, 0, 'out of her range before');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => hitsOn(h, u, far).length > 0, 3), 'hit while S3 runs');
assert.ok(u.extraRangeKeys.includes(tk(10, 8)) && u.extraRangeKeys.includes(tk(8, 6)), 'the x-6 in her range');
done(h);
});
test('T2 极限调度: from the battle start the 【罗德岛】 operators of her player (herself too) ATK +4 % (TAC-X stage 3 +8 %) for the whole battle; others not', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const tb = talentOf(tier, elite, mod, 1).bb;
assert.deepEqual(tb, tier === 6 && elite && mod === TX ? { cost: -6, atk: 0.08 } : { cost: -4, atk: 0.04 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 3, chessId: PODEGO, row: 12, col: 3 }, { uid: 4, chessId: YAK, row: 11, col: 3 }] });
const podego = h.unit(3), yak = h.unit(4);
assert.equal(podego.def.raw.nationId, 'rhodes');
for (const a of [u, podego]) assert.equal(a.findBuff(RHODES_KEY)?.mods.atkPct, tb.atk, `${label(f)}: ${a.defId}`);
assert.equal(yak.findBuff(RHODES_KEY), null, `${label(f)}: 角峰 is no 罗德岛 operator`);
h.b.kill(u, null);
h.run(1);
assert.equal(podego.findBuff(RHODES_KEY)?.mods.atkPct, tb.atk, `${label(f)}: kept while she is down (携带)`);
done(h);
}
});
test('TAC-X “给自己的小奖杯”: damage on one of her 援军 from an enemy it blocks ×0.85 (stages 1 and 3); not without the module', () => {
for (const f of [[5, false, null], [5, true, null], [5, true, TX], [6, true, TX]]) {
const [tier, elite, mod] = f;
const { h, u, t } = field({ tier, elite, mod, skill: 0 });
const guard = elite && mod === TX ? modOf(tier, TX).talentChanges.find((x) => x.talentIndex === -1).bb.damage_scale : 1;
if (elite && mod === TX) assert.equal(guard, 0.85);
const e = h.spawn('enemy_atk', { pos: [10, 6] });
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === e && c.target === t), 5), `${label(f)}: hit by the enemy it blocks`);
const c = h.hooksOf('damaged').find((x) => x.source === e && x.target === t);
approx(c.amount, Math.max(2000 - t.s.def, 100) * guard, `${label(f)}: ×${guard}`);
done(h);
void u;
}
});