feedback5 O23: 自选 kits for 白铁, 娜斯提 (with their 支援装置) and 死芒 (with her 悲叹的仆役); ally targets for 白铁's 铁钳号·原型机

- 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.
This commit is contained in:
sganggs committed 2026-10-06 05:28:54 +08:00
1 parent 996f5d20fe
commit 5ef47b0cf1
12 files changed
+2859 -100

No files matched your search

+457
View File
@@ -0,0 +1,457 @@
// 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);
}
});
+299
View File
@@ -0,0 +1,299 @@
// test/content/op_nasti.test.js — the 自选 operator kit of 娜斯提 (char_4212_nasti, 6★ 工匠; kit
// server/sim/content/kits/ops/op-nasti.js) and of her <支援装置> “质检专员” (token_10059_nasti_nstdef), “监工专员”
// (token_10060_nasti_nstchr) and “应急承重小组” (token_10061_nasti_nstbld: the 小工程师 and its 高台), fielded the production way (a
// DIY slot + its `diy` pick; the devices 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 CRA-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_nasti.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 NASTI = 'char_4212_nasti';
const DEF = 'token_10059_nasti_nstdef', CHR = 'token_10060_nasti_nstchr', BLD = 'token_10061_nasti_nstbld';
const TOK = [DEF, CHR, BLD];
const FORMS = BACKUPS.units[NASTI].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_nasti';
const S1 = 'skchr_nasti_1', S2 = 'skchr_nasti_2', S3 = 'skchr_nasti_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 tokOf = (id, tier, elite, skill, mod) => {
let v = BACKUPS.tokens[id].variants[`${NASTI}@${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_rng: dummy('enemy_rng', { range: 2 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
enemy_hit: enemyRec({ key: 'enemy_hit', hp: 1e9, speed: 0.6, mass: 0, atk: 400, bat: 1 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, X], [6, true, null], [6, true, X]];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
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: NASTI, 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', '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] && a.uid != null).sort((a, b) => a.uid - b.uid);
return { h, u, devs };
}
const st = (u) => u.mem.craftsman;
const platformOf = (u) => u.mem.nastiPlatform ?? null;
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const protectOf = (a) => a.findBuff('protect')?.mods ?? null;
test('娜斯提 in every 自选 form: her kit (all three skills authored), stats + module attributes, 1-1, blocks 2, melee physical ground-only, 协防 (no faction bond), no 特质, the data triggers; offered as a pick', () => {
assert.equal(OPERATOR_KITS[NASTI], KITS[NASTI]);
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], [NASTI, 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.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], [['emptyShip'], []], `${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 her`);
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], [{ maxHp: 78, atk: 26, def: 25 }, { maxHp: 145, atk: 40, def: 45 }]);
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(NASTI));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NASTI), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: NASTI, skillIndex: 1, uniEquipId: X } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: NASTI, skillIndex: 1, uniEquipId: X } } });
});
test('<支援装置> hand pieces: the picked skill\'s device (variant sources), 2 of them (deploy limit 2, "最多可部署2个"); CRA-X costs 2 / 2 / 3; stock 3 (CRA-X 4); CRA-X stage 3: the first device deployment after each of hers costs 3 less', () => {
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}`);
assert.equal(tokOf(id, 6, true, skill, null).stats.deployLimit, 2, `${id}: 2 hand pieces`);
}
assert.deepEqual(TOK.map((id, s) => tokOf(id, 6, true, s, null).stats.cost), [4, 4, 6]);
assert.deepEqual(TOK.map((id, s) => tokOf(id, 6, true, s, X).stats.cost), [2, 2, 3]);
assert.deepEqual(TOK.map((id, s) => tokOf(id, 6, true, s, null).stats.respawnTime), [8, 4, 6]);
for (const [tier, elite, mod, cap, cut] of [[5, false, null, 3, 0], [5, true, X, 4, 0], [6, true, X, 4, 3]]) {
const f = label([tier, elite, mod]);
const { h, u, devs } = field({ tier, elite, mod, skill: 0, pieces: [[11, 7], [9, 7]] });
const [a, b] = devs;
assert.deepEqual([st(u).stock, st(u).cap, a.alive, b.alive], [cap, cap, true, true], `${f}: stock, both deployed free`);
assert.equal(u.mem.nastiFirst, false, `${f}: the battle-start deployment used the first-device cut`);
// she leaves and comes back: the first device back costs `cut` less, the next one its full cost
h.b.kill(u, null);
assert.ok(!a.alive && !b.alive, `${f}: her devices leave with her`);
h.b.players[0].dp = 999;
assert.ok(h.runUntil(() => u.alive, 200), `${f}: back`);
h.b.players[0].dp = 100;
assert.ok(h.runUntil(() => a.alive && b.alive, 20), `${f}: both devices back`);
approx(h.b.players[0].dp, 100 - Math.max(0, a.base.cost - cut) - a.base.cost, `${f}: the first back ${Math.max(0, a.base.cost - cut)} DP, the second ${a.base.cost}`);
done(h);
}
});
test('注意安全: 10 % 庇护 on her; once a 远程 enemy is on the field (until she leaves) 15 % on her and on our 远程 operators (not the 近战 ones), and +1 SP every 6 s for them', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1['nasti_t2[res].damage_resistance'], t1['nasti_t2[res_plus].damage_resistance'], t1['nasti_t2[add_sp].interval']], [0.1, 0.15, 6]);
const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 3, chessId: 'chess_char_1_14_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_1_08_a', row: 12, col: 3 }] });
const prov = h.unit(3), tx = h.unit(4);
h.run(0.5);
assert.deepEqual([protectOf(u), protectOf(prov), protectOf(tx)], [{ physTakenMul: 0.9, artsTakenMul: 0.9 }, null, null], `T${tier}: 10 % on her only`);
h.spawn('enemy_dummy', { pos: [11, 9] });
h.run(0.5);
assert.deepEqual(protectOf(u), { physTakenMul: 0.9, artsTakenMul: 0.9 }, `T${tier}: a 近战 enemy changes nothing`);
const rng = h.spawn('enemy_rng', { pos: [12, 9] });
h.run(0.5);
assert.deepEqual([protectOf(u), protectOf(prov), protectOf(tx)], [{ physTakenMul: 0.85, artsTakenMul: 0.85 }, { physTakenMul: 0.85, artsTakenMul: 0.85 }, null], `T${tier}: 15 % on her and 格雷伊 (远程), not 德克萨斯`);
const gifts = (a) => h.hooksOf('spGain').filter((c) => c.unit === a && c.reason === 'talent').map((c) => c.t);
prov.skill.sp = 0;
u.skill.sp = 0;
const g0 = gifts(prov).length;
h.run(12.1);
assert.equal(gifts(prov).length - g0, 2, `T${tier}: +1 SP every 6 s (格雷伊)`);
assert.ok(gifts(u).length >= 2, `T${tier}: and her`);
assert.equal(gifts(tx).length, 0, `T${tier}: not 德克萨斯`);
h.b.kill(rng, null);
h.run(1);
assert.deepEqual(protectOf(prov), { physTakenMul: 0.85, artsTakenMul: 0.85 }, `T${tier}: permanent until she leaves`);
h.b.kill(u, null);
h.run(1);
assert.equal(protectOf(prov), null, `T${tier}: gone with her`);
done(h);
}
});
test('S1 “拱卫” + “质检专员”: DEF +25 % on the operator it faces; while S1 runs DEF +40 / 46 %, block +1 and the device −2 % max HP / s; S1 38 / 40 s, ATK / DEF +52 / 65 %, every blocked enemy at once, +1 stock at the start', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const v = tokOf(DEF, tier, elite, 0, null);
const { h, u, devs } = field({ tier, elite, skill: 0, 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, sk.bb['talent@def'], v.talents[1].bb.def, v.skill.bb['talent@block_cnt'], v.skill.bb['talent@hp_ratio']],
[elite ? 40 : 38, elite ? 0.65 : 0.52, elite ? 0.65 : 0.52, elite ? 0.46 : 0.4, 0.25, 1, 0.02], `T${tier}`);
h.step();
assert.deepEqual(yak.findBuff(`nasti:nstdef:${d.id}`)?.mods, { defPct: 0.25 }, `T${tier}: 严禁偷工减料`);
assert.ok(d.s.flags.untargetable && d.s.flags.invulnerable && d.s.flags.noHeal, `T${tier}: 不会受到攻击, 无敌, 禁疗`);
st(u).stock = 1;
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(st(u).stock, 2, `T${tier}: +1 stock`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.02);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.deepEqual(u.skill.spec.attack, { hitAllBlocked: true });
h.step();
assert.deepEqual(yak.findBuff(`nasti:nstdef:${d.id}`)?.mods, { defPct: sk.bb['talent@def'], blockCnt: 1 }, `T${tier}: the S1 mode`);
assert.equal(yak.s.blockCnt, yak.base.blockCnt + 1, `T${tier}: block +1`);
const hp0 = d.hp;
h.run(5.02);
approx(hp0 - d.hp, d.s.maxHp * 0.02 * 5, `T${tier}: −2 % / s`, 1e-3);
h.runUntil(() => !u.skill.active, sk.duration + 1);
h.step();
assert.deepEqual(yak.findBuff(`nasti:nstdef:${d.id}`)?.mods, { defPct: 0.25 }, `T${tier}: back to +25 %`);
approx(d.hp, d.s.maxHp * (1 - 0.02 * (sk.duration - 1)), `T${tier}: ${sk.duration - 1} ticks of −2 % (at 1 … ${sk.duration - 1} s; the skill ends at ${sk.duration})`, 1e-3);
done(h);
}
});
test('S2 “执行” + “监工专员”: deployed at 3 / 8 HP, +1 HP every 6 SP (阻回 at full HP); S2 4 s: no attack, a barrier of 6 / 8 % of her max HP each 0.5 s up to 48 / 64 %; the device: +1 SP and a 3 / 4 % barrier to the operator it faces each 0.5 s, −1 HP each, leaves at the end; +1 stock at the end; her barriers go when she leaves', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const v = tokOf(CHR, 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['attack@nasti_s2[update_shield].shield_each_hp_rate'], sk.bb['attack@nasti_s2[update_shield].shield_max_hp_rate'], v.skill.bb['talent@shield_each_hp_rate'], v.skill.bb['talent@shield_max_hp_rate'], v.stats.maxHp, v.talents[1].bb.init_hp, v.skill.spCost],
[4, elite ? 0.08 : 0.06, elite ? 0.64 : 0.48, elite ? 0.04 : 0.03, elite ? 0.64 : 0.48, 8, 3, 6], `T${tier}`);
assert.deepEqual([d.hp, d.s.maxHp], [3, 8], `T${tier}: 3 / 8 HP`);
h.run(12.2);
assert.equal(d.hp, 5, `T${tier}: +1 HP every 6 s`);
h.run(18.2);
assert.equal(d.hp, 8, `T${tier}: full`);
assert.ok(d.s.flags.noSp, `T${tier}: 阻回 at full HP`);
// S2: 8 pulses over 4 s on 角峰; the device dies on its last HP; her own barrier
st(u).stock = 1;
yak.skill.sp = 0;
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
assert.deepEqual(u.skill.spec.attack, { noAttack: true }, `T${tier}: 停止攻击`);
const t0 = h.b.time;
h.runUntil(() => !u.skill.active, 6);
approx(h.b.time - t0, 4, `T${tier}: 4 s`, 0.03);
const each = u.s.maxHp * v.skill.bb['talent@shield_each_hp_rate'];
approx(yak.findBuff(`nasti:shield:${u.id}`)?.shield ?? 0, 8 * each, `T${tier}: 8 pulses of ${v.skill.bb['talent@shield_each_hp_rate'] * 100} % of her max HP`, 1e-3);
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === yak && c.reason === 'device').length, 8, `T${tier}: 8 SP`);
approx(u.findBuff('nasti:s2shield').shield, u.s.maxHp * sk.bb['attack@nasti_s2[update_shield].shield_max_hp_rate'], `T${tier}: her barrier at the cap`, 1e-3);
assert.equal(d.alive, false, `T${tier}: the device spent (8 HP) / left at the end`);
assert.equal(st(u).stock, 2, `T${tier}: +1 stock at the end (the device not back yet)`);
assert.ok(h.runUntil(() => d.alive, 10), `T${tier}: back after 4 s`);
assert.deepEqual([d.hp, st(u).stock], [3, 1]);
// she leaves: every barrier of her S2 goes
h.b.kill(u, null);
assert.ok(!yak.findBuff(`nasti:shield:${u.id}`) && !u.findBuff('nasti:s2shield'), `T${tier}: barriers gone with her`);
done(h);
}
});
test('S3 栖脚地 + 应急承重小组: the 小工程师 builds the 高台 on the free low tile it faces (none on a taken tile); 50 SP a level (its own 1 / s + 1 / s from the 小工程师, 2 / s in S3); level 1 attackable, block 1; level 2 +1000 HP / +100 DEF / +5 RES; level 3 +2000 / +200 / +10, 阻回; heals 1 % (2 %) for −2 %; stays when she leaves only at level 3; S3 15 s, ATK / DEF +100 / 130 %', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const v = tokOf(BLD, tier, elite, 2, null);
const { h, u, devs } = field({ tier, elite, skill: 2, pieces: [[11, 4], [9, 4, 'UP']], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }] });
const [eng, blocked] = devs;
const p = platformOf(u);
assert.ok(p && p.alive, `T${tier}: the 高台 built`);
assert.deepEqual([p.tileR, p.tileC, p.mem.nastiLevel], [11, 5, 0], `T${tier}: on the tile it faces`);
assert.equal(h.b.allyUnits.filter((a) => a.defId === BLD && a.mem.nastiPlatform).length, 1, `T${tier}: one 高台 (the other 小工程师 faces 角峰)`);
void blocked;
assert.ok(p.s.flags.untargetable && p.s.flags.invulnerable && p.s.blockCnt === 0, `T${tier}: 未激活 — untargetable, invulnerable, blocks nobody`);
assert.ok(eng.s.flags.untargetable && eng.s.flags.invulnerable && eng.s.flags.noHeal, `T${tier}: the 小工程师 不会受到攻击`);
assert.deepEqual([v.skill.spCost, v.talents[0].bb.m1_sp, v.talents[0].bb.m2_sp, v.talents[0].bb['nasti_nstbld_m1[heal_m456].hp_ratio'], v.talents[0].bb['nasti_nstbld_m2[heal_m456].hp_ratio']], [50, 1, 2, 0.01, 0.02], `T${tier}`);
// level 1 after 25 s (1 + 1 SP / s)
assert.ok(h.runUntil(() => p.mem.nastiLevel === 1, 27), `T${tier}: level 1`);
approx(h.b.time, 25, `T${tier}: 50 SP at 2 / s`, 0.08);
assert.ok(!p.s.flags.untargetable && !p.s.flags.invulnerable && p.s.blockCnt === 1, `T${tier}: 激活 — attackable, block 1`);
// S3: 3 SP / s (1 own + 2), level 2
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
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 +${sk.bb.atk * 100} %`);
approx(u.skill.timeLeft, 15, `T${tier}: 15 s`, 0.05);
const sp0 = p.skill.sp, tS = h.b.time;
h.run(5);
approx(p.skill.sp - sp0, 3 * 5, `T${tier}: 3 SP / s in S3`, 0.05);
void tS;
assert.ok(h.runUntil(() => p.mem.nastiLevel === 2, 20), `T${tier}: level 2`);
approx(p.s.maxHp, v.stats.maxHp + 1000, `T${tier}: +1000 HP`);
assert.deepEqual([p.s.def, p.s.res], [v.stats.def + 100, v.stats.res + 5], `T${tier}: +100 DEF / +5 RES`);
// heal: 1 % of its max HP for −2 % of the 小工程师's
h.runUntil(() => !u.skill.active, 20);
p.hp = p.s.maxHp * 0.5;
const e0 = eng.hp, p0 = p.hp;
h.run(1.01);
approx(p.hp - p0, p.s.maxHp * 0.01, `T${tier}: heals 1 %`, 0.02);
approx(e0 - eng.hp, eng.s.maxHp * 0.02, `T${tier}: −2 % own`, 0.02);
// level 3: 阻回; stays when she leaves
p.skill.gainSp(999);
h.step(2);
assert.equal(p.mem.nastiLevel, 3, `T${tier}: level 3`);
approx(p.s.maxHp, v.stats.maxHp + 2000, `T${tier}: +2000 HP`);
assert.ok(p.s.flags.noSp, `T${tier}: 阻回 at the top level`);
h.b.kill(u, null);
assert.ok(p.alive, `T${tier}: a level-3 高台 stays when she leaves`);
assert.ok(!eng.alive, `T${tier}: the 小工程师 goes with her`);
done(h);
}
// below level 3 the 高台 goes with her; no 高台 on a taken tile
const { h, u } = field({ tier: 6, elite: true, skill: 2, pieces: [[11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] });
assert.equal(platformOf(u), null, 'the tile it faces is taken: no 高台');
done(h);
const r2 = field({ tier: 6, elite: true, skill: 2, pieces: [[11, 4]] });
const p2 = platformOf(r2.u);
r2.h.b.kill(r2.u, null);
assert.ok(!p2.alive, 'a level-0 高台 goes with her');
done(r2.h);
});
test('CRA-X stage 3 "装置可与攻击范围内其他装置共同提供效果": a 质检专员 with another on its range also reaches that one\'s range (the operator ahead gets both) — not at stage 1 or without the module', () => {
for (const [tier, mod, want] of [[6, X, 2], [5, X, 1], [6, null, 1]]) {
const { h, devs } = field({ tier, elite: true, mod, skill: 0, pieces: [[11, 3], [11, 4]], others: [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 5 }] });
const yak = h.unit(3);
h.step();
const n = devs.filter((d) => yak.findBuff(`nasti:nstdef:${d.id}`)).length;
assert.equal(n, want, `T${tier} ${mod ?? 'none'}: ${want} device(s) reach 角峰`);
approx(yak.s.def, yak.base.def * (1 + 0.25 * want), `T${tier}: DEF +${25 * want} %`);
done(h);
}
});
+390
View File
@@ -0,0 +1,390 @@
// test/content/op_necras.test.js — the 自选 operator kit of 死芒 (char_450_necras, 6★ 塑灵术师; kit
// server/sim/content/kits/ops/op-necras.js) and of her summon 悲叹的仆役 (token_10043_necras_skeltn, and its special form),
// fielded the production way (a DIY slot + its `diy` pick) in every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module)
// and elite (E2 Lv60, rank 7) with no module or SOC-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_necras.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 NEC = 'char_450_necras';
const SKEL = 'token_10043_necras_skeltn';
const FORMS = BACKUPS.units[NEC].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_necras';
const S1 = 'skchr_necras_1', S2 = 'skchr_necras_2', S3 = 'skchr_necras_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 skelOf = (tier, elite, skill) => {
let v = BACKUPS.tokens[SKEL].variants[`${NEC}@${statusOf(tier, elite)}`];
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
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 ENEMIES = {
enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }),
enemy_weak: enemyRec({ key: 'enemy_weak', hp: 100, speed: 0, mass: 0 }),
enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e9, speed: 0, mass: 0, motion: 'FLY' }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.5, mass: 0 }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], [5, true, null], [5, true, X], [6, true, null], [6, true, X]];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 死芒 as uid 1 at (10, 3) facing RIGHT: her 3-1 is rows 9–11 × cols 3–5 and (10, 6). */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'death', 'deploy', 'statusApplied', 'heal'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NEC, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const skels = (u) => (u.mem.necras?.skels ?? []).filter((s) => s.alive);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** Knock an enemy out at `pos` (the kill is credited to nobody: anyone's kill counts). */
function killAt(h, pos, key = 'enemy_weak') {
const e = h.spawn(key, { pos });
h.step();
h.b.kill(e, null);
return e;
}
test('死芒 in every 自选 form: her kit (all three skills authored), stats + module attributes, 3-1, blocks 1, ranged arts that hit air, 维多利亚, no 特质, the data triggers (S3 SKILL_RANGE on its 3-3); offered as a pick', () => {
assert.equal(OPERATOR_KITS[NEC], KITS[NEC]);
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], [NEC, 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.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'arts', 1.6], `${label(f)}: 塑灵术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-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`);
done(h);
}
}
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'SKILL_RANGE']);
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr], [{ maxHp: 120, atk: 35 }, { maxHp: 200, atk: 55 }]);
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(NEC));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NEC), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: NEC, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: NEC, skillIndex: 2, uniEquipId: X } } });
});
test('复燃: a knock-out on her range (anyone\'s, stealth ignored) makes a 悲叹的仆役 on its tile 0.1 s later (facing as she does); off her range nothing; up to 3, then the upgrade (+100 % HP / ATK, +40 % DEF, block +1, healed to full; not yet upgraded first), all upgraded: the lowest healed; they leave with her', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
const v = skelOf(tier, elite, 0);
assert.deepEqual([t0.max_token_cnt, t0.max_hp, t0.atk, t0.def, t0.block_cnt], [3, 1, 1, 0.4, 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
killAt(h, [10, 5]);
assert.equal(skels(u).length, 0, `T${tier}: not at once`);
h.run(0.11);
const [a] = skels(u);
assert.ok(a, `T${tier}: one 0.1 s later`);
assert.deepEqual([a.tileR, a.tileC, a.dir, a.defId], [10, 5, u.dir, SKEL], `T${tier}: on the kill tile, facing as she does`);
assert.deepEqual([a.base.maxHp, a.base.atk, a.base.def, a.s.blockCnt, a.profile.dmgType, a.profile.attack, a.profile.canHitFly], [v.stats.maxHp, v.stats.atk, v.stats.def, 1, 'arts', 'melee', false], `T${tier}: the token's stats, arts melee, ground only`);
assert.deepEqual(a.liveRangeGrid, v.rangeGrid, `T${tier}: x-5`);
assert.ok(a.s.flags.noHeal && a.s.flags.noSp && a.s.flags.silence, `T${tier}: 禁疗, 阻回, 沉默`);
killAt(h, [10, 8]);
h.run(0.2);
assert.equal(skels(u).length, 1, `T${tier}: off her range — nothing`);
killAt(h, [9, 4]);
h.run(0.2);
killAt(h, [11, 4]);
h.run(0.2);
assert.equal(skels(u).length, 3, `T${tier}: three`);
const [, b, c] = skels(u);
b.hp = b.s.maxHp * 0.5;
c.hp = c.s.maxHp * 0.3;
killAt(h, [10, 4]);
h.run(0.2);
assert.equal(skels(u).length, 3, `T${tier}: no 4th`);
assert.ok(c.findBuff('necras:upgrade') && !a.findBuff('necras:upgrade') && !b.findBuff('necras:upgrade'), `T${tier}: the lowest-HP one upgraded`);
assert.deepEqual(c.findBuff('necras:upgrade').mods, { hpPct: 1, atkPct: 1, defPct: 0.4, blockCnt: 1 });
assert.deepEqual([c.hp, c.s.maxHp, c.s.blockCnt], [c.base.maxHp * 2, c.base.maxHp * 2, 2], `T${tier}: healed to full, block 2`);
killAt(h, [10, 4]);
h.run(0.2);
killAt(h, [10, 4]);
h.run(0.2);
assert.ok(skels(u).every((s) => s.findBuff('necras:upgrade')), `T${tier}: all three upgraded`);
b.hp = 100;
killAt(h, [10, 4]);
h.run(0.2);
assert.equal(b.hp, b.s.maxHp, `T${tier}: all upgraded — the lowest healed`);
h.b.kill(u, null);
assert.equal(skels(u).length, 0, `T${tier}: they leave with her`);
done(h);
}
});
test('复燃: no deployable kill tile ⇒ a random free low melee tile of her range, those with an enemy first; no tile at all ⇒ the upgrade instead', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1, others: [
{ uid: 3, chessId: 'chess_char_1_02_a', row: 9, col: 3 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 9, col: 4 }, { uid: 5, chessId: 'chess_char_1_02_a', row: 9, col: 5 },
{ uid: 6, chessId: 'chess_char_1_02_a', row: 11, col: 3 }, { uid: 7, chessId: 'chess_char_1_02_a', row: 11, col: 4 }, { uid: 8, chessId: 'chess_char_1_02_a', row: 11, col: 5 },
] });
// her range: (9|10|11, 3–5) + (10, 6); free: (10, 4), (10, 5), (10, 6); an enemy stands on (10, 6)
h.spawn('enemy_dummy', { pos: [10, 6] });
// a kill on a taken tile (an operator on it) ⇒ the tile with the enemy
killAt(h, [9, 4]);
h.run(0.2);
const [a] = skels(u);
assert.deepEqual([a.tileR, a.tileC], [10, 6], 'the free tile with an enemy');
killAt(h, [9, 5]);
h.run(0.2);
killAt(h, [9, 5]);
h.run(0.2);
assert.deepEqual(skels(u).map((s) => [s.tileR, s.tileC]).sort(), [[10, 4], [10, 5], [10, 6]], 'then the other free tiles');
done(h);
});
test('回光黯淡: her and her summons\' damage on an enemy below 50 % HP ×1.4 (the final damage); SOC-X "攻击力提升至115%" on an enemy her summon blocks (stage 1 and 3), which she can attack anywhere', () => {
for (const [tier, elite, mod] of [[5, false, null], [5, true, X], [6, true, X]]) {
const f = label([tier, elite, mod]);
const { h, u } = field({ tier, elite, mod, skill: 1, row: 9, col: 3 });
const e = h.spawn('enemy_dummy', { pos: [9, 5] });
h.run(3);
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.dmg.isAttack);
const full = hits().at(-1).amount;
approx(full, u.s.atk, `${f}: ×1 at full HP`);
e.hp = e.s.maxHp * 0.49;
const n0 = hits().length;
h.run(2);
approx(hits().slice(n0).at(-1).amount, u.s.atk * 1.4, `${f}: ×1.4 below 50 %`);
done(h);
}
// the trait: she attacks the enemy her summon blocks out of her range (×1.15 with SOC-X)
for (const [tier, elite, mod, mul] of [[5, false, null, 1], [5, true, X, 1.15], [6, true, X, 1.15]]) {
const f = label([tier, elite, mod]);
const { h, u } = field({ tier, elite, mod, skill: 1, row: 12, col: 8 });
// her range: rows 11–13 × cols 8–10 and (12, 11); a 悲叹的仆役 made on (11, 9) blocks a walker on row 11 coming from the right
killAt(h, [11, 9]);
h.run(0.2);
const [s] = skels(u);
assert.deepEqual([s.tileR, s.tileC], [11, 9]);
const w = h.spawn('enemy_walk', { pos: [11, 13] });
assert.ok(h.runUntil(() => w.blockedBy === s, 20), `${f}: blocked by her summon`);
h.step(3);
assert.ok(u.extraRangeKeys && u.extraRangeKeys.includes(Math.round(w.y) * 21 + Math.round(w.x)), `${f}: its tile is her target`);
const n0 = h.hooksOf('damaged').filter((c) => c.source === u && c.target === w).length;
h.run(4);
const hit = h.hooksOf('damaged').filter((c) => c.source === u && c.target === w && c.dmg.isAttack).slice(n0);
assert.ok(hit.length >= 1, `${f}: she attacks it`);
approx(hit[0].amount, u.s.atk * mul, `${f}: ×${mul}`);
done(h);
}
});
test('SOC-X stage 3: a knock-out on her range or her summons\' ranges ⇒ her ATK +25 % for 10 s (refreshed); one on a summon\'s range only also makes a 悲叹的仆役 (a random tile of her range) — none of it at stage 1', () => {
for (const [tier, mod, on] of [[5, X, false], [6, X, true], [6, null, false]]) {
const f = label([tier, true, mod]);
const { h, u } = field({ tier, elite: true, mod, skill: 1, row: 10, col: 3 });
// a summon on the tip of her range (10, 6): its x-5 reaches (10, 7), (9, 6), (11, 6) — outside hers
killAt(h, [10, 6]);
h.run(0.2);
const [s] = skels(u);
assert.deepEqual([s.tileR, s.tileC], [10, 6]);
assert.equal(!!u.findBuff('necras:killAtk'), on, `${f}: ATK buff`);
if (on) {
assert.deepEqual(u.findBuff('necras:killAtk').mods, { atkPct: 0.25 });
approx(u.findBuff('necras:killAtk').timeLeft, 10 - 0.2, `${f}: 10 s`, 0.02);
h.run(5);
killAt(h, [9, 4]);
approx(u.findBuff('necras:killAtk').timeLeft, 10, `${f}: refreshed`, 0.02);
h.run(0.2);
}
const n = skels(u).length;
killAt(h, [10, 7]);
h.run(0.2);
assert.equal(skels(u).length, on ? n + 1 : n, `${f}: the summon-range kill makes one (stage 3 only)`);
done(h);
}
});
test('S1 噩愿: each 悲叹的仆役 made or upgraded strikes every enemy within 1.5 for her ATK × 310 % / 370 % arts (air units too); the cast re-makes them all — 1 per summon, 2 per upgraded one, 1 with none', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.bb.atk_scale, sk.bb.range_radius], [elite ? 3.7 : 3.1, 1.5]);
const { h, u } = field({ tier, elite, skill: 0 });
const near = h.spawn('enemy_fly', { pos: [11, 5] });
const far = h.spawn('enemy_dummy', { pos: [12, 7] });
killAt(h, [10, 5]);
h.run(0.2);
const by = (t) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === t && c.dmg.tags?.includes('necras:s1'));
assert.equal(by(near).length, 1, `T${tier}: the flyer within 1.5`);
approx(by(near)[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.equal(by(far).length, 0, `T${tier}: farther untouched`);
// fill to 3 and upgrade one (another strike)
killAt(h, [10, 4]);
h.run(0.2);
killAt(h, [11, 4]);
h.run(0.2);
const k0 = by(near).length;
killAt(h, [10, 4]);
h.run(0.2);
assert.ok(skels(u).some((s) => s.findBuff('necras:upgrade')));
assert.ok(by(near).length >= k0, `T${tier}: the upgrade strikes too`);
// the cast: 2 + 1 + 1 = 4 credits ⇒ 3 summons + 1 upgrade
const old = skels(u).map((s) => s.id);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4), `T${tier}: cast`);
h.run(0.5);
const now = skels(u);
assert.equal(now.length, 3, `T${tier}: three again`);
assert.ok(now.every((s) => !old.includes(s.id)), `T${tier}: all re-made`);
assert.equal(now.filter((s) => s.findBuff('necras:upgrade')).length, 1, `T${tier}: the 4th credit upgrades one`);
done(h);
}
// none on the field ⇒ one
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
h.run(0.3);
assert.equal(skels(u).length, 1, 'none ⇒ one');
done(h);
});
test('S2 折朽: 12 s; up to 2 enemies of her range sleep while linked and take her ATK × 100 % / 130 % arts every 0.5 s through it; no attack meanwhile; a linked one knocked out ⇒ +2 summons; every link broken ⇒ the skill ends and the sleep with it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.duration, sk.bb.atk_scale, sk.bb.max_target, sk.bb.interval, sk.bb.additional_token_cnt], [12, elite ? 1.3 : 1, 2, 0.5, 2], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
const a = h.spawn('enemy_dummy', { pos: [10, 5] });
const b = h.spawn('enemy_dummy', { pos: [9, 5] });
const c = h.spawn('enemy_dummy', { pos: [11, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
const slept = [a, b, c].filter((e) => e.findBuff('sleep'));
assert.equal(slept.length, 2, `T${tier}: two asleep`);
assert.deepEqual(u.skill.spec.attack, { noAttack: true });
const t0 = h.b.time;
h.run(3.01);
const ticks = h.hooksOf('damaged').filter((x) => x.source === u && slept.includes(x.target) && x.dmg.tags?.includes('necras:s2') && x.t > t0 - 1e-9);
assert.equal(ticks.length, 12, `T${tier}: 6 ticks each in 3 s`);
for (const x of ticks) approx(x.amount, u.s.atk * sk.bb.atk_scale * (1 - x.target.s.res / 100), `T${tier}: ${sk.bb.atk_scale * 100} % arts`, 1e-3);
assert.ok(ticks.every((x) => x.target.findBuff('sleep')), `T${tier}: through the sleep`);
// a linked one knocked out ⇒ +2 summons (and 复燃's own one on its tile)
const n0 = skels(u).length;
h.b.kill(slept[0], null);
h.run(0.4);
assert.equal(skels(u).length, n0 + 3, `T${tier}: 2 + 1 summons`);
assert.ok(u.skill.active, `T${tier}: one link left`);
h.b.kill(slept[1], null);
h.step(3);
assert.equal(u.skill.active, false, `T${tier}: every link broken ⇒ ends`);
done(h);
}
// the end releases the sleep; a stun interrupts it
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.ok(e.findBuff('sleep'));
h.b.applyStatus(u, 'stun', { duration: 1 });
h.step(3);
assert.ok(!u.skill.active && !e.findBuff('sleep'), 'stunned: interrupted, the sleep gone');
done(h);
});
test('S3 冠死以冕: the next 悲叹的仆役 is the special form (+200 % HP / ATK, +100 % DEF, +10 RES, +0.7 s, block +1; 怨火缠身 15 SP / 8 s, 120 % ATK / s on its 1-1); the cast (SKILL_RANGE, 3-3 meanwhile): 2 strikes at her attack interval on every enemy of the 3-3 (550 % / 650 %), each consuming a normal summon to upgrade it (twice for an upgraded one; +80 / 80 / 40 %, heal 20 %, 3rd: block +1, 攻击距离 +1)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const v = skelOf(tier, elite, 2);
const dr = formOf(tier, elite).talents.find((t) => t.index === 3).bb;
assert.deepEqual([dr.max_hp, dr.atk, dr.def, dr.magic_resistance, dr.base_attack_time, dr.block_cnt], [2, 2, 1, 10, 0.7, 1]);
assert.deepEqual([sk.bb['attack@atk_scale'], sk.bb['attack@max_hp'], sk.bb['attack@atk'], sk.bb['attack@def'], sk.bb['attack@hp_ratio'], sk.bb['attack@max_stack_cnt'], sk.rangeId], [elite ? 6.5 : 5.5, 0.8, 0.8, 0.4, 0.2, 6, '3-3'], `T${tier}`);
assert.deepEqual([v.skill.skillId, v.skill.spCost, v.skill.bb['attack@atk_scale'], v.skill.bb['attack@duration']], ['sktok_necras_skeltn_3', 15, 1.2, 8]);
const { h, u } = field({ tier, elite, skill: 2 });
killAt(h, [10, 5]);
h.run(0.2);
const [d] = skels(u);
assert.ok(d.mem.necrasDragon, `T${tier}: the first one is the special form`);
assert.deepEqual([d.s.maxHp, d.s.atk, d.s.def, d.s.res, d.s.blockCnt], [d.base.maxHp * 3, d.base.atk * 3, d.base.def * 2, d.base.res + 10, 2], `T${tier}: the special form's attributes`);
approx(d.s.bat, d.base.bat + 0.7, `T${tier}: +0.7 s`);
assert.ok(!d.s.flags.noSp && d.skill.spCost === 15, `T${tier}: its skill charges`);
killAt(h, [10, 4]);
h.run(0.2);
killAt(h, [11, 4]);
h.run(0.2);
const [, n1, n2] = skels(u);
assert.ok(!n1.mem.necrasDragon && !n2.mem.necrasDragon && n1.s.flags.noSp, `T${tier}: the others normal, 阻回`);
// upgrade n1 (the earliest normal one), so its consumption upgrades twice
killAt(h, [10, 4]);
h.run(0.2);
assert.equal([n1, n2].filter((x) => x.findBuff('necras:upgrade')).length, 1, `T${tier}: one normal summon upgraded`);
const up1 = n1.findBuff('necras:upgrade') ? n1 : n2;
// the cast
const foe = h.spawn('enemy_dummy', { pos: [9, 6] });
const u0atk = u.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: SKILL_RANGE — an enemy in the 3-3`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while it runs`);
const tCast = h.b.time;
assert.ok(h.runUntil(() => !u.skill.active, 5), `T${tier}: ends after its 2 strikes`);
const strikes = h.hooksOf('damaged').filter((x) => x.source === u && x.target === foe && x.dmg.tags?.includes('necras:s3'));
assert.equal(strikes.length, 2, `T${tier}: 2 strikes`);
approx(strikes[0].amount, u0atk * sk.bb['attack@atk_scale'] * (1 - foe.s.res / 100), `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % arts`, 1e-3);
approx(strikes[1].t - strikes[0].t, u.s.interval, `T${tier}: at her attack interval`, 0.05);
void tCast;
h.run(0.3);
assert.ok(!n1.alive && !n2.alive, `T${tier}: both normal summons consumed`);
void up1;
assert.equal(d.mem.necrasUps, 3, `T${tier}: 2 (the upgraded one) + 1 upgrades`);
assert.deepEqual(d.findBuff('necras:dragonUp').mods, { hpPct: 0.8 * 3, atkPct: 0.8 * 3, defPct: 0.4 * 3, blockCnt: 1, rangeExtend: 1 }, `T${tier}: 3 upgrades — block +1, 攻击距离 +1`);
assert.equal(u.liveRangeGrid.length, formOf(tier, elite).rangeGrid.length, `T${tier}: her range back`);
done(h);
}
});
test('怨火缠身 (the special form\'s skill): 15 SP over time, 8 s, its ATK × 120 % arts every second on every enemy of its 1-1; the normal forms never cast (阻回)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
killAt(h, [10, 5]);
h.run(0.2);
const [d] = skels(u);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => d.skill.active, 17), 'cast at 15 SP with an enemy in range');
approx(d.skill.timeLeft, 8, '8 s', 0.05);
const t0 = h.b.time;
h.runUntil(() => !d.skill.active, 10);
const ticks = h.hooksOf('damaged').filter((x) => x.source === d && x.target === e && x.dmg.isSkill && x.t >= t0 - 1e-9);
assert.equal(ticks.length, 8, 'one per second for 8 s');
approx(ticks[0].amount, d.s.atk * 1.2 * (1 - e.s.res / 100), '120 % arts', 1e-3);
done(h);
});
test('复燃: a stealthed enemy knocked out on her range counts (天赋无视隐匿); with no free tile left the summon becomes the upgrade', () => {
const tiles = [[9, 3], [9, 4], [9, 5], [11, 3], [11, 4], [11, 5], [10, 4], [10, 6]];
const { h, u } = field({ tier: 6, elite: true, skill: 1, others: tiles.map(([r, c], i) => ({ uid: 3 + i, chessId: 'chess_char_1_02_a', row: r, col: c })) });
const e = h.spawn('enemy_weak', { pos: [10, 5] });
h.step();
h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
h.step();
h.b.kill(e, null);
h.run(0.2);
const [s1] = skels(u);
assert.ok(s1, 'a summon for the stealthed one');
assert.deepEqual([s1.tileR, s1.tileC], [10, 5]);
killAt(h, [9, 4]);
h.run(0.2);
assert.equal(skels(u).length, 1, 'no free tile: no second summon');
assert.ok(s1.findBuff('necras:upgrade'), '… the upgrade instead');
done(h);
});