- kits/ops/op-ironmn.js — 白铁 (char_4072_ironmn): S1 / S2 / S3, 战地工程师 (device stock 3, CRA-X +1, refilled at each deployment; a device that left comes back on its tile after its redeploy time, paying its cost, one stock, while he stands; his devices leave with him), 节约经费 (prob 0.7 / CRA-X stage 3 0.9), CRA-X (stock, costs, stage 3 SP +0.2/s), CRA-Y (redeploy times, stage 3 ASPD +6 on device-buffed operators); the device kits 白铁™多功能平台 (S1 ATK +12 % ×2.5 / 3 in S1; S2 1 SP / 3.5 s, 2.5 / 2 s in S2, 0.4 % / s 流失 ×2) and 铁钳号·原型机 (an ally target: damage cancelled, 1 SP per hit, a 工匠's hit heals 1 %; its skill −2.5 % then 230 / 260 % + 110 / 120 % within 1.1 of 白铁's ATK; 团结的力量 ×0.8 physical behind it). Exports the shared 工匠 device helpers. - kits/ops/op-nasti.js — 娜斯提 (char_4212_nasti): S1 / S2 / S3, 前方施工 (stock; CRA-X stage 3 first device −3, combined ranges), 注意安全 (10 % 庇护, 15 % + 1 SP / 6 s for her and our ranged operators once a ranged enemy appeared), CRA-X; the device kits “质检专员”, “监工专员” (3 / 8 HP, its S2 pulses of SP and her barrier) and “应急承重小组” (小工程师 + the 高台 it builds and levels). - kits/ops/op-necras.js — 死芒 (char_450_necras): S1 / S2 / S3, 复燃 (summon on the kill tile or a random tile of her range, upgrade at 3), 回光黯淡, the trait (the enemies her summons block are her targets; SOC-X ×1.15), SOC-X stage 3; the 悲叹的仆役 kit and its special form (S3). - Engine (additive): Battle.setAllyTarget / allyTargetsInKeys / isAllyTarget (battle/queries.js); ai.js acquireTargets puts a registered ally target of the range after every enemy, performAttack flies ranged attacks to it. Nothing changes while none is registered: every golden family but diy unchanged. docs/SIM.md §6 documents it. - kits/index.js: the three files appended to OPERATOR_KIT_FILES. - Tests: test/content/op_ironmn.test.js, op_nasti.test.js, op_necras.test.js, test/sim/feedback5-o23-engine.test.js. - Golden: only diy moved — diy-106 … diy-109 re-chunked (the new operators' configurations come before the prototypes), diy-110 … diy-114 new.
458 lines
27 KiB
JavaScript
458 lines
27 KiB
JavaScript
// test/content/op_ironmn.test.js — the 自选 operator kit of 白铁 (char_4072_ironmn, 6★ 工匠; kit
|
||
// server/sim/content/kits/ops/op-ironmn.js) and of his <支援装置> 白铁™多功能平台 (token_10027_ironmn_pile1 / pile2) and 铁钳号·原型机
|
||
// (token_10027_ironmn_pile3), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy; the devices as the placed
|
||
// hand pieces of his player) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7)
|
||
// with no module, CRA-X 铁钳号·爬行者 or CRA-Y 海布里印记 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the
|
||
// fidelity checklist of kits/README.md (the engine's ally targets themselves: test/sim/feedback5-o23-engine.test.js).
|
||
// Run: node --test test/content/op_ironmn.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, diyRecordOf } 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 IRON = 'char_4072_ironmn';
|
||
const P1 = 'token_10027_ironmn_pile1', P2 = 'token_10027_ironmn_pile2', CRAB = 'token_10027_ironmn_pile3';
|
||
const TOK = [P1, P2, CRAB];
|
||
const FORMS = BACKUPS.units[IRON].forms;
|
||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||
const X = 'uniequip_002_ironmn', Y = 'uniequip_003_ironmn';
|
||
const S1 = 'skchr_ironmn_1', S2 = 'skchr_ironmn_2', S3 = 'skchr_ironmn_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);
|
||
/** A device's variant for a form, with the pick's skill / module (getDiyToken). */
|
||
const tokOf = (id, tier, elite, skill, mod) => {
|
||
let v = BACKUPS.tokens[id].variants[`${IRON}@${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 }),
|
||
enemy_hit: dummy('enemy_hit', { atk: 1000, bat: 1, blockCnt: 1 }),
|
||
};
|
||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))];
|
||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||
|
||
/**
|
||
* 白铁 as uid 1 at (row, col) facing `dir`; `pieces` = his device pieces [[r, c, dir?]] (uids 10…, the picked skill's
|
||
* device); `others` = more units.
|
||
*/
|
||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, dir = 'RIGHT', pieces = [], others = [], seed = 5, dp = 99 } = {}) {
|
||
const units = [
|
||
{ uid: 1, diy: { slot: SLOT[tier], charId: IRON, skillIndex: skill, uniEquipId: mod }, elite, row, col, dir },
|
||
...pieces.map(([r, c, d], i) => ({ uid: 10 + i, kind: 'token', tokenId: TOK[skill], ownerUid: 1, row: r, col: c, dir: d ?? 'RIGHT' })),
|
||
...others,
|
||
];
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'death', 'deploy', 'statusApplied', 'spGain', 'heal'], captureNoisy: true,
|
||
units,
|
||
});
|
||
h.step();
|
||
h.b.players[0].dp = dp;
|
||
const u = h.unit(1);
|
||
const devs = h.b.allyUnits.filter((a) => a.kind === 'token' && a.defId === TOK[skill]).sort((a, b) => a.uid - b.uid);
|
||
return { h, u, devs };
|
||
}
|
||
const st = (u) => u.mem.craftsman;
|
||
function done(h) {
|
||
checkInvariants(h.b);
|
||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||
}
|
||
/** Force every rng.chance roll to `v` (returns the probabilities asked for). */
|
||
function forceChance(h, v) {
|
||
const asked = [];
|
||
h.b.rng.chance = (p) => { asked.push(p); return v; };
|
||
return asked;
|
||
}
|
||
|
||
test('白铁 in every 自选 form: his kit (all three skills authored), stats + module attributes, 1-1, blocks 2, melee physical ground-only, 维多利亚, no 特质, the data triggers', () => {
|
||
assert.equal(OPERATOR_KITS[IRON], KITS[IRON]);
|
||
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], [IRON, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.sub, u.base.bat], [2, 'melee', false, 'phys', 'craftsman', 1.5], `${label(f)}: 工匠`);
|
||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`);
|
||
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
|
||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
|
||
done(h);
|
||
}
|
||
}
|
||
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'DEFAULT']);
|
||
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr],
|
||
[{ atk: 25, def: 25, aspd: 5 }, { atk: 45, def: 45, aspd: 5 }, { maxHp: 260, def: 45 }, { maxHp: 400, def: 60 }]);
|
||
const data = { chess: CHESS, backups: BACKUPS };
|
||
assert.deepEqual(diyRecordOf(CHESS[SLOT[5]], { charId: IRON, skillIndex: 0 }, data).bonds, ['victoriaShip']);
|
||
assert.ok(KITTED_CHARS.includes(IRON));
|
||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IRON), `tier ${t}`);
|
||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: IRON, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }),
|
||
{ ok: true, picks: { [SLOT[6]]: { charId: IRON, skillIndex: 2, uniEquipId: Y } } });
|
||
});
|
||
|
||
test('<支援装置> hand pieces: the picked skill\'s device only (variant sources), deploy limit 1 in the data (official 1 + max_deploy_count 1 = 2: reported), the module costs / redeploy times', () => {
|
||
for (const [skill, id] of TOK.entries()) {
|
||
for (const s of [0, 1, 2]) assert.equal(tokOf(id, 6, true, s, null).sources.includes('skill'), s === skill, `${id} under S${s + 1}`);
|
||
const v = tokOf(id, 6, true, skill, null);
|
||
assert.equal(v.stats.deployLimit, 1, `${id}: the data's deploy limit`);
|
||
assert.equal(v.talents.find((t) => t.bb.max_deploy_count != null).bb.max_deploy_count, 1, `${id}: the hidden max_deploy_count (E2 "最多可部署2个")`);
|
||
}
|
||
// CRA-X 部署费用 −1 / −1 / −4, CRA-Y 再部署时间 −5 / −5 / −10 (PRTS module page)
|
||
const cost = (mod) => TOK.map((id, s) => tokOf(id, 6, true, s, mod).stats.cost);
|
||
const resp = (mod) => TOK.map((id, s) => tokOf(id, 6, true, s, mod).stats.respawnTime);
|
||
assert.deepEqual([cost(null), cost(X), cost(Y)], [[3, 3, 10], [2, 2, 6], [3, 3, 10]]);
|
||
assert.deepEqual([resp(null), resp(X), resp(Y)], [[10, 10, 20], [10, 10, 20], [5, 5, 10]]);
|
||
});
|
||
|
||
test('战地工程师 / the device rules: stock 3 (CRA-X 4) after his deployment; a device that left comes back on its tile after its redeploy time, paying its cost and one stock; none without stock or DP; his devices leave with him and return after him', () => {
|
||
for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, Y]]) {
|
||
const f = label([tier, elite, mod]);
|
||
const { h, u, devs } = field({ tier, elite, mod, skill: 0, pieces: [[11, 7]], dp: 99 });
|
||
const d = devs[0];
|
||
const v = tokOf(P1, tier, elite, 0, mod);
|
||
const cap = 3 + (mod === X ? 1 : 0);
|
||
assert.deepEqual([st(u).stock, st(u).cap, d.alive, d.tileR, d.tileC], [cap, cap, true, 11, 7], `${f}: deployed for free with the board, stock ${cap}`);
|
||
assert.deepEqual([d.base.cost, d.base.respawnTime], [v.stats.cost, v.stats.respawnTime], `${f}: the variant's cost / redeploy time`);
|
||
assert.ok(d.s.flags.untargetable && d.s.flags.invulnerable && d.s.flags.noHeal, `${f}: 不会受到攻击, 无敌, 禁疗`);
|
||
forceChance(h, false);
|
||
h.b.kill(d, null);
|
||
assert.equal(d.alive, false);
|
||
h.run(v.stats.respawnTime - 0.3);
|
||
assert.equal(d.alive, false, `${f}: not before its redeploy time`);
|
||
h.run(0.6);
|
||
assert.deepEqual([d.alive, d.tileR, d.tileC, st(u).stock, h.b.players[0].dp], [true, 11, 7, cap - 1, 99 - v.stats.cost], `${f}: back on its tile, −${v.stats.cost} DP, −1 stock`);
|
||
// no stock ⇒ it waits
|
||
st(u).stock = 0;
|
||
h.b.kill(d, null);
|
||
h.run(v.stats.respawnTime + 2);
|
||
assert.equal(d.alive, false, `${f}: no stock, no return`);
|
||
st(u).stock = 1;
|
||
h.run(0.5);
|
||
assert.ok(d.alive, `${f}: back as soon as he holds one`);
|
||
// no DP ⇒ it waits
|
||
st(u).stock = 1;
|
||
h.b.players[0].dp = 0;
|
||
h.b.kill(d, null);
|
||
h.run(v.stats.respawnTime + 2);
|
||
assert.equal(d.alive, false, `${f}: no DP, no return`);
|
||
h.b.players[0].dp = 50;
|
||
h.run(0.5);
|
||
assert.ok(d.alive, `${f}: back once the DP is there`);
|
||
// he leaves ⇒ his devices go; his return refills the stock and brings them back
|
||
st(u).stock = 0;
|
||
h.b.kill(u, null);
|
||
assert.equal(d.alive, false, `${f}: gone with him (die_to_kill_token)`);
|
||
h.b.players[0].dp = 99;
|
||
assert.ok(h.runUntil(() => u.alive, 200), `${f}: he redeploys`);
|
||
assert.equal(st(u).stock, cap, `${f}: stock refilled (charge_token[born])`);
|
||
assert.ok(h.runUntil(() => d.alive, 30), `${f}: the device back after him`);
|
||
assert.equal(st(u).stock, cap - 1);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('节约经费: a device of his leaving his x-4 ⇒ prob (0.7; CRA-X stage 3 0.9) ⇒ +1 stock (capped); none when the roll fails, outside his x-4 or when it goes with him', () => {
|
||
for (const [tier, elite, mod, prob] of [[5, false, null, 0.7], [5, true, X, 0.7], [6, true, X, 0.9], [6, true, Y, 0.7]]) {
|
||
const f = label([tier, elite, mod]);
|
||
const { h, u, devs } = field({ tier, elite, mod, skill: 0, pieces: [[11, 6], [10, 8]], dp: 99 });
|
||
const [near, far] = devs;
|
||
const cap = st(u).cap;
|
||
st(u).stock = 1;
|
||
let asked = forceChance(h, true);
|
||
h.b.kill(near, null);
|
||
assert.deepEqual([asked, st(u).stock], [[prob], 2], `${f}: on his x-4 — rolled ${prob}, +1`);
|
||
asked = forceChance(h, true);
|
||
h.b.kill(far, null);
|
||
assert.deepEqual([asked, st(u).stock], [[], 2], `${f}: two tiles away — no roll`);
|
||
h.run(25);
|
||
assert.ok(near.alive && far.alive);
|
||
st(u).stock = 1;
|
||
forceChance(h, false);
|
||
h.b.kill(near, null);
|
||
assert.equal(st(u).stock, 1, `${f}: a failed roll`);
|
||
h.run(15);
|
||
st(u).stock = cap;
|
||
forceChance(h, true);
|
||
h.b.kill(near, null);
|
||
assert.equal(st(u).stock, cap, `${f}: capped at ${cap}`);
|
||
h.run(15);
|
||
st(u).stock = 0;
|
||
asked = forceChance(h, true);
|
||
h.b.kill(u, null);
|
||
assert.deepEqual([asked, st(u).stock], [[], 0], `${f}: leaving with him — no roll`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S1 极致火力 + 白铁™多功能平台: ATK +12 % on the operator it faces (×2.5 / ×3 while S1 runs); S1 16 / 17 s, 140 % / 170 % ATK attacks, +1 stock at the start, every device destroyed at its end', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S1);
|
||
const v = tokOf(P1, tier, elite, 0, null);
|
||
const { h, u, devs } = field({ tier, elite, skill: 0, pieces: [[11, 4], [9, 8]], others: [{ uid: 3, chessId: 'chess_char_1_08_a', row: 11, col: 5 }] });
|
||
const tx = h.unit(3);
|
||
const [d] = devs;
|
||
h.run(0.2);
|
||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb['attack@atk_scale'], sk.bb.fake_scale, v.skill.bb.talent_scale, v.talents[0].bb.atk], ['DEFAULT', elite ? 17 : 16, elite ? 1.7 : 1.4, elite ? 3 : 2.5, elite ? 3 : 2.5, 0.12], `T${tier}`);
|
||
assert.deepEqual(tx.findBuff(`ironmn:pile1:${d.id}`)?.mods, { atkPct: 0.12 }, `T${tier}: 支援火力 on the operator it faces`);
|
||
approx(tx.s.atk, tx.base.atk * 1.12, `T${tier}: ATK +12 %`);
|
||
assert.equal(u.findBuff(`ironmn:pile1:${d.id}`), null, `T${tier}: not on 白铁 (not on its range)`);
|
||
st(u).stock = 1;
|
||
forceChance(h, false);
|
||
u.skill.gainSp(999);
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in his range`);
|
||
assert.equal(st(u).stock, 2, `T${tier}: +1 stock at the start`);
|
||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.02);
|
||
h.step(2);
|
||
approx(tx.s.atk, tx.base.atk * (1 + 0.12 * sk.bb.fake_scale), `T${tier}: ×${sk.bb.fake_scale} while S1 runs`);
|
||
const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).length;
|
||
h.run(3);
|
||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).slice(n0);
|
||
assert.ok(hits.length >= 1);
|
||
for (const c of hits) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % ATK`);
|
||
const alive0 = devs.filter((x) => x.alive).length;
|
||
assert.equal(alive0, 2);
|
||
h.runUntil(() => !u.skill.active, sk.duration + 1);
|
||
h.step();
|
||
assert.deepEqual(devs.map((x) => x.alive), [false, false], `T${tier}: 技能结束时所有场上的装置被销毁`);
|
||
assert.equal(tx.findBuff(`ironmn:pile1:${d.id}`), null, `T${tier}: its buff gone with it`);
|
||
assert.ok(h.runUntil(() => devs.every((x) => x.alive), 30), `T${tier}: both back (stock 2)`);
|
||
approx(tx.s.atk, tx.base.atk * 1.12, `T${tier}: ×1 after the skill`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('S2 高效补给 + 白铁™多功能平台: +1 SP every 3.5 s to the operator it faces (2.5 / 2 s while S2 runs), the device loses 0.4 % (0.8 %) max HP / s; S2 30 s, ATK / DEF +25 / 40 %, every blocked enemy at once, +1 stock at its end', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S2);
|
||
const v = tokOf(P2, tier, elite, 1, null);
|
||
const { h, u, devs } = field({ tier, elite, skill: 1, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] });
|
||
const yak = h.unit(3);
|
||
const [d] = devs;
|
||
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.def, v.talents[0].bb['default.interval'], v.skill.bb['s2.interval'], v.talents.find((t) => t.bb.hp_ratio).bb.hp_ratio, v.skill.bb['s2.hp_ratio']],
|
||
[30, elite ? 0.4 : 0.25, elite ? 0.4 : 0.25, 3.5, elite ? 2 : 2.5, 0.004, 0.008], `T${tier}`);
|
||
const gifts = () => h.hooksOf('spGain').filter((c) => c.unit === yak && c.reason === 'device').map((c) => c.t);
|
||
yak.skill.sp = 0;
|
||
h.run(11);
|
||
const g = gifts();
|
||
assert.equal(g.length, 3, `T${tier}: 3 SP gifts in 11 s`);
|
||
approx(g[1] - g[0], 3.5, `T${tier}: every 3.5 s`, 0.02);
|
||
approx(d.hp, d.s.maxHp * (1 - 0.004 * Math.floor(h.b.time)), `T${tier}: 0.4 % / s`, 0.01);
|
||
// S2: two blocked enemies at once, ATK / DEF, interval and bleed
|
||
const a = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
const b = h.spawn('enemy_walk', { routeIndex: 0 });
|
||
void b;
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
|
||
assert.deepEqual(u.skill.spec.attack, { hitAllBlocked: true }, `T${tier}: 攻击所有阻挡的敌人`);
|
||
const hp0 = d.hp, t0 = h.b.time;
|
||
yak.skill.sp = 0;
|
||
const n0 = gifts().length;
|
||
h.run(10.05);
|
||
const g2 = gifts().slice(n0);
|
||
approx(g2[1] - g2[0], v.skill.bb['s2.interval'], `T${tier}: every ${v.skill.bb['s2.interval']} s while S2 runs`, 0.02);
|
||
approx(hp0 - d.hp, d.s.maxHp * 0.008 * Math.floor(h.b.time - t0 + 1e-9), `T${tier}: 0.8 % / s while S2 runs`, 0.05);
|
||
void a;
|
||
st(u).stock = 1;
|
||
h.runUntil(() => !u.skill.active, 25);
|
||
assert.equal(st(u).stock, 2, `T${tier}: +1 stock at its end`);
|
||
done(h);
|
||
}
|
||
// S2 hits every enemy it blocks
|
||
const { h, u } = field({ tier: 6, elite: true, skill: 1, row: 9, col: 5 });
|
||
h.spawn('enemy_walk', { routeIndex: 0 });
|
||
h.run(0.4);
|
||
h.spawn('enemy_walk', { routeIndex: 0 });
|
||
assert.ok(h.runUntil(() => u.blocking.length === 2, 40), 'two blocked');
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||
const n0 = h.hooksOf('damaged').length;
|
||
h.run(4);
|
||
const ids = new Set(h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.target.id));
|
||
assert.equal(ids.size, 2, 'both blocked enemies hit');
|
||
done(h);
|
||
});
|
||
|
||
test('S3 铁钳号·原型机: 30 s, ATK +25 / 40 %, ASPD +25 / 40, +1 stock at the start; the 铁钳号 is our attacks\' last target (an enemy first), takes no damage, +1 SP per hit, a 工匠\'s hit heals it 1 %; enemies never target it; immune to the PRTS statuses', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const sk = skillOf(tier, elite, S3);
|
||
const v = tokOf(CRAB, tier, elite, 2, null);
|
||
// the crab faces 白铁's front tile; 德克萨斯 (UP) has it on her range
|
||
const { h, u, devs } = field({ tier, elite, skill: 2, pieces: [[10, 6]], others: [{ uid: 3, chessId: 'chess_char_1_08_a', row: 9, col: 6, dir: 'UP' }] });
|
||
const tx = h.unit(3);
|
||
const [c] = devs;
|
||
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.attack_speed, v.skill.spCost, v.skill.spType, v.skill.bb.hp_ratio, v.trait.bb.hp_ratio, v.stats.tauntLevel],
|
||
[30, elite ? 0.4 : 0.25, elite ? 40 : 25, 5, 'INCREASE_WHEN_TAKEN_DAMAGE', 0.025, 0.01, -2], `T${tier}`);
|
||
assert.ok(h.b.isAllyTarget(c), `T${tier}: an ally target`);
|
||
assert.ok(c.s.flags.untargetable && c.s.flags.isolated && c.s.flags.noHeal && c.s.flags.noDisplace && !c.s.flags.invulnerable, `T${tier}: 不会受到攻击 (enemies), 孤立, 禁疗, 失衡免疫, not 无敌`);
|
||
h.run(3);
|
||
const hitsBy = (src) => h.hooksOf('damaged').filter((x) => x.target === c && x.source === src).length;
|
||
const atkOn = (src) => h.events.filter((e) => e[0] === 'atk' && e[1] === src.id && e[2] === c.id).length;
|
||
assert.ok(atkOn(u) > 0 && atkOn(tx) > 0, `T${tier}: 白铁 and 德克萨斯 attack it (no enemy around)`);
|
||
assert.equal(hitsBy(u) + hitsBy(tx), 0, `T${tier}: no damage reaches it`);
|
||
assert.equal(c.hp, c.s.maxHp);
|
||
assert.equal(c.skill.sp, 5, `T${tier}: 5 SP (full) after 3 s of hits — 受击回复`);
|
||
assert.ok(h.hooksOf('spGain').filter((x) => x.unit === c && x.reason === 'hurt').length >= 5, `T${tier}: one SP per hit`);
|
||
// an enemy in an attacker's range comes first (one on the crab's tile, in 白铁's range)
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
h.step();
|
||
const a0 = atkOn(u);
|
||
h.run(3);
|
||
assert.ok(h.events.filter((x) => x[0] === 'atk' && x[1] === u.id && x[2] === e.id).length >= 1, `T${tier}: 白铁 attacks the enemy`);
|
||
assert.equal(atkOn(u), a0, `T${tier}: not the crab meanwhile`);
|
||
h.b.kill(e, null);
|
||
h.step();
|
||
// heal from a 工匠's hit only
|
||
c.hp = c.s.maxHp * 0.5;
|
||
const healBy = () => h.hooksOf('heal').filter((x) => x.target === c).length;
|
||
const h0 = healBy();
|
||
h.b.dealDamage(u, c, { amount: 100, type: 'phys', isAttack: true });
|
||
approx(c.hp, c.s.maxHp * 0.51, `T${tier}: 白铁 heals it 1 %`);
|
||
h.b.dealDamage(tx, c, { amount: 100, type: 'phys', isAttack: true });
|
||
approx(c.hp, c.s.maxHp * 0.51, `T${tier}: 德克萨斯 does not`);
|
||
assert.equal(healBy() - h0, 1);
|
||
// statuses PRTS lists, element, enemies' attacks
|
||
for (const s of ['stun', 'cold', 'freeze', 'levitate', 'bind', 'silence', 'sleep']) assert.equal(h.b.applyStatus(c, s, { duration: 3, source: e }), false, `T${tier}: ${s} immune`);
|
||
// S3 itself (an enemy in his range: the data's DEFAULT)
|
||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
st(u).stock = 1;
|
||
u.skill.gainSp(999);
|
||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||
assert.equal(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `T${tier}: ASPD`);
|
||
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.05);
|
||
assert.equal(st(u).stock, 2, `T${tier}: +1 stock at the start`);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('铁钳号·原型机\'s skill (5 SP, an enemy in its 3-13, flyers too): −2.5 % max HP (流失), then 白铁\'s ATK × 230 % / 260 % physical on the first enemy and × 110 % / 120 % on every other within 1.1 of it; 团结的力量: the operator behind it takes ×0.8 physical (not arts)', () => {
|
||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||
const v = tokOf(CRAB, tier, elite, 2, null);
|
||
const { h, u, devs } = field({ tier, elite, skill: 2, row: 12, col: 3, pieces: [[10, 5]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }] });
|
||
const yak = h.unit(3);
|
||
const [c] = devs;
|
||
assert.deepEqual([v.skill.bb.atk_scale, v.skill.bb.atk_scale_2, v.talents[0].bb.damage_resistance], [elite ? 2.6 : 2.3, elite ? 1.2 : 1.1, 0.2], `T${tier}`);
|
||
assert.deepEqual(yak.findBuff(`ironmn:crab:${c.id}`)?.mods, { physTakenMul: 0.8 }, `T${tier}: 角峰 behind it`);
|
||
// damage taken by 角峰: physical ×0.8, arts unchanged
|
||
const p = h.b.dealDamage(null, yak, { amount: 1000, type: 'phys', canDodge: false, sourceless: true });
|
||
const ar = h.b.dealDamage(null, yak, { amount: 1000, type: 'arts', canDodge: false, sourceless: true });
|
||
approx(p, Math.max(1000 - yak.s.def, 50) * 0.8, `T${tier}: physical ×0.8`, 1e-3);
|
||
approx(ar, 1000 * (1 - yak.s.res / 100), `T${tier}: arts ×1`, 1e-3);
|
||
yak.hp = yak.s.maxHp;
|
||
// its skill: an enemy in the 3-13, SP full
|
||
const main = h.spawn('enemy_fly', { pos: [10, 7] });
|
||
const near = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||
const far = h.spawn('enemy_dummy', { pos: [12, 8] });
|
||
c.skill.gainSp(99);
|
||
const atk = u.s.atk;
|
||
const hp0 = c.hp;
|
||
assert.ok(h.runUntil(() => c.skill.activations === 1, 1), `T${tier}: cast at full SP with an enemy in range`);
|
||
approx(hp0 - c.hp, c.s.maxHp * 0.025, `T${tier}: −2.5 % max HP`);
|
||
h.run(1);
|
||
const by = (t) => h.hooksOf('damaged').filter((x) => x.source === c && x.target === t);
|
||
assert.equal(by(main).length, 1, `T${tier}: the flyer is the first target (可对空)`);
|
||
approx(by(main)[0].amount, atk * v.skill.bb.atk_scale, `T${tier}: ${v.skill.bb.atk_scale * 100} % of 白铁's ATK`);
|
||
assert.equal(by(near).length, 1, `T${tier}: the enemy within 1.1`);
|
||
approx(by(near)[0].amount, atk * v.skill.bb.atk_scale_2, `T${tier}: ${v.skill.bb.atk_scale_2 * 100} %`);
|
||
assert.equal(by(far).length, 0, `T${tier}: farther than 1.1 untouched`);
|
||
assert.deepEqual([by(main)[0].type, by(main)[0].dmg.isSkill], ['phys', true]);
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('CRA-X: stage 3 节约经费 "当白铁周围8格存在自身装置时技力回复速度+0.2/秒" (one effect) — none at stage 1, none with the device outside his x-4', () => {
|
||
for (const [tier, mod, want] of [[5, X, 0], [6, X, 0.2], [6, Y, 0], [6, null, 0]]) {
|
||
const f = label([tier, true, mod]);
|
||
const { h, u, devs } = field({ tier, elite: true, mod, skill: 0, pieces: [[11, 6], [9, 4]] });
|
||
h.step();
|
||
assert.equal(u.s.spRecovery, u.base.spRecovery + want, `${f}: SP recovery`);
|
||
if (want) {
|
||
assert.deepEqual(u.findBuff('talent:ironmn:spRecover').mods, { spRecoveryFlat: 0.2 });
|
||
for (const d of devs) h.b.retreat(d, { reason: 'expired' });
|
||
h.step();
|
||
assert.equal(u.s.spRecovery, u.base.spRecovery, `${f}: none once no device stands on his x-4`);
|
||
}
|
||
done(h);
|
||
}
|
||
});
|
||
|
||
test('CRA-Y: stage 3 "自身装置天赋生效的干员攻击速度+6(此效果不受技能影响)" — the operator a 平台 faces / the one behind a 铁钳号, +6 also while S1 runs; none at stage 1', () => {
|
||
for (const [tier, want] of [[5, 0], [6, 6]]) {
|
||
for (const skill of [0, 1, 2]) {
|
||
const f = `T${tier} CRA-Y S${skill + 1}`;
|
||
const piece = skill === 2 ? [10, 7] : [11, 4];
|
||
const op = skill === 2 ? { uid: 3, chessId: 'chess_char_1_08_a', row: 10, col: 6 } : { uid: 3, chessId: 'chess_char_1_08_a', row: 11, col: 5 };
|
||
const { h, devs } = field({ tier, elite: true, mod: Y, skill, pieces: [piece], others: [op] });
|
||
const tx = h.unit(3);
|
||
h.step();
|
||
assert.equal(tx.s.aspd, tx.base.aspd + want, `${f}: ASPD`);
|
||
if (want) assert.deepEqual(tx.findBuff(`ironmn:aspd:${devs[0].id}`).mods, { aspd: 6 });
|
||
done(h);
|
||
}
|
||
}
|
||
const { h, u } = field({ tier: 6, elite: true, mod: Y, skill: 0, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_08_a', row: 11, col: 5 }] });
|
||
const tx = h.unit(3);
|
||
u.skill.gainSp(999);
|
||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||
h.step();
|
||
assert.equal(tx.s.aspd, tx.base.aspd + 6, 'not scaled by S1');
|
||
done(h);
|
||
});
|
||
|
||
test('铁钳号·原型机: enemies never select it (a ranged enemy beside it shoots 白铁 instead); its own HP loss can knock it out — then 节约经费 rolls and it returns like any device (20 s, CRA-Y 10 s; 10 DP, CRA-X 6)', () => {
|
||
const shooter = enemyRec({ key: 'enemy_shooter', hp: 1e9, speed: 0, mass: 0, range: 2.5, atk: 50, bat: 1 });
|
||
for (const [tier, elite, mod] of [[5, false, null], [6, true, X], [6, true, Y]]) {
|
||
const f = label([tier, elite, mod]);
|
||
const units = [
|
||
{ uid: 1, diy: { slot: SLOT[tier], charId: IRON, skillIndex: 2, uniEquipId: mod }, elite, row: 10, col: 5 },
|
||
{ uid: 10, kind: 'token', tokenId: CRAB, ownerUid: 1, row: 11, col: 6 },
|
||
];
|
||
const h = makeBattle({ defs: { enemies: { ...ENEMIES, enemy_shooter: shooter } }, timeLimit: 900, autoFinish: false, seed: 7, flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'death'], captureNoisy: true, units });
|
||
h.step();
|
||
h.b.players[0].dp = 99;
|
||
const u = h.unit(1);
|
||
const c = h.b.allyUnits.find((a) => a.defId === CRAB);
|
||
const e = h.spawn('enemy_shooter', { pos: [9, 7] });
|
||
h.run(5);
|
||
const atk = h.events.filter((x) => x[0] === 'atk' && x[1] === e.id);
|
||
assert.ok(atk.length > 0 && atk.every((x) => x[2] !== c.id), `${f}: the enemy shoots, never at the crab`);
|
||
assert.equal(c.hp, c.s.maxHp, `${f}: untouched`);
|
||
// knocked out by its own HP loss
|
||
h.b.kill(e, null);
|
||
h.step();
|
||
const v = tokOf(CRAB, tier, elite, 2, mod);
|
||
c.hp = 10;
|
||
c.skill.gainSp(99);
|
||
const asked = forceChance(h, true);
|
||
h.spawn('enemy_dummy', { pos: [11, 8] });
|
||
assert.ok(h.runUntil(() => !c.alive, 2), `${f}: its −2.5 % knocks it out`);
|
||
assert.deepEqual(asked.length, 1, `${f}: 节约经费 rolled (on his x-4)`);
|
||
const t0 = h.b.time;
|
||
assert.ok(h.runUntil(() => c.alive, v.stats.respawnTime + 2), `${f}: back`);
|
||
approx(h.b.time - t0, v.stats.respawnTime, `${f}: after ${v.stats.respawnTime} s`, 0.3);
|
||
approx(h.b.players[0].dp, 99 - v.stats.cost, `${f}: −${v.stats.cost} DP`);
|
||
assert.equal(c.hp, c.s.maxHp, `${f}: at full HP`);
|
||
void u;
|
||
done(h);
|
||
}
|
||
});
|