348 lines
24 KiB
JavaScript
348 lines
24 KiB
JavaScript
// 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;
|
||
}
|
||
});
|