feedback5 WK: merge feedback5 (O2–O8 自选 operator kits and their engine hooks; 焰狐龙梓兰 out of the 自选 pool) — no conflict; golden unchanged (the 自选 match and the 52 diy scenarios match)
This commit is contained in:
commit
335f3f6045
68 files changed
+14722
-148
No files matched your search
@@ -1,7 +1,7 @@
|
||||
// test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json
|
||||
// (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js, shared/diy.js). These tests pin the data the
|
||||
// gameplay reads: the 133 base chess and their types, the 17 stand-in characters and their forms, every NORMAL chess
|
||||
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 72 owned 6★ with their forms and summons, the
|
||||
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 71 owned 6★ with their forms and summons, the
|
||||
// prototypes and their locked selections) and the faction bonds.
|
||||
// With the official-data cache (.cache/gamedata) the backup fields, the stand-in numbers and the bond derivation are
|
||||
// re-derived from the raw tables.
|
||||
@@ -234,9 +234,9 @@ test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves
|
||||
assert.equal(diyRecord(chess.chess_char_5_01_a, 'char_608_acpion', backups, { skillIndex: 2 }), null, 'not a DIY slot');
|
||||
});
|
||||
|
||||
test('DIY: the 72 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
|
||||
test('DIY: the 71 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
|
||||
const { ownedPool } = backups.diy;
|
||||
assert.equal(ownedPool.length, 72);
|
||||
assert.equal(ownedPool.length, 71);
|
||||
let summoners = 0;
|
||||
for (const id of ownedPool) {
|
||||
const u = backups.units[id];
|
||||
@@ -308,7 +308,7 @@ test('DIY: prototype picks carry the skill / module of their 补位 rows at the
|
||||
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
|
||||
const { ownedPool, operators, prototypes } = backups.diy;
|
||||
const roster = new Set(base.map((c) => c.charId).filter(Boolean));
|
||||
assert.equal(ownedPool.length, 72);
|
||||
assert.equal(ownedPool.length, 71);
|
||||
for (const id of ownedPool) {
|
||||
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
|
||||
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
|
||||
@@ -322,11 +322,11 @@ test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every
|
||||
}
|
||||
const tally = {};
|
||||
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
|
||||
assert.deepEqual(tally, { emptyShip: 39, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
|
||||
assert.deepEqual(tally, { emptyShip: 38, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
|
||||
// the collab operators are out of the data and the pool (the owner's decision of 2026-10-05: copyright)
|
||||
assert.deepEqual(backups.diy.excluded.map((id) => [id, backups.units[id], operators[id]]),
|
||||
['char_456_ash', 'char_1029_yato2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
|
||||
'灰烬, 麒麟R夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理');
|
||||
['char_456_ash', 'char_1029_yato2', 'char_1048_orchd2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
|
||||
'灰烬, 麒麟R夜刀, 焰狐龙梓兰 (MH05: a 联动寻访, by its display number), 艾拉, 玛露西尔, 丰川祥子, 结城理');
|
||||
assert.deepEqual(operators.char_017_huang.bonds, ['yanShip', 'victoriaShip'], '煌: 炎 + 维多利亚 from her subPower (mainPower rhodes / elite)');
|
||||
for (const id of [...prototypes['5'], ...prototypes['6']]) assert.deepEqual(operators[id].bonds, ['emptyShip'], `${id}: no faction`);
|
||||
});
|
||||
@@ -335,7 +335,8 @@ test('DIY: powers and the owned pool re-derived from character_table', { skip: !
|
||||
const CT = raw('character_table');
|
||||
const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess;
|
||||
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
|
||||
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId));
|
||||
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId))
|
||||
|| /^(?:MH|RS|AM|PS|DD)\d/.test(c.displayNumber || '');
|
||||
const legal = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id));
|
||||
assert.equal(legal.length, 78, 'the excel\'s 78 obtainable 6★ outside the chess pool');
|
||||
assert.deepEqual([...backups.diy.ownedPool].sort(), legal.filter(([, c]) => !collab(c)).map(([id]) => id).sort());
|
||||
|
||||
@@ -13,10 +13,13 @@ import { getDefaultSource, DataSource } from '../../server/sim/simdata.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||
import { isDiyDef, kitOf } from '../../server/sim/content/index.js';
|
||||
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const BACKUPS = load('backups');
|
||||
const SIEGE = 'char_112_siege';
|
||||
/** An owned 6★ without an operator kit yet (the generic kit's case) — the first of diy.ownedPool, null once all have one. */
|
||||
const KITLESS = BACKUPS.diy.ownedPool.find((id) => !KITTED_CHARS.includes(id)) ?? null;
|
||||
const T5 = 'chess_char_5_diy1_a', T6 = 'chess_char_6_diy1_a';
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
|
||||
const ENEMIES = { enemy_dummy: dummy('enemy_dummy') };
|
||||
@@ -54,18 +57,21 @@ test('a battle fields 自选 pieces by their kit: an owned 6★\'s operator kit
|
||||
{ diy: { slot: 5, charId: SIEGE, skillIndex: 1 }, row: 10, col: 3 },
|
||||
{ diy: { slot: 'chess_char_5_diy2_a', charId: 'char_609_acguad' }, elite: true, row: 10, col: 5 },
|
||||
{ diy: { slot: T6, charId: 'char_617_sharp2' }, elite: true, row: 10, col: 7 },
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_017_huang', skillIndex: 2 }, row: 11, col: 3 },
|
||||
...(KITLESS ? [{ diy: { slot: 'chess_char_6_diy2_a', charId: KITLESS, skillIndex: 2 }, row: 11, col: 3 }] : []),
|
||||
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_601_cguard' }, row: 11, col: 5 }, // illegal at tier 6: never fielded
|
||||
], { kits: { [SIEGE]: siegeKit, chess_char_5_diy1_a: () => { throw new Error('a 自选 piece never runs a kit of its slot id'); } } });
|
||||
h.step();
|
||||
const [siege, sharp, sharp2, huang] = [1, 2, 3, 4].map((uid) => h.unit(uid));
|
||||
assert.equal(h.unit(5), null, 'an illegal pick fields nothing');
|
||||
const [siege, sharp, sharp2, kitless] = [1, 2, 3, 4].map((uid) => h.unit(uid));
|
||||
assert.equal(h.unit(KITLESS ? 5 : 4), null, 'an illegal pick fields nothing');
|
||||
assert.deepEqual([siege.defId, siege.def.charId, siege.skill.id, siege.kit.diyProbe, siege.kit.skillSource], [T5, SIEGE, 'skchr_siege_2', true, 'skills']);
|
||||
assert.equal(kitOf(siege.def, { [T5]: 'slot', chess_char_5_diy1: 'bare', [SIEGE]: 'mine' }), 'mine', 'kitOf: by charId only');
|
||||
assert.equal(kitOf(siege.def, { [T5]: 'slot' }), undefined);
|
||||
assert.deepEqual([sharp.defId, sharp.def.charId, sharp.skill.id, !!sharp.kit.generic, sharp.kit.skillSource, sharp.def.raw.module.level], ['chess_char_5_diy2_b', 'char_609_acguad', 'skchr_acguad_3', false, 'skills', 1]);
|
||||
assert.deepEqual([sharp2.skill.id, !!sharp2.kit.generic, sharp2.def.raw.module.level, sharp2.def.raw.module.active], ['skchr_sharp2_1', false, 3, true], '领主·Sharp at tier 6: S1 + LOR-X stage 3');
|
||||
assert.deepEqual([huang.def.charId, huang.skill.id, !!huang.kit.generic, huang.def.bonds], ['char_017_huang', 'skchr_huang_3', true, ['yanShip', 'victoriaShip']], '煌 has no kit yet: the generic kit');
|
||||
if (KITLESS) {
|
||||
assert.deepEqual([kitless.def.charId, kitless.skill.id, !!kitless.kit.generic, kitless.def.bonds],
|
||||
[KITLESS, BACKUPS.units[KITLESS].forms['2/1/4/0'].skills[2].skillId, true, BACKUPS.diy.operators[KITLESS].bonds], `${KITLESS} has no kit yet: the generic kit`);
|
||||
}
|
||||
// the elite reserve uses the generic kit with its exact stat talents (as a 补位 stand-in does)
|
||||
const h2 = battle([{ diy: { slot: 5, charId: 'char_601_cguard' }, elite: true, row: 10, col: 3 }]);
|
||||
h2.step();
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// test/content/op_amgoat.test.js — the 自选 operator kit of 艾雅法拉 (char_180_amgoat, 6★ 中坚术师; kit
|
||||
// server/sim/content/kits/ops/op-amgoat.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-X
|
||||
// “错过的声音” or CCR-Y “宠物大赛第一名” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json
|
||||
// (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_amgoat.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 ID = 'char_180_amgoat';
|
||||
const FORMS = BACKUPS.units[ID].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const CCRX = 'uniequip_002_amgoat', CCRY = 'uniequip_003_amgoat';
|
||||
const S1 = 'skchr_amgoat_1', S2 = 'skchr_amgoat_2', S3 = 'skchr_amgoat_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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, res: 50, ...o });
|
||||
const ENEMIES = {
|
||||
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
|
||||
enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRX, CCRY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 艾雅法拉 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, setup = null } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain', 'deploy'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
setup,
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
/** Arts damage of `amount` on RES `res` with `ignore` flat RES ignored. */
|
||||
const arts = (amount, res, ignore = 0) => Math.max(amount * (1 - Math.max(0, res - ignore) / 100), amount * 0.05);
|
||||
|
||||
test('艾雅法拉 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-1 range, ranged arts that hits air, block 1, 1.6 s, 空 bonds, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
|
||||
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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 20], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.base.bat], [1, 'ranged', 'arts', true, false, 1.6], `${label(f)}: 中坚术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.tokens], [['emptyShip'], [], []], `${label(f)}: bonds / 特质 / no summon`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 1359 / 535 / 109, E2 Lv60 1614 / 608 / 118; CCR-X +130 / +40 → +180 / +65,
|
||||
// CCR-Y +180 / +25 → +230 / +50 HP / ATK
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1359, 535, 1614, 608]);
|
||||
assert.deepEqual([modOf(5, CCRX).attr, modOf(6, CCRX).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 130, atk: 40 }, { maxHp: 180, atk: 65 }, { maxHp: 180, atk: 25 }, { maxHp: 230, atk: 50 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 艾雅法拉 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(ID));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: CCRY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: CCRY } } });
|
||||
});
|
||||
|
||||
test('S1 二重咏唱 (MANUAL, data DEFAULT, 25 s): ASPD +38 / +45 on the first cast of a deployment; ASPD and ATK +38 % / +45 % from the second; the count restarts after a redeploy', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
const a = sk.bb['amgoat_s_1[a].attack_speed'], b = sk.bb['amgoat_s_1[b].attack_speed'], atk = sk.bb['amgoat_s_1[b].atk'];
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, a, b, atk], ['DEFAULT', 25, elite ? 39 : 42, elite ? 20 : 15, elite ? 45 : 38, elite ? 45 : 38, elite ? 0.45 : 0.38], `T${tier}`);
|
||||
h.run(0.6);
|
||||
const aspd0 = u.s.aspd, atk0 = u.s.atk;
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in her range`);
|
||||
approx(u.skill.timeLeft, 25, `T${tier}: 25 s`, 0.01);
|
||||
assert.deepEqual(u.findBuff('skill:amgoat:duet')?.mods, { aspd: a }, `T${tier}: the first cast`);
|
||||
approx(u.s.aspd, aspd0 + a, `T${tier}: ASPD`);
|
||||
approx(u.s.atk, atk0, `T${tier}: no ATK on the first cast`);
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 26));
|
||||
assert.equal(u.findBuff('skill:amgoat:duet'), null, `T${tier}: gone after it`);
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: the second cast`);
|
||||
assert.deepEqual(u.findBuff('skill:amgoat:duet')?.mods, { aspd: b, atkPct: atk }, `T${tier}: the second cast`);
|
||||
approx(u.s.atk, u.base.atk * (1 + 0.14 + atk), `T${tier}: ATK with 炎息`);
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u);
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: a cast after the redeploy`);
|
||||
assert.deepEqual(u.findBuff('skill:amgoat:duet')?.mods, { aspd: a }, `T${tier}: the count restarted (PRTS 备注)`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 点燃 (AUTO, 2 charges, data DEFAULT): main target RES ×0.85 / ×0.8 first, ½ explosion on every enemy within 1.5 (flyers too), then ½ again on the main target; the others\' RES cut after their hit, 6 s', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, CCRX]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, sk.spCost, sk.bb.atk_scale, sk.bb.atk_scale_2, sk.bb.magic_resistance, sk.bb.duration],
|
||||
['DEFAULT', 'charges', 2, elite ? 6 : 7, elite ? 1.55 : 1.375, elite ? 1.55 : 1.375, elite ? -0.2 : -0.15, 6], `T${tier}`);
|
||||
const ign = mod === CCRX ? 10 : 0;
|
||||
h.run(0.6);
|
||||
// 乱火 filled 1–2 charges at the deployment: spent before the scene is set
|
||||
u.skill.charges = 0;
|
||||
u.skill.sp = 0;
|
||||
// the main target alone in her range (its tip 0,3); three around it outside the range, one beyond the explosion
|
||||
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
const n1 = h.spawn('enemy_dummy', { pos: [10, 9] }), n2 = h.spawn('enemy_dummy', { pos: [11, 9] }), fly = h.spawn('enemy_fly', { pos: [9, 9] });
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 10] });
|
||||
u.skill.gainSp(999);
|
||||
assert.equal(u.skill.charges, 2);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).slice(n0).filter((c) => c.target === main).length >= 2, 4), `T${tier}: the ignited attack lands`);
|
||||
h.step();
|
||||
const hits = hitsBy(h, u).slice(n0);
|
||||
const atk = u.s.atk;
|
||||
const cut = 50 * (1 + sk.bb.magic_resistance);
|
||||
const onMain = hits.filter((c) => c.target === main);
|
||||
assert.equal(onMain.length, 2, `T${tier}: two ½ hits on the main target`);
|
||||
for (const c of onMain) approx(c.amount, arts(atk * sk.bb.atk_scale, cut, ign), `T${tier}: ½ on the main target after its RES cut`);
|
||||
for (const e of [n1, n2, fly]) {
|
||||
const hs = hits.filter((c) => c.target === e);
|
||||
assert.equal(hs.length, 1, `T${tier}: one explosion hit on ${e.tileR},${e.tileC}`);
|
||||
approx(hs[0].amount, arts(atk * sk.bb.atk_scale, 50, ign), `T${tier}: ½ before its own RES cut`);
|
||||
assert.deepEqual([hs[0].type, hs[0].dmg.isSplash, hs[0].dmg.isSkill], ['arts', true, true]);
|
||||
}
|
||||
assert.ok(!hits.some((c) => c.target === far), `T${tier}: 2 tiles away — outside the 1.5 explosion`);
|
||||
for (const e of [main, n1, n2, fly]) approx(e.s.res, cut, `T${tier}: RES ×${1 + sk.bb.magic_resistance}`);
|
||||
approx(far.s.res, 50, `T${tier}: untouched`);
|
||||
assert.equal(u.skill.charges, 1, `T${tier}: one charge spent`);
|
||||
// the next attack spends the second charge; the cut stays one (同名效果取最高) and lasts 6 s
|
||||
assert.ok(h.runUntil(() => u.skill.charges === 0, 4));
|
||||
h.run(0.5);
|
||||
approx(main.s.res, cut, `T${tier}: one RES cut, not two`);
|
||||
h.b.retreat(u); // no further ignition: the cut runs out
|
||||
h.run(6.2);
|
||||
approx(main.s.res, 50, `T${tier}: back after 6 s`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 火山 (MANUAL, data ACTIVE_RANGE on x-3 — 「攻击范围增大」 is an attack-range change, owner rule 2026-10-05): 15 s, ATK +70 % / +85 %, interval 1.6 − 1.1 = 0.5 s, range x-3 while on, lava on 4 / 5 random different enemies of it per attack (all when fewer); back after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 9 });
|
||||
const n = sk.bb['attack@max_target'];
|
||||
assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb.base_attack_time, n, sk.spCost, sk.initSp],
|
||||
['ACTIVE_RANGE', 'DEFAULT', 'x-3', 15, elite ? 0.85 : 0.7, -1.1, elite ? 5 : 4, 80, elite ? 40 : 33], `T${tier}`);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is x-3`);
|
||||
h.run(0.6);
|
||||
const atk0 = u.s.atk;
|
||||
approx(u.s.interval, 1.6, `T${tier}: own interval`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: nobody on x-3`);
|
||||
// seven enemies: x-3 holds (8,5) / (12,5) / (10,2), none of them in her own 3-1
|
||||
const spots = [[10, 6], [9, 7], [11, 7], [10, 8], [8, 5], [12, 5], [10, 2]];
|
||||
const foes = spots.map((p) => h.spawn('enemy_dummy', { pos: p }));
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, 15, `T${tier}: 15 s`, 0.05);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-3 while on`);
|
||||
approx(u.s.interval, 0.5, `T${tier}: 0.5 s`);
|
||||
approx(u.s.atk, atk0 + u.base.atk * sk.bb.atk, `T${tier}: ATK +${sk.bb.atk * 100} %`);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(6);
|
||||
assert.ok(u.skill.active);
|
||||
u.skill.end('test');
|
||||
h.run(0.5); // the last lava lands
|
||||
const hits = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isAttack && c.dmg.isSkill);
|
||||
const byAttack = new Map();
|
||||
for (const c of hits) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
|
||||
assert.ok(byAttack.size >= 11, `T${tier}: ${byAttack.size} attacks in 6 s (one per 0.5 s)`);
|
||||
const counts = new Map(foes.map((e) => [e, 0]));
|
||||
for (const [, ts] of byAttack) {
|
||||
assert.equal(ts.length, n, `T${tier}: ${n} lava per attack`);
|
||||
assert.equal(new Set(ts).size, n, `T${tier}: different enemies`);
|
||||
for (const t of ts) counts.set(t, counts.get(t) + 1);
|
||||
}
|
||||
for (const [e, k] of counts) assert.ok(k > 0, `T${tier}: ${e.tileR},${e.tileC} drew lava (random draw)`);
|
||||
for (const c of hits.slice(0, 4)) approx(c.amount, arts(atk0 + u.base.atk * sk.bb.atk, 50), `T${tier}: 100 % of her S3 ATK, arts`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
// two enemies of x-3 only: both every attack
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 4 });
|
||||
const two = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 8] })];
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(3);
|
||||
const byAttack = new Map();
|
||||
for (const c of hitsBy(h, u).slice(n0).filter((x) => x.dmg.isSkill)) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
|
||||
[...byAttack.keys()].slice(0, -1).forEach((k) => assert.deepEqual(new Set(byAttack.get(k)), new Set(two), 'fewer enemies than lava: each of them'));
|
||||
assert.ok(byAttack.size >= 5);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 炎息: every 【术师】 operator of the field ATK +14 % while she is deployed (惊蛰 yes, 德克萨斯 no); CCR-X stage 3 "携带时" +22 %, kept while she is knocked out', () => {
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_03_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_08_a', row: 12, col: 8 }];
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, others });
|
||||
h.run(0.6);
|
||||
const leizi = h.unit(2), texas = h.unit(3);
|
||||
const carried = elite && tier === 6 && mod === CCRX;
|
||||
const v = carried ? 0.22 : 0.14;
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0);
|
||||
assert.equal(t0.bb.atk, v, `${label(f)}: the data`);
|
||||
assert.match(t0.desc, carried ? /^携带时/ : /^在场时/, `${label(f)}: the text`);
|
||||
assert.deepEqual(leizi.findBuff('talent:amgoat:casters')?.mods, { atkPct: v }, `${label(f)}: 惊蛰 (术师)`);
|
||||
assert.deepEqual(u.findBuff('talent:amgoat:casters')?.mods, { atkPct: v }, `${label(f)}: herself`);
|
||||
assert.equal(texas.findBuff('talent:amgoat:casters'), null, `${label(f)}: 德克萨斯 (先锋) gets nothing`);
|
||||
h.b.kill(u, null);
|
||||
h.run(1.2);
|
||||
assert.equal(!!leizi.findBuff('talent:amgoat:casters'), carried, `${label(f)}: after she is knocked out`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 乱火: 7–15 SP at every deployment (a floored draw); CCR-Y stage 3 also ASPD +6–15, both the maxima (15 / 15) with an elite or leader in range at the deployment; stage 1 and no module: no ASPD, no maximum rule', () => {
|
||||
const seen = new Set(), seenAs = new Set();
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
const gifts = [];
|
||||
const setup = (b) => b.on('deploy', (c) => { if (c.unit.uid === 1) gifts.push([c.unit.skill.sp, c.unit.findBuff('talent:amgoat:wildfire')?.mods?.aspd ?? 0]); }, { priority: -100 });
|
||||
const y3 = seed % 2 === 0;
|
||||
const { h } = field({ tier: 6, elite: true, mod: y3 ? CCRY : null, skill: 2, seed, setup });
|
||||
const sk = skillOf(6, true, S3);
|
||||
const [sp, as] = gifts[0];
|
||||
const k = sp - sk.initSp;
|
||||
assert.ok(Number.isInteger(k) && k >= 7 && k <= 15, `seed ${seed}: +${k} SP`);
|
||||
seen.add(k);
|
||||
if (y3) { assert.ok(Number.isInteger(as) && as >= 6 && as <= 15, `seed ${seed}: ASPD +${as}`); seenAs.add(as); } else assert.equal(as, 0);
|
||||
done(h);
|
||||
}
|
||||
assert.ok(seen.size >= 6 && seenAs.size >= 4, `a spread of draws: SP ${[...seen].sort((a, b) => a - b)}, ASPD ${[...seenAs].sort((a, b) => a - b)}`);
|
||||
// an elite / leader in range at a deployment: the maxima with CCR-Y stage 3; a draw otherwise
|
||||
for (const [tier, mod, rank] of [[6, CCRY, 'enemy_elite'], [6, CCRY, 'enemy_boss'], [5, CCRY, 'enemy_elite'], [6, null, 'enemy_elite'], [6, CCRY, 'enemy_dummy']]) {
|
||||
const lab = `T${tier} ${mod ?? 'none'} ${rank}`;
|
||||
const top = tier === 6 && mod === CCRY && rank !== 'enemy_dummy';
|
||||
let maxed = 0;
|
||||
for (let seed = 1; seed <= 6; seed++) {
|
||||
const gifts = [];
|
||||
const setup = (b) => b.on('deploy', (c) => { if (c.unit.uid === 1) gifts.push([c.unit.skill.sp, c.unit.findBuff('talent:amgoat:wildfire')?.mods?.aspd ?? 0]); }, { priority: -100 });
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 2, seed, setup });
|
||||
const sk = skillOf(tier, true, S3);
|
||||
h.spawn(rank, { pos: [10, 6] });
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u);
|
||||
const [sp, as] = gifts[1];
|
||||
if (top) assert.deepEqual([sp - sk.initSp, as], [15, 15], `${lab} seed ${seed}: the maxima`);
|
||||
else {
|
||||
assert.ok(sp - sk.initSp >= 7 && sp - sk.initSp <= 15, lab);
|
||||
if (sp - sk.initSp === 15) maxed++;
|
||||
if (mod !== CCRY || tier === 5) assert.equal(as, 0, `${lab}: no ASPD`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
if (!top) assert.ok(maxed < 6, `${lab}: not always the maximum`);
|
||||
}
|
||||
// the talent's text and blackboard (stage 3 CCR-Y)
|
||||
const t1 = formOf(6, true).modules.find((m) => m.uniEquipId === CCRY).talentChanges.find((t) => t.talentIndex === 1);
|
||||
assert.deepEqual(t1.bb, { sp_min: 7, sp_max: 16, attack_speed_min: 6, attack_speed_max: 16, factor: 1 });
|
||||
assert.match(t1.desc, /随机获得7~15点技力并随机提升6~15的攻击速度/);
|
||||
});
|
||||
|
||||
test('modules: CCR-X "无视目标10点法术抗性" at stages 1 and 3 (her normal attack on RES 50 deals × 0.6); CCR-Y "普通攻击命中精英或领袖敌人时获得1点技力" — not on a normal enemy, not from S2\'s explosion', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const x = elite && mod === CCRX, y = elite && mod === CCRY;
|
||||
assert.equal(u.s.resIgnoreFlat, x ? 10 : 0, `${label(f)}: RES ignore`);
|
||||
if (x) assert.equal(u.def.raw.trait.moduleDesc, '无视目标10点法术抗性');
|
||||
if (y) assert.deepEqual(u.def.raw.trait.bb, { sp: 1 });
|
||||
const foe = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(0.6);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(4);
|
||||
const hits = hitsBy(h, u).slice(n0).filter((c) => c.target === foe && c.dmg.isAttack);
|
||||
assert.ok(hits.length >= 2, label(f));
|
||||
approx(hits[0].amount, arts(u.s.atk, 50, x ? 10 : 0), `${label(f)}: arts on RES 50`);
|
||||
const talentSp = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
|
||||
assert.equal(talentSp(), 0, `${label(f)}: no SP from a normal enemy`);
|
||||
h.b.kill(foe, null);
|
||||
const el = h.spawn('enemy_elite', { pos: [10, 6] });
|
||||
const k0 = talentSp(), a0 = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length;
|
||||
h.run(4);
|
||||
const hitsE = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length - a0;
|
||||
assert.ok(hitsE >= 2, label(f));
|
||||
const gained = talentSp() - k0;
|
||||
assert.equal(gained, y ? hitsE : 0, `${label(f)}: +1 SP per hit on an elite`);
|
||||
if (y) for (const c of h.hooksOf('spGain').filter((s) => s.unit === u && s.reason === 'trait')) assert.equal(c.amount, 1);
|
||||
done(h);
|
||||
}
|
||||
// S2's explosion and second ½ are no further 命中 (CCR-Y stage 3): one SP per ignited attack on an elite
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: CCRY, skill: 1 });
|
||||
h.run(0.6);
|
||||
u.skill.charges = 0;
|
||||
u.skill.sp = 0;
|
||||
const el = h.spawn('enemy_elite', { pos: [10, 8] });
|
||||
h.spawn('enemy_elite', { pos: [10, 9] });
|
||||
u.skill.gainSp(999);
|
||||
const k0 = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).filter((c) => c.target === el && c.dmg.isSkill).length >= 2, 4));
|
||||
h.step();
|
||||
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length - k0, 1, 'one SP for the ignited attack');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// test/content/op_aphris.test.js — the 自选 operator kit of 谬因 (char_4229_aphris, 6★ 轰击术师; kit
|
||||
// server/sim/content/kits/ops/op-aphris.js) and her summon “中继器” (token_10070_aphris_pc), fielded the production way (a DIY
|
||||
// slot + its `diy` pick, simdata getDiy; the 中继器 as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1,
|
||||
// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or BLA-Y “第三相态” at stage 1 (tier 5) / 3 (tier 6).
|
||||
// Every number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity
|
||||
// checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_aphris.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 APHRIS = 'char_4229_aphris';
|
||||
const RELAY = 'token_10070_aphris_pc';
|
||||
const FORMS = BACKUPS.units[APHRIS].forms;
|
||||
const TOKEN = BACKUPS.tokens[RELAY];
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const BLAY = 'uniequip_002_aphris';
|
||||
const S1 = 'skchr_aphris_1', S2 = 'skchr_aphris_2', S3 = 'skchr_aphris_3';
|
||||
const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
|
||||
const formOf = (tier, elite) => FORMS[statusKey(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 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_hitter: dummy('enemy_hitter', { atk: 500, range: 3, bat: 1 }) };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const K = (r, c) => r * 21 + c;
|
||||
const sortN = (a) => [...a].sort((x, y) => x - y);
|
||||
/** 谬因 at (9, 2) facing RIGHT; her own line (9, 2) … (9, 7). */
|
||||
const OWN = [[9, 2], [9, 3], [9, 4], [9, 5], [9, 6], [9, 7]];
|
||||
/** With her 中继器 on (9, 4) facing UP: two steps to it, then 5 more (5 + 2) turned up. */
|
||||
const BENT = [[9, 2], [9, 3], [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4]];
|
||||
|
||||
/** A battle with 谬因 as uid 1 at (9, 2) facing RIGHT (`relay`: her piece as uid 2 at relay.row / col / dir), plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 1, relay = null, others = [], seed = 5, dp = 99 } = {}) {
|
||||
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: APHRIS, skillIndex: skill, uniEquipId: mod }, elite, row: 9, col: 2 }];
|
||||
if (relay) units.push({ uid: 2, kind: 'token', tokenId: RELAY, ownerUid: 1, row: relay.row, col: relay.col, dir: relay.dir });
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy'], captureNoisy: true,
|
||||
units: [...units, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1), r: relay ? h.unit(2) : null };
|
||||
}
|
||||
const UP4 = { row: 9, col: 4, dir: 'UP' };
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes(tag));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('谬因 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (BLA-Y: 部署费用 −8), 5-1 line without a 中继器, 轰击术师, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[APHRIS], KITS[APHRIS]);
|
||||
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], [APHRIS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.cost], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.cost + (m?.attr.cost ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile], ['arts', true, true, 'beam'], `${label(f)}: 轰击术师`);
|
||||
assert.deepEqual(sortN(u.rangeKeys), sortN(OWN.map(([a, b]) => K(a, b))), `${label(f)}: her own 5-1`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: the card shows 5-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [BACKUPS.diy.operators[APHRIS].bonds, [], [RELAY]], `${label(f)}: bonds / 特质 / summon`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1261 / 727, E2 Lv60 1526 / 826, cost 34; BLA-Y −8 cost, +120 / +45 → +200 / +70 HP / ATK
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].stats.cost], [1261, 727, 1526, 826, 34]);
|
||||
assert.deepEqual([modOf(5, BLAY).attr, modOf(6, BLAY).attr], [{ cost: -8, maxHp: 120, atk: 45 }, { cost: -8, maxHp: 200, atk: 70 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 谬因 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(APHRIS));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(APHRIS), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: APHRIS, skillIndex: 1, uniEquipId: BLAY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: APHRIS, skillIndex: 1, uniEquipId: BLAY } } });
|
||||
});
|
||||
|
||||
test('链路协议 range: her 中继器 turns the path to its facing and allows 2 more tiles; one attack strikes every enemy of the bent path (air too), none of the straight line past it; the card shows the bent path', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const { h, u, r } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 1, relay: UP4 });
|
||||
assert.deepEqual([r.alive, r.dir, r.ownerUnit === u, r.def.id], [true, 'UP', true, RELAY], `${label(f)}: the piece`);
|
||||
assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `${label(f)}: bent path`);
|
||||
assert.deepEqual(sortN(u.baseRangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `${label(f)}: the DEFAULT trigger reads it too`);
|
||||
assert.equal(u.liveRangeGrid.length, BENT.length, `${label(f)}: the card`);
|
||||
const before = h.spawn('enemy_dummy', { pos: [9, 3] }), past = h.spawn('enemy_fly', { pos: [12, 4] }), straight = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.runUntil(() => atkHits(h, u).length >= 2, 4);
|
||||
const hits = atkHits(h, u);
|
||||
const first = hits.filter((c) => c.dmg.attackId === hits[0].dmg.attackId);
|
||||
assert.deepEqual(first.map((c) => c.target.id).sort(), [before.id, past.id].sort(), `${label(f)}: the bent path`);
|
||||
assert.ok(!hits.some((c) => c.target === straight), `${label(f)}: not the straight line past the piece`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('the 中继器 (T1 链路协议): ATK +25 % (T6 BLA-Y +35 %) while it stands; it withdraws after 25 s (T6 BLA-Y 35 s) and comes back on its tile 30 s (T6 BLA-Y 20 s) later for 3 DP; untargetable, 无敌, 禁疗, 孤立, blocks nothing', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, r } = field({ tier, elite, mod, skill: 1, relay: UP4 });
|
||||
const y3 = elite && tier === 6 && mod === BLAY;
|
||||
const v = TOKEN.variants[`${APHRIS}@${statusKey(tier, elite)}`];
|
||||
const stats = (mod && v.byModule?.[mod]?.stats) || v.stats;
|
||||
const life = y3 ? 35 : 25, back = y3 ? 20 : 30, atk = y3 ? 0.35 : 0.25;
|
||||
assert.deepEqual([stats.respawnTime, stats.cost, r.base.respawnTime, r.base.blockCnt], [back, 3, back, 0], `${label(f)}: token data`);
|
||||
assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.atk, atk, `${label(f)}: talent`);
|
||||
assert.deepEqual([r.s.flags.untargetable, r.s.flags.invulnerable, r.s.flags.isolated, r.s.flags.noHeal], [true, true, true, true], `${label(f)}: flags`);
|
||||
h.run(0.1);
|
||||
approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: ATK +${atk * 100} %`);
|
||||
h.run(life - 0.3);
|
||||
assert.ok(r.alive, `${label(f)}: still there before ${life} s`);
|
||||
const dp0 = h.b.players[0].dp;
|
||||
h.run(0.4);
|
||||
assert.ok(!r.alive && !r.removed, `${label(f)}: withdrawn after ${life} s, waiting`);
|
||||
h.step();
|
||||
approx(u.s.atk, u.base.atk, `${label(f)}: no bonus without it`);
|
||||
assert.deepEqual(sortN(u.rangeKeys), sortN(OWN.map(([a, b]) => K(a, b))), `${label(f)}: the straight line again`);
|
||||
h.run(back - 0.4);
|
||||
assert.ok(!r.alive, `${label(f)}: not before ${back} s`);
|
||||
h.run(0.6);
|
||||
assert.ok(r.alive && r.tileR === 9 && r.tileC === 4 && r.dir === 'UP', `${label(f)}: back on its tile`);
|
||||
approx(dp0 - h.b.players[0].dp, 3, `${label(f)}: 3 DP`);
|
||||
h.step();
|
||||
approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: the bonus again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 取样优化: every allied operator standing on her path — her own tile too — ignores 10 RES (法术穿透); none off it', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 9, col: 6 }];
|
||||
const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 1, relay: UP4, others });
|
||||
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.magic_resist_penetrate_fixed, 10, label(f));
|
||||
h.run(0.3);
|
||||
assert.deepEqual([u.s.resIgnoreFlat, h.unit(3).s.resIgnoreFlat, h.unit(4).s.resIgnoreFlat], [10, 10, 0], `${label(f)}: herself, the one on the bent path, not the one on the straight line`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 连续映射 (AUTO, 持续时间无限 — the kit\'s SP_FULL: no enemy needed): at full SP (45 / 40); ATK +90 % / +120 %; a 中继器 whose time runs out stays', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, BLAY]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u, r } = field({ tier, elite, mod, skill: 0, relay: UP4 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.bb.atk], ['SP_FULL', 'toggle', elite ? 40 : 45, elite ? 1.2 : 0.9], `T${tier}`);
|
||||
h.run(sk.spCost - 1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before full SP`);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast at full SP, no enemy on the field`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK (no 中继器 bonus: it withdrew at its time)`);
|
||||
// the piece back (cooldown), then its time runs out while 连续映射 runs: it stays
|
||||
h.runUntil(() => r.alive, 40);
|
||||
h.run((elite && tier === 6 ? 35 : 25) + 5);
|
||||
assert.ok(r.alive, `T${tier}: the 中继器 stays`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk + (elite && tier === 6 ? 0.35 : 0.25)), `T${tier}: both bonuses`);
|
||||
h.run(60);
|
||||
assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: infinite`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): no normal attack; from 0.167 s every 0.5 s the beam hits every enemy along an unbounded path — turned by her 中继器 and by any allied operator it reaches — for 90 % / 120 % ATK arts; 16 hits; her range is back after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 4, dir: 'RIGHT' }];
|
||||
const { h, u } = field({ tier, elite, skill: 1, relay: UP4, others });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk_scale, sk.bb.interval, u.skill.spCost], ['DEFAULT', 8, elite ? 1.2 : 0.9, 0.5, elite ? 20 : 22], `T${tier}`);
|
||||
const inPath = h.spawn('enemy_dummy', { pos: [10, 4] }), afterOp = h.spawn('enemy_fly', { pos: [11, 9] });
|
||||
const pastOp = h.spawn('enemy_dummy', { pos: [12, 4] }), straight = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
// the beam: (9,2) → (9,4) 中继器 UP → (11,4) 角峰 RIGHT → the field's edge
|
||||
for (const [a, b] of [[10, 4], [11, 4], [11, 5], [11, 10], [11, 20]]) assert.ok(u.rangeKeySet.has(K(a, b)), `T${tier}: (${a}, ${b}) on the beam`);
|
||||
for (const [a, b] of [[12, 4], [9, 5], [9, 6]]) assert.ok(!u.rangeKeySet.has(K(a, b)), `T${tier}: (${a}, ${b}) off it`);
|
||||
const n0 = atkHits(h, u).length, cast = h.hooksOf('skillStart').at(-1).t;
|
||||
h.runUntil(() => !u.skill.active, 10);
|
||||
const endT = h.hooksOf('skillEnd').at(-1).t;
|
||||
const beam = tagged(h, u, 'aphris:beam');
|
||||
approx(beam[0].t - cast, 0.167, `T${tier}: the first hit after the Begin clip (0.167 s)`, 0.2);
|
||||
approx(beam.filter((c) => c.target === inPath)[1].t - beam[0].t, 0.5, `T${tier}: then every 0.5 s`, 0.07);
|
||||
for (const e of [inPath, afterOp]) {
|
||||
const mine = beam.filter((c) => c.target === e);
|
||||
assert.equal(mine.length, 16, `T${tier}: 16 beam hits on ${e.defId}`);
|
||||
approx(mine[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([mine[0].type, mine[0].dmg.isAttack], ['arts', false], `T${tier}: arts, no normal attack`);
|
||||
}
|
||||
assert.ok(!beam.some((c) => c.target === pastOp || c.target === straight), `T${tier}: nothing off the beam`);
|
||||
assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`);
|
||||
assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `T${tier}: the bent 5-1 path again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 混沌的本质 (MANUAL, data DEFAULT, 20 bullets): 3 s 强制缴械 from the cast, every 中继器 withdrawn and back at once; interval 2.9 → 1.8 s, ATK +95 % / +115 %, 140 % / 155 % + 停顿 0.2 / 0.4 s; past a 中继器 +25 % / +30 % ATK and 停顿 +0.2 / +0.4 s; no 中继器 withdraws until it ends, all do then and come back at once', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, BLAY]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u, r } = field({ tier, elite, mod, skill: 2, relay: UP4 });
|
||||
const atk = elite && tier === 6 ? 0.35 : 0.25;
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.ammo, sk.bb.charge_time, sk.bb.base_attack_time, sk.bb.atk, sk.bb['attack@atk_scale'], sk.bb['attack@origin_sluggish'], sk.bb['attack@extra_atk_scale'], sk.bb['attack@extra_sluggish']],
|
||||
['DEFAULT', 'ammo', 20, 3, -1.1, elite ? 1.15 : 0.95, elite ? 1.55 : 1.4, elite ? 0.4 : 0.2, elite ? 0.3 : 0.25, elite ? 0.4 : 0.2], `T${tier}`);
|
||||
const before = h.spawn('enemy_dummy', { pos: [9, 3] }), past = h.spawn('enemy_dummy', { pos: [11, 4] }), off = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
const t0 = h.hooksOf('skillStart').at(-1).t, n0 = atkHits(h, u).length;
|
||||
assert.ok(h.hooksOf('death').some((c) => c.unit === r && Math.abs(c.t - t0) < 1e-9), `T${tier}: the 中继器 withdrawn at the cast`);
|
||||
assert.ok(h.runUntil(() => r.alive, 0.5), `T${tier}: and back at once (redeploy time cleared)`);
|
||||
h.run(2.8 - (h.b.time - t0));
|
||||
assert.equal(atkHits(h, u).length, n0, `T${tier}: 强制缴械 3 s`);
|
||||
approx(u.s.interval, 1.8, `T${tier}: 1.8 s`);
|
||||
approx(u.s.atk, u.base.atk * (1 + atk + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.runUntil(() => atkHits(h, u).length > n0, 1);
|
||||
const hits = atkHits(h, u).slice(n0);
|
||||
assert.ok(h.b.time - t0 >= 3 - 1e-6, `T${tier}: the first attack after 3 s`);
|
||||
assert.deepEqual(hits.map((c) => c.target.id).sort(), [before.id, past.id].sort(), `T${tier}: the bent path`);
|
||||
for (const c of hits) { approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); assert.equal(c.type, 'arts'); }
|
||||
assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `T${tier}: the bent 5-1 path while it runs`);
|
||||
const extra = tagged(h, u, 'aphris:relay');
|
||||
assert.deepEqual(extra.map((c) => c.target.id), [past.id], `T${tier}: the extra hit past the 中继器 only`);
|
||||
approx(extra[0].amount, u.s.atk * sk.bb['attack@extra_atk_scale'], `T${tier}: +${sk.bb['attack@extra_atk_scale'] * 100} % ATK`);
|
||||
const slow = (e) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && c.target === e).at(-1)?.duration;
|
||||
approx(slow(before), sk.bb['attack@origin_sluggish'], `T${tier}: 停顿 before it`);
|
||||
approx(slow(past), sk.bb['attack@origin_sluggish'] + sk.bb['attack@extra_sluggish'], `T${tier}: 停顿 past it`);
|
||||
assert.ok(!hits.some((c) => c.target === off));
|
||||
// its time runs out during the skill: it stays; all go when the skill ends — and come back at once (redeploy time
|
||||
// cleared again), with their normal time
|
||||
h.runUntil(() => !u.skill.active, 60);
|
||||
assert.equal(atkHits(h, u).filter((c) => c.dmg.isSkill).length, 2 * 20, `T${tier}: 20 bullets (2 targets each)`);
|
||||
const end = h.hooksOf('skillEnd').at(-1).t;
|
||||
assert.ok(end - t0 > (elite && tier === 6 ? 35 : 25), `T${tier}: it outlasted the piece's time`);
|
||||
assert.ok(h.hooksOf('death').some((c) => c.unit === r && Math.abs(c.t - end) < 1e-9), `T${tier}: withdrawn at the end`);
|
||||
assert.ok(h.runUntil(() => r.alive, 0.5), `T${tier}: back at once`);
|
||||
const back = h.b.time;
|
||||
h.run((elite && tier === 6 ? 35 : 25) + 0.5);
|
||||
assert.ok(h.hooksOf('death').some((c) => c.unit === r && c.t > back), `T${tier}: and withdraws at its time again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('BLA-Y “第三相态” (stages 1 and 3): the 部署费用 attribute (34 → 26); stage 3 only: 35 s 中继器, its redeploy time 30 → 20 s (再部署时间变短), ATK +35 %', () => {
|
||||
for (const tier of [5, 6]) {
|
||||
for (const mod of [null, BLAY]) {
|
||||
const { h, u, r } = field({ tier, elite: true, mod, relay: UP4 });
|
||||
assert.equal(u.base.cost, mod ? 26 : 34, `T${tier} ${mod}: cost`);
|
||||
const y3 = tier === 6 && mod === BLAY;
|
||||
assert.deepEqual(r.def.talents.map((t) => t.bb.duration), y3 ? [25, 35] : [25], `T${tier} ${mod}: the piece's talents`);
|
||||
assert.equal(r.base.respawnTime, y3 ? 20 : 30, `T${tier} ${mod}: redeploy time`);
|
||||
if (y3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /35秒.*再部署时间变短.*攻击力\+35%/);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('链路协议: her 中继器 leaves the field with her (die_to_kill_token) and is back on its tile as soon as she is (charge_token[born]), not before', () => {
|
||||
const { h, u, r } = field({ tier: 6, elite: true, mod: BLAY, skill: 1, relay: UP4 });
|
||||
h.run(2);
|
||||
assert.ok(r.alive);
|
||||
const e = h.spawn('enemy_dummy', { pos: [9, 3] });
|
||||
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
|
||||
assert.equal(u.alive, false, 'knocked out');
|
||||
assert.ok(!r.alive && !r.removed, 'the piece withdrawn with her');
|
||||
h.run(40);
|
||||
assert.ok(!r.alive, 'not while she is down (its redeploy time is long over)');
|
||||
h.runUntil(() => u.alive, 60);
|
||||
assert.ok(h.runUntil(() => r.alive, 0.5), 'back with her');
|
||||
assert.ok(r.tileR === 9 && r.tileC === 4 && r.dir === 'UP', 'on its tile');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
// test/content/op_bgsnow.test.js — the 自选 operator kit of 鸿雪 (char_4055_bgsnow, 6★ 重射手; kit
|
||||
// server/sim/content/kits/ops/op-bgsnow.js) and of her summon “打字机” (token_10026_bgsnow_subbow), fielded the production
|
||||
// way (a DIY slot + its `diy` pick, simdata getDiy; the typewriter as the placed hand piece of her player) in every form:
|
||||
// tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, ARC-Y “打字机色带” or
|
||||
// ARC-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_bgsnow.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 SNOW = 'char_4055_bgsnow';
|
||||
const TOK = 'token_10026_bgsnow_subbow';
|
||||
const FORMS = BACKUPS.units[SNOW].forms;
|
||||
const TOKREC = BACKUPS.tokens[TOK];
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const Y = 'uniequip_002_bgsnow', X = 'uniequip_003_bgsnow';
|
||||
const S1 = 'skchr_bgsnow_1', S2 = 'skchr_bgsnow_2', S3 = 'skchr_bgsnow_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);
|
||||
/** The typewriter's variant of a form, with the pick's skill / module (getDiyToken). */
|
||||
const tokOf = (tier, elite, skill, mod) => {
|
||||
let v = TOKREC.variants[`${SNOW}@${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' }) };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, Y, X].map((m) => [t, true, m]))];
|
||||
|
||||
/** 鸿雪 as uid 1 at (10, 5) facing RIGHT; `tok` = the typewriter piece's tile (uid 2) or null. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, tok = null, tokFirst = false, seed = 5, others = [] } = {}) {
|
||||
const op = { uid: 1, diy: { slot: SLOT[tier], charId: SNOW, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 };
|
||||
const piece = tok ? [{ uid: 2, kind: 'token', tokenId: TOK, ownerUid: 1, row: tok[0], col: tok[1] }] : [];
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'dodge'], captureNoisy: true,
|
||||
units: tokFirst ? [...piece, op, ...others] : [op, ...piece, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1), t: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === TOK) ?? null };
|
||||
}
|
||||
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
test('鸿雪 in every 自选 form: her kit, the form\'s stats + module attributes, 3-6, ranged physical, hits air, blocks 1, ground-targetable, the data triggers; offered as a pick', () => {
|
||||
assert.equal(OPERATOR_KITS[SNOW], KITS[SNOW]);
|
||||
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], [SNOW, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.respawnTime],
|
||||
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.respawnTime + (m?.attr.respawnTime ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'phys', 1.6], `${label(f)}: 重射手`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`);
|
||||
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', 'SKILL_RANGE', 'ACTIVE_RANGE']);
|
||||
assert.deepEqual([modOf(5, Y).attr, modOf(6, Y).attr, modOf(5, X).attr, modOf(6, X).attr],
|
||||
[{ maxHp: 130, atk: 55 }, { maxHp: 170, atk: 75 }, { atk: 80, def: 10, respawnTime: -25 }, { atk: 120, def: 16, respawnTime: -25 }]);
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(SNOW) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(SNOW)));
|
||||
assert.equal(validateDiyPicks({ [SLOT[5]]: { charId: SNOW, skillIndex: 1, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true);
|
||||
});
|
||||
|
||||
test('S1 抑扬格 (AUTO, attack SP 29 / 25, data DEFAULT): after that many attacks ATK +35 % / +50 % until she leaves the field, every attack meanwhile 30 % to deal ×145 % / ×160 %', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
assert.deepEqual([sk.spCost, sk.bb.atk, sk.bb.prob, sk.bb.atk_scale], elite ? [25, 0.5, 0.3, 1.6] : [29, 0.35, 0.3, 1.45], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 0, seed: 12 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spType], ['toggle', 'DEFAULT', 'attack'], `T${tier}`);
|
||||
h.spawn('enemy_dummy', { pos: [11, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 60), `T${tier}: cast`);
|
||||
const before = from(h, u);
|
||||
assert.equal(before.length, sk.spCost, `T${tier}: after ${sk.spCost} attacks`);
|
||||
assert.ok(before.every((c) => Math.abs(c.amount - u.base.atk) < 1e-6), `T${tier}: plain before it`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.run(500);
|
||||
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
|
||||
const after = from(h, u).slice(before.length);
|
||||
const atk = u.base.atk * (1 + sk.bb.atk);
|
||||
for (const c of after) assert.ok([atk, atk * sk.bb.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: ${c.amount}`);
|
||||
const r = after.filter((c) => c.amount > atk + 1e-6).length / after.length;
|
||||
assert.ok(Math.abs(r - sk.bb.prob) < 0.06, `T${tier}: ${r}`);
|
||||
h.b.retreat(u);
|
||||
assert.ok(!u.skill.active, `T${tier}: ends when she leaves`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 点题 (MANUAL, 2 charges, data SKILL_RANGE on 3-1): an enemy 3 tiles ahead (3-1, not 3-6) casts it and is struck at once: atk_scale × ATK as a 普通伤害 attack, then twice as 持续伤害 (not dodgeable); her card range stays 3-6', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
assert.deepEqual([sk.bb.atk_scale, sk.bb.respawn_time, sk.maxChargeTime, sk.spCost, sk.initSp], elite ? [2, 0.6, 2, 10, 6] : [1.8, 0.65, 2, 11, 3], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 1, seed: 3 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.maxCharges], ['charges', 'SKILL_RANGE', 2], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast for the enemy on 3-1`);
|
||||
const tc = h.b.time;
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: the card keeps 3-6`);
|
||||
h.run(0.5);
|
||||
const atk = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
approx(atk[0].t, tc, `T${tier}: 立即`, 0.04);
|
||||
assert.equal(atk[0].isSkill, true);
|
||||
const dodged = h.hooksOf('dodge').filter((c) => c.source === u);
|
||||
const hits = from(h, u);
|
||||
assert.equal(dodged.length, 1, `T${tier}: the 普通伤害 hit can be dodged (a dodge enemy)`);
|
||||
assert.equal(hits.length, 2, `T${tier}: the two 持续伤害 hits cannot`);
|
||||
for (const c of hits) {
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ×${sk.bb.atk_scale}`);
|
||||
assert.deepEqual([c.dmg.isAttack, c.dmg.canDodge, c.dmg.tags.includes('dot'), c.type], [false, false, true, 'phys']);
|
||||
}
|
||||
assert.equal(u.skill.charges, 1, `T${tier}: one of two charges spent`);
|
||||
h.b.removeBuff(e, 'test:dodge');
|
||||
const n0 = from(h, u).length;
|
||||
h.run(3.5);
|
||||
const second = from(h, u).slice(n0).filter((c) => c.dmg.isSkill);
|
||||
assert.deepEqual(second.map((c) => [c.dmg.isAttack, c.dmg.tags.includes('dot')]), [[true, false], [false, true], [false, true]], `T${tier}: the second charge 3 s later: 普通 then 2 × 持续`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 锐笔速写 (MANUAL, data ACTIVE_RANGE on 3-1): an enemy only on [0,3] casts it; range 3-1 (and back), interval 1.6 − 0.6 s, ×155 % / 170 % off her line, ×185 % / 215 % on the 正前方3格', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
assert.deepEqual([sk.duration, sk.bb.base_attack_time, sk.bb.atk_scale, sk.bb['bgsnow_s_3[atk_up].atk_scale'], sk.rangeId], elite ? [27, -0.6, 1.7, 2.15, '3-1'] : [26, -0.6, 1.55, 1.85, '3-1'], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule], ['duration', 'ACTIVE_RANGE']);
|
||||
u.skill.gainSp(999);
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for [0,3] (outside 3-6)`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-1 while it runs`);
|
||||
approx(u.s.interval, 1.0, `T${tier}: interval`);
|
||||
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
h.b.kill(far, null);
|
||||
const front = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(8);
|
||||
const onFront = from(h, u).filter((c) => c.dmg.isAttack && c.target === front);
|
||||
h.b.kill(front, null);
|
||||
h.run(4);
|
||||
const onSide2 = from(h, u).filter((c) => c.dmg.isAttack && c.target === side);
|
||||
assert.ok(onFront.length > 3 && onSide2.length > 1, `T${tier}: both struck (${onFront.length} / ${onSide2.length})`);
|
||||
for (const c of onFront) approx(c.amount, u.base.atk * sk.bb['bgsnow_s_3[atk_up].atk_scale'], `T${tier}: 正前方3格`);
|
||||
for (const c of onSide2) approx(c.amount, u.base.atk * sk.bb.atk_scale, `T${tier}: off the line`);
|
||||
u.skill.extend(-999);
|
||||
h.step(2);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-6`);
|
||||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('ARC-Y 打字机色带 trait: ×105 % and no dodge on her straight line ahead (stages 1 and 3) — 点题\'s 持续伤害 hits too; off the line it dodges; ARC-X / no module nothing', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y = elite && mod === Y;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 3 });
|
||||
u.skill.charges = 0; u.skill.sp = 0;
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } });
|
||||
const front = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
for (const e of [front, side]) h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
|
||||
h.run(12);
|
||||
const onFront = from(h, u).filter((c) => c.target === front);
|
||||
if (y) {
|
||||
assert.ok(onFront.length > 4, `${label(f)}: the front enemy is hit`);
|
||||
for (const c of onFront) approx(c.amount, u.s.atk * 1.05, `${label(f)}: ×1.05`);
|
||||
// 点题 on the front enemy: all three hits ×1.05
|
||||
h.b.removeBuff(u, 'test:noSp');
|
||||
u.skill.addCharge(1);
|
||||
const n0 = from(h, u).length;
|
||||
h.run(0.5);
|
||||
const s2 = from(h, u).slice(n0).filter((c) => c.dmg.isSkill);
|
||||
assert.equal(s2.length, 3, `${label(f)}: 点题's three hits land`);
|
||||
for (const c of s2) approx(c.amount, u.s.atk * skillOf(tier, true, S2).bb.atk_scale * 1.05, `${label(f)}: 点题 ×1.05`);
|
||||
h.b.kill(front, null);
|
||||
h.run(6);
|
||||
assert.ok(h.hooksOf('dodge').filter((c) => c.source === u && c.target === side).length > 2, `${label(f)}: off the line it dodges`);
|
||||
} else {
|
||||
assert.equal(onFront.length, 0, `${label(f)}: every attack dodged`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('“打字机”: her placed piece runs its kit (not the generic one) — the variant\'s stats (ARC-Y stage 3 stronger), untargetable, 3-2, blocks 0, hits air; deploys with the board, leaves after 25 s, back on its tile after 40 s (S2: ×65 % / ×60 %) for 5 DP, never while 鸿雪 is down', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
for (const skill of [0, 1, 2]) {
|
||||
const v = tokOf(tier, elite, skill, elite ? mod : null);
|
||||
const { h, t } = field({ tier, elite, mod, skill, tok: [12, 3], tokFirst: skill === 1 });
|
||||
assert.ok(t && t.alive && t.deployed, `${label(f)} S${skill + 1}: deployed with the board`);
|
||||
assert.deepEqual([!!t.kit.generic, t.skill.id, t.base.atk, t.base.maxHp, t.base.respawnTime, t.base.cost], [false, v.skill.skillId, v.stats.atk, v.stats.maxHp, 40, 5], `${label(f)} S${skill + 1}`);
|
||||
assert.deepEqual([!!t.s.flags.untargetable, t.s.blockCnt, t.profile.canHitFly, t.profile.attack, t.liveRangeGrid], [true, 0, true, 'ranged', [[0, 0], [0, 1], [0, 2], [0, 3]]], `${label(f)} S${skill + 1}: 不会受到攻击`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([tokOf(5, false, 0).stats.atk, tokOf(5, true, 0).stats.atk, tokOf(6, true, 0, Y).stats.atk, tokOf(5, true, 0, Y).stats.atk], [666, 799, 869, 799]);
|
||||
// life and redeploys (S1: 40 s; S2: ×respawn_time)
|
||||
for (const [tier, elite, skill, mul] of [[5, false, 0, 1], [5, false, 1, 0.65], [6, true, 1, 0.6]]) {
|
||||
const { h, u, t } = field({ tier, elite, skill, tok: [12, 3] });
|
||||
h.b.players[0].dp = 20;
|
||||
assert.ok(h.runUntil(() => !t.alive, 26));
|
||||
approx(h.b.time, 25, `T${tier} S${skill + 1}: 25 s`, 0.05);
|
||||
assert.equal(h.hooksOf('death').find((c) => c.unit === t).reason, 'expired');
|
||||
assert.ok(h.runUntil(() => t.alive, 60));
|
||||
approx(h.b.time, 25 + 40 * mul, `T${tier} S${skill + 1}: back after ${40 * mul} s`, 0.3);
|
||||
assert.deepEqual([t.tileR, t.tileC, h.b.players[0].dp], [12, 3, 15], `T${tier} S${skill + 1}: on its tile, 5 DP`);
|
||||
assert.ok(u.alive);
|
||||
done(h);
|
||||
}
|
||||
// 鸿雪 down: the typewriter waits for her
|
||||
const { h, u, t } = field({ tier: 5, elite: false, skill: 0, tok: [12, 3] });
|
||||
h.b.players[0].dp = 0;
|
||||
h.run(10);
|
||||
h.b.kill(u, null);
|
||||
h.run(60);
|
||||
assert.ok(!t.alive, 'not while 鸿雪 is down');
|
||||
h.b.players[0].dp = 25;
|
||||
assert.ok(h.runUntil(() => u.alive, 30), '鸿雪 back');
|
||||
assert.ok(h.runUntil(() => t.alive, 1), 'then the typewriter');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('“打字机”\'s skill is the copy of hers, free once per deployment: S1 ATK +35 % / +50 % for good with the 30 % proc, S2 three hits of ×180 % / ×200 % (普通 + 2 × 持续), S3 4-1 + interval −0.6 s + ×185 % / ×215 % for 16 / 17 s', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
for (const skill of [0, 1, 2]) {
|
||||
const v = tokOf(tier, elite, skill, null);
|
||||
const tb = v.skill.bb;
|
||||
const { h, t } = field({ tier, elite, skill, tok: [11, 5], seed: 4 });
|
||||
h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
assert.deepEqual([t.skill.rule, t.skill.spCost, t.skill.charges], ['DEFAULT', 0, 1], `T${tier} S${skill + 1}: free, ready`);
|
||||
assert.ok(h.runUntil(() => t.skill.activations === 1, 1), `T${tier} S${skill + 1}: cast at its first attack`);
|
||||
if (skill === 0) {
|
||||
approx(t.s.atk, t.base.atk * (1 + tb.atk), `T${tier}: S1 copy ATK`);
|
||||
h.run(20);
|
||||
assert.ok(t.skill.active, `T${tier}: S1 copy stays`);
|
||||
const hits = from(h, t);
|
||||
const atk = t.base.atk * (1 + tb.atk);
|
||||
assert.ok(hits.some((c) => Math.abs(c.amount - atk * tb.atk_scale) < 1e-6) && hits.some((c) => Math.abs(c.amount - atk) < 1e-6), `T${tier}: S1 copy procs ×${tb.atk_scale}`);
|
||||
} else if (skill === 1) {
|
||||
h.run(0.5);
|
||||
const s2 = from(h, t).filter((c) => c.dmg.isSkill);
|
||||
assert.deepEqual(s2.map((c) => [c.dmg.isAttack, c.dmg.tags.includes('dot')]), [[true, false], [false, true], [false, true]], `T${tier}: S2 copy`);
|
||||
for (const c of s2) approx(c.amount, t.base.atk * tb.atk_scale, `T${tier}: S2 copy ×${tb.atk_scale}`);
|
||||
} else {
|
||||
assert.deepEqual(t.liveRangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]], `T${tier}: S3 copy 4-1`);
|
||||
approx(t.s.interval, 1.0, `T${tier}: S3 copy interval`);
|
||||
approx(t.skill.timeLeft, v.skill.duration, `T${tier}: ${v.skill.duration} s`, 0.05);
|
||||
h.run(3);
|
||||
for (const c of from(h, t).filter((x) => x.dmg.isSkill)) approx(c.amount, t.base.atk * tb['attack@atk_scale'], `T${tier}: S3 copy ×${tb['attack@atk_scale']}`);
|
||||
}
|
||||
h.run(24);
|
||||
assert.equal(t.skill.activations, 1, `T${tier} S${skill + 1}: once per deployment`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('弱点速记: every damage of the typewriter cuts the target\'s DEF 18 % for 4 s, 23 % on one of the 4 tiles next to 鸿雪 (not diagonal); ARC-X stage 3: 28 % / 33 % and the 8 tiles; 鸿雪 down ⇒ the base cut', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const x3 = tier === 6 && elite && mod === X;
|
||||
const tal = tokOf(tier, elite, 2, elite ? mod : null).talents.find((x) => x && x.bb.duration);
|
||||
const c1 = -tal.bb['bgsnow_token[def_down]_1.def'], c2 = -tal.bb['bgsnow_token[def_down]_2.def'];
|
||||
assert.deepEqual([c1, c2, tal.bb.duration], x3 ? [0.28, 0.33, 4] : [0.18, 0.23, 4], label(f));
|
||||
for (const [tile, near] of [[[11, 5], true], [[11, 4], x3], [[12, 3], false]]) {
|
||||
const { h, u, t } = field({ tier, elite, mod, skill: 0, tok: tile, seed: 2 });
|
||||
h.spawn('enemy_dummy', { pos: [tile[0], tile[1] + 2] });
|
||||
h.run(4);
|
||||
const cuts = h.hooksOf('statusApplied').filter((c) => c.source === t && c.status === 'defDown');
|
||||
assert.ok(cuts.length >= 2, `${label(f)} ${tile}: cuts`);
|
||||
for (const c of cuts) assert.deepEqual([c.value, c.duration], [near ? c2 : c1, 4], `${label(f)} ${tile}: ${near ? 'near' : 'apart'}`);
|
||||
if (near) {
|
||||
h.b.kill(u, null);
|
||||
h.run(3);
|
||||
const after = h.hooksOf('statusApplied').filter((c) => c.source === t && c.status === 'defDown').slice(cuts.length);
|
||||
assert.ok(after.length && after.every((c) => c.value === c1), `${label(f)}: 鸿雪 down ⇒ ${c1}`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// test/content/op_cerber.test.js — the 自选 operator kit of 刻俄柏 (char_2013_cerber, 6★ 中坚术师; kit
|
||||
// server/sim/content/kits/ops/op-cerber.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-X
|
||||
// “很干的面包” or CCR-Y “我打的刀” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
|
||||
// form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_cerber.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 ID = 'char_2013_cerber';
|
||||
const FORMS = BACKUPS.units[ID].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const CCRX = 'uniequip_002_cerber', CCRY = 'uniequip_003_cerber';
|
||||
const S1 = 'skchr_cerber_1', S2 = 'skchr_cerber_2', S3 = 'skchr_cerber_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_d100: dummy('enemy_d100', { def: 100 }), enemy_d200: dummy('enemy_d200', { def: 200 }), enemy_d300: dummy('enemy_d300', { def: 300 }),
|
||||
enemy_d400: dummy('enemy_d400', { def: 400 }), enemy_r50: dummy('enemy_r50', { res: 50 }),
|
||||
enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRX, CCRY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const SHELL = 'cerber:shell';
|
||||
const shellOf = (c) => !!c.dmg?.tags?.includes(SHELL);
|
||||
|
||||
/** A battle with 刻俄柏 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, setup = null } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
setup,
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const arts = (amount, res, ignore = 0) => Math.max(amount * (1 - Math.max(0, res - ignore) / 100), amount * 0.05);
|
||||
|
||||
test('刻俄柏 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-1 range, ranged arts that hits air, block 1, 1.6 s, 空 bonds, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
|
||||
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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 20], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.base.bat], [1, 'ranged', 'arts', true, false, 1.6], `${label(f)}: 中坚术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.tokens], [['emptyShip'], [], []], `${label(f)}: bonds / 特质 / no summon`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1220 / 553 / 114, E2 Lv60 1449 / 629 / 123; CCR-X +130 / +40 → +180 / +65, CCR-Y +80 / +40 / +17 → +120 / +56 / +23
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1220, 553, 1449, 629]);
|
||||
assert.deepEqual([modOf(5, CCRX).attr, modOf(6, CCRX).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 130, atk: 40 }, { maxHp: 180, atk: 65 }, { maxHp: 80, atk: 40, def: 17 }, { maxHp: 120, atk: 56, def: 23 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 刻俄柏 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(ID));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: ID, skillIndex: 0, uniEquipId: CCRX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: ID, skillIndex: 0, uniEquipId: CCRX } } });
|
||||
});
|
||||
|
||||
test('S1 “很冰的斧” (AUTO, 1 / 2 charges, data DEFAULT): the next attack at 165 % / 180 % ATK arts on an unblocked enemy rather than a blocked one, binding it 2 / 2.5 s', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
// 角峰 blocks the dummy spawned on its tile; the other dummy is free (both in her range; the blocked one is nearer its goal)
|
||||
const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 7 }] });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, sk.spCost, sk.bb.atk_scale, sk.bb.duration], ['DEFAULT', elite ? 'charges' : 'instant', elite ? 2 : 1, 7, elite ? 1.8 : 1.65, elite ? 2.5 : 2], `T${tier}`);
|
||||
u.skill.charges = 0;
|
||||
u.skill.sp = 0;
|
||||
const blocked = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const free = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
h.run(0.2);
|
||||
assert.equal(blocked.blockedBy, h.unit(2), `T${tier}: 角峰 blocks it`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(1.7);
|
||||
const plain = atkHits(h, u).slice(n0);
|
||||
assert.ok(plain.length >= 1 && plain.every((c) => c.target === blocked && !c.dmg.isSkill), `T${tier}: her plain attacks go to the blocked one`);
|
||||
u.skill.gainSp(999);
|
||||
const n1 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).slice(n1).some((c) => c.dmg.isSkill), 3), `T${tier}: cast`);
|
||||
const sh = atkHits(h, u).slice(n1).find((c) => c.dmg.isSkill);
|
||||
assert.equal(sh.target, free, `T${tier}: "优先攻击没有被阻挡的目标"`);
|
||||
approx(sh.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.equal(sh.type, 'arts');
|
||||
const bind = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'bind');
|
||||
assert.ok(bind && bind.target === free, `T${tier}: bound`);
|
||||
approx(bind.duration, sk.bb.duration, `T${tier}: 束缚 ${sk.bb.duration} s`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 “很热的刀” (MANUAL, data DEFAULT): 33 / 36 s, attack interval × 0.6 / × 0.4 (PRTS "(*0.4)"), the highest-DEF enemy first; all back after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.base_attack_time, sk.spCost, sk.initSp], ['DEFAULT', elite ? 36 : 33, elite ? 0.4 : 0.6, elite ? 44 : 47, elite ? 16 : 13], `T${tier}`);
|
||||
h.run(0.6);
|
||||
const iv0 = u.s.interval;
|
||||
const d1 = h.spawn('enemy_d100', { pos: [10, 6] }), d3 = h.spawn('enemy_d300', { pos: [11, 7] }), d2 = h.spawn('enemy_d200', { pos: [9, 6] });
|
||||
void d1; void d2;
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.05);
|
||||
approx(u.s.interval, iv0 * sk.bb.base_attack_time, `T${tier}: interval × ${sk.bb.base_attack_time}`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(5);
|
||||
const hits = atkHits(h, u).slice(n0);
|
||||
assert.ok(hits.length >= Math.floor(5 / u.s.interval) - 1, `T${tier}: ${hits.length} attacks`);
|
||||
assert.ok(hits.every((c) => c.target === d3), `T${tier}: the DEF 300 enemy`);
|
||||
assert.ok(h.runUntil(() => !u.skill.active, sk.duration));
|
||||
approx(u.s.interval, iv0, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 “很重的枪” (MANUAL, data ACTIVE_RANGE on 3-3): 56 / 57 s, range 3-3 while on, ATK +130 % / +160 %, physical, the lowest-DEF enemy first, each hit silences 2.5 / 3 s; arts and 3-1 again after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
|
||||
assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@silence'], sk.initSp], ['ACTIVE_RANGE', 'DEFAULT', '3-3', elite ? 57 : 56, elite ? 1.6 : 1.3, elite ? 3 : 2.5, elite ? 47 : 41], `T${tier}`);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is 3-3`);
|
||||
h.run(0.6);
|
||||
const atk0 = u.s.atk;
|
||||
u.skill.gainSp(999);
|
||||
// (9,8) / (11,8) are on 3-3, not on her 3-1: the cast comes with an enemy there
|
||||
const hi = h.spawn('enemy_d300', { pos: [9, 8] });
|
||||
const lo = h.spawn('enemy_d100', { pos: [11, 8] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with enemies on 3-3 only`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while on`);
|
||||
approx(u.s.atk, atk0 + u.base.atk * sk.bb.atk, `T${tier}: ATK`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(5);
|
||||
const hits = atkHits(h, u).slice(n0);
|
||||
assert.ok(hits.length >= 2 && hits.every((c) => c.target === lo && c.type === 'phys'), `T${tier}: physical, on the lowest DEF`);
|
||||
approx(hits[0].amount, Math.max(u.s.atk - 100, u.s.atk * 0.05), `T${tier}: physical on DEF 100`);
|
||||
const sil = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'silence');
|
||||
assert.ok(sil.length >= hits.length && sil.every((c) => c.target === lo), `T${tier}: silenced on every hit`);
|
||||
approx(sil[0].duration, sk.bb['attack@silence'], `T${tier}: ${sk.bb['attack@silence']} s`);
|
||||
// 剥壳 stays arts: DEF 100 × 40 %
|
||||
const shells = hitsBy(h, u).slice(n0).filter(shellOf);
|
||||
assert.ok(shells.length >= hits.length - 1 && shells.every((c) => c.type === 'arts'), `T${tier}: 剥壳 arts`);
|
||||
u.skill.end('test');
|
||||
h.run(0.1);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
void hi;
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 剥壳: each hit of her attacks adds DEF × 40 % arts (none on DEF 0); CCR-X stage 3: 50 % +5 % per hit on the same target up to 75 %, back to 50 % on another; stage 1 keeps 40 %', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
const x3 = elite && tier === 6 && mod === CCRX;
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0);
|
||||
assert.equal(t0.bb.atk_scale, x3 ? 0.5 : 0.4, `${label(f)}: the data`);
|
||||
u.skill.charges = 0;
|
||||
const a = h.spawn('enemy_d400', { pos: [10, 6] });
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(1.6 * 8 + 0.4);
|
||||
const shells = hitsBy(h, u).slice(n0).filter((c) => shellOf(c) && c.target === a);
|
||||
const plain = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isAttack && c.target === a);
|
||||
assert.ok(plain.length >= 7 && shells.length === plain.length, `${label(f)}: one 剥壳 per hit (${shells.length} / ${plain.length})`);
|
||||
const want = x3 ? [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.75] : Array(7).fill(0.4);
|
||||
shells.slice(0, 7).forEach((c, i) => {
|
||||
approx(c.amount, 400 * want[i], `${label(f)}: hit ${i + 1}`);
|
||||
assert.deepEqual([c.type, !!c.dmg.isAttack], ['arts', false], `${label(f)}: a talent arts instance`);
|
||||
});
|
||||
// another target: back to the base ratio; a DEF-0 target takes no 剥壳
|
||||
h.b.kill(a, null);
|
||||
const b = h.spawn('enemy_d200', { pos: [10, 6] });
|
||||
const n1 = hitsBy(h, u).length;
|
||||
h.run(1.7);
|
||||
const sb = hitsBy(h, u).slice(n1).filter((c) => shellOf(c) && c.target === b);
|
||||
assert.ok(sb.length >= 1);
|
||||
approx(sb[0].amount, 200 * (x3 ? 0.5 : 0.4), `${label(f)}: a new target — the base ratio`);
|
||||
h.b.kill(b, null);
|
||||
const z = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const n2 = hitsBy(h, u).length;
|
||||
h.run(1.7);
|
||||
assert.ok(hitsBy(h, u).slice(n2).some((c) => c.target === z && c.dmg.isAttack), label(f));
|
||||
assert.ok(!hitsBy(h, u).slice(n2).some((c) => c.target === z && shellOf(c)), `${label(f)}: no 剥壳 on DEF 0`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 独行长路: ATK +8 % / ASPD +8 while nobody else is on her x-5 (a device too), off with a neighbour; an enemy-like ally does not switch it off but, seen while it is off, keeps it off until it leaves; CCR-Y stage 3: +15 % / +15 decided once per deployment', () => {
|
||||
const lone = (u) => u.findBuff('talent:cerber:lone');
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y3 = elite && tier === 6 && mod === CCRY;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
h.step();
|
||||
const v = y3 ? { atkPct: 0.15, aspd: 15 } : { atkPct: 0.08, aspd: 8 };
|
||||
assert.deepEqual(lone(u)?.mods, v, `${label(f)}: alone`);
|
||||
const dev = h.b.spawnDevice('trap_1105_accrate', 10, 6, { name: 'test crate' });
|
||||
assert.ok(dev && dev.side === 'ally', label(f));
|
||||
h.step();
|
||||
assert.equal(!!lone(u), y3, `${label(f)}: a crate in front of her ${y3 ? '(the deployment decided)' : ''}`);
|
||||
h.b.kill(dev, null);
|
||||
h.step();
|
||||
assert.deepEqual(lone(u)?.mods, v, `${label(f)}: alone again`);
|
||||
if (!y3) {
|
||||
// an enemy-like ally (炎佑's kind: an ally built from an enemy record): stays on while on …
|
||||
const like = h.b.spawnDevice('trap_1105_accrate', 9, 5, { name: 'enemy-like' });
|
||||
like.defId = 'enemy_9012_acloon';
|
||||
h.step();
|
||||
assert.ok(lone(u), `${label(f)}: an enemy-like ally does not switch it off`);
|
||||
// … but seen while off (an ordinary neighbour came first) it keeps it off until it leaves the field
|
||||
const mate = h.b.spawnDevice('trap_1105_accrate', 11, 5, { name: 'mate' });
|
||||
h.step();
|
||||
assert.equal(lone(u), null, `${label(f)}: off with an ordinary neighbour`);
|
||||
h.b.kill(mate, null);
|
||||
h.step();
|
||||
assert.equal(lone(u), null, `${label(f)}: the enemy-like ally seen while off keeps it off`);
|
||||
h.b.relocate(like, 12, 9);
|
||||
h.step();
|
||||
assert.equal(lone(u), null, `${label(f)}: even away from her`);
|
||||
h.b.kill(like, null);
|
||||
h.step();
|
||||
assert.ok(lone(u), `${label(f)}: on once it left the field`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
// CCR-Y stage 3 with a neighbour at the deployment: none for that deployment, then a lone redeploy gets it
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: CCRY, skill: 1, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 5 }] });
|
||||
h.run(0.5);
|
||||
assert.equal(lone(u), null, 'a neighbour at the battle-start deployment');
|
||||
h.b.retreat(h.unit(2));
|
||||
h.run(0.5);
|
||||
assert.equal(lone(u), null, 'still none: decided at the deployment');
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u);
|
||||
h.step();
|
||||
assert.deepEqual(lone(u)?.mods, { atkPct: 0.15, aspd: 15 }, 'a lone redeploy');
|
||||
done(h);
|
||||
const t1 = formOf(6, true).modules.find((m) => m.uniEquipId === CCRY).talentChanges.find((t) => t.talentIndex === 1);
|
||||
assert.match(t1.desc, /^部署时若周围四格内没有其他友方单位/);
|
||||
});
|
||||
|
||||
test('modules: CCR-X "无视目标10点法术抗性" at stages 1 and 3 (× 0.6 on RES 50); CCR-Y "普通攻击命中精英或领袖敌人时获得1点技力" — not on a normal enemy, not from 剥壳', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const x = elite && mod === CCRX, y = elite && mod === CCRY;
|
||||
assert.equal(u.s.resIgnoreFlat, x ? 10 : 0, `${label(f)}: RES ignore`);
|
||||
if (y) assert.deepEqual(u.def.raw.trait.bb, { sp: 1 });
|
||||
const foe = h.spawn('enemy_r50', { pos: [10, 6] });
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(3.5);
|
||||
const hits = hitsBy(h, u).slice(n0).filter((c) => c.target === foe && c.dmg.isAttack);
|
||||
assert.ok(hits.length >= 2, label(f));
|
||||
approx(hits[0].amount, arts(u.s.atk, 50, x ? 10 : 0), `${label(f)}: arts on RES 50`);
|
||||
const traitSp = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
|
||||
assert.equal(traitSp(), 0, `${label(f)}: no SP from a normal enemy`);
|
||||
h.b.kill(foe, null);
|
||||
for (const key of ['enemy_elite', 'enemy_boss']) {
|
||||
const el = h.spawn(key, { pos: [10, 6] });
|
||||
const k0 = traitSp(), a0 = atkHits(h, u).filter((c) => c.target === el).length;
|
||||
h.run(3.5);
|
||||
const hitsE = atkHits(h, u).filter((c) => c.target === el).length - a0;
|
||||
assert.ok(hitsE >= 2, label(f));
|
||||
assert.equal(traitSp() - k0, y ? hitsE : 0, `${label(f)}: +1 SP per hit on an ${key}`);
|
||||
h.b.kill(el, null);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
// test/content/op_cgbird.test.js — the 自选 operator kit of 夜莺 (char_179_cgbird, 6★ 群愈师; kit
|
||||
// server/sim/content/kits/ops/op-cgbird.js) and her summon 幻影 (token_10003_cgbird_bird), fielded the production way (a DIY
|
||||
// slot + its `diy` pick, simdata getDiy; the 幻影 as a board token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1,
|
||||
// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, RIN-X “封闭的希望” or RIN-Y “莺歌” at stage 1
|
||||
// (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity checklist of kits/README.md.
|
||||
// Run: node --test test/content/op_cgbird.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
|
||||
import { diyPool, validateDiyPicks, diyRecord } from '../../shared/diy.js';
|
||||
import { attackRangeGrid } from '../../shared/loadoutRecord.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 CGBIRD = 'char_179_cgbird';
|
||||
const PHANTOM = 'token_10003_cgbird_bird';
|
||||
const FORMS = BACKUPS.units[CGBIRD].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const RINX = 'uniequip_002_cgbird', RINY = 'uniequip_003_cgbird';
|
||||
const S1 = 'skcom_heal_up[3]', S2 = 'skchr_cgbird_2', S3 = 'skchr_cgbird_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 tokenVariant = (tier, elite, mod) => {
|
||||
const v = BACKUPS.tokens[PHANTOM].variants[`${CGBIRD}@${statusOf(tier, elite)}`];
|
||||
return mod && v.byModule?.[mod] ? { ...v, ...v.byModule[mod] } : 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 }) };
|
||||
const ALLIES = { test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, res: 10, atk: 0 }, skill: null }) };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, RINX, RINY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/**
|
||||
* 夜莺 as uid 1 on the 高台 tile (11,2) facing RIGHT — her y-2: rows 10–12, cols 1–4; RIN-X adds (11,5); 圣域's y-4 adds
|
||||
* (11,5) and (9,1)–(9,3) —, allies (melee, no skill) at (11,3) uid 2, (10,3) uid 3, (12,3) uid 4, (10,4) uid 5 (all in
|
||||
* y-2), (11,5) uid 6 (RIN-X / y-4 only), (9,3) uid 7 (y-4 only), (11,8) uid 8 (outside); `phantom` = a board 幻影 of hers.
|
||||
*/
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, phantom = null, dp = 0 } = {}) {
|
||||
const ally = (uid, row, col) => ({ uid, chessId: 'test_melee_a', row, col });
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'heal', 'attack', 'skillStart', 'skillEnd', 'death', 'deploy'], captureNoisy: true,
|
||||
units: [
|
||||
{ uid: 1, diy: { slot: SLOT[tier], charId: CGBIRD, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
|
||||
ally(2, 11, 3), ally(3, 10, 3), ally(4, 12, 3), ally(5, 10, 4), ally(6, 11, 5), ally(7, 9, 3), ally(8, 11, 8),
|
||||
...(phantom ? [{ uid: 9, kind: 'token', tokenId: PHANTOM, ownerUid: 1, row: phantom[0], col: phantom[1] }] : []),
|
||||
],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
return { h, u, a: (uid) => h.unit(uid), bird: phantom ? h.b.allyUnits.find((t) => t.kind === 'token' && t.uid === 9) : null };
|
||||
}
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const rangeOf = (tier, elite, mod) => {
|
||||
const rec = diyRecord(SLOT[tier], { charId: CGBIRD, skillIndex: 0, uniEquipId: mod }, { elite, data: { chess: CHESS, backups: BACKUPS } });
|
||||
return attackRangeGrid(rec);
|
||||
};
|
||||
|
||||
test('夜莺 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-2 (RIN-X: its grid), 3 heals (RIN-Y: 4), no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[CGBIRD], KITS[CGBIRD]);
|
||||
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], [CGBIRD, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.aspd],
|
||||
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.heal?.count, u.profile.attack], ['heal', 'multi', elite && mod === RINY ? 4 : 3, 'ranged'], `${label(f)}: 群愈师`);
|
||||
const rinx = elite && mod === RINX;
|
||||
const mg = rinx ? modOf(tier, RINX).talentChanges.find((t) => t.talentIndex === -1).rangeGrid : form.rangeGrid;
|
||||
assert.deepEqual(u.liveRangeGrid, mg, `${label(f)}: range`);
|
||||
assert.deepEqual(u.liveRangeGrid, rangeOf(tier, elite, mod), `${label(f)}: the range the card draws (attackRangeGrid)`);
|
||||
assert.equal(mg.length, rinx ? 13 : 12, `${label(f)}: y-2 (+ [0,3])`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([modOf(5, RINX).attr, modOf(6, RINX).attr, modOf(5, RINY).attr, modOf(6, RINY).attr], [{ atk: 40, res: 5 }, { atk: 65, res: 5 }, { maxHp: 160, aspd: 5 }, { maxHp: 220, aspd: 7 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 夜莺 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(CGBIRD));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CGBIRD), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } } });
|
||||
});
|
||||
|
||||
test('群愈师 heals 3 allies at once; RIN-Y “莺歌” 4 (stages 1 and 3)', () => {
|
||||
for (const f of [[5, false, null], [5, true, RINY], [6, true, RINY], [6, true, RINX]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
|
||||
for (const uid of [2, 3, 4, 5]) a(uid).hp = 1000 + uid;
|
||||
const n0 = h.hooksOf('attack').length;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 6), label(f));
|
||||
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
|
||||
assert.equal(new Set(at.targets.map((t) => t.uid)).size, elite && mod === RINY ? 4 : 3, `${label(f)}: heals per attack`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 治疗强化·γ型 (MANUAL, data DEFAULT): 30 s of ATK +55 % / +70 %, cast on an injured ally in range; 35 / 32 SP from 20', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u, a } = field({ tier, elite, mod: elite ? RINX : null, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk], ['DEFAULT', elite ? 32 : 35, 20, 30, elite ? 0.7 : 0.55], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
a(8).hp = 100; // outside her range: no cast
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured in range`);
|
||||
a(2).hp = 100;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.runUntil(() => !u.skill.active, 35);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 法术护盾 (AUTO, data DEFAULT, 2 charges): the next heal gives each of its targets a 屏障 of 50 % / 60 % ATK for 3.5 / 4 s that absorbs arts damage only, and RES +10 / +15 while it holds', () => {
|
||||
for (const f of [[5, false, null], [6, true, RINY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u, a } = field({ tier, elite, mod, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.magic_resistance],
|
||||
['DEFAULT', 2, 'charges', 10, elite ? 0.6 : 0.5, elite ? 4 : 3.5, elite ? 15 : 10], label(f));
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured, no cast`);
|
||||
const hurt = [2, 3, 4, 5].map(a);
|
||||
hurt.forEach((x, i) => { x.hp = 1000 + i; });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && hurt.some((x) => x.findBuff('cgbird:barrier')), 5), `${label(f)}: cast with a heal`);
|
||||
const got = hurt.filter((x) => x.findBuff('cgbird:barrier'));
|
||||
const first = got.map((x) => x.findBuff('cgbird:barrier'));
|
||||
assert.equal(got.length, elite && mod === RINY ? 4 : 3, `${label(f)}: every target of that heal`);
|
||||
for (const x of got) {
|
||||
const b = x.findBuff('cgbird:barrier');
|
||||
approx(b.shield, u.s.atk * sk.bb.atk_scale, `${label(f)}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
approx(b.duration, sk.bb.duration, `${label(f)}: ${sk.bb.duration} s`);
|
||||
assert.equal(b.shieldType, 'arts', `${label(f)}: arts only`);
|
||||
assert.deepEqual(x.findBuff('cgbird:barrierRes')?.mods, { resFlat: sk.bb.magic_resistance }, `${label(f)}: RES bonus`);
|
||||
}
|
||||
const x = got[0], b = x.findBuff('cgbird:barrier');
|
||||
const hp0 = x.hp, sh0 = b.shield;
|
||||
h.b.dealDamage(null, x, { amount: 50, type: 'phys', canDodge: false });
|
||||
assert.ok(x.hp < hp0, `${label(f)}: physical damage passes`);
|
||||
approx(b.shield, sh0, `${label(f)}: the barrier untouched by physical damage`);
|
||||
h.b.dealDamage(null, x, { amount: 20, type: 'true' });
|
||||
approx(b.shield, sh0, `${label(f)}: … and by true damage`);
|
||||
const hp1 = x.hp;
|
||||
h.b.dealDamage(null, x, { amount: 30, type: 'arts', canDodge: false });
|
||||
approx(x.hp, hp1, `${label(f)}: arts damage absorbed`);
|
||||
assert.ok(b.shield < sh0, `${label(f)}: the barrier took it`);
|
||||
h.b.dealDamage(null, x, { amount: 1e6, type: 'arts', canDodge: false, resIgnorePct: 1 });
|
||||
assert.equal(x.findBuff('cgbird:barrier'), null, `${label(f)}: broken`);
|
||||
assert.equal(x.findBuff('cgbird:barrierRes'), null, `${label(f)}: the RES bonus goes with it`);
|
||||
h.run(sk.bb.duration + 0.1);
|
||||
got.forEach((y, i) => assert.ok(!y.buffs.includes(first[i]), `${label(f)}: the first heal's barrier expired`));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 圣域 (MANUAL, data ACTIVE_RANGE on y-4): cast for an injured ally inside y-4 only; 60 s, range y-4, ATK +40 % / +50 %, allies in it RES ×1.9 / ×2.05 and 15 % / 20 % arts dodge; all back after', () => {
|
||||
for (const f of [[5, false, null], [6, true, null], [6, true, RINX]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u, a } = field({ tier, elite, mod, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.rangeId, sk.bb.atk, sk.bb.magic_resistance, sk.bb.prob],
|
||||
['ACTIVE_RANGE', 60, 120, 'y-4', elite ? 0.5 : 0.4, elite ? 1.05 : 0.9, elite ? 0.2 : 0.15], label(f));
|
||||
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid, `${label(f)}: the trigger grid is its running range`);
|
||||
const base = u.liveRangeGrid;
|
||||
u.skill.gainSp(999);
|
||||
a(8).hp = 100; // outside y-4
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `${label(f)}: no injured ally in y-4`);
|
||||
a(7).hp = 100; // (9,3): y-4 only
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `${label(f)}: cast for the ally 2 rows down`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: y-4 while it runs`);
|
||||
h.run(0.3);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
|
||||
for (const uid of [2, 6, 7]) assert.deepEqual(a(uid).findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: uid ${uid} in y-4`);
|
||||
assert.deepEqual(u.findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: herself`);
|
||||
assert.equal(a(8).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: outside`);
|
||||
// RES = (10 + 15 talent) × (1 + v) on an ally (base 10)
|
||||
approx(a(7).s.res, Math.min(100, (10 + 15) * (1 + sk.bb.magic_resistance)), `${label(f)}: RES`);
|
||||
approx(a(7).s.dodgeArts, sk.bb.prob, `${label(f)}: arts dodge`);
|
||||
u.skill.end('test');
|
||||
h.run(0.3);
|
||||
assert.deepEqual(u.liveRangeGrid, base, `${label(f)}: own range back`);
|
||||
for (const uid of [2, 6, 7]) assert.equal(a(uid).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: uid ${uid} after`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 白恶魔的庇护: allies in her range RES +15 (herself too, not outside); RIN-X: its range tile too, stage 3 also 受到的治疗效果 ×1.05', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
|
||||
h.run(0.3);
|
||||
const rinx = elite && mod === RINX, x3 = rinx && tier === 6;
|
||||
const want = x3 ? { resFlat: 15, healingTakenMul: 1.05 } : { resFlat: 15 };
|
||||
for (const uid of [2, 3, 4, 5]) assert.deepEqual(a(uid).findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: uid ${uid}`);
|
||||
assert.deepEqual(u.findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: herself`);
|
||||
assert.deepEqual(a(6).findBuff('talent:cgbird:res')?.mods ?? null, rinx ? want : null, `${label(f)}: (11,5) — the RIN-X tile`);
|
||||
for (const uid of [7, 8]) assert.equal(a(uid).findBuff('talent:cgbird:res'), null, `${label(f)}: uid ${uid} outside`);
|
||||
approx(a(2).s.res, 10 + 15, `${label(f)}: RES`);
|
||||
if (x3) {
|
||||
a(2).hp = 1000;
|
||||
const n0 = h.hooksOf('heal').length;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('heal').slice(n0).some((c) => c.target === a(2) && c.source === u), 6));
|
||||
const c = h.hooksOf('heal').slice(n0).find((x) => x.target === a(2) && x.source === u);
|
||||
approx(c.amount, u.s.atk * 1.05, `${label(f)}: healing received ×1.05`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('幻影 (T2): the token of her form — HP 3000 / 4989, RES 75, blocks nothing, taunt 1, no attack; 30 % physical dodge (RIN-Y stage 3: 40 %), loses 3 % of its max HP each second', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8] }); // outside her range: no heals on it
|
||||
const v = tokenVariant(tier, elite, mod);
|
||||
assert.ok(bird && bird.alive, `${label(f)}: deployed with the board`);
|
||||
assert.equal(bird.ownerUnit, u, `${label(f)}: hers`);
|
||||
assert.deepEqual([bird.base.maxHp, bird.base.res, bird.s.blockCnt, bird.s.taunt, bird.profile.noAttack], [v.stats.maxHp, 75, 0, 1, true], `${label(f)}: stats`);
|
||||
assert.equal(v.stats.maxHp, elite ? 4989 : 3000);
|
||||
const y3 = elite && tier === 6 && mod === RINY;
|
||||
approx(bird.s.dodgePhys, y3 ? 0.4 : 0.3, `${label(f)}: physical dodge`);
|
||||
assert.equal(bird.s.dodgeArts, 0, `${label(f)}: no arts dodge`);
|
||||
const hp0 = bird.hp;
|
||||
h.run(3.02);
|
||||
approx(hp0 - bird.hp, 3 * 0.03 * bird.s.maxHp, `${label(f)}: 3 × 3 % in 3 s`, 1e-6);
|
||||
assert.ok(h.hooksOf('damaged').some((c) => c.target === bird && c.dmg?.tags?.includes('hpLoss')), `${label(f)}: a 流失`);
|
||||
// enemies: a 30 % dodge on physical hits, none on arts
|
||||
let dodged = 0;
|
||||
for (let i = 0; i < 400; i++) if (h.b.dealDamage(null, bird, { amount: 1, type: 'phys' }) === 0) dodged++;
|
||||
assert.ok(Math.abs(dodged / 400 - (y3 ? 0.4 : 0.3)) < 0.08, `${label(f)}: ${dodged} / 400 dodged`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('幻影: each 夜莺 deployment adds 2 (RIN-Y stage 3: 3) to her stock, at most 3; a fallen 幻影 comes back on its tile 20 s later using one and paying 5 DP; it stays when she leaves', () => {
|
||||
for (const f of [[5, true, RINY], [6, true, RINY], [6, false, null]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8], dp: 50 });
|
||||
const cnt = elite && tier === 6 && mod === RINY ? 3 : 2;
|
||||
assert.equal(u.mem.phantomStock, cnt, `${label(f)}: her deployment gave ${cnt}; the board piece used none`);
|
||||
const tile = [bird.tileR, bird.tileC];
|
||||
h.b.kill(bird, null);
|
||||
assert.ok(!bird.alive && !bird.removed, `${label(f)}: fallen, kept`);
|
||||
h.run(19.5);
|
||||
assert.ok(!bird.alive, `${label(f)}: not before its 20 s`);
|
||||
const dp0 = h.b.players[0].dp;
|
||||
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: back after 20 s`);
|
||||
assert.deepEqual([bird.tileR, bird.tileC], tile, `${label(f)}: on its own tile`);
|
||||
assert.equal(u.mem.phantomStock, cnt - 1, `${label(f)}: one used`);
|
||||
approx(dp0 - h.b.players[0].dp, 5, `${label(f)}: its DP cost`);
|
||||
approx(bird.hp, bird.s.maxHp, `${label(f)}: full HP`, 0.05);
|
||||
approx(bird.s.dodgePhys, tokenVariant(tier, elite, mod).talents.at(-1).bb.prob, `${label(f)}: its talent again`);
|
||||
// she leaves: the deployed 幻影 stays; she comes back: +cnt (cap 3)
|
||||
h.b.retreat(u);
|
||||
h.step();
|
||||
assert.ok(bird.alive, `${label(f)}: stays when she leaves`);
|
||||
h.b.redeploy(u, { free: true });
|
||||
assert.equal(u.mem.phantomStock, Math.min(3, cnt - 1 + cnt), `${label(f)}: +${cnt}, at most 3`);
|
||||
// none held: a fallen 幻影 waits
|
||||
u.mem.phantomStock = 0;
|
||||
h.b.kill(bird, null);
|
||||
h.run(30);
|
||||
assert.ok(!bird.alive, `${label(f)}: no 幻影 held, none comes`);
|
||||
u.mem.phantomStock = 1;
|
||||
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: comes as soon as one is held`);
|
||||
assert.equal(u.mem.phantomStock, 0);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('幻影 without DP waits for it; 夜莺 heals her 幻影 (an injured 友方单位 in range) and her S2 barrier reaches it', () => {
|
||||
const { h, u, bird } = field({ tier: 6, elite: true, mod: RINY, skill: 1, phantom: [12, 4], dp: 0 });
|
||||
// the drain makes it an injured ally of her range
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => bird.findBuff('cgbird:barrier'), 6), 'barrier on the 幻影 with the next heal');
|
||||
assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.target === bird), 'healed');
|
||||
h.b.kill(bird, null);
|
||||
h.run(25);
|
||||
assert.ok(!bird.alive, 'no DP: still down');
|
||||
h.b.players[0].dp = 5;
|
||||
assert.ok(h.runUntil(() => bird.alive, 1), 'back once the DP is there');
|
||||
approx(h.b.players[0].dp, 0, 'paid 5');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
// test/content/op_chen.test.js — the 自选 operator kit of 陈 (char_010_chen, 6★ 剑豪; kit
|
||||
// server/sim/content/kits/ops/op-chen.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
|
||||
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SWO-X “罗德岛制式剑”
|
||||
// or SWO-Y “往昔时光” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that
|
||||
// slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_chen.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 CHEN = 'char_010_chen';
|
||||
const FORMS = BACKUPS.units[CHEN].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SWOX = 'uniequip_002_chen', SWOY = 'uniequip_003_chen';
|
||||
const S1 = 'skchr_chen_1', S2 = 'skchr_chen_2', S3 = 'skchr_chen_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_def: dummy('enemy_def', { def: 300 }),
|
||||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), enemy_weak: dummy('enemy_weak', { hp: 1000 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SWOX, SWOY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
/** The SWO-X multiplier of her skill damage in a form. */
|
||||
const skillMulOf = (elite, mod) => (elite && mod === SWOX ? 1.1 : 1);
|
||||
|
||||
/** A battle with 陈 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, startOpCooldown } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999, ...(startOpCooldown != null ? { startOpCooldown } : null) },
|
||||
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('陈 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, two hits per attack, melee ground-only, 炎 (yanShip), no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[CHEN], KITS[CHEN]);
|
||||
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], [CHEN, 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), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.hits, u.profile.dmgType, u.base.bat], [2, 'melee', false, 2, 'phys', 1.3], `${label(f)}: 剑豪`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
assert.deepEqual(u.def.raw.tokens, [], `${label(f)}: no summons`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 2188 / 469 / 288, E2 Lv60 2647 / 562 / 330; SWO-X +50 ATK +5 ASPD → +80 / +7,
|
||||
// SWO-Y +52 / +28 → +85 / +42 ATK / DEF
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2188, 469, 2647, 562]);
|
||||
assert.deepEqual([modOf(5, SWOX).attr, modOf(6, SWOX).attr, modOf(5, SWOY).attr, modOf(6, SWOY).attr], [{ atk: 50, aspd: 5 }, { atk: 80, aspd: 7 }, { atk: 52, def: 28 }, { atk: 85, def: 42 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 陈 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(CHEN));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHEN), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CHEN, skillIndex: 2, uniEquipId: SWOX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: CHEN, skillIndex: 2, uniEquipId: SWOX } } });
|
||||
});
|
||||
|
||||
test('剑豪 trait: a normal attack is two physical hits of 100 % ATK on her target (one attack id); she gains 1 attack SP per attack', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, SWOY]]) {
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const sp0 = u.skill.sp;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 2, 3), 'she attacks');
|
||||
const [a, b] = hitsBy(h, u);
|
||||
assert.deepEqual([a.target, b.target, a.dmg.attackId === b.dmg.attackId, a.type, a.dmg.isAttack, a.dmg.isSkill], [e, e, true, 'phys', true, false]);
|
||||
approx(a.amount, u.s.atk, 'hit 1'); approx(b.amount, u.s.atk, 'hit 2');
|
||||
approx(u.skill.sp, sp0 + 1, 'one SP per attack (not per hit)');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 鞘击 (AUTO, attack SP 6 / 5, data DEFAULT): the next attack is ONE strike of 230 % / 260 % ATK physical that stuns its target 1.25 / 1.5 s; then two-hit attacks again', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [5, true, SWOX], [6, true, SWOX], [6, true, SWOY]]) {
|
||||
const sk = skillOf(tier, elite, S1), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.stun], ['DEFAULT', 'attack', elite ? 5 : 6, 0, elite ? 2.6 : 2.3, elite ? 1.5 : 1.25], lb);
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && hitsBy(h, u).length > 0, 3), `${lb}: cast on her next attack`);
|
||||
h.step();
|
||||
const first = hitsBy(h, u);
|
||||
assert.equal(first.filter((c) => c.dmg.attackId === first[0].dmg.attackId).length, 1, `${lb}: one strike, not two`);
|
||||
assert.deepEqual([first[0].target, first[0].type, first[0].dmg.isSkill], [e, 'phys', true], lb);
|
||||
approx(first[0].amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} % ATK${mul > 1 ? ' × 1.1 (SWO-X)' : ''}`);
|
||||
const stun = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'stun');
|
||||
assert.ok(stun && stun.target === e, `${lb}: the target is stunned`);
|
||||
approx(stun.duration, sk.bb.stun, `${lb}: stun ${sk.bb.stun} s`);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).length >= n0 + 2, 4), `${lb}: next attack`);
|
||||
const next = hitsBy(h, u).slice(n0);
|
||||
assert.deepEqual([next[0].dmg.isSkill, next[1].dmg.attackId === next[0].dmg.attackId], [false, true], `${lb}: two plain hits again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 赤霄·拔刀 (MANUAL, data SKILL_RANGE on 3-12): cast with an enemy in the skill range (no attack needed); up to 5 / 6 enemies there — flyers too — each take 370 % / 410 % ATK arts, then the same physical; nothing outside', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
|
||||
const sk = skillOf(tier, elite, S2), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.sp, sk.bb.atk_scale, sk.bb.max_target, sk.rangeId],
|
||||
['SKILL_RANGE', 'attack', elite ? 25 : 26, elite ? 14 : 12, elite ? 4.1 : 3.7, elite ? 6 : 5, '3-12'], lb);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `${lb}: nobody in range`);
|
||||
// 3 tiles ahead: inside 3-12, outside her 1-1 — she cannot attack it
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
const out1 = h.spawn('enemy_dummy', { pos: [10, 9] }), out2 = h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
const fly = h.spawn('enemy_fly', { pos: [9, 6] });
|
||||
const more = [[11, 6], [10, 7], [9, 5]].map((pos) => h.spawn('enemy_dummy', { pos }));
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `${lb}: cast`);
|
||||
const hit = hitsBy(h, u);
|
||||
const victims = [...new Set(hit.map((c) => c.target))];
|
||||
assert.equal(victims.length, Math.min(sk.bb.max_target, 5), `${lb}: every enemy of the skill range up to ${sk.bb.max_target}`);
|
||||
for (const e of [far, fly, ...more]) {
|
||||
const mine = hit.filter((c) => c.target === e);
|
||||
assert.deepEqual(mine.map((c) => c.type), ['arts', 'phys'], `${lb}: ${e.tileR},${e.tileC} arts then physical`);
|
||||
for (const c of mine) { approx(c.amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} %`); assert.equal(c.dmg.isSkill, true); }
|
||||
}
|
||||
for (const e of [out1, out2]) assert.ok(!hit.some((c) => c.target === e), `${lb}: ${e.tileR},${e.tileC} outside 3-12`);
|
||||
assert.ok(!h.hooksOf('attack').some((c) => c.attacker === u), `${lb}: no attack — nobody stood in her 1-1`);
|
||||
done(h);
|
||||
}
|
||||
// more enemies than max_target: exactly max_target are cut
|
||||
const sk = skillOf(5, false, S2);
|
||||
const { h, u } = field({ tier: 5, skill: 1 });
|
||||
u.skill.gainSp(999);
|
||||
for (const pos of [[11, 5], [11, 6], [10, 6], [10, 7], [10, 8], [9, 5], [9, 6]]) h.spawn('enemy_dummy', { pos });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
|
||||
assert.equal(new Set(hitsBy(h, u).filter((c) => c.dmg.isSkill).map((c) => c.target)).size, sk.bb.max_target, `${sk.bb.max_target} of 7`);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 赤霄·绝影 (MANUAL, data SKILL_RANGE on x-1): not opened with only a flyer in range; then 10 slashes of 230 % / 260 % ATK on the nearest ground enemy at the skeleton\'s times, the last stunning 2.5 / 3 s; 2.933 s, invulnerable, unable to block, stun immune', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
|
||||
const sk = skillOf(tier, elite, S3), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, row: 10, col: 5 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.sp, sk.bb.atk_scale, sk.bb.times, sk.bb.stun, sk.rangeId],
|
||||
['SKILL_RANGE', 'attack', elite ? 36 : 38, 10, elite ? 2.6 : 2.3, 10, elite ? 3 : 2.5, 'x-1'], lb);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
|
||||
u.skill.gainSp(999);
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
h.run(1.5);
|
||||
assert.equal(u.skill.activations, 0, `${lb}: a flyer alone in x-1 does not open it (不可对空), the skill stays ready`);
|
||||
assert.equal(u.skill.charges, 1, lb);
|
||||
const near = h.spawn('enemy_dummy', { pos: [9, 6] }); // √2 away
|
||||
const farther = h.spawn('enemy_dummy', { pos: [10, 7] }); // 2 away
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb}: opened with a ground enemy in range`);
|
||||
const t0 = u.skill.lastStart;
|
||||
assert.deepEqual([!!u.s.flags.invulnerable, !!u.s.flags.noBlock], [true, true], `${lb}: 无敌, 无法阻挡`);
|
||||
assert.equal(h.b.applyStatus(u, 'stun', { duration: 2, source: near }), false, `${lb}: 眩晕免疫`);
|
||||
assert.equal(h.b.applyStatus(u, 'freeze', { duration: 2, source: near }), false, `${lb}: 冻结免疫`);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.runUntil(() => !u.skill.active, 4);
|
||||
const end = h.hooksOf('skillEnd').find((c) => c.unit === u);
|
||||
assert.deepEqual([end.reason, Math.round((end.t - t0) * 30)], ['duration', 88], `${lb}: the Skill_3 clip (2.933 s = 88 ticks)`);
|
||||
const slashes = hitsBy(h, u).slice(n0);
|
||||
assert.equal(slashes.length, 10, `${lb}: 10 slashes`);
|
||||
assert.ok(slashes.every((c) => c.target === near && c.type === 'phys' && c.dmg.isSkill), `${lb}: all on the nearest ground enemy`);
|
||||
for (const c of slashes) approx(c.amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} %`);
|
||||
const at = slashes.map((c) => Math.round((c.t - t0) * 30));
|
||||
assert.deepEqual(at, [18, 24, 30, 36, 42, 48, 54, 60, 66, 85], `${lb}: 0.6 … 2.2 s, 2.833 s (ticks)`);
|
||||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||||
assert.equal(stuns.length, 1, `${lb}: the last slash stuns`);
|
||||
assert.deepEqual([stuns[0].target, stuns[0].duration], [near, sk.bb.stun], lb);
|
||||
assert.ok(!hitsBy(h, u).some((c) => c.target === fly || c.target === farther), `${lb}: nobody else`);
|
||||
assert.deepEqual([!!u.s.flags.invulnerable, !!u.s.flags.noBlock], [false, false], `${lb}: back to normal`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${lb}: her range stays 1-1`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3: no normal attack and no SP while it runs; a slain target is replaced by the next nearest; with no target left in x-1 it ends at once', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
const weak = h.spawn('enemy_weak', { pos: [10, 6] });
|
||||
const next = h.spawn('enemy_dummy', { pos: [11, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1));
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.runUntil(() => !u.skill.active, 4);
|
||||
const hits = hitsBy(h, u).slice(n0);
|
||||
assert.ok(!weak.alive, 'the first target fell');
|
||||
assert.ok(hits.length === 10 && hits.every((c) => c.dmg.isSkill), 'ten slashes, no normal attack');
|
||||
assert.ok(hits.some((c) => c.target === next) && hits[hits.length - 1].target === next, 'the rest on the next nearest');
|
||||
assert.ok(!h.hooksOf('attack').some((c) => c.attacker === u && c.t > u.skill.lastStart && c.t < u.skill.lastStart + 2.9), 'no attack meanwhile');
|
||||
done(h);
|
||||
// the only target dies at the first slash: the skill ends right there (no 2.9 s)
|
||||
const r = field({ tier: 5, skill: 2 });
|
||||
r.u.skill.gainSp(999);
|
||||
r.h.spawn('enemy_weak', { pos: [10, 6] });
|
||||
assert.ok(r.h.runUntil(() => r.u.skill.active, 1));
|
||||
const t0 = r.u.skill.lastStart;
|
||||
r.h.runUntil(() => !r.u.skill.active, 4);
|
||||
const early = r.h.hooksOf('skillEnd').find((c) => c.unit === r.u);
|
||||
assert.deepEqual([early.reason, Math.round((early.t - t0) * 30)], ['noTarget', 19], 'ended the tick after the first slash felled it');
|
||||
assert.equal(r.u.skill.sp, 0, 'SP spent');
|
||||
done(r.h);
|
||||
// the 10th slash fells the only target: the slashes are done, so the clip runs out (only "未到10次之前" ends it early)
|
||||
const z = field({ tier: 5, skill: 2 });
|
||||
const slash = z.u.s.atk * skillOf(5, false, S3).bb.atk_scale;
|
||||
const tenth = z.h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
tenth.hp = 9.5 * slash;
|
||||
z.u.skill.gainSp(999);
|
||||
assert.ok(z.h.runUntil(() => z.u.skill.active, 1));
|
||||
const z0 = z.u.skill.lastStart;
|
||||
z.h.runUntil(() => !z.u.skill.active, 4);
|
||||
assert.ok(!tenth.alive, 'felled by the 10th slash');
|
||||
assert.equal(hitsBy(z.h, z.u).length, 10);
|
||||
const zEnd = z.h.hooksOf('skillEnd').find((c) => c.unit === z.u);
|
||||
assert.deepEqual([zEnd.reason, Math.round((zEnd.t - z0) * 30)], ['duration', 88], 'the clip runs out');
|
||||
done(z.h);
|
||||
// a walker she blocked walks on while she slashes (无法阻挡)
|
||||
const w = field({ tier: 6, elite: true, skill: 2, row: 9, col: 5 });
|
||||
w.h.spawn('enemy_walk', { routeIndex: 0 });
|
||||
assert.ok(w.h.runUntil(() => w.u.blocking.length > 0, 30), 'blocked');
|
||||
const walker = w.u.blocking[0];
|
||||
w.u.skill.gainSp(999);
|
||||
assert.ok(w.h.runUntil(() => w.u.skill.active, 2));
|
||||
w.h.step();
|
||||
assert.equal(walker.blockedBy, null, 'released');
|
||||
done(w.h);
|
||||
});
|
||||
|
||||
test('T1 呵斥: every 4 s on the field (3 s with SWO-X stage 3) every attack / hurt SP ally gets 1 SP — her too, not time SP; SWO-X stage 3: +1 more for her; nothing once she left', () => {
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_09_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_12_a', row: 12, col: 5 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 12, col: 7 }];
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const x3 = elite && mod === SWOX && tier === 6;
|
||||
const t0 = formOf(tier, elite).talents[0].bb;
|
||||
const iv = x3 ? 3 : 4;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, others });
|
||||
if (!x3) assert.deepEqual(u.def.raw.talents.find((t) => t.index === 0).bb, t0, label(f));
|
||||
const [snipe, guard, tank] = [h.unit(2), h.unit(3), h.unit(4)];
|
||||
assert.deepEqual([snipe.skill.spType, guard.skill.spType, tank.skill.spType], ['attack', 'hurt', 'time']);
|
||||
const sp = () => [u.skill.sp, snipe.skill.sp, guard.skill.sp];
|
||||
const base = sp();
|
||||
h.run(iv - 0.1 - h.b.time);
|
||||
assert.deepEqual(sp(), base, `${label(f)}: nothing before ${iv} s`);
|
||||
h.run(0.2);
|
||||
assert.deepEqual(sp(), [base[0] + (x3 ? 2 : 1), base[1] + 1, base[2] + 1], `${label(f)}: +1 each at ${iv} s${x3 ? ', +2 for her' : ''}`);
|
||||
approx(tank.skill.sp, Math.min(tank.skill.spCost, 0 + h.b.time), `${label(f)}: the time-SP 角峰 only recovers with time`, 0.05);
|
||||
h.run(iv);
|
||||
assert.deepEqual(sp(), [base[0] + (x3 ? 4 : 2), base[1] + 2, base[2] + 2], `${label(f)}: again at ${2 * iv} s`);
|
||||
h.b.retreat(u);
|
||||
const off = [snipe.skill.sp, guard.skill.sp];
|
||||
h.run(3 * iv);
|
||||
assert.deepEqual([snipe.skill.sp, guard.skill.sp], off, `${label(f)}: nothing once she left`);
|
||||
done(h);
|
||||
}
|
||||
// SWO-X stage 3 is the module's text: 每3秒…,自身额外回复1点技力
|
||||
assert.match(modOf(6, SWOX).talentChanges.find((t) => !t.hidden).desc, /每3秒回复全场友方角色1点攻击\/受击技力,自身额外回复1点技力/);
|
||||
});
|
||||
|
||||
test('T2 持刀格斗术: ATK +5 %, DEF +5 %, 物理闪避 10 %; SWO-Y stage 3: +15 % / +15 % / 18 %', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
const y3 = elite && mod === SWOY && tier === 6;
|
||||
const [a, d, p] = y3 ? [0.15, 0.15, 0.18] : [0.05, 0.05, 0.1];
|
||||
assert.deepEqual(u.findBuff('talent:chen:knife')?.mods, { atkPct: a, defPct: d, dodgePhys: p }, label(f));
|
||||
approx(u.s.atk, u.base.atk * (1 + a), `${label(f)}: ATK`);
|
||||
approx(u.s.def, u.base.def * (1 + d), `${label(f)}: DEF`);
|
||||
approx(u.s.dodgePhys, p, `${label(f)}: 物理闪避`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('modules: SWO-X skill damage ×1.1 during her skill (PRTS 修正 技能期间), normal attacks unchanged; SWO-Y ignores 70 DEF on every hit (stages 1 and 3); none without', () => {
|
||||
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
h.spawn('enemy_def', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 2, 3));
|
||||
const pen = mod === SWOY ? 70 : 0;
|
||||
approx(hitsBy(h, u)[0].amount, u.s.atk - (300 - pen), `${label(f)}: a normal hit vs DEF 300`);
|
||||
if (mod === SWOY) assert.deepEqual(u.def.raw.trait.bb, { def_penetrate_fixed: 70 });
|
||||
if (mod === SWOX) assert.deepEqual(u.def.raw.trait.bb, { damage_scale: 1.1, value: 0.1 });
|
||||
// S2 on the same target
|
||||
u.skill.gainSp(999);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
|
||||
const cut = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill && c.type === 'phys')[0];
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
approx(cut.amount, (u.s.atk * sk.bb.atk_scale - (300 - pen)) * skillMulOf(elite, mod), `${label(f)}: S2 physical vs DEF 300`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
// test/content/op_chyue.test.js — the 自选 operator kit of 重岳 (char_2024_chyue, 6★ 斗士; kit
|
||||
// server/sim/content/kits/ops/op-chyue.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, FGT-X
|
||||
// “自晦及明” or FGT-Y “朔” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
|
||||
// that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_chyue.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 CHYUE = 'char_2024_chyue';
|
||||
const FORMS = BACKUPS.units[CHYUE].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const FGTX = 'uniequip_002_chyue', FGTY = 'uniequip_003_chyue';
|
||||
const S1 = 'skchr_chyue_1', S2 = 'skchr_chyue_2', S3 = 'skchr_chyue_3';
|
||||
const X6 = [[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]];
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_frail: dummy('enemy_frail', { hp: 100 }), enemy_mid: dummy('enemy_mid', { hp: 3000 }),
|
||||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, FGTX, FGTY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const markOf = (u, e) => e.findBuff(`talent:chyue:mark#${u.id}`);
|
||||
|
||||
/** A battle with 重岳 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHYUE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target?.side === 'enemy');
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('重岳 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 炎, no 特质; FGT-X 15 % physical dodge, FGT-Y +10 ASPD above 50 % HP', () => {
|
||||
assert.equal(OPERATOR_KITS[CHYUE], KITS[CHYUE]);
|
||||
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], [CHYUE, 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, 'melee', false, 'phys', 0.78], `${label(f)}: 斗士`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target him`);
|
||||
assert.equal(u.s.dodgePhys, elite && mod === FGTX ? 0.15 : 0, `${label(f)}: FGT-X 物理闪避`);
|
||||
assert.equal(u.s.aspd, elite && mod === FGTY ? 110 : 100, `${label(f)}: FGT-Y at full HP`);
|
||||
if (elite && mod === FGTY) {
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
h.step();
|
||||
assert.equal(u.s.aspd, 100, `${label(f)}: FGT-Y off at 50 % (not above)`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 2160 / 477 / 304, E2 Lv60 2475 / 552 / 343; FGT-X +210 / +25 / +20 →
|
||||
// +260 / +45 / +30, FGT-Y +52 / +25 → +75 / +35 ATK / DEF
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2160, 477, 2475, 552]);
|
||||
assert.deepEqual([modOf(5, FGTX).attr, modOf(6, FGTX).attr, modOf(5, FGTY).attr, modOf(6, FGTY).attr],
|
||||
[{ maxHp: 210, atk: 25, def: 20 }, { maxHp: 260, atk: 45, def: 30 }, { atk: 52, def: 25 }, { atk: 75, def: 35 }]);
|
||||
assert.deepEqual([modOf(5, FGTX).traitOverride.bb, modOf(6, FGTY).traitOverride.bb], [{ prob: 0.15 }, { attack_speed: 10, hp_ratio: 0.5 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 重岳 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(CHYUE));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHYUE), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CHYUE, skillIndex: 0, uniEquipId: FGTX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: CHYUE, skillIndex: 0, uniEquipId: FGTX } } });
|
||||
});
|
||||
|
||||
test('S1 冲盈 (MANUAL, attack SP 5 / 4, 3 charges, data DEFAULT): 250 % / 290 % ATK on the attack target; from a full stack (蓄力) every charge goes and it strikes 3 times', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.maxCharges, sk.initSp, sk.bb.atk_scale, sk.bb.times],
|
||||
['DEFAULT', 'attack', elite ? 4 : 5, 3, 0, elite ? 2.9 : 2.5, 3], `T${tier}`);
|
||||
// a full stack: 蓄力
|
||||
u.skill.gainSp(999);
|
||||
assert.equal(u.skill.charges, 3);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast`);
|
||||
h.step();
|
||||
const strike = hitsBy(h, u).filter((c) => c.dmg.isSkill);
|
||||
assert.equal(strike.length, 3, `T${tier}: three strikes`);
|
||||
for (const c of strike) {
|
||||
assert.equal(c.target, e, `T${tier}: the ground target`);
|
||||
approx(c.dmg.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
|
||||
assert.ok(c.dmg.mul === 1 || Math.abs(c.dmg.mul - 1.65) < 1e-9, `T${tier}: ×1 or ×1.65 (止戈)`);
|
||||
approx(c.amount, c.dmg.amount * c.dmg.mul, `T${tier}: physical, DEF 0`);
|
||||
}
|
||||
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: every charge spent`);
|
||||
assert.ok(!hitsBy(h, u).some((c) => c.target === fly), `T${tier}: never the flyer`);
|
||||
// one charge: one strike
|
||||
u.skill.gainSp(u.skill.spCost);
|
||||
assert.equal(u.skill.charges, 1);
|
||||
const n0 = hitsBy(h, u).filter((c) => c.dmg.isSkill).length;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 2, 4), `T${tier}: second cast`);
|
||||
h.step();
|
||||
assert.equal(hitsBy(h, u).filter((c) => c.dmg.isSkill).length - n0, 1, `T${tier}: one strike`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 冲盈: exact damage — 290 % ATK per strike, ×1.65 on a 止戈-marked target; the strike rolls no 止戈 and gives no attack SP', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
|
||||
const sk = skillOf(6, true, S1);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
u.skill.gainSp(u.skill.spCost);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
|
||||
h.step();
|
||||
const c = hitsBy(h, u).find((x) => x.dmg.isSkill);
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, '290 % (no mark yet)');
|
||||
assert.equal(markOf(u, e), null, 'the skill strike marks nothing');
|
||||
assert.equal(u.skill.sp, 0, 'no attack SP for the strike');
|
||||
h.b.addBuff(e, { key: `talent:chyue:mark#${u.id}`, duration: 2.5 });
|
||||
u.skill.gainSp(u.skill.spCost);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 2, 4));
|
||||
h.step();
|
||||
const c2 = hitsBy(h, u).filter((x) => x.dmg.isSkill)[1];
|
||||
approx(c2.amount, u.s.atk * sk.bb.atk_scale * 1.65, '×1.65 on the marked target');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S2 拂尘 (MANUAL, 2 charges, data SKILL_RANGE x-4): ≤ 4 enemies around (air too; his marked ones first, then the one he blocks) take 290 % / 350 %; the marked ground ones 浮空 2 s; 0.5 s later every 浮空 enemy around loses it, takes 410 % / 480 % and is marked', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1, row: 9 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.maxCharges, sk.initSp, sk.rangeId, sk.bb.max_target, sk.bb.atk_scale, sk.bb.atk_scale_down],
|
||||
['SKILL_RANGE', 'time', elite ? 11 : 12, 2, elite ? 7 : 6, 'x-4', 4, elite ? 3.5 : 2.9, elite ? 4.8 : 4.1], `T${tier}`);
|
||||
const blocked = h.spawn('enemy_dummy', { pos: [9, 5] });
|
||||
const m1 = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const m2 = h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
const flyM = h.spawn('enemy_fly', { pos: [8, 6] });
|
||||
const plain1 = h.spawn('enemy_dummy', { pos: [8, 4] });
|
||||
const plain2 = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
const outside = h.spawn('enemy_dummy', { pos: [9, 7] });
|
||||
const lifted = h.spawn('enemy_dummy', { pos: [10, 5] }); // 浮空 by another source, not marked
|
||||
h.step();
|
||||
assert.equal(blocked.blockedBy, u);
|
||||
for (const e of [m1, m2, flyM]) h.b.addBuff(e, { key: `talent:chyue:mark#${u.id}`, duration: 9 });
|
||||
h.b.applyStatus(lifted, 'levitate', { duration: 5, source: null });
|
||||
assert.ok(lifted.isFlying);
|
||||
u.skill.gainSp(sk.spCost);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), `T${tier}: cast`);
|
||||
const t0 = h.b.time;
|
||||
const first = hitsBy(h, u).filter((c) => c.dmg.isSkill);
|
||||
const hitSet = new Set(first.map((c) => c.target));
|
||||
assert.equal(first.length, 4, `T${tier}: four targets`);
|
||||
assert.deepEqual([m1, m2, flyM, blocked].map((e) => hitSet.has(e)), [true, true, true, true], `T${tier}: the marked (flyer included), then the blocked one`);
|
||||
assert.ok(![plain1, plain2, outside].some((e) => hitSet.has(e)), `T${tier}: no room for the rest`);
|
||||
for (const c of first) approx(c.amount, u.s.atk * sk.bb.atk_scale * (markOf(u, c.target) ? 1.65 : 1), `T${tier}: ${sk.bb.atk_scale * 100} %`);
|
||||
assert.deepEqual([m1, m2, flyM, blocked].map((e) => !!e.s.flags.levitate), [true, true, false, false], `T${tier}: the marked ground ones 浮空 (not a flyer, not the unmarked)`);
|
||||
approx(m1.findBuff('levitate').timeLeft, 2, `T${tier}: 2 s`, 0.05);
|
||||
assert.equal(blocked.blockedBy, u, 'still blocked');
|
||||
h.run(0.4);
|
||||
assert.equal(hitsBy(h, u).filter((c) => c.dmg.isSkill).length, 4, `T${tier}: nothing yet`);
|
||||
h.runUntil(() => h.b.time >= t0 + 0.5 - 1e-9, 0.3);
|
||||
h.step();
|
||||
const second = hitsBy(h, u).filter((c) => c.dmg.isSkill).slice(4);
|
||||
assert.deepEqual(new Set(second.map((c) => c.target)), new Set([m1, m2, lifted]), `T${tier}: every 浮空 enemy around (the other source's too)`);
|
||||
for (const c of second) approx(c.amount, u.s.atk * sk.bb.atk_scale_down * (c.target === lifted ? 1 : 1.65), `T${tier}: ${sk.bb.atk_scale_down * 100} % (its own mark comes after the damage)`);
|
||||
for (const e of [m1, m2, lifted]) {
|
||||
assert.ok(!e.s.flags.levitate, `T${tier}: 浮空 over`);
|
||||
approx(markOf(u, e).timeLeft, 2.5, `T${tier}: marked 2.5 s`, 0.1);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 我无 (MANUAL, attack SP 11 / 10, data DEFAULT): its strike hits the target and the ground enemies within 0.8 for 240 % / 300 %; the 5th cast turns him — x-6, double normal hits, +1 SP per attack (阻回 ignored), a self-cast the moment SP is full, the strike lands twice; a redeploy undoes it', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 9 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.cast_cnt], ['DEFAULT', 'attack', elite ? 10 : 11, 0, elite ? 3 : 2.4, 5], `T${tier}`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const near = h.spawn('enemy_dummy', { pos: [10.6, 6.4] });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
const far = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
const strikes = () => hitsBy(h, u).filter((c) => c.dmg.isSkill);
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === n, 5), `T${tier}: cast ${n} (3 s apart: the automatic-operation cooldown)`);
|
||||
h.step();
|
||||
assert.equal(u.mem.chyueAwake, n === 5, `T${tier}: transformed after the 5th cast only`);
|
||||
}
|
||||
const pre = strikes();
|
||||
assert.equal(pre.length, 10, `T${tier}: each of the five strikes hits the target and the one within 0.8`);
|
||||
assert.deepEqual(new Set(pre.map((c) => c.target)), new Set([e, near]), `T${tier}: not the flyer, not the one 1.4 away`);
|
||||
for (const c of pre) {
|
||||
approx(c.dmg.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
|
||||
approx(c.amount, c.dmg.amount * c.dmg.mul, `T${tier}: physical (×1.65 on a marked one)`);
|
||||
}
|
||||
assert.deepEqual(u.liveRangeGrid, X6, `T${tier}: x-6`);
|
||||
assert.ok(!hitsBy(h, u).some((c) => c.target === fly || c.target === far), `T${tier}: never the flyer / far one before`);
|
||||
// transformed: two hits per normal attack, 2 SP per attack (阻回 does not stop the extra one)
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: 30 });
|
||||
const sp0 = u.skill.sp;
|
||||
h.runUntil(() => hitsBy(h, u).length > n0, 3);
|
||||
h.step();
|
||||
const plain = hitsBy(h, u).slice(n0).filter((c) => !c.dmg.isSkill);
|
||||
assert.ok(plain.length >= 1 && plain.length % 2 === 0, `T${tier}: double hits`);
|
||||
assert.equal(new Set(plain.map((c) => c.dmg.attackId)).size, plain.length / 2, `T${tier}: two per attack`);
|
||||
approx(u.skill.sp - sp0, plain.length / 2, `T${tier}: +1 SP per attack under 阻回`);
|
||||
h.b.removeBuff(u, 'test:noSp');
|
||||
// the self-cast: it fires the moment its SP is full (no 3 s operation cooldown), the strike twice
|
||||
const c6 = u.skill.activations;
|
||||
const s0 = strikes().length;
|
||||
u.skill.gainSp(999);
|
||||
h.step();
|
||||
assert.equal(u.skill.activations, c6 + 1, `T${tier}: cast at once`);
|
||||
const twice = strikes().slice(s0);
|
||||
assert.equal(twice.filter((c) => c.target === e).length, 2, `T${tier}: the strike lands twice on its target`);
|
||||
assert.equal(twice.filter((c) => c.target === near).length, 2, `T${tier}: and twice around it`);
|
||||
assert.ok(far.alive && !twice.some((c) => c.target === far || c.target === fly));
|
||||
// a redeploy starts untransformed
|
||||
h.b.kill(u, e);
|
||||
h.b.redeploy(u);
|
||||
h.step();
|
||||
assert.deepEqual([u.mem.chyueAwake, u.mem.chyueCasts], [false, 0], `T${tier}: reset`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 止戈: each normal-attack damage instance rolls 23 % to mark its target for 2.5 s (not that instance), every damage of his to a marked target ×1.65 — FGT-Y stage 3 ×1.75 (stage 1 ×1.65)', () => {
|
||||
for (const f of [[5, false, null], [5, true, FGTY], [6, true, FGTY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
const scale = tier === 6 && mod === FGTY ? 1.75 : 1.65;
|
||||
assert.deepEqual([t0.prob, t0.damage_scale, t0.up_duration], [0.23, scale, 2.5], label(f));
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
let marked = 0, plainHits = 0, prevMarked = false;
|
||||
h.b.on('hit', (c) => { if (c.source === u && c.target === e && c.dmg.isAttack && !c.dmg.isSkill) prevMarked = !!markOf(u, e); }, { priority: 100 });
|
||||
const amounts = [];
|
||||
h.b.on('damaged', (c) => {
|
||||
if (c.source !== u || c.target !== e || c.dmg.isSkill) return;
|
||||
plainHits++;
|
||||
amounts.push([c.amount, prevMarked]);
|
||||
if (!prevMarked && markOf(u, e)) marked++;
|
||||
});
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity }); // keep S1 off: plain attacks only
|
||||
h.run(400);
|
||||
const unmarkedHits = amounts.filter(([, m]) => !m).length;
|
||||
const rate = marked / unmarkedHits;
|
||||
assert.ok(rate > 0.18 && rate < 0.28, `${label(f)}: ${marked} / ${unmarkedHits} ≈ 23 %`);
|
||||
for (const [amt, m] of amounts.slice(0, 50)) approx(amt, u.s.atk * (m ? scale : 1), `${label(f)}: ×${scale} while marked`);
|
||||
assert.ok(plainHits > 400);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 万象为宾: a skill kill gives 3 SP when the skill ends (阻回 ignored; FGT-X stage 3: 4, and 1 when it killed nobody — stage 1: 3 / 0); a normal-attack kill gives nothing; S2\'s second segment is judged on its own', () => {
|
||||
for (const f of [[5, true, null], [5, true, FGTX], [6, true, FGTX]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const x3 = tier === 6 && mod === FGTX;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1, row: 9 });
|
||||
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
|
||||
assert.equal(t1.sp, x3 ? 4 : 3, label(f));
|
||||
const s2 = skillOf(tier, elite, S2);
|
||||
// a cast that kills: +sp at its end; the second segment (no 浮空 enemy) gives the unkill part alone
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
|
||||
const frail = h.spawn('enemy_frail', { pos: [10, 6] });
|
||||
u.skill.sp = 0;
|
||||
u.skill.addCharge(1);
|
||||
const sp0 = u.skill.sp;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), label(f));
|
||||
assert.ok(!frail.alive, `${label(f)}: killed by the skill`);
|
||||
approx(u.skill.sp - sp0, x3 ? 4 : 3, `${label(f)}: +${x3 ? 4 : 3} under 阻回`);
|
||||
h.run(1.05);
|
||||
approx(u.skill.sp - sp0, (x3 ? 4 : 3) + (x3 ? 1 : 0), `${label(f)}: the second segment killed nobody: +${x3 ? 1 : 0}`);
|
||||
// a cast that kills nobody
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const sp1 = u.skill.sp;
|
||||
u.skill.addCharge(1);
|
||||
h.run(3.5);
|
||||
assert.equal(u.skill.activations, 2, label(f));
|
||||
approx(u.skill.sp - sp1, x3 ? 2 : 0, `${label(f)}: no kill: ${x3 ? '1 + 1' : 'nothing'}`);
|
||||
assert.ok(e.alive);
|
||||
assert.ok(s2.spCost > 0);
|
||||
done(h);
|
||||
}
|
||||
// a normal-attack kill gives nothing; S2's second segment killing gives its own SP
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: FGTX, skill: 1, row: 9 });
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
|
||||
u.skill.sp = 0;
|
||||
const weak = h.spawn('enemy_frail', { pos: [9, 6] });
|
||||
h.runUntil(() => !weak.alive, 3);
|
||||
assert.equal(u.skill.sp, 0, 'a normal-attack kill: nothing (and 阻回 blocks the attack SP: time SP anyway)');
|
||||
const tough = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const late = h.spawn('enemy_mid', { pos: [10, 5] }); // survives segment 1 (350 %), not segment 2 (+480 %)
|
||||
h.b.applyStatus(late, 'levitate', { duration: 5, source: null });
|
||||
h.b.addBuff(tough, { key: `talent:chyue:mark#${u.id}`, duration: 9 });
|
||||
u.skill.addCharge(1);
|
||||
const sp0 = u.skill.sp;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
|
||||
approx(u.skill.sp - sp0, 1, 'segment 1 killed nobody: +1');
|
||||
h.run(1.05);
|
||||
assert.ok(!late.alive, 'the second segment killed the 浮空 one');
|
||||
approx(u.skill.sp - sp0, 1 + 4, 'segment 2 killed: +4');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
// test/content/op_helage.test.js — the 自选 operator kit of 赫拉格 (char_188_helage, 6★ 武者; kit
|
||||
// server/sim/content/kits/ops/op-helage.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SBL-X
|
||||
// “藏锋” or SBL-Y “热的雪” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
|
||||
// that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_helage.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 HELAGE = 'char_188_helage';
|
||||
const FORMS = BACKUPS.units[HELAGE].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SBLX = 'uniequip_002_helage', SBLY = 'uniequip_003_helage';
|
||||
const S1 = 'skchr_helage_1', S2 = 'skchr_helage_2', S3 = 'skchr_helage_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SBLX, SBLY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 赫拉格 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 },
|
||||
hooks: ['damaged', 'skillStart', 'skillEnd', 'heal', 'attack'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HELAGE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const selfHeals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && c.target === u && !c.opts?.regen);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('赫拉格 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 武者 (no heal from others, 70 HP per hit), no core bond (emptyShip), no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[HELAGE], KITS[HELAGE]);
|
||||
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], [HELAGE, 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.hits, u.profile.noHeal, u.profile.selfHeal, u.base.bat], [1, 'melee', false, 1, true, 70, 1.2], `${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.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
|
||||
assert.deepEqual(u.def.raw.tokens, [], `${label(f)}: no summons`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 2868 / 632 / 283, E2 Lv60 3502 / 718 / 317; SBL-X +230 HP +55 ATK → +350 / +90,
|
||||
// SBL-Y +55 ATK +50 DEF → +90 / +60
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2868, 632, 3502, 718]);
|
||||
assert.deepEqual([modOf(5, SBLX).attr, modOf(6, SBLX).attr, modOf(5, SBLY).attr, modOf(6, SBLY).attr], [{ maxHp: 230, atk: 55 }, { maxHp: 350, atk: 90 }, { atk: 55, def: 50 }, { atk: 90, def: 60 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 赫拉格 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(HELAGE));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HELAGE), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: HELAGE, skillIndex: 2, uniEquipId: SBLY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: HELAGE, skillIndex: 2, uniEquipId: SBLY } } });
|
||||
});
|
||||
|
||||
test('武者 trait: one hit of 100 % ATK per attack heals him 70; another unit\'s heal does not reach him', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_06_a', row: 12, col: 5 }] });
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 1, 3));
|
||||
h.step();
|
||||
const [a] = hitsBy(h, u);
|
||||
assert.deepEqual([a.target, a.type, a.dmg.isAttack], [e, 'phys', true]);
|
||||
approx(a.amount, u.s.atk, 'one hit');
|
||||
assert.deepEqual(selfHeals(h, u).map((c) => c.amount), [70], 'healed 70');
|
||||
const hp = u.hp;
|
||||
assert.equal(h.b.heal(h.unit(2), u, 500), 0, 'not a heal target of others');
|
||||
assert.equal(u.hp, hp);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S1 新月 (AUTO, attack SP 3, data DEFAULT): the next attack hits twice at 125 % / 145 % ATK — and heals twice (one trait heal per damage instance)', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SBLY]]) {
|
||||
const sk = skillOf(tier, elite, S1), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, elite ? 1.45 : 1.25], lb);
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && hitsBy(h, u).length >= 2, 3), `${lb}: cast on his next attack`);
|
||||
h.step();
|
||||
const [a, b] = hitsBy(h, u);
|
||||
assert.deepEqual([a.target, b.target, a.dmg.attackId === b.dmg.attackId, a.dmg.isSkill], [e, e, true, true], lb);
|
||||
for (const c of [a, b]) approx(c.amount, u.s.atk * sk.bb.atk_scale, `${lb}: ${sk.bb.atk_scale * 100} %`);
|
||||
assert.deepEqual(selfHeals(h, u).map((c) => c.amount), [70, 70], `${lb}: 70 per hit`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 弦月 (MANUAL, time SP 32 / 29 from 5 / 10, data DEFAULT): 11 / 12 s of ATK +35 % / +50 %, two hits per attack (two heals), 物理闪避 75 %; all back after', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SBLX]]) {
|
||||
const sk = skillOf(tier, elite, S2), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.prob],
|
||||
['DEFAULT', 'time', elite ? 29 : 32, elite ? 10 : 5, elite ? 12 : 11, elite ? 0.5 : 0.35, 0.75], lb);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `${lb}: no enemy, no cast`);
|
||||
u.hp = u.s.maxHp * 0.6;
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `${lb}: cast with an enemy in his range`);
|
||||
approx(u.skill.timeLeft, sk.duration, `${lb}: ${sk.duration} s`, 0.01);
|
||||
approx(u.s.dodgePhys, sk.bb.prob, `${lb}: 物理闪避`);
|
||||
const n0 = hitsBy(h, u).length, h0 = selfHeals(h, u).length;
|
||||
h.run(4);
|
||||
const hits = hitsBy(h, u).slice(n0);
|
||||
const ids = [...new Set(hits.map((c) => c.dmg.attackId))];
|
||||
assert.ok(ids.length >= 2, `${lb}: attacks`);
|
||||
for (const id of ids.slice(0, -1)) assert.equal(hits.filter((c) => c.dmg.attackId === id).length, 2, `${lb}: 二连击`);
|
||||
for (const c of hits) approx(c.amount, u.s.atk, `${lb}: ATK +${sk.bb.atk * 100} % per hit`);
|
||||
approx(u.s.atk / u.base.atk, 1 + sk.bb.atk, `${lb}: ATK`);
|
||||
assert.ok(selfHeals(h, u).length - h0 >= 2 * (ids.length - 1), `${lb}: a heal per hit`);
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
approx(u.s.atk, u.base.atk, `${lb}: ATK back`);
|
||||
approx(u.s.dodgePhys, 0, `${lb}: no dodge after`);
|
||||
const n1 = hitsBy(h, u).length;
|
||||
h.run(3);
|
||||
const after = hitsBy(h, u).slice(n1);
|
||||
assert.equal(after.length, new Set(after.map((c) => c.dmg.attackId)).size, `${lb}: one hit per attack again`);
|
||||
assert.ok(e.alive);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 满月 (MANUAL, time SP 43 / 41 from 15, data ACTIVE_RANGE): cast with an enemy 2 tiles ahead (outside 1-1); 13 / 14 s of ATK +55 % / +70 %, range 1-1 + 1 forward, up to 3 targets; back after', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SBLX]]) {
|
||||
const sk = skillOf(tier, elite, S3), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.ability_range_forward_extend, sk.bb['attack@max_target']],
|
||||
['ACTIVE_RANGE', 'time', elite ? 41 : 43, 15, elite ? 14 : 13, elite ? 0.7 : 0.55, 1, 3], lb);
|
||||
assert.deepEqual(u.skill.triggerGrid, [[0, 0], [0, 1], [0, 2]], `${lb}: the trigger grid = the running range`);
|
||||
u.skill.gainSp(999);
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb}: cast with an enemy in the running range only`);
|
||||
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2]], `${lb}: 1-1 + 1`);
|
||||
approx(u.skill.timeLeft, sk.duration, `${lb}: ${sk.duration} s`, 0.01);
|
||||
approx(u.s.atk / u.base.atk, 1 + sk.bb.atk, `${lb}: ATK`);
|
||||
const near = h.spawn('enemy_dummy', { pos: [10, 6] }), third = h.spawn('enemy_dummy', { pos: [10, 6] }), fourth = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(2);
|
||||
const hits = hitsBy(h, u).slice(n0);
|
||||
const id = hits[0].dmg.attackId;
|
||||
assert.equal(hits.filter((c) => c.dmg.attackId === id).length, 3, `${lb}: three targets per attack`);
|
||||
assert.ok(hits.every((c) => [far, near, third, fourth].includes(c.target)), `${lb}: ground enemies only (the flyer is never struck)`);
|
||||
assert.ok(!hits.some((c) => c.target === fly), lb);
|
||||
h.runUntil(() => !u.skill.active, 20);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${lb}: back to 1-1`);
|
||||
approx(u.s.atk, u.base.atk, `${lb}: ATK back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 月盈星亏: 坚忍 ASPD up to +100 at 70 % of his HP lost (linear); SBL-X stage 3: +130 at 50 %', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const x3 = elite && mod === SBLX && tier === 6;
|
||||
const [maxAs, minHp] = x3 ? [130, 0.5] : [100, 0.3];
|
||||
for (const r of [1, 0.85, 0.65, 0.5, 0.3, 0.1]) {
|
||||
u.hp = u.s.maxHp * r;
|
||||
h.step();
|
||||
approx(u.s.aspd, 100 + maxAs * Math.min(1, (1 - r) / (1 - minHp)), `${label(f)}: at ${r * 100} % HP`, 0.002);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 运筹帷幄: 60 HP / s (生命回复速度) while he blocks nobody, none while blocking; SBL-Y stage 3: 80 / s, and also while blocking below 30 % HP', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y3 = elite && mod === SBLY && tier === 6;
|
||||
const regen = y3 ? 80 : 60;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9, col: 5 });
|
||||
h.step();
|
||||
assert.equal(u.s.hpRegen, regen, `${label(f)}: blocking nobody`);
|
||||
u.hp = u.s.maxHp * 0.5;
|
||||
const hp = u.hp;
|
||||
h.run(1);
|
||||
approx(u.hp - hp, regen, `${label(f)}: +${regen} HP in 1 s`, 0.05);
|
||||
assert.ok(!selfHeals(h, u).length, `${label(f)}: an HP-regen attribute, no heal`);
|
||||
h.spawn('enemy_walk', { routeIndex: 0 });
|
||||
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches him`);
|
||||
h.step();
|
||||
assert.equal(u.s.hpRegen, 0, `${label(f)}: none while blocking above 30 % HP`);
|
||||
u.hp = u.s.maxHp * 0.25;
|
||||
h.step();
|
||||
assert.equal(u.s.hpRegen, y3 ? 80 : 0, `${label(f)}: blocking below 30 % HP`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('SBL-X “藏锋”: below 50 % HP the physical and arts damage he takes ×0.75 (庇护, stages 1 and 3), true damage untouched; none without it', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [9, 9] });
|
||||
const shelter = elite && mod === SBLX;
|
||||
if (shelter) assert.deepEqual([u.def.raw.talents[0].bb.hp_ratio, u.def.raw.talents[0].bb.damage_resistance], [0.5, 0.25], label(f));
|
||||
const at = (r, dmg) => { u.hp = u.s.maxHp * r; return h.b.dealDamage(e, u, { canDodge: false, ...dmg }); };
|
||||
approx(at(0.8, { amount: 400, type: 'arts' }), 400, `${label(f)}: above 50 %`);
|
||||
approx(at(0.4, { amount: 400, type: 'arts' }), shelter ? 300 : 400, `${label(f)}: arts below 50 %`);
|
||||
approx(at(0.4, { amount: 900, type: 'phys' }), (900 - u.s.def) * (shelter ? 0.75 : 1), `${label(f)}: physical below 50 %`);
|
||||
approx(at(0.4, { amount: 400, type: 'true' }), 400, `${label(f)}: true damage`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('SBL-Y “热的雪”: the first lethal blow of a deployment leaves him at 30 % HP (stages 1 and 3); the next one knocks him out; a new deployment has it again; none without it', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [9, 9] });
|
||||
const rise = elite && mod === SBLY;
|
||||
if (rise) assert.deepEqual(u.def.raw.trait.bb, { value: 70, hp_ratio: 0.3 }, label(f));
|
||||
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
|
||||
assert.equal(u.alive, rise, `${label(f)}: the first lethal blow`);
|
||||
if (!rise) { done(h); continue; }
|
||||
approx(u.hp, u.s.maxHp * 0.3, `${label(f)}: at 30 %`);
|
||||
h.step();
|
||||
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
|
||||
assert.equal(u.alive, false, `${label(f)}: once per deployment`);
|
||||
h.b.redeploy(u);
|
||||
assert.ok(u.alive && u.deployed, 'redeployed');
|
||||
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
|
||||
assert.equal(u.alive, true, `${label(f)}: again after a new deployment`);
|
||||
approx(u.hp, u.s.maxHp * 0.3, `${label(f)}: at 30 % again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
// test/content/op_heyak.test.js — the 自选 operator kit of 霍尔海雅 (char_4027_heyak, 6★ 中坚术师; kit
|
||||
// server/sim/content/kits/ops/op-heyak.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-X
|
||||
// “图书馆”, CCR-Y “羽蛇的时间博物馆” or ISW-A “霍尔海雅特限证章” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
|
||||
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_heyak.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 ID = 'char_4027_heyak';
|
||||
const FORMS = BACKUPS.units[ID].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const CCRX = 'uniequip_002_heyak', CCRY = 'uniequip_003_heyak', ISWA = 'uniequip_004_heyak';
|
||||
const S1 = 'skchr_heyak_1', S2 = 'skchr_heyak_2', S3 = 'skchr_heyak_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_heavy: dummy('enemy_heavy', { mass: 2 }),
|
||||
enemy_r50: dummy('enemy_r50', { res: 50 }), enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRX, CCRY, ISWA].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 霍尔海雅 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, setup = null } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
setup,
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const statusBy = (h, u, key) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === key);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const arts = (amount, res, ignore = 0) => Math.max(amount * (1 - Math.max(0, res - ignore) / 100), amount * 0.05);
|
||||
/** 传承终焉's numbers in a form: [atk_scale, silence]. */
|
||||
const t0Of = (tier, elite, mod) => {
|
||||
const t = (mod ? modOf(tier, mod).talentChanges.find((c) => c.talentIndex === 0) : null) ?? formOf(tier, elite).talents.find((c) => c.index === 0);
|
||||
return [t.bb.atk_scale, t.bb.silence];
|
||||
};
|
||||
|
||||
test('霍尔海雅 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-1 range, ranged arts that hits air, block 1, 1.6 s, 空 bonds, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
|
||||
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], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 20], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.base.bat], [1, 'ranged', 'arts', true, false, 1.6], `${label(f)}: 中坚术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.tokens], [['emptyShip'], [], []], `${label(f)}: bonds / 特质 / no summon`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1380 / 525 / 116, E2 Lv60 1639 / 597 / 125; CCR-X +120 / +40 → +175 / +65, CCR-Y +105 / +32 / +15 → +140 / +45 /
|
||||
// +25, ISW-A +100 / +55 → +130 / +80
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1380, 525, 1639, 597]);
|
||||
assert.deepEqual([modOf(5, CCRX).attr, modOf(6, CCRX).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
|
||||
[{ maxHp: 120, atk: 40 }, { maxHp: 175, atk: 65 }, { maxHp: 105, atk: 32, def: 15 }, { maxHp: 140, atk: 45, def: 25 }, { maxHp: 100, atk: 55 }, { maxHp: 130, atk: 80 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 霍尔海雅 is offered at tiers 5 and 6 and a roster with her (ISW-A included) passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(ID));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: ISWA } } });
|
||||
});
|
||||
|
||||
test('S1 但为求索 (AUTO, 1 / 2 charges, data DEFAULT): the next attack hits two enemies at 200 % / 240 % ATK arts; with one enemy only it lifts it 2.5 / 3 s — before 传承终焉, so that hit is × 1.2 and silences', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const [ts, sil] = t0Of(tier, elite, null);
|
||||
assert.deepEqual([sk.skillType, sk.spCost, sk.maxChargeTime, sk.bb.atk_scale, sk.bb.levitate, ts, sil], ['AUTO', 8, elite ? 2 : 1, elite ? 2.4 : 2, elite ? 3 : 2.5, 1.2, 3], `T${tier}`);
|
||||
// two enemies: two shots, no lift
|
||||
{
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges], ['DEFAULT', elite ? 'charges' : 'instant', elite ? 2 : 1], `T${tier}`);
|
||||
u.skill.charges = 0;
|
||||
u.skill.sp = 0;
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 6] }), b = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
u.skill.gainSp(999);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill).length >= 2, 3), `T${tier}: cast`);
|
||||
const sh = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill);
|
||||
assert.deepEqual(new Set(sh.slice(0, 2).map((c) => c.target)), new Set([a, b]), `T${tier}: one more target`);
|
||||
for (const c of sh.slice(0, 2)) approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.equal(statusBy(h, u, 'levitate').length, 0, `T${tier}: two shots — no lift`);
|
||||
done(h);
|
||||
}
|
||||
// one enemy: lifted, and that hit already counts as one on an air unit
|
||||
{
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
u.skill.charges = 0;
|
||||
u.skill.sp = 0;
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
u.skill.gainSp(999);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).slice(n0).some((c) => c.dmg.isSkill), 3), `T${tier}: cast`);
|
||||
const sh = hitsBy(h, u).slice(n0).find((c) => c.dmg.isSkill);
|
||||
assert.equal(sh.target, a);
|
||||
approx(sh.amount, u.s.atk * sk.bb.atk_scale * ts, `T${tier}: × ${ts} (lifted first)`);
|
||||
const lev = statusBy(h, u, 'levitate');
|
||||
assert.equal(lev.length, 1, `T${tier}: lifted`);
|
||||
approx(lev[0].duration, sk.bb.levitate, `T${tier}: 浮空 ${sk.bb.levitate} s`);
|
||||
const sl = statusBy(h, u, 'silence');
|
||||
assert.ok(sl.length >= 1 && sl[0].target === a, `T${tier}: silenced`);
|
||||
approx(sl[0].duration, sil, `T${tier}: ${sil} s`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 群星逶迤 (MANUAL, data DEFAULT): 16 s, each attack 9 shots of 27 % / 35 % ATK at random enemies of her range, each rolling 10 % / 12 % to lift 1 s; a lifted or flying target takes × 1.2', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const sc = sk.bb['attack@atk_scale'], p = sk.bb['attack@prob'];
|
||||
assert.deepEqual([sk.duration, sc, p, sk.bb['attack@levitate'], sk.spCost, sk.initSp], [16, elite ? 0.35 : 0.27, elite ? 0.12 : 0.1, 1, elite ? 32 : 36, elite ? 22 : 21], `T${tier}`);
|
||||
assert.match(sk.desc, /9连发/);
|
||||
const flying = [];
|
||||
const setup = (b) => b.on('damaged', (c) => { if (c.source?.uid === 1) flying.push(c.target.isFlying); }, { priority: -100 });
|
||||
const { h, u } = field({ tier, elite, skill: 1, seed: 21, setup });
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
let rolls = 0;
|
||||
const chance = h.b.rng.chance;
|
||||
h.b.rng.chance = (q) => { if (q === p) rolls++; return chance(q); };
|
||||
const foes = [[10, 6], [9, 6], [11, 6], [10, 7], [9, 7], [11, 7]].map((pos) => h.spawn('enemy_dummy', { pos }));
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 8] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, 16, `T${tier}: 16 s`, 0.05);
|
||||
const n0 = hitsBy(h, u).length, f0 = flying.length;
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 17));
|
||||
h.run(0.6);
|
||||
const shots = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill);
|
||||
const fl = flying.slice(f0, f0 + hitsBy(h, u).slice(n0).length);
|
||||
const byAttack = new Map();
|
||||
for (const c of shots) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
|
||||
assert.ok(byAttack.size >= 9, `T${tier}: ${byAttack.size} attacks`);
|
||||
for (const [, ts] of byAttack) assert.equal(ts.length, 9, `T${tier}: 9 shots per attack`);
|
||||
for (const e of [...foes, fly]) assert.ok(shots.some((c) => c.target === e), `T${tier}: ${e.tileR},${e.tileC} drew shots (random)`);
|
||||
assert.equal(rolls, shots.length, `T${tier}: one ${p * 100} % roll per shot`);
|
||||
const lifts = statusBy(h, u, 'levitate');
|
||||
assert.ok(lifts.length > 0.5 * p * shots.length && lifts.length <= 1.3 * p * shots.length, `T${tier}: ${lifts.length} lifts of ${shots.length} shots`);
|
||||
assert.ok(lifts.every((c) => c.target !== fly && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 1 s, never on a data flyer`);
|
||||
const all = hitsBy(h, u).slice(n0);
|
||||
all.forEach((c, i) => {
|
||||
if (!c.dmg.isSkill) return;
|
||||
approx(c.amount, u.s.atk * sc * (fl[i] ? 1.2 : 1), `T${tier}: shot ${i} (${fl[i] ? 'air' : 'ground'})`);
|
||||
});
|
||||
assert.ok(fl.filter(Boolean).length > shots.filter((c) => c.target === fly).length, `T${tier}: lifted ground enemies took × 1.2 too`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 博览者的狂语 (MANUAL, data ACTIVE_RANGE on 3-10): 45 s, interval 1.6 + 1.4 = 3.0 s, range 3-10 while on; each attack three whirlwinds (her row and the rows beside), 1 tile/s, each lifting the first enemy it touches then dealing 2.33→3.5 / 2.53→3.8 × ATK over 1.77 s (× 1.2: lifted)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const lo = sk.bb['attack@min_atk_scale'], hi = sk.bb['attack@max_atk_scale'], lev = sk.bb['attack@levitate'];
|
||||
assert.deepEqual([sk.rangeId, sk.duration, sk.bb.base_attack_time, lo, hi, lev, sk.spCost, sk.initSp], ['3-10', 45, 1.4, elite ? 2.53 : 2.33, elite ? 3.8 : 3.5, elite ? 2 : 1.8, elite ? 64 : 67, elite ? 42 : 41], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.triggerGrid], ['ACTIVE_RANGE', sk.rangeGrid], `T${tier}: the trigger`);
|
||||
h.run(0.6);
|
||||
u.skill.gainSp(999);
|
||||
// (10,9) is on 3-10 only: the cast comes with it; lanes: row 9 → (9,6) 1 tile ahead, row 10 → (10,8) before (10,9), row 11 → (11,9)
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with an enemy on 3-10 only`);
|
||||
const near = h.spawn('enemy_dummy', { pos: [9, 6] }), mid = h.spawn('enemy_dummy', { pos: [10, 8] }), low = h.spawn('enemy_dummy', { pos: [11, 9] });
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-10 while on`);
|
||||
approx(u.s.interval, 3, `T${tier}: 3.0 s`);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(6.5);
|
||||
const hits = hitsBy(h, u).slice(n0);
|
||||
const first = (e) => hits.find((c) => c.target === e);
|
||||
const scale = (age) => lo + (hi - lo) * Math.min(1, age / 1.77);
|
||||
approx(first(near).amount, u.s.atk * scale(0.5) * 1.2, `T${tier}: 0.5 tile flown`, 1e-3);
|
||||
approx(first(mid).amount, u.s.atk * hi * 1.2, `T${tier}: 2.5 tiles — the maximum`);
|
||||
approx(first(low).amount, u.s.atk * hi * 1.2, `T${tier}: 3.5 tiles`);
|
||||
assert.ok(!hits.some((c) => c.target === far), `T${tier}: (10,9) behind (10,8) in its lane is never reached`);
|
||||
for (const c of hits) assert.deepEqual([c.type, c.dmg.isAttack, c.dmg.isSkill], ['arts', true, true]);
|
||||
const lifts = statusBy(h, u, 'levitate');
|
||||
assert.ok(lifts.length >= 3 && lifts.every((c) => Math.abs(c.duration - lev) < 1e-9), `T${tier}: 浮空 ${lev} s`);
|
||||
assert.ok(statusBy(h, u, 'silence').length >= 3, `T${tier}: 传承终焉 silences the lifted`);
|
||||
u.skill.end('test');
|
||||
h.run(0.1);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 whirlwinds: they fly 5 tiles at most, a heavy (mass > 3) target is lifted half as long, and those out when she leaves fly on without 传承终焉 or her module\'s RES ignore', () => {
|
||||
// 5 tiles: from her centre (col 4) a box reaches col 9.5 — (11,9) is struck 4.5 tiles out, (10,10) never
|
||||
{
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2, col: 4 });
|
||||
u.skill.gainSp(999);
|
||||
const trig = h.spawn('enemy_dummy', { pos: [9, 8] });
|
||||
const edge = h.spawn('enemy_dummy', { pos: [11, 9] }), beyond = h.spawn('enemy_dummy', { pos: [10, 10] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||||
h.run(7);
|
||||
assert.ok(hitsBy(h, u).some((c) => c.target === trig && c.dmg.isSkill), 'the lane-9 whirlwind');
|
||||
assert.ok(hitsBy(h, u).some((c) => c.target === edge && c.dmg.isSkill), 'struck 4.5 tiles out');
|
||||
assert.ok(!hitsBy(h, u).some((c) => c.target === beyond), '6 tiles out: the whirlwind is gone');
|
||||
done(h);
|
||||
}
|
||||
// her module leaves with her (CCR-X stage 3 on RES 50): a whirlwind out when she retreats hits × 1 without the RES ignore
|
||||
{
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: CCRX, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_r50', { pos: [10, 9] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||||
assert.ok(h.runUntil(() => u.mem.heyakWinds.length > 0, 5), 'whirlwinds out');
|
||||
h.run(0.5);
|
||||
const atk = u.s.atk;
|
||||
h.b.retreat(u);
|
||||
const s0 = statusBy(h, u, 'silence').length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === e), 6), 'the whirlwind flies on');
|
||||
const c = hitsBy(h, u).find((x) => x.target === e);
|
||||
approx(c.amount, arts(atk * 3.8, 50, 0), 'no 传承终焉 (× 1.2) and no RES ignore');
|
||||
assert.equal(statusBy(h, u, 'silence').length, s0, 'no silence');
|
||||
assert.equal(statusBy(h, u, 'levitate').filter((x) => x.target === e).length, 1, 'still lifted');
|
||||
done(h);
|
||||
}
|
||||
// a mass-4 target: 浮空 halved (2 s → 1 s at rank 7); 曾有羽翼's 失重 lowers it to 3 while above 80 % HP
|
||||
{
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
const heavy = h.spawn('enemy_heavy', { pos: [10, 9] });
|
||||
heavy.base.massLevel = 4;
|
||||
heavy.markDirty();
|
||||
heavy.hp = heavy.s.maxHp * 0.7; // below 80 %: no 失重
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||||
assert.ok(h.runUntil(() => statusBy(h, u, 'levitate').length > 0, 8));
|
||||
approx(statusBy(h, u, 'levitate')[0].duration, 1, 'mass 4: half of 2 s');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 传承终焉: hits on air units × 1.2 and silence 3 s (CCR-X stage 3: × 1.35 / 5 s, ISW-A stage 3: × 1.45 / 5 s; stage 1 modules keep 1.2 / 3); ground targets: neither', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const [ts, sil] = t0Of(tier, elite, elite ? mod : null);
|
||||
const want = elite && tier === 6 && mod === CCRX ? [1.35, 5] : elite && tier === 6 && mod === ISWA ? [1.45, 5] : [1.2, 3];
|
||||
assert.deepEqual([ts, sil], want, `${label(f)}: the data`);
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(1.7);
|
||||
const c = hitsBy(h, u).slice(n0).find((x) => x.target === fly && x.dmg.isAttack);
|
||||
assert.ok(c, label(f));
|
||||
approx(c.amount, u.s.atk * ts, `${label(f)}: × ${ts} on a flyer`);
|
||||
const sl = statusBy(h, u, 'silence').find((x) => x.target === fly);
|
||||
assert.ok(sl, `${label(f)}: silenced`);
|
||||
approx(sl.duration, sil, `${label(f)}: ${sil} s`);
|
||||
h.b.kill(fly, null);
|
||||
const g = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const n1 = hitsBy(h, u).length, s1 = statusBy(h, u, 'silence').length;
|
||||
h.run(1.7);
|
||||
const cg = hitsBy(h, u).slice(n1).find((x) => x.target === g && x.dmg.isAttack);
|
||||
approx(cg.amount, u.s.atk, `${label(f)}: × 1 on the ground`);
|
||||
assert.equal(statusBy(h, u, 'silence').length, s1, `${label(f)}: no silence on the ground`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 曾有羽翼: 失重 (weight −1) on every enemy of her range above 80 % HP, gone below it or out of range, never on top of another 失重; CCR-Y stage 3: above 50 %, and 20 % 【法术脆弱】 below 50 %', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y3 = elite && tier === 6 && mod === CCRY;
|
||||
const thr = y3 ? 0.5 : 0.8;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
const inR = h.spawn('enemy_heavy', { pos: [10, 6] }), out = h.spawn('enemy_heavy', { pos: [10, 10] });
|
||||
h.step(2);
|
||||
assert.ok(inR.findBuff('heyak:weightless'), `${label(f)}: full HP in range`);
|
||||
assert.equal(inR.weight, 1, `${label(f)}: mass 2 → 1`);
|
||||
assert.equal(out.findBuff('heyak:weightless'), null, `${label(f)}: out of range`);
|
||||
inR.hp = inR.s.maxHp * (thr + 0.01);
|
||||
h.step();
|
||||
assert.ok(inR.findBuff('heyak:weightless'), `${label(f)}: above ${thr * 100} %`);
|
||||
inR.hp = inR.s.maxHp * (thr - 0.01);
|
||||
h.step();
|
||||
assert.equal(inR.findBuff('heyak:weightless'), null, `${label(f)}: below ${thr * 100} %`);
|
||||
assert.equal(inR.weight, 2, `${label(f)}: mass back`);
|
||||
inR.hp = inR.s.maxHp * 0.3;
|
||||
h.run(0.5);
|
||||
const fr = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'artsFragile' && c.target === inR);
|
||||
assert.equal(fr.length > 0, y3, `${label(f)}: 【法术脆弱】 below 50 %`);
|
||||
if (y3) { assert.equal(fr[0].value, 0.2); approx(inR.s.artsTakenMul, 1.2, `${label(f)}: arts taken × 1.2`); }
|
||||
// another source's 失重 (安洁莉娜's key): not doubled
|
||||
inR.hp = inR.s.maxHp;
|
||||
h.b.addBuff(inR, { key: 'aglina:weightless', status: 'weightless', duration: 1, mods: { massFlat: -1 } });
|
||||
h.step();
|
||||
assert.equal(inR.findBuff('heyak:weightless'), null, `${label(f)}: 同名效果不可叠加`);
|
||||
assert.equal(inR.weight, 1);
|
||||
h.run(1.1);
|
||||
assert.ok(inR.findBuff('heyak:weightless'), `${label(f)}: hers again once the other ended`);
|
||||
h.b.retreat(u);
|
||||
h.step();
|
||||
assert.equal(inR.findBuff('heyak:weightless'), null, `${label(f)}: gone when she leaves`);
|
||||
done(h);
|
||||
}
|
||||
const hp = formOf(6, true).modules.find((m) => m.uniEquipId === CCRY).talentChanges;
|
||||
assert.deepEqual([hp.find((t) => t.talentIndex === 1).bb, hp.find((t) => t.talentIndex === -1).bb], [{ hp_ratio: 0.5 }, { damage_scale: 1.2, hp_ratio: 0.5 }]);
|
||||
});
|
||||
|
||||
test('modules: CCR-X "无视目标10点法术抗性" (stages 1 and 3); CCR-Y "普通攻击命中精英或领袖敌人时获得1点技力"; ISW-A (集成战略 only): no RES ignore, no SP, no goal-tile 浮空, no radius-1.1 skill splash — its stats and 传承终焉 numbers only', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const x = elite && mod === CCRX, y = elite && mod === CCRY, w = elite && mod === ISWA;
|
||||
assert.equal(u.s.resIgnoreFlat, x ? 10 : 0, `${label(f)}: RES ignore`);
|
||||
if (w) assert.match(u.def.raw.trait.moduleDesc, /^在集成战略中/, `${label(f)}: the ISW trait text`);
|
||||
const foe = h.spawn('enemy_r50', { pos: [10, 6] });
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(3.5);
|
||||
const hits = hitsBy(h, u).slice(n0).filter((c) => c.target === foe && c.dmg.isAttack);
|
||||
assert.ok(hits.length >= 2, label(f));
|
||||
approx(hits[0].amount, arts(u.s.atk, 50, x ? 10 : 0), `${label(f)}: arts on RES 50`);
|
||||
const traitSp = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
|
||||
assert.equal(traitSp(), 0, `${label(f)}: no SP from a normal enemy`);
|
||||
h.b.kill(foe, null);
|
||||
const el = h.spawn('enemy_elite', { pos: [10, 6] });
|
||||
const k0 = traitSp(), a0 = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length;
|
||||
h.run(3.5);
|
||||
const hitsE = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length - a0;
|
||||
assert.equal(traitSp() - k0, y ? hitsE : 0, `${label(f)}: +1 SP per hit on an elite`);
|
||||
done(h);
|
||||
}
|
||||
// ISW-A stage 3: a whirlwind strikes its target only (the 1.1 splash is 集成战略-only)
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: ISWA, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
const t = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
const side = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
side.x = 9.6; side.y = 9.7; // 0.67 from it, behind it in lane 10 (whose whirlwind is spent on it), outside lane 9
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||||
h.run(8);
|
||||
assert.ok(hitsBy(h, u).some((c) => c.target === t), 'the target');
|
||||
assert.ok(!hitsBy(h, u).some((c) => c.target === side), 'nothing beside it');
|
||||
assert.ok(!statusBy(h, u, 'levitate').some((c) => c.target === side), 'no splash 浮空');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
// test/content/op_huang.test.js — the 自选 operator kit of 煌 (char_017_huang, 6★ 强攻手; kit
|
||||
// server/sim/content/kits/ops/op-huang.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CEN-X
|
||||
// “过载的武器与过热的心脏” or CEN-Y “稍后回来” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
|
||||
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_huang.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 HUANG = 'char_017_huang';
|
||||
const FORMS = BACKUPS.units[HUANG].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const CENX = 'uniequip_002_huang', CENY = 'uniequip_003_huang';
|
||||
const S1 = 'skchr_huang_1', S2 = 'skchr_huang_2', S3 = 'skchr_huang_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_hitter: dummy('enemy_hitter', { atk: 400, bat: 1 }),
|
||||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CENX, CENY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 煌 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HUANG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target?.side === 'enemy');
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('煌 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 3 and strikes every blocked enemy, melee ground-only, 炎 + 维多利亚, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[HUANG], KITS[HUANG]);
|
||||
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], [HUANG, 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.hitAllBlocked, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, true, 'melee', false, 'phys', 1.2], `${label(f)}: 强攻手`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip', 'victoriaShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 2115 / 581 / 303, E2 Lv60 2583 / 703 / 347; CEN-X +240 / +50 → +285 / +86
|
||||
// HP / ATK, CEN-Y +300 / +40 → +345 / +66
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2115, 581, 2583, 703]);
|
||||
assert.deepEqual([modOf(5, CENX).attr, modOf(6, CENX).attr, modOf(5, CENY).attr, modOf(6, CENY).attr], [{ maxHp: 240, atk: 50 }, { maxHp: 285, atk: 86 }, { maxHp: 300, atk: 40 }, { maxHp: 345, atk: 66 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 煌 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(HUANG));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HUANG), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: HUANG, skillIndex: 2, uniEquipId: CENY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: HUANG, skillIndex: 2, uniEquipId: CENY } } });
|
||||
});
|
||||
|
||||
test('S1 强力击·γ型 (AUTO, attack SP 3, data DEFAULT): her 4th attack is the skill one — 205 % / 225 % ATK on every enemy she blocks', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0, row: 9 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, elite ? 2.25 : 2.05], `T${tier}`);
|
||||
const foes = [0, 1, 2].map(() => h.spawn('enemy_dummy', { pos: [9, 5] }));
|
||||
h.step();
|
||||
assert.equal(u.blocking.length, 3, `T${tier}: blocks all three`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 10), `T${tier}: cast`);
|
||||
h.step();
|
||||
const attacks = [...new Set(hitsBy(h, u).map((c) => c.dmg.attackId))];
|
||||
assert.equal(attacks.length, 4, `T${tier}: the 4th attack (3 SP, then the skill attack)`);
|
||||
const last = hitsBy(h, u).filter((c) => c.dmg.attackId === attacks[3]);
|
||||
assert.deepEqual(last.map((c) => c.target).sort((a, b) => a.id - b.id), foes.sort((a, b) => a.id - b.id), `T${tier}: every blocked enemy`);
|
||||
for (const c of last) {
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
|
||||
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]);
|
||||
}
|
||||
for (const c of hitsBy(h, u).filter((c) => c.dmg.attackId !== attacks[3])) approx(c.amount, u.s.atk, `T${tier}: plain attacks`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 链锯延伸模块 (AUTO, 持续时间无限): on as soon as its 85 / 80 SP are full — no enemy needed —, ATK +55 % / +70 %, DEF +15 % / +20 %, range 2-2, never ends', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.atk, sk.bb.def, sk.duration], ['toggle', 'SP_FULL', elite ? 80 : 85, 0, elite ? 0.7 : 0.55, elite ? 0.2 : 0.15, -1], `T${tier}`);
|
||||
h.run(sk.spCost - 1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: on at full SP, no enemy on the field`);
|
||||
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.liveRangeGrid, sk.rangeGrid, `T${tier}: 2-2`);
|
||||
assert.deepEqual(sk.rangeGrid, [[0, 0], [0, 1], [0, 2]]);
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(3);
|
||||
assert.ok(hitsBy(h, u).some((c) => c.target === far), `T${tier}: strikes two tiles ahead`);
|
||||
h.run(300);
|
||||
assert.ok(u.skill.active, `T${tier}: still on (持续时间无限)`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 沸腾爆裂 (data DEFAULT, 维持技能状态, 10 s): no normal attack; a cut every second for 8 s on the ground enemies of her 1-1 (not air), ATK / DEF ramp to +45 % / +60 % (full at 8.95 s); at 9 s −25 % max HP (never below 1) and 320 % / 340 % on the 3 × 3 ahead, air too', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.atk, sk.bb.def, sk.bb.hp_ratio, sk.bb.damage_by_atk_scale, sk.rangeGrid],
|
||||
['DEFAULT', 'duration', 10, elite ? 0.6 : 0.45, elite ? 0.6 : 0.45, 0.25, elite ? 3.4 : 3.2, null], `T${tier}`);
|
||||
const front = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const own = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
const flyIn = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
const side = h.spawn('enemy_dummy', { pos: [9, 6] }); // 3-6, not 1-1
|
||||
const ahead2 = h.spawn('enemy_dummy', { pos: [10, 7] }); // 3-6, not 1-1
|
||||
const flySide = h.spawn('enemy_fly', { pos: [11, 7] }); // 3-6 (air)
|
||||
const outside = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in her range`);
|
||||
const t0 = h.b.time;
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: no bonus before the first update`);
|
||||
const at = [], hits = [];
|
||||
let ended = false, lossAt = null;
|
||||
h.b.on('damaged', (c) => { if (c.target === u && c.dmg?.tags?.includes('hpLoss')) lossAt = h.b.time - t0; });
|
||||
h.b.on('skillEnd', (c) => { if (c.unit === u) ended = true; });
|
||||
h.b.on('damaged', (c) => {
|
||||
if (c.source !== u || c.target?.side !== 'enemy' || ended) return;
|
||||
hits.push(c);
|
||||
if (c.target === front) at.push({ t: h.b.time - t0, amt: c.amount, atk: u.s.atk, def: u.s.def });
|
||||
});
|
||||
h.runUntil(() => !u.skill.active, 12);
|
||||
const span = h.b.time - t0;
|
||||
assert.ok(span > 9.9 && span < 10.1, `T${tier}: the state lasts its 10 s (${span})`);
|
||||
approx(lossAt, 9, `T${tier}: the finish at 9 s`, 0.05);
|
||||
approx(at[8].t, 9, `T${tier}: the burst at 9 s`, 0.05);
|
||||
assert.ok(hits.every((c) => c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: no normal attack while it runs`);
|
||||
const cutsOn = (e) => hits.filter((c) => c.target === e).length;
|
||||
assert.deepEqual([cutsOn(front), cutsOn(own)], [9, 9], `T${tier}: 8 cuts + the burst on her 1-1`);
|
||||
assert.deepEqual([cutsOn(flyIn), cutsOn(side), cutsOn(ahead2), cutsOn(flySide), cutsOn(outside)], [1, 1, 1, 1, 0], `T${tier}: the burst alone on the 3 × 3 (air too); nothing outside`);
|
||||
const D = sk.duration - 1.05;
|
||||
for (let k = 1; k <= 8; k++) {
|
||||
const c = at[k - 1];
|
||||
approx(c.t, k, `T${tier}: cut ${k} at ${k} s`, 0.05);
|
||||
approx(c.atk, u.base.atk * (1 + sk.bb.atk * Math.min(1, k / D)), `T${tier}: ATK at cut ${k}`);
|
||||
approx(c.def, u.base.def * (1 + sk.bb.def * Math.min(1, k / D)), `T${tier}: DEF at cut ${k}`);
|
||||
approx(c.amt, c.atk, `T${tier}: cut ${k} = 100 % ATK`);
|
||||
}
|
||||
approx(at[8].amt, u.base.atk * (1 + sk.bb.atk) * sk.bb.damage_by_atk_scale, `T${tier}: the burst at full ATK`);
|
||||
const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hpLoss'));
|
||||
assert.equal(loss.length, 1, `T${tier}: one 流失`);
|
||||
approx(loss[0].amount, u.s.maxHp * 0.25, `T${tier}: 25 % of MAX HP`);
|
||||
assert.equal(u.findBuff('skill:huang:ramp'), null, `T${tier}: the bonus goes with the skill`);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her range throughout`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 沸腾爆裂: the 流失 never takes her below 1 HP; a 晕眩 ends the 维持技能状态 early — it still finishes (流失 + burst)', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, 'DEFAULT: no enemy, no cast');
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||||
h.run(3.2);
|
||||
const n0 = hitsBy(h, u).filter((c) => c.target === e).length;
|
||||
assert.equal(n0, 3, 'three cuts');
|
||||
h.b.applyStatus(u, 'stun', { duration: 2, source: e });
|
||||
h.step();
|
||||
assert.ok(!u.skill.active, 'the stun ended it');
|
||||
const after = hitsBy(h, u).filter((c) => c.target === e);
|
||||
assert.equal(after.length, 4, 'the burst on its end');
|
||||
approx(after[3].amount, u.base.atk * (1 + 0.6 * 3 / 8.95) * 3.4, 'the burst at the ATK of that moment (three updates)');
|
||||
done(h);
|
||||
// the non-lethal 流失: at 10 % HP the skill end leaves her at 1 HP (her 紧急除颤 then fires: HP ≤ 25 %)
|
||||
const r = field({ tier: 5, elite: false, skill: 2 });
|
||||
r.h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
r.u.mem.huangDefib.undeadA = false; // talent already spent: only the 流失's own floor
|
||||
r.u.hp = r.u.s.maxHp * 0.1;
|
||||
r.u.skill.gainSp(999);
|
||||
assert.ok(r.h.runUntil(() => r.u.skill.active, 3));
|
||||
r.h.runUntil(() => !r.u.skill.active, 12);
|
||||
assert.ok(r.u.alive && Math.abs(r.u.hp - 1) < 1e-6, `left at 1 HP (${r.u.hp})`);
|
||||
done(r.h);
|
||||
});
|
||||
|
||||
test('T1 紧急除颤: 不死 until her HP first reaches ≤ 25 % — then +50 % max HP and for 6 s no damage takes her below 50 % (a 流失 still does); once per deployment, again after a redeploy', () => {
|
||||
for (const f of [[5, false, null], [6, true, CENX], [5, true, CENY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
assert.deepEqual([t0.hp_ratio, t0['huang_t_1[heal].hp_ratio'], t0['huang_t_1[lock].min_hp_ratio'], t0['huang_t_1[lock].duration']], [0.25, 0.5, 0.5, 6], label(f));
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const max = u.s.maxHp;
|
||||
// a lethal hit from full HP: 不死 keeps her at 1 HP, the talent heals 50 %
|
||||
h.b.dealDamage(e, u, { amount: max * 5, type: 'true' });
|
||||
assert.ok(u.alive, `${label(f)}: 不死`);
|
||||
approx(u.hp, 1 + max * 0.5, `${label(f)}: 1 HP + 50 %`);
|
||||
const lockStart = h.b.time;
|
||||
// inside 6 s: a 60 % hit stops at exactly 50 %
|
||||
h.b.dealDamage(e, u, { amount: max * 0.6, type: 'true' });
|
||||
approx(u.hp, max * 0.5, `${label(f)}: held at 50 %`);
|
||||
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' });
|
||||
approx(u.hp, max * 0.5, `${label(f)}: below 50 % nothing passes`);
|
||||
h.b.loseHp(u, max * 0.1, { source: e });
|
||||
approx(u.hp, max * 0.4, `${label(f)}: a 流失 is not held`);
|
||||
h.run(6 - (h.b.time - lockStart) + 0.1);
|
||||
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' });
|
||||
approx(u.hp, max * 0.1, `${label(f)}: the lock is over`);
|
||||
h.b.dealDamage(e, u, { amount: max, type: 'true' });
|
||||
assert.ok(!u.alive, `${label(f)}: spent — the next lethal hit kills`);
|
||||
// a redeploy re-arms it
|
||||
h.b.redeploy(u);
|
||||
h.step();
|
||||
h.b.dealDamage(e, u, { amount: u.s.maxHp * 0.8, type: 'true' });
|
||||
approx(u.hp, u.s.maxHp * 0.7, `${label(f)}: again after the redeploy (20 % + 50 %)`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 严酷训练: 15 s after each deployment 抵抗 (control statuses last half as long); CEN-X stage 3 adds ATK +6 % after 30 s and ASPD +12 after 45 s (stage 1: nothing more)', () => {
|
||||
for (const f of [[5, false, null], [5, true, CENX], [6, true, CENX], [6, true, CENY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const x3 = tier === 6 && mod === CENX;
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(14.8);
|
||||
assert.equal(h.b.resistOf(u), 0, `${label(f)}: no 抵抗 before 15 s`);
|
||||
h.run(0.4);
|
||||
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 at 15 s`);
|
||||
h.b.applyStatus(u, 'stun', { duration: 2, source: e });
|
||||
approx(u.findBuff('stun').timeLeft, 1, `${label(f)}: a 2 s stun lasts 1 s`);
|
||||
h.run(15);
|
||||
assert.equal(!!u.findBuff('talent:huang:atk'), x3, `${label(f)}: ATK +6 % after 30 s`);
|
||||
if (x3) approx(u.s.atk, u.base.atk * 1.06, 'ATK +6 %');
|
||||
h.run(15);
|
||||
assert.equal(!!u.findBuff('talent:huang:aspd'), x3, `${label(f)}: ASPD +12 after 45 s`);
|
||||
if (x3) assert.equal(u.s.aspd, 112);
|
||||
// per deployment: knocked out and back, the timer starts again
|
||||
h.b.kill(u, e);
|
||||
assert.ok(!u.alive);
|
||||
h.b.redeploy(u);
|
||||
h.step();
|
||||
assert.deepEqual([h.b.resistOf(u), !!u.findBuff('talent:huang:atk')], [0, false], `${label(f)}: reset by the redeploy`);
|
||||
h.run(15.2);
|
||||
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 again 15 s later`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('CEN-X (stages 1 and 3): ×1.1 ATK scale on every damage of hers to a blocked enemy (normal attacks and S3 cuts) — none to an unblocked one; none without the module', () => {
|
||||
for (const f of [[5, true, null], [5, true, CENX], [6, true, CENX], [6, true, CENY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const mul = mod === CENX ? 1.1 : 1;
|
||||
if (mod === CENX) assert.deepEqual(formOf(tier, true).modules.find((m) => m.uniEquipId === CENX).traitOverride.bb, { atk_scale: 1.1 });
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9 });
|
||||
const blocked = h.spawn('enemy_dummy', { pos: [9, 5] });
|
||||
h.step();
|
||||
assert.equal(blocked.blockedBy, u, `${label(f)}: blocked`);
|
||||
h.run(3);
|
||||
const plain = hitsBy(h, u).filter((c) => c.target === blocked && !c.dmg.isSkill);
|
||||
assert.ok(plain.length >= 2, label(f));
|
||||
for (const c of plain) approx(c.amount, u.s.atk * mul, `${label(f)}: blocked target`);
|
||||
const free = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||||
h.run(1.1);
|
||||
const cut = hitsBy(h, u).filter((c) => c.dmg.isSkill);
|
||||
approx(cut.find((c) => c.target === blocked).amount, u.s.atk * mul, `${label(f)}: S3 cut on the blocked one`);
|
||||
approx(cut.find((c) => c.target === free).amount, u.s.atk, `${label(f)}: S3 cut on the free one`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('CEN-Y stage 1: −20 % physical damage taken above 50 % HP (arts untouched); stage 3: 紧急除颤 at 50 % then a second trigger at 25 % (its own 不死, 8 s), and 150 DEF ignored while above 50 %', () => {
|
||||
for (const tier of [5, 6]) {
|
||||
const { h, u } = field({ tier, elite: true, mod: CENY, skill: 0 });
|
||||
assert.deepEqual(u.def.raw.trait.bb, { hp_ratio: 0.5, damage_resistance: 0.2 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.step();
|
||||
assert.equal(u.s.defIgnoreFlat, tier === 6 ? 150 : 0, `T${tier}: 150 DEF ignored above 50 % from stage 2 on`);
|
||||
const max = u.s.maxHp;
|
||||
let hp = u.hp;
|
||||
h.b.dealDamage(e, u, { amount: 1000, type: 'phys' });
|
||||
approx(hp - u.hp, Math.max(1000 - u.s.def, 50) * 0.8, `T${tier}: physical ×0.8`);
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(e, u, { amount: 300, type: 'arts' });
|
||||
approx(hp - u.hp, 300, `T${tier}: arts untouched`);
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
|
||||
approx(hp - u.hp, 200, `T${tier}: true untouched`);
|
||||
u.mem.huangDefib.undeadA = u.mem.huangDefib.undeadB = false; // (stage 3's talent would fire at 50 %)
|
||||
u.hp = max * 0.5;
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(e, u, { amount: 1000, type: 'phys' });
|
||||
approx(hp - u.hp, Math.max(1000 - u.s.def, 50), `T${tier}: at 50 % (not above) the full physical damage`);
|
||||
done(h);
|
||||
}
|
||||
// stage 3: the talent fires at 50 % (8 s), the hidden part at 25 % — only once the first is spent
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: CENY, skill: 0 });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
const hid = Object.assign({}, ...u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb));
|
||||
assert.deepEqual([t0.hp_ratio, t0['huang_t_1[lock].duration'], hid.def_penetrate_fixed, hid['huang_e_003[lock].check_hp_ratio'], hid['huang_e_003[lock].duration']], [0.5, 8, 150, 0.25, 8]);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.step();
|
||||
assert.equal(u.s.defIgnoreFlat, 150, 'above 50 %: 150 DEF ignored');
|
||||
const max = u.s.maxHp;
|
||||
h.b.dealDamage(e, u, { amount: max * 0.6, type: 'true' }); // 100 % → 40 %: the 50 % trigger
|
||||
approx(u.hp, max * 0.9, '50 % trigger: +50 %');
|
||||
h.run(8.2);
|
||||
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' }); // 90 % → 60 %
|
||||
h.step();
|
||||
assert.equal(u.s.defIgnoreFlat, 150);
|
||||
h.b.dealDamage(e, u, { amount: max * 0.2, type: 'true' }); // 60 % → 40 %: nothing (the first is spent)
|
||||
approx(u.hp, max * 0.4, 'no second 50 % trigger');
|
||||
h.step();
|
||||
assert.equal(u.s.defIgnoreFlat, 0, 'at 40 % the DEF penetration is off');
|
||||
h.b.dealDamage(e, u, { amount: max * 5, type: 'true' }); // lethal: its own 不死, then the 25 % trigger
|
||||
assert.ok(u.alive, 'the second 不死');
|
||||
approx(u.hp, 1 + max * 0.5, '25 % trigger: +50 %');
|
||||
h.b.dealDamage(e, u, { amount: max * 0.4, type: 'true' });
|
||||
approx(u.hp, max * 0.5, 'its 8 s lock holds 50 %');
|
||||
h.run(8.2);
|
||||
h.b.dealDamage(e, u, { amount: max, type: 'true' });
|
||||
assert.ok(!u.alive, 'both spent');
|
||||
done(h);
|
||||
// a single hit from above 50 % to 10 %: the 50 % trigger first (+50 % ⇒ 60 %), the 25 % one waits for a later drop
|
||||
const r = field({ tier: 6, elite: true, mod: CENY, skill: 0 });
|
||||
const e2 = r.h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const m2 = r.u.s.maxHp;
|
||||
r.h.b.dealDamage(e2, r.u, { amount: m2 * 0.9, type: 'true' });
|
||||
approx(r.u.hp, m2 * 0.6, 'the 50 % trigger first');
|
||||
assert.deepEqual([r.u.mem.huangDefib.undeadA, r.u.mem.huangDefib.undeadB], [false, true], 'the 25 % trigger still armed');
|
||||
done(r.h);
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// test/content/op_ifrit.test.js — the 自选 operator kit of 伊芙利特 (char_134_ifrit, 6★ 轰击术师; kit
|
||||
// server/sim/content/kits/ops/op-ifrit.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, BLA-X
|
||||
// “同熔” or BLA-D “热成形记忆” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
|
||||
// that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_ifrit.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 IFRIT = 'char_134_ifrit';
|
||||
const FORMS = BACKUPS.units[IFRIT].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const BLAX = 'uniequip_002_ifrit', BLAD = 'uniequip_003_ifrit';
|
||||
const S1 = 'skchr_ifrit_1', S2 = 'skchr_ifrit_2', S3 = 'skchr_ifrit_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_res: dummy('enemy_res', { res: 50 }), enemy_def: dummy('enemy_def', { def: 300 }),
|
||||
enemy_hitter: dummy('enemy_hitter', { atk: 1000, range: 0, bat: 1 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAX, BLAD].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 伊芙利特 as uid 1 at (row, col) facing RIGHT (her line: that row, col … col + 5), plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5, flags = {} } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999, ...flags }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: IFRIT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => (c.source === u || c.credit === u) && c.dmg?.tags?.includes(tag));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('伊芙利特 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 5-1 line, 轰击术师 (every enemy of the line, air too, arts, instant), no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[IFRIT], KITS[IFRIT]);
|
||||
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], [IFRIT, 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), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile, u.base.bat], ['ranged', 'arts', true, true, 'beam', 2.9], `${label(f)}: 轰击术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 5-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[IFRIT].bonds, []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1276 / 722, E2 Lv60 1544 / 820; BLA-X +50 ATK +5 ASPD → +72 / +7, BLA-D +120 HP +45 ATK → +200 / +70
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1276, 722, 1544, 820]);
|
||||
assert.deepEqual([modOf(5, BLAX).attr, modOf(6, BLAX).attr, modOf(5, BLAD).attr, modOf(6, BLAD).attr], [{ atk: 50, aspd: 5 }, { atk: 72, aspd: 7 }, { maxHp: 120, atk: 45 }, { maxHp: 200, atk: 70 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 伊芙利特 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(IFRIT));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IFRIT), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } } });
|
||||
});
|
||||
|
||||
test('trait 轰击术师: one attack strikes every enemy of her 5-1 line at once — ground and air, the same damage near and far (no module) — and nothing off the line', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_dummy', { pos: [11, 5] }), behind = h.spawn('enemy_dummy', { pos: [10, 3] });
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length > 0, 4), `T${tier}: she attacks`);
|
||||
const first = atkHits(h, u);
|
||||
const hit = first.filter((c) => c.dmg.attackId === first[0].dmg.attackId);
|
||||
assert.deepEqual(hit.map((c) => c.target.id).sort(), [near, far, fly].map((e) => e.id).sort(), `T${tier}: her line, air included`);
|
||||
for (const c of hit) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK at any distance`); assert.equal(c.type, 'arts'); }
|
||||
for (const e of [off, behind]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: off the line`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +10 % / +20 %, ASPD +58 / +67; all back after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.attack_speed, u.skill.spCost], ['DEFAULT', 20, elite ? 0.2 : 0.1, elite ? 67 : 58, elite ? 44 : 47], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast on her attack`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
|
||||
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
|
||||
assert.equal(u.s.aspd, 100, `T${tier}: ASPD back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 炎爆 (AUTO, 2 / 3 charges, data DEFAULT): the next attack hits her whole line for 160 % / 190 %, DEF −100 / −200 for 3 s and the 灼伤 (33 % of her current ATK per s, 3 ticks, no dodge) on every enemy hit — the burn ticks on after she leaves', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, u.skill.spCost, sk.bb.atk_scale, sk.bb.def, sk.bb['burn.atk_scale'], sk.bb.duration],
|
||||
['DEFAULT', 'charges', elite ? 3 : 2, 8, elite ? 1.9 : 1.6, elite ? -200 : -100, 0.33, 3.01], `T${tier}`);
|
||||
const a = h.spawn('enemy_def', { pos: [10, 6] }), b = h.spawn('enemy_fly', { pos: [10, 8] }), off = h.spawn('enemy_dummy', { pos: [11, 6] });
|
||||
u.skill.gainSp(999);
|
||||
const n0 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 4), `T${tier}: cast on her next attack`);
|
||||
const hits = atkHits(h, u).slice(n0);
|
||||
const t0 = h.b.time;
|
||||
for (const c of hits) assert.equal(c.dmg.isSkill, true);
|
||||
approx(hits.find((c) => c.target === b).amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % on the flyer`);
|
||||
approx(a.s.def, 300 + sk.bb.def < 0 ? 0 : 300 + sk.bb.def, `T${tier}: DEF cut`);
|
||||
assert.equal(off.findBuff('ifrit:pyroDef'), null, `T${tier}: off the line`);
|
||||
assert.ok(a.findBuff('ifrit:pyroBurn') && b.findBuff('ifrit:pyroBurn'), `T${tier}: both burn`);
|
||||
// the burn: 3 ticks of 33 % of her current ATK; she leaves the field after the first one
|
||||
h.runUntil(() => tagged(h, u, 'ifrit:burn').filter((c) => c.target === b).length >= 1, 2);
|
||||
const atkNow = u.s.atk;
|
||||
h.b.retreat(u);
|
||||
h.run(3);
|
||||
const burns = tagged(h, u, 'ifrit:burn').filter((c) => c.target === b);
|
||||
assert.equal(burns.length, 3, `T${tier}: 3 ticks`);
|
||||
approx(burns[0].amount, atkNow * 0.33, `T${tier}: 33 % ATK arts`);
|
||||
for (const c of burns) { assert.deepEqual([c.type, c.dmg.canDodge], ['arts', false]); assert.ok(c.t - t0 <= 3.05, `T${tier}: within 3 s`); }
|
||||
assert.equal(a.findBuff('ifrit:pyroDef'), null, `T${tier}: DEF cut over after 3 s`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 灼地 (MANUAL, data DEFAULT, 20 s, 维持技能状态): no normal attack; from the Begin clip\'s event (0.4 s) every second each ground enemy of her line takes 90 % / 110 % ATK arts and RES −7 / −10 for 1 s; she loses 2 % max HP per s; 20 ticks; a stun ends it at once', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk_scale, sk.bb.magic_resistance, sk.bb.hp_ratio], ['DEFAULT', 20, elite ? 1.1 : 0.9, elite ? -10 : -7, 0.02], `T${tier}`);
|
||||
const g = h.spawn('enemy_res', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
const n0 = atkHits(h, u).length, hp0 = u.hp, cast = h.hooksOf('skillStart').at(-1).t;
|
||||
h.run(1.02);
|
||||
// RES 50 × 0.6 (精神融解) − 7 / −10: (50 − cut) × 0.6
|
||||
approx(g.s.res, (50 + sk.bb.magic_resistance) * 0.6, `T${tier}: RES cut + 精神融解`, 1e-9);
|
||||
approx(off.s.res, 50, `T${tier}: off the line`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`);
|
||||
const ticks = () => tagged(h, u, 'ifrit:scorch').filter((c) => c.type === 'arts');
|
||||
assert.equal(ticks().length, 1, `T${tier}: one tick after 1 s`);
|
||||
approx(ticks()[0].t - cast, 0.4, `T${tier}: the first tick at the Begin clip's attack event`, 0.05);
|
||||
approx(ticks()[0].amount, u.s.atk * sk.bb.atk_scale * (1 - (50 + sk.bb.magic_resistance) * 0.6 / 100), `T${tier}: ${sk.bb.atk_scale * 100} % ATK arts`);
|
||||
assert.ok(!ticks().some((c) => c.target === fly || c.target === off), `T${tier}: ground enemies of her line only`);
|
||||
approx(hp0 - u.hp, u.s.maxHp * 0.02, `T${tier}: 2 % max HP`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
assert.equal(ticks().length, 20, `T${tier}: 20 ticks`);
|
||||
const endT = h.hooksOf('skillEnd').at(-1).t;
|
||||
assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`);
|
||||
// a second cast, cut short by a stun
|
||||
u.hp = u.s.maxHp;
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 6), `T${tier}: cast again`);
|
||||
h.run(2.5);
|
||||
h.b.applyStatus(u, 'stun', { duration: 1, force: true });
|
||||
h.step();
|
||||
assert.equal(u.skill.active, false, `T${tier}: the stun ends 灼地`);
|
||||
assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'interrupted');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 精神融解: RES ×0.6 on every enemy of her line (gone once it leaves), none off it; BLA-D stage 3: her attacks and 灼地 ticks on an enemy in its 灼燃 burst add 50 % ATK 元素伤害 (a 持续伤害)', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0);
|
||||
assert.equal(t0.bb.magic_resistance, -0.4, label(f));
|
||||
const on = h.spawn('enemy_res', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 7] });
|
||||
h.run(0.3);
|
||||
approx(on.s.res, 30, `${label(f)}: 50 → 30`);
|
||||
approx(off.s.res, 50, `${label(f)}: off the line`);
|
||||
h.b.retreat(u);
|
||||
h.run(0.6);
|
||||
approx(on.s.res, 50, `${label(f)}: gone with her`);
|
||||
done(h);
|
||||
}
|
||||
for (const [tier, mod, extra] of [[6, BLAD, 0.5], [5, BLAD, 0], [6, BLAX, 0], [6, null, 0]]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const calm = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.b.addBuff(e, { key: 'burnBurst', duration: 60, flags: { burstLock: true } });
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.runUntil(() => atkHits(h, u).length > n0, 4);
|
||||
h.step();
|
||||
const el = () => tagged(h, u, 'ifrit:meltdown');
|
||||
assert.equal(el().length, extra ? 1 : 0, `T${tier} ${mod}: the extra hit on her attack`);
|
||||
if (extra) {
|
||||
approx(el()[0].amount, u.s.atk * extra, 'T6 BLA-D: 50 % ATK');
|
||||
assert.deepEqual([el()[0].type, el()[0].dmg.element, el()[0].dmg.tags.includes('dot'), el()[0].target], ['elemental', 'burn', true, e]);
|
||||
assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /灼燃损伤.*爆发期间.*50%的元素伤害/);
|
||||
}
|
||||
// the 灼地 ticks are attacks of hers too (the equip adds it to every ability family)
|
||||
u.skill.gainSp(999);
|
||||
h.runUntil(() => u.skill.active, 4);
|
||||
h.run(2.5);
|
||||
const onTicks = el().filter((c) => c.t > h.hooksOf('skillStart').at(-1).t);
|
||||
assert.equal(onTicks.length, extra ? 3 : 0, `T${tier} ${mod}: one per 灼地 tick (0.4 / 1.4 / 2.4 s)`);
|
||||
assert.ok(!el().some((c) => c.target === calm), `T${tier} ${mod}: none on an enemy outside a burst`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 莱茵回路: +2 SP every 6 s of a deployment; BLA-X stage 3: a 30 % roll for +5 SP more each 6 s (none in any other form)', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
|
||||
const dice = elite && tier === 6 && mod === BLAX;
|
||||
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
|
||||
assert.deepEqual([t1.sp, t1.interval, t1['ifrit_e_002[dice_sp].prob'] ?? 0, t1['ifrit_e_002[dice_sp].sp'] ?? 0], [2, 6, dice ? 0.3 : 0, dice ? 5 : 0], label(f));
|
||||
const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
|
||||
for (let i = 0; i < 60; i++) { u.skill.sp = 0; h.run(6); }
|
||||
const g = gains();
|
||||
const twos = g.filter((c) => c.amount === 2).length, fives = g.filter((c) => c.amount === 5).length;
|
||||
assert.ok(twos >= 59 && twos <= 60, `${label(f)}: ${twos} gifts of 2 SP in 360 s`);
|
||||
if (dice) assert.ok(fives >= 9 && fives <= 30, `${label(f)}: ${fives} / 60 rolls of +5 ≈ 30 %`);
|
||||
else assert.equal(fives, 0, `${label(f)}: no roll`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('BLA-X “同熔” (stages 1 and 3): ×(1 + 0.1 × d / 4) by the distance to the target, capped at 4 tiles — on her attacks, the 灼伤 while she stands and the 灼地 ticks; none without it', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const blax = elite && mod === BLAX;
|
||||
if (blax) assert.deepEqual(u.def.raw.trait.bb, { min_dist: 0, max_dist: 4, damage_scale: 0.1 }, label(f));
|
||||
const at = [5, 6, 8, 9].map((c) => h.spawn('enemy_dummy', { pos: [10, c] }));
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.runUntil(() => atkHits(h, u).length >= n0 + 4, 4);
|
||||
const hits = atkHits(h, u).slice(n0, n0 + 4);
|
||||
for (const [i, d] of [[0, 1], [1, 2], [2, 4], [3, 5]]) {
|
||||
const c = hits.find((x) => x.target === at[i]);
|
||||
approx(c.amount, u.s.atk * (blax ? 1 + 0.1 * Math.min(1, d / 4) : 1), `${label(f)}: d = ${d}`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
// the 灼伤 while she stands and after she left; the 灼地 tick
|
||||
for (const [skill, tag] of [[1, 'ifrit:burn'], [2, 'ifrit:scorch']]) {
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: BLAX, skill });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // 4 tiles: ×1.1
|
||||
u.skill.gainSp(999);
|
||||
h.runUntil(() => tagged(h, u, tag).filter((c) => c.type === 'arts').length >= 1, 6);
|
||||
const c = tagged(h, u, tag).find((x) => x.type === 'arts' && x.target === e);
|
||||
const scale = skill === 1 ? 0.33 : skillOf(6, true, S3).bb.atk_scale;
|
||||
approx(c.amount, u.s.atk * scale * 1.1, `${tag}: ×1.1 at 4 tiles`);
|
||||
if (skill === 1) {
|
||||
h.b.retreat(u);
|
||||
h.run(2.1);
|
||||
const after = tagged(h, u, tag).filter((x) => x.target === e).at(-1);
|
||||
approx(after.amount, u.s.atk * scale, '灼伤 after she left: no module effect');
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('BLA-D “热成形记忆” (stages 1 and 3): every arts damage she deals while on the field attaches 8 % of it as 灼燃损伤 — her attacks and the 灼伤 — and no 元素损伤 without it', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
const blad = elite && mod === BLAD;
|
||||
if (blad) assert.equal(u.def.raw.trait.bb.ep_damage_ratio, 0.08, label(f));
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
u.skill.gainSp(999);
|
||||
h.runUntil(() => tagged(h, u, 'ifrit:burn').length >= 2, 6);
|
||||
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts').reduce((s, c) => s + c.amount, 0);
|
||||
approx(e.elem.burn, blad ? arts * 0.08 : 0, `${label(f)}: 8 % of ${Math.round(arts)} arts damage as 灼燃损伤`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
// test/content/op_irene.test.js — the 自选 operator kit of 艾丽妮 (char_4009_irene, 6★ 剑豪; kit
|
||||
// server/sim/content/kits/ops/op-irene.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SWO-Y
|
||||
// “厚重的经卷”, SWO-X “审判官口粮” or ISW-A “艾丽妮特限证章” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
|
||||
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_irene.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 IRENE = 'char_4009_irene';
|
||||
const FORMS = BACKUPS.units[IRENE].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SWOY = 'uniequip_002_irene', SWOX = 'uniequip_003_irene', ISWA = 'uniequip_004_irene';
|
||||
const S1 = 'skchr_irene_1', S2 = 'skchr_irene_2', S3 = 'skchr_irene_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_def: dummy('enemy_def', { def: 400 }), enemy_fly_def: dummy('enemy_fly_def', { motion: 'FLY', def: 400 }),
|
||||
enemy_heavy: dummy('enemy_heavy', { mass: 4 }), enemy_fly_weak: dummy('enemy_fly_weak', { motion: 'FLY', hp: 2000 }),
|
||||
enemy_frail: dummy('enemy_frail', { hp: 1000 }), enemy_weak2k: dummy('enemy_weak2k', { hp: 2000 }), enemy_sea: { ...dummy('enemy_sea'), tags: ['seamonster'] },
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SWOY, SWOX, ISWA].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const skillMulOf = (elite, mod) => (elite && mod === SWOX ? 1.1 : 1);
|
||||
/** 审判之火's DEF share ignored in a form (SWO-X stage 3: 55 %). */
|
||||
const penOf = (tier, elite, mod) => (elite && mod === SWOX && tier === 6 ? 0.55 : 0.5);
|
||||
|
||||
/** A battle with 艾丽妮 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 },
|
||||
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: IRENE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('艾丽妮 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, two hits per attack, melee ground-only, 无 core bond (emptyShip), no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[IRENE], KITS[IRENE]);
|
||||
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], [IRENE, 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), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.hits, u.profile.dmgType, u.base.bat], [2, 'melee', false, 2, 'phys', 1.3], `${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.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
assert.deepEqual(u.def.raw.tokens, [], `${label(f)}: no summons`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 2201 / 484 / 282, E2 Lv60 2688 / 575 / 326; SWO-Y +50 / +35 → +65 / +50
|
||||
// ATK / DEF, SWO-X +50 ATK +5 ASPD → +80 / +7, ISW-A +240 HP +40 ATK → +300 / +65
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2201, 484, 2688, 575]);
|
||||
assert.deepEqual([SWOY, SWOX, ISWA].map((m) => [modOf(5, m).attr, modOf(6, m).attr]),
|
||||
[[{ atk: 50, def: 35 }, { atk: 65, def: 50 }], [{ atk: 50, aspd: 5 }, { atk: 80, aspd: 7 }], [{ maxHp: 240, atk: 40 }, { maxHp: 300, atk: 65 }]]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 艾丽妮 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(IRENE));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IRENE), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: IRENE, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: IRENE, skillIndex: 1, uniEquipId: ISWA } } });
|
||||
});
|
||||
|
||||
test('T1 审判之火 vs an air unit: every physical instance ignores 50 % (SWO-X stage 3: 55 %) of its DEF with no random draw; a levitated enemy is an air unit; arts untouched', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const pen = penOf(tier, elite, mod), flat = elite && mod === SWOY ? 70 : 0;
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
assert.deepEqual([t0.prob, t0.def_penetrate], [0.5, pen], `${label(f)}: the data`);
|
||||
const fly = h.spawn('enemy_fly_def', { pos: [9, 8] });
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const st = h.b.rng.state();
|
||||
approx(h.b.dealDamage(u, fly, { amount: 1000, type: 'phys' }), 1000 - Math.max(0, 400 * (1 - pen) - flat), `${label(f)}: air #${i}`);
|
||||
assert.equal(h.b.rng.state(), st, `${label(f)}: no random draw against an air unit`);
|
||||
}
|
||||
const g = h.spawn('enemy_def', { pos: [9, 9] });
|
||||
h.b.applyStatus(g, 'levitate', { duration: 5, source: u });
|
||||
assert.ok(g.isFlying, 'levitated = an air unit');
|
||||
for (let i = 0; i < 5; i++) approx(h.b.dealDamage(u, g, { amount: 1000, type: 'phys' }), 1000 - Math.max(0, 400 * (1 - pen) - flat), `${label(f)}: levitated #${i}`);
|
||||
const st = h.b.rng.state();
|
||||
approx(h.b.dealDamage(u, fly, { amount: 1000, type: 'arts' }), 1000, `${label(f)}: arts (RES 0)`);
|
||||
assert.equal(h.b.rng.state(), st, `${label(f)}: arts draws nothing`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 审判之火 vs a ground enemy: one draw per physical instance at 50 %; a won charge is spent only by an instance that lands — a dodged one keeps it and the next lands pierced without a draw', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, SWOX], [6, true, ISWA]]) {
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 17 });
|
||||
const pen = penOf(tier, elite, mod);
|
||||
const g = h.spawn('enemy_def', { pos: [9, 9] });
|
||||
let pierced = 0;
|
||||
const N = 400;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const st = h.b.rng.state();
|
||||
const d = h.b.dealDamage(u, g, { amount: 1000, type: 'phys' });
|
||||
assert.notEqual(h.b.rng.state(), st, 'a draw per ground instance (no charge held)');
|
||||
if (Math.abs(d - (1000 - 400 * (1 - pen))) < 1e-6) pierced++;
|
||||
else approx(d, 600, 'or no pierce');
|
||||
}
|
||||
assert.ok(pierced > 0.4 * N && pierced < 0.6 * N, `${pierced} / ${N} pierced ≈ 50 %`);
|
||||
// a charge drawn against a flyer that dodges is kept: the next ground instance lands pierced and draws nothing
|
||||
const fly = h.spawn('enemy_fly_def', { pos: [9, 8] });
|
||||
h.b.addBuff(fly, { key: 'test:dodge', mods: { dodgePhys: 1 } });
|
||||
assert.equal(h.b.dealDamage(u, fly, { amount: 1000, type: 'phys' }), 0, 'dodged');
|
||||
assert.equal(u.mem.ireneCharge, true, 'the charge is kept');
|
||||
const st = h.b.rng.state();
|
||||
approx(h.b.dealDamage(u, g, { amount: 1000, type: 'phys' }), 1000 - 400 * (1 - pen), 'pierced');
|
||||
assert.equal(h.b.rng.state(), st, 'no draw while a charge is held');
|
||||
assert.equal(u.mem.ireneCharge, false, 'spent');
|
||||
// a held charge does not outlive her deployment (a buff on her)
|
||||
assert.equal(h.b.dealDamage(u, fly, { amount: 1000, type: 'phys' }), 0, 'dodged again');
|
||||
assert.equal(u.mem.ireneCharge, true, 'held');
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u);
|
||||
assert.ok(u.alive && u.deployed, 'redeployed');
|
||||
assert.equal(u.mem.ireneCharge, false, 'a new deployment holds no charge');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 净化之剑: ASPD +18 (SWO-Y stage 3: ATK +5 % too), doubled while a 【海怪】 enemy is on the field', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const y3 = elite && mod === SWOY && tier === 6;
|
||||
const atk = y3 ? 0.05 : 0;
|
||||
approx(u.s.aspd, u.base.aspd + 18, `${label(f)}: ASPD +18`);
|
||||
approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: ATK`);
|
||||
const sea = h.spawn('enemy_sea', { pos: [9, 9] });
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd + 36, `${label(f)}: ×2 with a 海怪 on the field`);
|
||||
approx(u.s.atk, u.base.atk * (1 + 2 * atk), `${label(f)}: ATK ×2`);
|
||||
h.b.kill(sea, null);
|
||||
h.step();
|
||||
approx(u.s.aspd, u.base.aspd + 18, `${label(f)}: back once it is gone`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 起风 (AUTO, attack SP 6 / 5, data DEFAULT): the next attack hits its target for 130 % / 160 % ATK, levitates it 1 s, then hits it again for 130 % / 160 % — an air unit by then, so 审判之火 always pierces', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SWOX], [6, true, SWOY]]) {
|
||||
const sk = skillOf(tier, elite, S1), mul = skillMulOf(elite, mod), pen = penOf(tier, elite, mod), flat = elite && mod === SWOY ? 70 : 0;
|
||||
const lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.levitate], ['DEFAULT', 'attack', elite ? 5 : 6, 0, elite ? 1.6 : 1.3, 1], lb);
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_def', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && hitsBy(h, u).length >= 2, 3), `${lb}: cast on her next attack`);
|
||||
const [a, b] = hitsBy(h, u);
|
||||
assert.deepEqual([a.target, b.target, a.dmg.attackId === b.dmg.attackId, a.dmg.isSkill, b.dmg.isSkill, a.type, b.type], [e, e, true, true, true, 'phys', 'phys'], lb);
|
||||
const A = u.s.atk * sk.bb.atk_scale;
|
||||
const raw = (p) => (A - Math.max(0, 400 * (1 - p) - flat)) * mul;
|
||||
assert.ok([raw(0), raw(pen)].some((v) => Math.abs(v - a.amount) < 1e-6), `${lb}: the first strike (${a.amount})`);
|
||||
approx(b.amount, raw(pen), `${lb}: the second on the levitated target, pierced`);
|
||||
const lev = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'levitate');
|
||||
assert.ok(lev && lev.target === e && Math.abs(lev.duration - 1) < 1e-9, `${lb}: levitate 1 s`);
|
||||
assert.equal(hitsBy(h, u).length, 2, `${lb}: two hits in all`);
|
||||
done(h);
|
||||
}
|
||||
// 浮空 rules: a 重量 > 3 target floats half as long (engine)
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
|
||||
u.skill.gainSp(999);
|
||||
const heavy = h.spawn('enemy_heavy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
|
||||
h.step();
|
||||
const lev = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'levitate');
|
||||
assert.ok(lev && lev.target === heavy && Math.abs(lev.duration - 0.5) < 1e-9, 'half on a heavy target');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S2 裂潮 (MANUAL, 2 charges, data SKILL_RANGE on 3-12): not opened with only a flyer in range; up to 5 / 6 ground enemies there take 310 % / 340 % ATK, those of 重量 ≤ 3 float 2 / 3 s; the second charge ≥ 3 s later', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
|
||||
const sk = skillOf(tier, elite, S2), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.max_target, sk.bb.levitate, sk.bb.value, sk.rangeId],
|
||||
['SKILL_RANGE', 'charges', 2, elite ? 10 : 11, 0, elite ? 3.4 : 3.1, elite ? 6 : 5, elite ? 3 : 2, 3, '3-12'], lb);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
|
||||
u.skill.gainSp(999);
|
||||
assert.equal(u.skill.charges, 2, lb);
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
h.run(1.5);
|
||||
assert.equal(u.skill.activations, 0, `${lb}: a flyer alone does not open it (地面敌人 only)`);
|
||||
const light = h.spawn('enemy_dummy', { pos: [10, 8] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] });
|
||||
const out = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `${lb}: opened`);
|
||||
const hit = hitsBy(h, u);
|
||||
assert.deepEqual([...new Set(hit.map((c) => c.target))].sort((a, b) => a.id - b.id), [light, heavy].sort((a, b) => a.id - b.id), `${lb}: the ground enemies of 3-12`);
|
||||
for (const c of hit) { approx(c.amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} %`); assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]); }
|
||||
assert.ok(!hit.some((c) => c.target === fly || c.target === out), `${lb}: no flyer, nothing outside`);
|
||||
const lev = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'levitate');
|
||||
assert.deepEqual(lev.map((c) => [c.target, c.duration]), [[light, sk.bb.levitate]], `${lb}: only the light one floats (重量 ≤ 3)`);
|
||||
assert.equal(u.skill.charges, 1, `${lb}: one charge left`);
|
||||
const t1 = u.skill.lastStart;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 2, 5), `${lb}: the second charge`);
|
||||
assert.ok(u.skill.lastStart - t1 >= 3 - 1e-9, `${lb}: 3 s apart (automatic operation cooldown)`);
|
||||
done(h);
|
||||
}
|
||||
// seven ground enemies: max_target of them
|
||||
const sk = skillOf(5, false, S2);
|
||||
const { h, u } = field({ tier: 5, skill: 1 });
|
||||
u.skill.gainSp(999);
|
||||
for (const pos of [[11, 5], [11, 6], [10, 6], [10, 7], [10, 8], [9, 5], [9, 6]]) h.spawn('enemy_dummy', { pos });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
|
||||
assert.equal(new Set(hitsBy(h, u).filter((c) => c.dmg.isSkill).map((c) => c.target)).size, sk.bb.max_target, `${sk.bb.max_target} of 7`);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 判决 (MANUAL, data SKILL_RANGE on x-1): every ground enemy of x-1 takes 250 % ATK and floats 2 / 3 s, then 10 bombardments every 0.3 s on a random enemy of x-1, radius 1.1 (air too) at 200 % / 230 %; 3.5 s, no normal attack', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
|
||||
const sk = skillOf(tier, elite, S3), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, u.skill.sp, sk.bb.atk_scale, sk.bb.levitate, sk.bb.multi_atk_scale, sk.bb.multi_times, sk.bb.multi_hit_interval, sk.rangeId],
|
||||
['SKILL_RANGE', elite ? 30 : 32, elite ? 16 : 8, 2.5, elite ? 3 : 2, elite ? 2.3 : 2, 10, 0.3, 'x-1'], lb);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
|
||||
u.skill.gainSp(999);
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 7] }); // in x-1
|
||||
const b = h.spawn('enemy_dummy', { pos: [10, 8] }); // outside x-1, 1 tile from a
|
||||
const c = h.spawn('enemy_dummy', { pos: [10, 9] }); // 2 tiles from a
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb}: opened`);
|
||||
const t0 = u.skill.lastStart;
|
||||
h.runUntil(() => !u.skill.active, 5);
|
||||
approx(h.b.time - t0, 3.5, `${lb}: 3.5 s`, 0.02);
|
||||
const hit = hitsBy(h, u);
|
||||
const opening = hit.filter((x) => Math.abs(x.t - t0) < 1e-6);
|
||||
assert.deepEqual(opening.map((x) => x.target), [a], `${lb}: the opening strike on x-1's ground enemies only`);
|
||||
approx(opening[0].amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: 250 %`);
|
||||
const lev = h.hooksOf('statusApplied').find((x) => x.source === u && x.status === 'levitate');
|
||||
assert.deepEqual([lev.target, lev.duration], [a, sk.bb.levitate], `${lb}: floats`);
|
||||
const bomb = hit.filter((x) => x.t > t0 + 1e-6);
|
||||
assert.equal(bomb.filter((x) => x.target === a).length, 10, `${lb}: 10 bombardments on the only enemy of x-1 (an air unit by then)`);
|
||||
assert.equal(bomb.filter((x) => x.target === b).length, 10, `${lb}: each also hits within 1.1 (outside x-1 too)`);
|
||||
assert.ok(!bomb.some((x) => x.target === c), `${lb}: nothing farther than 1.1`);
|
||||
for (const x of bomb) approx(x.amount, u.s.atk * sk.bb.multi_atk_scale * mul, `${lb}: ${sk.bb.multi_atk_scale * 100} %`);
|
||||
const at = [...new Set(bomb.map((x) => Math.round((x.t - t0) * 30)))];
|
||||
assert.deepEqual(at, [9, 18, 27, 36, 45, 54, 63, 72, 81, 90], `${lb}: every 0.3 s (ticks)`);
|
||||
assert.ok(!h.hooksOf('attack').some((x) => x.attacker === u && x.t > t0 && x.t < t0 + 3.5), `${lb}: no normal attack meanwhile`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${lb}: her range stays 1-1`);
|
||||
done(h);
|
||||
}
|
||||
// opened with only a flyer in range (PRTS 备注), the bombardments reach it
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
const fly = h.spawn('enemy_fly', { pos: [9, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), 'a flyer alone opens it');
|
||||
h.runUntil(() => !u.skill.active, 5);
|
||||
assert.equal(hitsBy(h, u).filter((x) => x.target === fly).length, 10, 'ten bombardments on the flyer');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('SWO-X stage 3 审判之火: an air unit knocked out during her skill gives 6 SP once the skill ends — a levitated one too — not at stage 1, not a ground kill, not without the module', () => {
|
||||
for (const [tier, elite, mod] of [[6, true, SWOX], [5, true, SWOX], [6, true, SWOY], [6, false, null]]) {
|
||||
const lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const sp = elite && mod === SWOX && tier === 6 ? 6 : 0;
|
||||
if (sp) assert.equal(formOf(6, true).modules.find((m) => m.uniEquipId === SWOX).talentChanges[0].bb.sp, 6);
|
||||
// a flyer (the bombardments); a ground enemy the opening strike levitates and the bombardments finish (an air unit when
|
||||
// it falls); a ground enemy the opening strike itself knocks out (before it floats)
|
||||
for (const [key, pos, air] of [['enemy_fly_weak', [9, 6], true], ['enemy_weak2k', [10, 7], true], ['enemy_frail', [10, 7], false]]) {
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn(key, { pos });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb} ${key}: opened`);
|
||||
h.runUntil(() => !u.skill.active, 5);
|
||||
h.step();
|
||||
assert.ok(!e.alive, `${lb} ${key}: knocked out during the skill`);
|
||||
assert.equal(e.isFlying, air, `${lb} ${key}: an air unit when it fell`);
|
||||
approx(u.skill.sp, air ? sp : 0, `${lb} ${key}: SP after the skill`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('modules: SWO-X skill damage ×1.1, normal attacks unchanged; SWO-Y ignores 70 DEF (stages 1 and 3); ISW-A only its attributes (集成战略-only parts: no deploy SP, 50 % / 50 %)', () => {
|
||||
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
assert.equal(u.s.defIgnoreFlat, mod === SWOY ? 70 : 0, `${label(f)}: SWO-Y`);
|
||||
if (mod === SWOX) assert.deepEqual(u.def.raw.trait.bb, { damage_scale: 1.1, value: 0.1 });
|
||||
if (mod === ISWA) {
|
||||
assert.match(u.def.raw.trait.moduleDesc, /集成战略/);
|
||||
assert.equal(u.skill.sp, skillOf(tier, elite, S2).initSp, `${label(f)}: no 部署后立即获得8点技力 here`);
|
||||
assert.equal(u.base.respawnTime, formOf(tier, elite).stats.respawnTime, `${label(f)}: no 再部署时间-60%`);
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0);
|
||||
assert.deepEqual([t0.bb.prob, t0.bb.def_penetrate], [0.5, 0.5], `${label(f)}: 审判之火 unchanged`);
|
||||
}
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 2, 3));
|
||||
approx(hitsBy(h, u)[0].amount, u.s.atk, `${label(f)}: a normal hit (DEF 0)`);
|
||||
u.skill.gainSp(999);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
|
||||
const cut = hitsBy(h, u).slice(n0).find((c) => c.dmg.isSkill && c.target === e);
|
||||
approx(cut.amount, u.s.atk * skillOf(tier, elite, S2).bb.atk_scale * skillMulOf(elite, mod), `${label(f)}: S2`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
// test/content/op_lin.test.js — the 自选 operator kit of 林 (char_4080_lin, 6★ 阵法术师; kit
|
||||
// server/sim/content/kits/ops/op-lin.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, PLX-X
|
||||
// “七窍玲珑” or PLX-Y “情与义” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form
|
||||
// of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_lin.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 LIN = 'char_4080_lin';
|
||||
const FORMS = BACKUPS.units[LIN].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const PLXX = 'uniequip_002_lin', PLXY = 'uniequip_003_lin';
|
||||
const S1 = 'skchr_lin_1', S2 = 'skchr_lin_2', S3 = 'skchr_lin_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_weak: dummy('enemy_weak', { hp: 10 }) };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PLXX, PLXY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
/** Her x-1 (and x-2) range from (10, 5) facing RIGHT, as [row, col]. */
|
||||
const X1 = [[12, 5], [11, 4], [11, 5], [11, 6], [10, 3], [10, 4], [10, 5], [10, 6], [10, 7], [9, 4], [9, 5], [9, 6], [8, 5]];
|
||||
|
||||
/** A battle with 林 as uid 1 at (10, 5) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, others = [], seed = 5, setup = null } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LIN, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const glassHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('lin:glass'));
|
||||
const glassOf = (a) => a.findBuff('lin:glass');
|
||||
/** True damage straight to `t` from `src` (no DEF / RES: the exact amount meets the 琉璃璧). */
|
||||
const hurt = (h, src, t, n) => h.b.dealDamage(src, t, { amount: n, type: 'true' });
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('林 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, x-1 range, 阵法术师 (no attack and the guard while no skill runs; every enemy of her range, air too, while one does)', () => {
|
||||
assert.equal(OPERATOR_KITS[LIN], KITS[LIN]);
|
||||
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], [LIN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.bat], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 15, 2], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.profile.noAttackUnlessSkill, u.profile.allInRange, u.profile.canHitFly, u.profile.dmgType, u.profile.projectile], [true, true, true, 'arts', 'beam'], `${label(f)}: 阵法术师`);
|
||||
const plxx = elite && mod === PLXX;
|
||||
assert.deepEqual([u.profile.guardDef, u.profile.guardRes], plxx ? [2.15, 25] : [2, 20], `${label(f)}: trait tunables`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: x-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[LIN].bonds, []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1679 / 730 / 212, E2 Lv60 1924 / 809 / 232; PLX-X +62 / +45 → +77 / +53 ATK / DEF, PLX-Y +50 / +20 → +73 / +40
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1679, 730, 1924, 809]);
|
||||
assert.deepEqual([modOf(5, PLXX).attr, modOf(6, PLXX).attr, modOf(5, PLXY).attr, modOf(6, PLXY).attr], [{ atk: 62, def: 45 }, { atk: 77, def: 53 }, { atk: 50, def: 20 }, { atk: 73, def: 40 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 林 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(LIN));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LIN), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } } });
|
||||
});
|
||||
|
||||
test('trait + PLX-X: DEF ×3 / RES +20 while no skill runs (PLX-X ×3.15 / +25); while one runs the guard is off — PLX-X keeps DEF +100 % / RES +10 (stages 1 and 3)', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1 });
|
||||
const plxx = elite && mod === PLXX;
|
||||
if (plxx) assert.deepEqual(u.def.raw.trait.bb, { def: 2.15, magic_resistance: 25, 'lin_e_002[buff].def': 1, 'lin_e_002[buff].magic_resistance': 10 }, label(f));
|
||||
approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: guard DEF`);
|
||||
approx(u.s.res, 15 + (plxx ? 25 : 20), `${label(f)}: guard RES`);
|
||||
h.run(1);
|
||||
assert.equal(atkHits(h, u).length, 0, `${label(f)}: no attack while no skill runs`);
|
||||
u.skill.gainSp(999);
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: 荫庇`);
|
||||
approx(u.s.def, u.base.def * (1 + (plxx ? 1 : 0)), `${label(f)}: skill DEF`);
|
||||
approx(u.s.res, 15 + (plxx ? 10 : 0), `${label(f)}: skill RES`);
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: the guard is back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once and kept): cast as soon as an enemy is in her range; ATK +25 % / +40 %, interval 2 → 3 s, every enemy of her range (air too) 停顿 0.8 / 0.9 s per attack', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.bb.atk, sk.bb['attack@sluggish'], sk.bb.base_attack_time, u.skill.spCost], ['SEARCH', 'toggle', elite ? 0.4 : 0.25, elite ? 0.9 : 0.8, 1, elite ? 6 : 8], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`);
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 7] }), b = h.spawn('enemy_fly', { pos: [11, 5] }), off = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast at once (SEARCH)`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
approx(u.s.interval, 3, `T${tier}: 3 s`);
|
||||
h.runUntil(() => atkHits(h, u).length >= 2, 4);
|
||||
const hits = atkHits(h, u);
|
||||
assert.deepEqual(hits.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: both in range, air too`);
|
||||
for (const c of hits) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK`); assert.equal(c.type, 'arts', `T${tier}: arts`); }
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 while it runs`);
|
||||
const slow = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
|
||||
assert.deepEqual(slow.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: 停顿 on each`);
|
||||
for (const c of slow) approx(c.duration, sk.bb['attack@sluggish'], `T${tier}: 停顿 ${sk.bb['attack@sluggish']} s`);
|
||||
assert.ok(!hits.some((c) => c.target === off), `T${tier}: outside x-1`);
|
||||
h.run(120);
|
||||
assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: kept on`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +50 / +90, taunt −1; every allied unit of her range gets its own 琉璃璧 (threshold 200, Lin\'s ATK, centred on it, its own regrowth), lost at the end; none outside', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, PLXY]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const t0 = formOf(tier, elite).modules?.find((m) => m.uniEquipId === mod)?.talentChanges.find((t) => t.talentIndex === 0)?.bb ?? formOf(tier, elite).talents[0].bb;
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 9 }];
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1, others });
|
||||
const near = h.unit(2), far = h.unit(3);
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.attack_speed, sk.bb.taunt_level], ['SEARCH', 25, elite ? 90 : 50, -1], `T${tier}`);
|
||||
assert.equal(glassOf(near), null, `T${tier}: no copy before the skill`);
|
||||
u.skill.gainSp(999);
|
||||
const e0 = h.spawn('enemy_dummy', { pos: [11, 5] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast`);
|
||||
h.run(0.2);
|
||||
assert.deepEqual([u.s.aspd, u.s.taunt], [100 + sk.bb.attack_speed, -1], `T${tier}: ASPD / taunt`);
|
||||
assert.deepEqual([glassOf(near)?.shield, glassOf(near)?.source === u, glassOf(far)], [200, true, null], `T${tier}: a copy in range only`);
|
||||
// the copy breaks on a hit over 200: burst on Lin's range centred on the ally, Lin's ATK, source Lin
|
||||
const inBurst = h.spawn('enemy_dummy', { pos: [10, 8] }), outBurst = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
h.step();
|
||||
const hp0 = near.hp;
|
||||
hurt(h, e0, near, 300);
|
||||
approx(hp0 - near.hp, 100, `T${tier}: 300 − 200 through`);
|
||||
assert.equal(glassOf(near), null, `T${tier}: broken`);
|
||||
const gh = glassHits(h, u);
|
||||
assert.ok(gh.some((c) => c.target === inBurst) && !gh.some((c) => c.target === outBurst), `T${tier}: centred on the ally`);
|
||||
approx(gh.find((c) => c.target === inBurst).amount, u.s.atk * t0.atk_scale * (mod === PLXY ? 1 + 0.03 : 1), `T${tier}: ${t0.atk_scale * 100} % Lin ATK`);
|
||||
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && c.target === inBurst), `T${tier}: stunned`);
|
||||
h.run(t0.interval - 0.2);
|
||||
assert.equal(glassOf(near), null, `T${tier}: not before ${t0.interval} s`);
|
||||
h.run(0.4);
|
||||
assert.equal(glassOf(near)?.shield, 200, `T${tier}: back after ${t0.interval} s`);
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
assert.equal(glassOf(near), null, `T${tier}: gone with the skill`);
|
||||
assert.ok(glassOf(u), `T${tier}: hers stays`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 流光乍裂 (MANUAL, data CUSTOM_RANGE x-2, 26 / 28 s): cast with an enemy in x-2; range x-2 while on (bursts too), back after; ATK +125 % / +160 %; her 琉璃璧 threshold ×2 / ×2.5; a knock-out by her attack breaks it (burst) and raises it at once', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.talent_scale, sk.rangeId], ['CUSTOM_RANGE', elite ? 28 : 26, elite ? 1.6 : 1.25, elite ? 2.5 : 2, 'x-2'], `T${tier}`);
|
||||
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid);
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [11, 7] }); // x-2 [1, 2], not x-1
|
||||
assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast with an enemy in x-2`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
const thr = 200 * sk.bb.talent_scale;
|
||||
assert.equal(glassOf(u).shield, thr, `T${tier}: threshold ${thr}`);
|
||||
const hp0 = u.hp;
|
||||
hurt(h, e, u, 450);
|
||||
if (thr > 450) { assert.equal(u.hp, hp0, `T${tier}: 450 absorbed`); assert.ok(glassOf(u)); } else { approx(hp0 - u.hp, 450 - thr, `T${tier}: 450 − ${thr}`); assert.equal(glassOf(u), null); }
|
||||
// a knock-out by her attack: burst (x-2 around her) and the 琉璃璧 standing again at once
|
||||
if (!glassOf(u)) { h.run(9); assert.ok(glassOf(u), `T${tier}: regrown`); }
|
||||
const witness = h.spawn('enemy_dummy', { pos: [9, 3] }); // x-2 [−1, −2]
|
||||
const weak = h.spawn('enemy_weak', { pos: [10, 6] });
|
||||
h.step();
|
||||
const g0 = glassHits(h, u).length;
|
||||
h.runUntil(() => !weak.alive, 4);
|
||||
h.step();
|
||||
assert.ok(glassHits(h, u).slice(g0).some((c) => c.target === witness), `T${tier}: the kill's burst reaches x-2`);
|
||||
assert.equal(glassOf(u)?.shield, thr, `T${tier}: raised again at once`);
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 again`);
|
||||
assert.equal(glassOf(u)?.shield, 200, `T${tier}: plain threshold again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 计出万全 (every form): a 琉璃璧 from deployment — a hit ≤ 200 absorbed whole, a hit over 200 goes through less 200 and breaks it: stun 1 s first, then 100 % / 110 % ATK arts on her range, regrowing after 8 s / 6 s (PLX-Y stage 3); a lethal hit still breaks it', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const order = [];
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, setup: (b) => {
|
||||
b.on('statusApplied', (c) => { if (c.status === 'stun' && c.source?.uid === 1) order.push('stun'); });
|
||||
b.on('damaged', (c) => { if (c.dmg?.tags?.includes('lin:glass')) order.push(c.target.s.flags.stun ? 'hit-stunned' : 'hit'); });
|
||||
} });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
const y3 = elite && tier === 6 && mod === PLXY;
|
||||
assert.deepEqual([t0.value, t0.atk_scale, t0.stun, t0.interval], [200, y3 ? 1.1 : 1, 1, y3 ? 6 : 8], label(f));
|
||||
assert.equal(glassOf(u)?.shield, 200, `${label(f)}: up from deployment`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] }), out = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
h.step();
|
||||
const hp0 = u.hp;
|
||||
hurt(h, e, u, 150);
|
||||
hurt(h, e, u, 200);
|
||||
assert.deepEqual([u.hp, glassOf(u)?.shield], [hp0, 200], `${label(f)}: 150 and 200 absorbed, full again`);
|
||||
hurt(h, e, u, 300);
|
||||
approx(hp0 - u.hp, 100, `${label(f)}: 300 − 200`);
|
||||
assert.equal(glassOf(u), null, `${label(f)}: broken`);
|
||||
const gh = glassHits(h, u);
|
||||
assert.deepEqual(gh.map((c) => c.target.id), [e.id], `${label(f)}: her range only`);
|
||||
const plxy = elite && mod === PLXY ? 1 + 0.03 * 1 : 1;
|
||||
approx(gh[0].amount, u.s.atk * t0.atk_scale * plxy, `${label(f)}: ${t0.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual(order, ['stun', 'hit-stunned'], `${label(f)}: stun first`);
|
||||
assert.ok(!h.hooksOf('statusApplied').some((c) => c.target === out && c.status === 'stun'), `${label(f)}: none outside`);
|
||||
h.run(t0.interval - 0.1);
|
||||
assert.equal(glassOf(u), null, `${label(f)}: not yet`);
|
||||
h.run(0.2);
|
||||
assert.equal(glassOf(u)?.shield, 200, `${label(f)}: back after ${t0.interval} s`);
|
||||
// a lethal hit: the burst still comes
|
||||
const n0 = glassHits(h, u).length;
|
||||
hurt(h, e, u, u.hp + 1000);
|
||||
assert.equal(u.alive, false, `${label(f)}: knocked out`);
|
||||
assert.equal(glassHits(h, u).length, n0 + 1, `${label(f)}: the lethal hit broke it`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 韬光: each damage instance she takes rolls 50 % for +1 SP (PLX-X stage 3: 75 %, +2) — one the 琉璃璧 absorbs whole too; a 流失 never', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 33 });
|
||||
const x3 = elite && tier === 6 && mod === PLXX;
|
||||
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
|
||||
assert.deepEqual([t1.prob, t1.sp], x3 ? [0.75, 2] : [0.5, 1], label(f));
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
h.step();
|
||||
const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
|
||||
const hp0 = u.hp;
|
||||
for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 50); }
|
||||
assert.equal(u.hp, hp0, `${label(f)}: all absorbed`);
|
||||
const absorbed = gains();
|
||||
assert.ok(Math.abs(absorbed.length / 200 - t1.prob) < 0.12, `${label(f)}: absorbed hits roll too: ${absorbed.length} / 200 ≈ ${t1.prob * 100} %`);
|
||||
assert.ok(absorbed.every((c) => c.amount === t1.sp), `${label(f)}: +${t1.sp} SP`);
|
||||
hurt(h, e, u, 400); // breaks it: 200 through
|
||||
const n0 = gains().length;
|
||||
for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 1); }
|
||||
const g = gains().slice(n0);
|
||||
assert.ok(Math.abs(g.length / 200 - t1.prob) < 0.12, `${label(f)}: ${g.length} / 200 ≈ ${t1.prob * 100} %`);
|
||||
const n1 = gains().length;
|
||||
for (let i = 0; i < 50; i++) { u.skill.sp = 0; h.b.loseHp(u, 1, { source: e }); }
|
||||
assert.equal(gains().length, n1, `${label(f)}: a 流失 rolls nothing`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('PLX-Y “情与义” (stages 1 and 3): +3 % damage per enemy on her range, at most 5 (+15 %); none without it', () => {
|
||||
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
|
||||
const [tier, , mod] = f;
|
||||
const plxy = mod === PLXY;
|
||||
for (const n of [2, 7]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
|
||||
if (plxy) assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb), [{ damage_scale: 0.03, max_valid_stack_cnt: 5 }], label(f));
|
||||
const tiles = [[10, 6], [10, 7], [11, 5], [9, 5], [11, 4], [9, 4], [11, 6]].slice(0, n);
|
||||
for (const p of tiles) h.spawn('enemy_dummy', { pos: p });
|
||||
u.skill.gainSp(999);
|
||||
h.runUntil(() => atkHits(h, u).length >= n, 5);
|
||||
for (const c of atkHits(h, u).slice(0, n)) approx(c.amount, u.s.atk * (plxy ? 1 + 0.03 * Math.min(5, n) : 1), `${label(f)}: ${n} enemies`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the x-1 grid of the tests is her data range (from (10, 5) facing RIGHT)', () => {
|
||||
const { h, u } = field({});
|
||||
assert.deepEqual([...u.rangeKeys].sort((a, b) => a - b), X1.map(([r, c]) => r * 21 + c).sort((a, b) => a - b));
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// test/content/op_logos.test.js — the 自选 operator kit of 逻各斯 (char_4133_logos, 6★ 中坚术师; kit
|
||||
// server/sim/content/kits/ops/op-logos.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-Δ
|
||||
// “来自河谷的笔盒” or CCR-Y “《语义范式百科》” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
|
||||
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_logos.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 LOGOS = 'char_4133_logos';
|
||||
const FORMS = BACKUPS.units[LOGOS].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const CCRD = 'uniequip_002_logos', CCRY = 'uniequip_003_logos';
|
||||
const S1 = 'skchr_logos_1', S2 = 'skchr_logos_2', S3 = 'skchr_logos_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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);
|
||||
/** The talent bb of a form with a module's changes (stage 2+ rewrites 语汇演化). */
|
||||
const talentOf = (tier, elite, mod, i) => {
|
||||
const m = elite ? modOf(tier, mod) : null;
|
||||
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
|
||||
};
|
||||
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_elite: dummy('enemy_elite', { rank: 'ELITE' }),
|
||||
enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }), enemy_low: dummy('enemy_low', { hp: 300 }),
|
||||
enemy_gun: dummy('enemy_gun', { atk: 100, range: 3, bat: 1, applyWay: 'RANGED' }),
|
||||
};
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRD, CCRY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 逻各斯 as uid 1 at (row, col) facing RIGHT. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup,
|
||||
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LOGOS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes(tag));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
/** Start the skill now (SP filled, no enemy needed). */
|
||||
function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); }
|
||||
|
||||
test('逻各斯 in every 自选 form: his operator kit (all three skills authored), stats + module attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[LOGOS], KITS[LOGOS]);
|
||||
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], [LOGOS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 中坚术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 1297 / 556, E2 Lv60 1540 / 632; CCR-Δ +100 / +36 → +170 / +67 HP / ATK,
|
||||
// CCR-Y +35 / +5 → +60 / +5 ATK / RES
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1297, 556, 1540, 632]);
|
||||
assert.deepEqual([modOf(5, CCRD).attr, modOf(6, CCRD).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 100, atk: 36 }, { maxHp: 170, atk: 67 }, { atk: 35, res: 5 }, { atk: 60, res: 5 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 逻各斯 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(LOGOS));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LOGOS), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } } });
|
||||
});
|
||||
|
||||
test('S1 殁亡 (AUTO, data DEFAULT): 74 / 68 SP from 0, cast as he is about to attack an enemy of his 3-1; then for good the 3-3 range and ATK +40 % / +70 %', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.initSp, sk.bb.atk, sk.rangeId], ['DEFAULT', 'toggle', elite ? 68 : 74, 0, elite ? 0.7 : 0.4, '3-3'], `T${tier}`);
|
||||
h.run(sk.spCost - 1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
|
||||
h.run(3);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: full SP, no enemy: no cast`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in his range`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while on`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.run(200);
|
||||
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: still 3-3`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 殁亡 execution: an enemy of his range under 90 % / 120 % of his ATK falls at once (9999999 无来源 true damage, no dodge), then another random enemy of the range takes arts damage equal to the HP it had left; one try only on a survivor; air units too', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
let guard = null;
|
||||
const { h, u } = field({ tier, elite, skill: 0, setup: (b) => b.on('fatal', (c) => { if (c.unit === guard) c.prevented = true; }) });
|
||||
cast(u);
|
||||
const thr = sk.bb['attack@kill_atk_scale'] * u.s.atk;
|
||||
assert.equal(sk.bb['attack@kill_damage'], 9999999);
|
||||
const big = h.spawn('enemy_dummy', { pos: [11, 8] }); // a 3-3 tile outside the 3-1 (the toggle's range counts)
|
||||
const low = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
const hp0 = thr * 0.6;
|
||||
low.hp = hp0;
|
||||
const high = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.step();
|
||||
assert.ok(!low.alive, `T${tier}: the flyer under the threshold fell`);
|
||||
const kill = h.hooksOf('damaged').find((c) => c.target === low && (c.dmg?.tags || []).includes('execute'));
|
||||
assert.ok(kill && kill.source === null && kill.credit === u && kill.type === 'true' && kill.dmg.sourceless && !kill.dmg.canDodge, `T${tier}: 无来源 true, credited to him`);
|
||||
assert.equal(low.removeReason, 'killed');
|
||||
const follow = tagged(h, u, 'logosPerish');
|
||||
assert.equal(follow.length, 1, `T${tier}: one follow-up`);
|
||||
assert.ok([big, high].includes(follow[0].target) && follow[0].type === 'arts', `T${tier}: on another enemy of his range`);
|
||||
approx(follow[0].amount, hp0, `T${tier}: the HP the fallen one had left`);
|
||||
assert.ok(![big, high].some((e) => h.hooksOf('damaged').some((c) => c.target === e && (c.dmg?.tags || []).includes('execute'))), `T${tier}: above the threshold: no execution`);
|
||||
// an enemy that falls under the threshold while on his range is executed then (the aura checks every tick)
|
||||
high.hp = thr * 0.9;
|
||||
h.step();
|
||||
assert.ok(!high.alive, `T${tier}: executed once under the threshold`);
|
||||
// a survivor (不死) is never tried again
|
||||
guard = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
guard.hp = thr * 0.5;
|
||||
h.step();
|
||||
const tries = () => h.hooksOf('damaged').filter((c) => c.target === guard && (c.dmg?.tags || []).includes('execute')).length;
|
||||
assert.equal(tries(), 1, `T${tier}: tried once`);
|
||||
assert.ok(guard.alive && guard.hp <= 1 + 1e-9, `T${tier}: held at 1 HP`);
|
||||
h.run(3);
|
||||
assert.equal(tries(), 1, `T${tier}: never again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 提喻 (MANUAL, data DEFAULT): 20 s, RES +40 / +50; locks one target with a hit every 0.5 s whatever the attack speed, 35 %→105 % / 50 %→150 % of ATK and its speed down to 40 % over 10 hits; a stun breaks the lock', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const b = sk.bb;
|
||||
const { h, u } = field({ tier, elite, skill: 1, seed: 7 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, b.magic_resistance, b['attack@cooldown'], b['attack@atk_scale_base'], b['attack@atk_scale_delta'], b['attack@move_speed'], b['attack@max_stack_cnt']],
|
||||
['DEFAULT', 'duration', 20, elite ? 50 : 40, 0.5, elite ? 0.5 : 0.35, elite ? 0.1 : 0.07, -0.06, 10], `T${tier}`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 300 } }); // the lock ignores the attack speed
|
||||
const res0 = u.s.res;
|
||||
cast(u);
|
||||
approx(u.s.res, res0 + b.magic_resistance, `T${tier}: RES`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(6.02);
|
||||
const hits = atkHits(h, u).slice(n0);
|
||||
assert.ok(hits.length >= 12 && hits.length <= 13, `T${tier}: ${hits.length} hits in 6 s (one per 0.5 s)`);
|
||||
for (let i = 1; i < hits.length; i++) approx(hits[i].t - hits[i - 1].t, 0.5, `T${tier}: cadence`, 0.07);
|
||||
hits.forEach((c, i) => {
|
||||
assert.equal(c.target, e, `T${tier}: the locked target`);
|
||||
// + 剜魂具辞's 150 (the first hit marks it already); the target's RES 0
|
||||
approx(c.amount, u.s.atk * (b['attack@atk_scale_base'] + b['attack@atk_scale_delta'] * Math.min(10, i)) + 150, `T${tier}: hit ${i}`, 1e-4);
|
||||
});
|
||||
assert.ok(Math.abs(hits[10].amount - (u.s.atk * b['attack@atk_scale_base'] * 3 + 150)) < 1e-3 * hits[10].amount, `T${tier}: 3× after 5 s`);
|
||||
approx(e.findBuff(`logos:lockSlow:${u.id}`).mods.moveMul, 0.4, `T${tier}: 40 % speed`);
|
||||
// a stun breaks it: the slow goes, the next hits start over
|
||||
h.b.applyStatus(u, 'stun', { duration: 1, source: e });
|
||||
h.step();
|
||||
assert.equal(e.findBuff(`logos:lockSlow:${u.id}`), null, `T${tier}: slow gone with the lock`);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(1.6);
|
||||
const after = atkHits(h, u).slice(n1);
|
||||
approx(after[0].amount, u.s.atk * b['attack@atk_scale_base'] + 150, `T${tier}: reset`, 1e-4);
|
||||
// beam: the hit lands as the attack is made (no bolt in flight)
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
|
||||
assert.ok(Math.abs(atks[atks.length - 1].t - after[after.length - 1].t) < 1e-9, `T${tier}: instant`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
approx(u.s.res, res0, `T${tier}: RES back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 提喻 keeps its lock while another enemy comes first in the order, and locks anew when its target leaves the field', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
cast(u);
|
||||
h.run(1.1);
|
||||
assert.ok(atkHits(h, u).every((c) => c.target === a));
|
||||
h.spawn('enemy_dummy', { pos: [10, 6], routeIndex: 0 }); // nearer the goal: first in the order
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(1.6);
|
||||
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === a), 'still the locked one');
|
||||
h.b.kill(a, null);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(1.1);
|
||||
const next = atkHits(h, u).slice(n1);
|
||||
assert.ok(next.length >= 2 && next.every((c) => c.target !== a));
|
||||
approx(next[0].amount, u.s.atk * 0.5 + 150, 'a new lock starts at the base scale', 1e-4);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 延异视阈 (MANUAL, data ACTIVE_RANGE on its 3-4): cast with an enemy on the 3-4 only, 30 s, ATK +160 % / +220 %, 3 targets at once', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.projectile_move_scale], ['ACTIVE_RANGE', '3-4', 30, elite ? 2.2 : 1.6, 3, 0.05], `T${tier}`);
|
||||
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is the 3-4`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy`);
|
||||
const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-4 only
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy outside his own range`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-4 while on`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
const more = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_fly', { pos: [9, 7] }), h.spawn('enemy_dummy', { pos: [11, 8] })];
|
||||
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
h.run(5);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0);
|
||||
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 3), `T${tier}: 3 targets per attack`);
|
||||
assert.ok(atks.every((c) => c.targets.every((e) => [far, ...more].includes(e))));
|
||||
h.runUntil(() => !u.skill.active, 40);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 延异视阈: enemy shots on his range fly at 5 % of their speed (back once they leave it) and are cleared when the skill ends — not when he is disarmed then', () => {
|
||||
for (const disarm of [false, true]) {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
h.spawn('enemy_gun', { pos: [10, 7] });
|
||||
h.run(1.5);
|
||||
cast(u);
|
||||
h.run(0.2);
|
||||
h.run(3);
|
||||
const shots = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
|
||||
assert.ok(shots.length >= 2, `${shots.length} enemy shots held`);
|
||||
for (const p of shots) approx(p.speed, 10 * 0.05, 'slowed to 0.05×');
|
||||
if (disarm) h.b.applyStatus(u, 'disarm', { duration: 40, source: null });
|
||||
h.runUntil(() => u.skill.timeLeft < 0.5, 40);
|
||||
for (const g of h.enemies()) h.b.applyStatus(g, 'stun', { duration: 99, source: null }); // no new shot
|
||||
const held = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy').length;
|
||||
assert.ok(held >= 1, `${held} shots in the air as it ends`);
|
||||
const taken = u.stats.taken;
|
||||
h.runUntil(() => !u.skill.active, 2);
|
||||
h.step();
|
||||
const left = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
|
||||
if (!disarm) {
|
||||
assert.equal(left.length, 0, 'cleared');
|
||||
h.run(5);
|
||||
assert.equal(u.stats.taken, taken, 'none of the held shots hit him');
|
||||
} else {
|
||||
assert.ok(left.length > 0 && left.every((p) => Math.abs(p.speed - 10) < 1e-9), 'disarmed: kept, at full speed again');
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 语汇演化: 40 % per attacked target for an extra 60 % ATK arts hit on a random enemy of his range + 停顿 0.8 s; CCR-Δ stage 3: 60 % and 60 % ATK 元素伤害 on a target in its 凋亡 burst; CCR-Y stage 3: two executions at 65 %', () => {
|
||||
for (const f of [[5, false, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t = talentOf(tier, elite, mod, 0);
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
|
||||
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 500 } });
|
||||
const es = [h.spawn('enemy_dummy', { pos: [10, 7] }), h.spawn('enemy_fly', { pos: [11, 6] })];
|
||||
h.run(50);
|
||||
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).reduce((n, c) => n + c.targets.length, 0);
|
||||
const extra = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'arts');
|
||||
const emit = t.emit_count ?? 1;
|
||||
const ratio = extra.length / (attacks * emit);
|
||||
assert.ok(attacks > 150 && Math.abs(ratio - t.prob) < 0.08, `${label(f)}: ${extra.length} / ${attacks}×${emit} ≈ ${t.prob}`);
|
||||
for (const c of extra.slice(0, 6)) {
|
||||
assert.ok(es.includes(c.target), `${label(f)}: an enemy of his range (air too)`);
|
||||
const marked = h.hooksOf('damaged').some((d) => d.source === u && d.target === c.target && d.dmg?.isAttack && d.t <= c.t && c.t - d.t <= 5);
|
||||
approx(c.amount, u.s.atk * t.atk_scale + (marked ? 150 : 0), `${label(f)}: ${t.atk_scale * 100} % ATK (+ 剜魂具辞 on a marked one)`, 1e-4);
|
||||
}
|
||||
const slug = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
|
||||
assert.ok(slug.length >= extra.length * 0.95 && slug.every((c) => Math.abs(c.duration - t.sluggish) < 1e-9), `${label(f)}: 停顿 ${t.sluggish} s`);
|
||||
const elem = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'elemental');
|
||||
if (t.element_atk_scale) {
|
||||
assert.ok(elem.length > 0, `${label(f)}: 元素伤害 during the 凋亡 bursts (his own CCR-Δ fills them)`);
|
||||
for (const c of elem) {
|
||||
approx(c.amount, u.s.atk * t.element_atk_scale, `${label(f)}: ${t.element_atk_scale * 100} % ATK 元素伤害`, 1e-4);
|
||||
assert.ok(h.hooksOf('elementBurst').some((b) => b.target === c.target && b.element === 'apoptosis' && b.t <= c.t && c.t - b.t < 15), 'in its 凋亡 burst');
|
||||
}
|
||||
} else assert.equal(elem.length, 0, `${label(f)}: no 元素伤害`);
|
||||
done(h);
|
||||
}
|
||||
// the module texts
|
||||
assert.match(modOf(6, CCRD).talentChanges.find((t) => t.talentIndex === 0).desc, /60%几率.*凋亡损伤爆发期间则同时造成相当于攻击力60%的元素伤害/);
|
||||
assert.match(modOf(6, CCRY).talentChanges.find((t) => t.talentIndex === 0).desc, /两个随机目标造成相当于攻击力65%/);
|
||||
assert.equal(modOf(5, CCRD).talentChanges.find((t) => t.talentIndex === 0), undefined, 'stage 1: the talent unchanged');
|
||||
});
|
||||
|
||||
test('T1 语汇演化 also follows 殁亡\'s follow-up hit (备注 "可触发第一天赋") and works while he is disarmed', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 0, seed: 3 });
|
||||
cast(u);
|
||||
h.b.applyStatus(u, 'disarm', { duration: 999, source: null });
|
||||
const thr = 1.2 * u.s.atk;
|
||||
let rolls = 0;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
h.spawn('enemy_dummy', { pos: [9, 7] });
|
||||
const low = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
low.hp = thr * 0.5;
|
||||
h.step();
|
||||
rolls++;
|
||||
for (const e of h.enemies()) h.b.kill(e, null);
|
||||
h.step();
|
||||
}
|
||||
assert.equal(atkHits(h, u).length, 0, 'disarmed: no attack');
|
||||
assert.equal(tagged(h, u, 'logosPerish').length, rolls);
|
||||
const extra = tagged(h, u, 'logosLexicon');
|
||||
assert.ok(extra.length > rolls * 0.2 && extra.length < rolls * 0.6, `${extra.length} / ${rolls} ≈ 40 %`);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T2 剜魂具辞: each of his attack hits gives the target RES −10 and +150 on every arts damage it takes for 5 s — the hit itself included, other casters\' arts too; one instance per enemy', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, CCRY]]) {
|
||||
const t = talentOf(tier, elite, mod, 1);
|
||||
assert.deepEqual(t, { duration: 5, magic_resistance: -10, atk_addition: 150 });
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 9 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
e.base.res = 30; e.markDirty();
|
||||
const first = () => atkHits(h, u)[0];
|
||||
assert.ok(h.runUntil(() => !!first(), 4));
|
||||
const c = first();
|
||||
approx(c.amount, (u.s.atk + 150) * (1 - 0.2), `T${tier}: (ATK + 150) × (1 − (30 − 10) %) on the very hit`, 1e-4);
|
||||
const mark = e.findBuff('logos:soulRend');
|
||||
assert.ok(mark && Math.abs(mark.mods.resFlat + 10) < 1e-9 && mark.data.value === 150);
|
||||
approx(e.s.res, 20, `T${tier}: RES −10`);
|
||||
// another source's arts damage gets the 150 too; physical does not
|
||||
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'arts', canDodge: false }), (1000 + 150) * 0.8, 'arts +150');
|
||||
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'phys', canDodge: false }), 1000, 'phys unchanged');
|
||||
// it lapses 5 s after his last hit
|
||||
h.b.applyStatus(u, 'stun', { duration: 20, source: null });
|
||||
h.run(5.2);
|
||||
assert.equal(e.findBuff('logos:soulRend'), null, `T${tier}: gone after 5 s`);
|
||||
approx(e.s.res, 30, `T${tier}: RES back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCR-Δ “来自河谷的笔盒” (stages 1 and 3): 8 % of every arts damage he deals as 凋亡 损伤; none without it', () => {
|
||||
for (const f of [[5, false, null], [5, true, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 4 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(4);
|
||||
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts');
|
||||
const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'element');
|
||||
assert.ok(arts.length > 0);
|
||||
if (elite && mod === CCRD) {
|
||||
assert.equal(u.def.raw.talents.find((t) => t.index === -1).bb.ep_damage_ratio, 0.08);
|
||||
assert.equal(fills.length, arts.length, `${label(f)}: one fill per arts hit`);
|
||||
fills.forEach((c, i) => {
|
||||
assert.equal(c.dmg.element, 'apoptosis');
|
||||
approx(c.amount, arts[i].amount * 0.08, `${label(f)}: 8 %`);
|
||||
});
|
||||
} else assert.equal(fills.length, 0, `${label(f)}: no 凋亡`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCR-Y “《语义范式百科》” (stages 1 and 3): +1 SP per normal-attack hit on an elite or leader enemy (none on a normal one, none while a skill runs); none without it', () => {
|
||||
for (const f of [[5, true, CCRY], [6, true, CCRY], [6, true, CCRD], [6, false, null]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y = elite && mod === CCRY;
|
||||
if (y) assert.deepEqual(modOf(tier, CCRY).traitOverride.bb, { sp: 1 });
|
||||
for (const key of ['enemy_dummy', 'enemy_elite', 'enemy_boss']) {
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 2 });
|
||||
const e = h.spawn(key, { pos: [10, 7] });
|
||||
h.b.applyStatus(u, 'disarm', { duration: 3, source: null });
|
||||
h.run(3.1);
|
||||
const sp0 = u.skill.sp, t0 = h.b.time;
|
||||
h.run(5);
|
||||
const hits = [...atkHits(h, u), ...tagged(h, u, 'logosLexicon')].filter((c) => c.target === e && c.t >= t0 - 1e-9).length;
|
||||
const gain = u.skill.sp - sp0 - 5; // 5 s of natural SP
|
||||
const want = y && key !== 'enemy_dummy' ? hits : 0;
|
||||
approx(gain, want, `${label(f)} ${key}: ${hits} hits`, 1e-6);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
// test/content/op_mantra.test.js — the 自选 operator kit of 真言 (char_4204_mantra, 6★ 本源术师; kit
|
||||
// server/sim/content/kits/ops/op-mantra.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or PRI-X
|
||||
// “中枢神经探测模块” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that
|
||||
// slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_mantra.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 MANTRA = 'char_4204_mantra';
|
||||
const FORMS = BACKUPS.units[MANTRA].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const PRIX = 'uniequip_002_mantra';
|
||||
const S1 = 'skchr_mantra_1', S2 = 'skchr_mantra_2', S3 = 'skchr_mantra_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
|
||||
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
|
||||
const talentOf = (tier, elite, mod, i) => {
|
||||
const m = elite ? modOf(tier, mod) : null;
|
||||
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
|
||||
};
|
||||
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_shooter: dummy('enemy_shooter', { atk: 1, bat: 1, range: 4 }),
|
||||
enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e9, speed: 0.3, mass: 0 }),
|
||||
enemy_immune: dummy('enemy_immune', { otherImmunities: ['palsy'] }),
|
||||
};
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PRIX].map((m) => [t, true, m]))];
|
||||
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, others = [], seed = 5, setup, stage } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup, ...(stage ? { flat: stage } : null),
|
||||
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: MANTRA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const tagged = (h, u, tag, src = u) => h.hooksOf('damaged').filter((c) => (c.source === src || c.credit === src) && (c.dmg?.tags || []).includes(tag));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); }
|
||||
const palsy = (e) => e.findBuff('palsy')?.stacks ?? 0;
|
||||
|
||||
test('真言 in every 自选 form: her operator kit (all three skills authored), stats + PRI-X attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 萨尔贡 bond, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[MANTRA], KITS[MANTRA]);
|
||||
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], [MANTRA, 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], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 本源术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1071, 583, 1308, 657]);
|
||||
assert.deepEqual([modOf(5, PRIX).attr, modOf(6, PRIX).attr], [{ maxHp: 90, atk: 43 }, { maxHp: 145, atk: 87 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 真言 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(MANTRA));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MANTRA), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } } });
|
||||
});
|
||||
|
||||
test('S1 共鸣溃缩 (AUTO, attack SP 3, data DEFAULT): every 4th attack deals 230 % / 275 % ATK arts, then 15 % / 20 % of it as 神经 损伤, then — on a target in its 神经 burst — 150 % / 160 % ATK 元素伤害', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, sk.bb.atk_scale, sk.bb.ep_damage_ratio, sk.bb.element_atk_scale],
|
||||
['DEFAULT', 'instant', 'attack', 3, elite ? 2.75 : 2.3, elite ? 0.2 : 0.15, elite ? 1.6 : 1.5], `T${tier}`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(16 * 1.6 + 0.5);
|
||||
const hits = atkHits(h, u).filter((c) => c.target === e);
|
||||
const skillHits = hits.filter((c) => c.dmg.isSkill);
|
||||
assert.ok(hits.length >= 15, `T${tier}: ${hits.length} attacks`);
|
||||
hits.forEach((c, i) => assert.equal(c.dmg.isSkill, i % 4 === 3, `T${tier}: attack ${i + 1}`));
|
||||
const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.type === 'element');
|
||||
for (const c of skillHits) {
|
||||
const fill = fills.find((x) => Math.abs(x.t - c.t) < 1e-9);
|
||||
if (fill) approx(fill.amount, c.amount * sk.bb.ep_damage_ratio, `T${tier}: 神经 = ${sk.bb.ep_damage_ratio * 100} % of the damage`);
|
||||
}
|
||||
approx(skillHits[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.ok(fills.length >= 1 && fills.every((x) => x.dmg.element === 'neural'));
|
||||
// in its 神经 burst: + element_atk_scale × ATK 元素伤害 after the 神经 fill (a fresh target one point short of it)
|
||||
h.b.kill(e, null);
|
||||
const e2 = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
e2.elem.neural = e2.gaugeMax - 1;
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isSkill && c.dmg?.isAttack), 8);
|
||||
h.step();
|
||||
const after = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.type === 'elemental' && (c.dmg.tags || []).includes('mantra'));
|
||||
assert.ok(h.hooksOf('elementBurst').some((c) => c.target === e2 && c.element === 'neural'), `T${tier}: her 神经 burst it`);
|
||||
assert.equal(after.length, 1, `T${tier}: the burst made by its own 神经 counts`);
|
||||
approx(after[0].amount, u.s.atk * sk.bb.element_atk_scale, `T${tier}: ${sk.bb.element_atk_scale * 100} % ATK 元素伤害`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 意识联协 (MANUAL, data DEFAULT): 25 s, attack interval 1.6 − 0.6 / 0.7 s; 170 % / 210 % ATK on the target, then 2 / 3 jumps at 3.2 tiles/s to the nearest new enemy within 2.0 tiles for 100 % / 110 %; 神经 riders on every hit', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const b = sk.bb;
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, b.base_attack_time, b['attack@atk_scale'], b['attack@chain.atk_scale'], b.chain_times, b['attack@ep_damage_ratio'], b['attack@element_atk_scale']],
|
||||
['DEFAULT', 25, elite ? -0.7 : -0.6, elite ? 2.1 : 1.7, elite ? 1.1 : 1, elite ? 3 : 2, elite ? 0.15 : 0.13, elite ? 0.2 : 0.18], `T${tier}`);
|
||||
// a line of enemies 1.5 tiles apart (the 4th is 2.1 from the 3rd: out of the jump radius at tier 6)
|
||||
const main = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const j1 = h.spawn('enemy_dummy', { pos: [10, 8.5] });
|
||||
const j2 = h.spawn('enemy_fly', { pos: [10, 10] });
|
||||
const j3 = h.spawn('enemy_dummy', { pos: [11.5, 10] });
|
||||
const out = h.spawn('enemy_dummy', { pos: [11.5, 12.1] });
|
||||
cast(u);
|
||||
approx(u.s.interval, 1.6 + b.base_attack_time, `T${tier}: interval`);
|
||||
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === u).length > a0, 2);
|
||||
const at = h.hooksOf('attack').filter((c) => c.attacker === u).slice(-1)[0];
|
||||
assert.equal(at.targets[0], main, `T${tier}: the first target`);
|
||||
h.run(3);
|
||||
const id = atkHits(h, u).find((c) => c.target === main && c.t >= at.t).dmg.attackId;
|
||||
const chainHits = atkHits(h, u).filter((c) => c.dmg.attackId === id);
|
||||
const order = chainHits.map((c) => c.target);
|
||||
assert.deepEqual(order, [main, j1, j2, j3].slice(0, 1 + b.chain_times), `T${tier}: the nearest new enemy each time`);
|
||||
assert.ok(!order.includes(out), `T${tier}: 2.1 tiles: out of reach`);
|
||||
approx(chainHits[0].amount, u.s.atk * b['attack@atk_scale'], `T${tier}: main`);
|
||||
for (const c of chainHits.slice(1)) {
|
||||
approx(c.amount, u.s.atk * b['attack@chain.atk_scale'], `T${tier}: jump`);
|
||||
assert.ok(c.dmg.tags.includes('chain') && c.dmg.isSkill);
|
||||
}
|
||||
approx(chainHits[2].t - chainHits[1].t, 1.5 / 3.2, `T${tier}: 3.2 tiles/s`, 0.1);
|
||||
for (const [i, c] of chainHits.entries()) {
|
||||
const fill = h.hooksOf('damaged').find((x) => x.source === u && x.type === 'element' && x.target === c.target && Math.abs(x.t - c.t) < 1e-9);
|
||||
assert.ok(fill, `T${tier}: a 神经 fill with hit ${i}`);
|
||||
approx(fill.amount, c.amount * b['attack@ep_damage_ratio'], `T${tier}: 神经 ${i}`);
|
||||
}
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 无言为真 (MANUAL, data ACTIVE_RANGE on its 3-17): cast for an enemy outside her own range, 40 s, ATK +170 % / +210 %, 2 targets, the 3-17 while on; 隐匿 enemies on it are revealed', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.max_trigger_cnt, sk.bb.per_active, sk.bb.interval_projectile_trigger, sk.bb.atk_scale],
|
||||
['ACTIVE_RANGE', '3-17', 40, elite ? 2.1 : 1.7, 2, 3, 2, 1.5, elite ? 1.45 : 1.1], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-17 only
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
|
||||
assert.ok(!formOf(tier, elite).rangeGrid.some(([r, c]) => r === far.y - 10 && c === far.x - 5), `T${tier}: the enemy is not on her own 3-1`);
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-17`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
const sneak = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.b.addBuff(sneak, { key: 'test:stealth', flags: { stealth: true } });
|
||||
h.step();
|
||||
assert.ok(sneak.s.flags.reveal, `T${tier}: revealed on her range`);
|
||||
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
h.run(4);
|
||||
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0);
|
||||
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 2), `T${tier}: 2 targets`);
|
||||
h.runUntil(() => !u.skill.active, 45);
|
||||
h.run(0.3);
|
||||
assert.ok(!sneak.s.flags.reveal, `T${tier}: hidden again once it ends`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 无言为真 麻痹付与 and the cap 2: 0.1 s after the cast every enemy on her range gets 2 麻痹; another operator on her range starting a skill grants again (3 times per cast, a grant kept while nobody is there); stacks over 2 become 溢出麻痹 — each an 元素伤害 bounce of 110 % / 145 % ATK on the holder and the nearest other enemy within 2.0 tiles, one at once and one per 1.5 s', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }, { uid: 3, chessId: 'chess_char_1_08_a', row: 9, col: 2 }];
|
||||
const { h, u } = field({ tier, elite, skill: 2, others });
|
||||
const inside = h.unit(2), outside = h.unit(3);
|
||||
for (const a of [inside, outside]) { a.skill.sp = 0; a.skill.charges = 0; }
|
||||
cast(u);
|
||||
h.run(0.05);
|
||||
// nobody on her range yet: the grant at 0.1 s is kept
|
||||
h.run(0.1);
|
||||
assert.equal(u.mem.mantraS3.grants, 3, `T${tier}: kept`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const near = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
h.step();
|
||||
// an operator outside her range starting a skill: nothing
|
||||
outside.skill.gainSp(999); outside.skill.activate('test');
|
||||
h.step();
|
||||
assert.equal(palsy(e), 0, `T${tier}: an operator off her range`);
|
||||
// the one on her range: the first grant (2 stacks, at the cap)
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.deepEqual([palsy(e), palsy(near), u.mem.mantraS3.grants], [2, 2, 2], `T${tier}: 2 麻痹 each`);
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').length, 0, `T${tier}: no overflow yet`);
|
||||
// the second: 2 over the cap each ⇒ 溢出麻痹 2 each, one bounce at once each
|
||||
const n0 = h.hooksOf('damaged').length;
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.deepEqual([palsy(e), palsy(near)], [2, 2], `T${tier}: capped at 2`);
|
||||
let bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
|
||||
assert.deepEqual(bounces.map((c) => c.target.id).sort(), [e.id, near.id].sort(), `T${tier}: each holder hit at once`);
|
||||
for (const c of bounces) {
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([c.type, c.dmg.element], ['elemental', 'neural']);
|
||||
}
|
||||
h.run(0.5); // the jumps land (1 tile at 3.2 tiles/s)
|
||||
bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
|
||||
assert.equal(bounces.length, 4, `T${tier}: holder + the nearest other, twice`);
|
||||
h.run(1.1); // 1.5 s after: the second 溢出麻痹 of each
|
||||
bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
|
||||
assert.ok(bounces.length >= 6, `T${tier}: the next one 1.5 s later (${bounces.length})`);
|
||||
// the third grant, then no more
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.equal(u.mem.mantraS3.grants, 0, `T${tier}: 3 grants used`);
|
||||
const g = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length;
|
||||
inside.skill.gainSp(999); inside.skill.activate('test');
|
||||
h.step();
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length, g, `T${tier}: no 4th grant`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 无言为真: an enemy holding 3 麻痹 entering her range keeps 2 and bounces the third; 溢出麻痹 left when it leaves the range or the skill ends all bounce at once; a 麻痹免疫 enemy counts the stacks she gives it', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
cast(u);
|
||||
h.run(0.2); // the grant of 0.1 s finds nobody (kept, never fired again here)
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 12] }); // off her range
|
||||
h.b.applyStatus(e, 'palsy', { value: 3 });
|
||||
h.step();
|
||||
assert.equal(palsy(e), 3, 'off her range: cap 3');
|
||||
e.x = 7; h.step();
|
||||
assert.equal(palsy(e), 2, 'on her range: 2');
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === e).length, 1, 'the third bounced');
|
||||
// a neural burst's 3 麻痹 on a holder at the cap: 3 溢出麻痹 — one at once, two left; it leaves ⇒ both at once
|
||||
const n0 = tagged(h, u, 'mantraOverflow').length;
|
||||
h.b.applyStatus(e, 'palsy', { value: 3 });
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 1, 'one at once');
|
||||
e.x = 13; h.step();
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 3, 'the two left at once when it leaves');
|
||||
assert.equal(palsy(e), 2, 'its stacks stay (cap 3 again)');
|
||||
// the skill ends with 溢出麻痹 held: all of it bounces
|
||||
const f = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.step();
|
||||
h.b.applyStatus(f, 'palsy', { value: 3 });
|
||||
h.b.applyStatus(f, 'palsy', { value: 3 });
|
||||
const n1 = tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length;
|
||||
u.skill.end('test');
|
||||
assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length - n1, 2, 'the rest at the end');
|
||||
// 麻痹免疫: her grants count (ineffective stacks), so the second grant overflows
|
||||
const { h: h2, u: u2 } = field({ tier: 6, elite: true, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }] });
|
||||
const imm = h2.spawn('enemy_immune', { pos: [10, 7] });
|
||||
cast(u2);
|
||||
h2.run(0.2);
|
||||
assert.equal(palsy(imm), 0, 'no 麻痹 buff (immune)');
|
||||
assert.equal(imm.mem.mantraPalsy, 2, 'counted');
|
||||
const ally = h2.unit(2);
|
||||
ally.skill.gainSp(999); ally.skill.activate('test');
|
||||
h2.step();
|
||||
assert.equal(tagged(h2, u2, 'mantraOverflow').filter((c) => c.target === imm).length, 1, 'its 溢出麻痹 bounces');
|
||||
done(h);
|
||||
done(h2);
|
||||
});
|
||||
|
||||
test('T1 噤声限域: an enemy whose attack a 麻痹 stack interrupts takes 135 % (PRI-X stage 3: 160 %) of her ATK as 元素伤害 (tagged 持续伤害); 10 % / 15 % of the interrupts keep the stack; not for an enemy she cannot select (隐匿)', () => {
|
||||
for (const f of [[5, false, null], [6, true, null], [5, true, PRIX], [6, true, PRIX]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t = talentOf(tier, elite, mod, 0);
|
||||
assert.deepEqual(t, elite && mod === PRIX && tier === 6 ? { atk_scale: 1.6, prob: 0.15 } : { atk_scale: 1.35, prob: 0.1 }, label(f));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 13 });
|
||||
const e = h.spawn('enemy_shooter', { pos: [10, 8] });
|
||||
h.b.applyStatus(u, 'disarm', { duration: 999, source: null }); // her attacks would kill nothing, but keep it clean
|
||||
h.run(0.6); // the aura's 0.5 s check has marked it
|
||||
let interrupts = 0, kept = 0;
|
||||
h.b.on('palsyTrigger', (c) => { if (c.enemy === e) { interrupts++; } });
|
||||
for (let i = 0; i < 400; i++) {
|
||||
h.b.applyStatus(e, 'palsy', { value: 1 });
|
||||
const before = interrupts;
|
||||
h.runUntil(() => interrupts > before, 3);
|
||||
h.step();
|
||||
if (palsy(e) > 0) { kept++; h.b.removeBuff(e, 'palsy'); }
|
||||
}
|
||||
const hush = tagged(h, u, 'mantraHush');
|
||||
assert.equal(hush.length, interrupts, `${label(f)}: one hit per interrupt`);
|
||||
for (const c of hush.slice(0, 5)) {
|
||||
approx(c.amount, u.s.atk * t.atk_scale, `${label(f)}: ${t.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([c.type, c.dmg.element, c.dmg.tags.includes('dot')], ['elemental', 'neural', true]);
|
||||
}
|
||||
assert.ok(Math.abs(kept / interrupts - t.prob) < 0.05, `${label(f)}: kept ${kept} / ${interrupts} ≈ ${t.prob}`);
|
||||
done(h);
|
||||
}
|
||||
// 隐匿: no mark, no hit; during S3 the check ignores 隐匿
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 1 });
|
||||
const e = h.spawn('enemy_shooter', { pos: [10, 2] }); // behind her: it shoots her, she cannot see it (and S3 does not reveal it)
|
||||
h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
|
||||
let interrupts = 0;
|
||||
h.b.on('palsyTrigger', (c) => { if (c.enemy === e) interrupts++; });
|
||||
h.run(0.6);
|
||||
h.b.applyStatus(e, 'palsy', { value: 2 });
|
||||
h.run(2.2);
|
||||
assert.equal(interrupts, 2, 'its attacks were interrupted');
|
||||
assert.equal(tagged(h, u, 'mantraHush').length, 0, 'a 隐匿 enemy: no effect');
|
||||
cast(u);
|
||||
h.run(0.6);
|
||||
assert.ok(!e.s.flags.reveal, 'not revealed (off her range)');
|
||||
h.b.applyStatus(e, 'palsy', { value: 2 });
|
||||
h.run(2.2);
|
||||
assert.equal(tagged(h, u, 'mantraHush').length, 2, 'S3: the check ignores 隐匿');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T2 全局洞悉: the 侵入点 nearest to her (Manhattan) — every enemy that comes out of it gets 1 麻痹 0.1 s later (隐匿 too); the other gate\'s do not', () => {
|
||||
for (const [row, gate, other] of [[10, 0, 1], [11, 1, 0]]) { // flat stage gates: route 0 starts at (9,10), route 1 at (12,10)
|
||||
const { h, u } = field({ tier: 5, skill: 0, row, seed: 2 });
|
||||
assert.deepEqual(u.mem.mantraGate, gate === 0 ? [9, 10] : [12, 10], `row ${row}: the nearest gate`);
|
||||
const a = h.spawn('enemy_walker', { routeIndex: gate });
|
||||
const b = h.spawn('enemy_walker', { routeIndex: other });
|
||||
h.b.addBuff(a, { key: 'test:stealth', flags: { stealth: true } });
|
||||
h.step();
|
||||
assert.equal(palsy(a), 0, 'not at once');
|
||||
h.run(0.1);
|
||||
assert.deepEqual([palsy(a), palsy(b)], [1, 0], `row ${row}`);
|
||||
done(h);
|
||||
}
|
||||
// a tie (two gates at the same distance) is drawn at random
|
||||
const picks = new Set();
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { h, u } = field({ tier: 5, skill: 0, row: 10, col: 10, seed, stage: { rows: { 11: '##hrrrrrrrSrrrrrrrf##' } } });
|
||||
picks.add(String(u.mem.mantraGate));
|
||||
done(h);
|
||||
}
|
||||
assert.deepEqual([...picks].sort(), ['11,10', '9,10'], 'both gates one tile away get drawn');
|
||||
});
|
||||
|
||||
test('PRI-X “中枢神经探测模块” (stages 1 and 3): damage to an enemy in an element burst ×1.1; none without it', () => {
|
||||
for (const f of [[5, true, PRIX], [6, true, PRIX], [6, true, null], [5, false, null]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 8 });
|
||||
const plain = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.run(1.7);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.b.addBuff(plain, { key: 'burnBurst', duration: 99, flags: { burstLock: true } });
|
||||
h.run(1.7);
|
||||
const a = atkHits(h, u)[0].amount, b = atkHits(h, u).slice(n0)[0].amount;
|
||||
approx(b / a, elite && mod === PRIX ? 1.1 : 1, label(f));
|
||||
if (elite && mod === PRIX) assert.equal(modOf(tier, PRIX).traitOverride.bb.damage_scale, 1.1);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
// test/content/op_narant.test.js — the 自选 operator kit of 娜仁图亚 (char_4138_narant, 6★ 回环射手; kit
|
||||
// server/sim/content/kits/ops/op-narant.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, LPS-X
|
||||
// “吹尽狂沙” or LPS-Y “致我们的老大” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
|
||||
// form of that slot status); the fidelity checklist of kits/README.md item by item. Also the two engine hooks of the
|
||||
// boomerang flight it relies on (ai.js throwBoomerang: `boomerangCaught`, a profile's `boomerangOnward`).
|
||||
// Run: node --test test/content/op_narant.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
|
||||
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
|
||||
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
|
||||
|
||||
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
|
||||
const CHESS = load('chess');
|
||||
const BACKUPS = load('backups');
|
||||
const NARANT = 'char_4138_narant';
|
||||
const FORMS = BACKUPS.units[NARANT].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const LPSX = 'uniequip_002_narant', LPSY = 'uniequip_003_narant';
|
||||
const S1 = 'skchr_narant_1', S2 = 'skchr_narant_2', S3 = 'skchr_narant_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_brute: dummy('enemy_brute', { atk: 400, def: 300 }),
|
||||
enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
|
||||
// a ranged attacker that shoots twice a second at whatever ally its 2.5-tile radius holds (an ATK her steals cannot drain)
|
||||
enemy_shooter: dummy('enemy_shooter', { atk: 5000, bat: 0.5, range: 2.5 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LPSX, LPSY].map((m) => [t, true, m]))];
|
||||
/** LPS-Y stage 3 婀娜虚影: ATK +15 % while unhurt — she starts unhurt. */
|
||||
const calmAtk = (tier, elite, mod) => (elite && mod === LPSY && tier === 6 ? 0.15 : 0);
|
||||
|
||||
/** A battle with 娜仁图亚 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5, hooks = [] } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'boomerangCaught', 'spGain', 'attack', ...hooks], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NARANT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
const TICK = 1 / 30;
|
||||
|
||||
test('娜仁图亚 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-7 range, ranged physical boomerangs that hit air, block 1, 35 % dodge, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[NARANT], KITS[NARANT]);
|
||||
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], [NARANT, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.projectile, u.profile.canHitFly, u.base.bat, typeof u.profile.canAttack],
|
||||
[1, 'ranged', 'phys', 'boomerang', true, 1, 'function'], `${label(f)}: 回环射手`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-7`);
|
||||
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.35, 0.35], `${label(f)}: 婀娜虚影 dodge`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[NARANT].bonds, []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 2000 / 546 / 144, E2 Lv60 2331 / 632 / 161; LPS-X +25 / +25 → +45 / +45 ATK / DEF, LPS-Y +210 / +20 → +300 / +40 HP / ATK
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2000, 546, 2331, 632]);
|
||||
assert.deepEqual([modOf(5, LPSX).attr, modOf(6, LPSX).attr, modOf(5, LPSY).attr, modOf(6, LPSY).attr], [{ atk: 25, def: 25 }, { atk: 45, def: 45 }, { maxHp: 210, atk: 20 }, { maxHp: 300, atk: 40 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 娜仁图亚 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(NARANT));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NARANT), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } } });
|
||||
});
|
||||
|
||||
test('trait 回环射手 (engine): one boomerang out at 15 and back at 3.75 tiles/s, the next throw once it is caught — each catch fires boomerangCaught { unit, attackId, isSkill }; a flyer is hit', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
h.run(6);
|
||||
const hits = atkHits(h, u), caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u);
|
||||
assert.ok(hits.length >= 2 && hits.every((c) => c.target === fly), '可对空');
|
||||
assert.ok(caught.length >= 2);
|
||||
for (const c of caught) assert.ok(hits.some((x) => x.dmg.attackId === c.attackId) && c.isSkill === false, 'the catch names its attack');
|
||||
const d = 3;
|
||||
const flight = d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
|
||||
approx(caught[0].t - hits[0].t, d / BOOMERANG_RETURN_SPEED, 'back at 3.75 tiles/s', 0.1);
|
||||
approx(hits[1].t - hits[0].t, flight, 'she waits for it (longer than her 1 s interval)', 0.1);
|
||||
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S1 旋刃 (切换: on until she leaves; data DEFAULT): range −1 (the far column of y-7), 140 % / 160 % ATK, then up to 3 bounces (radius 1.7, an enemy this attack has not hit first, then the nearest; 6 tiles/s), then back; a lone enemy: no bounce', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.bb['attack@times'], sk.bb['attack@atk_scale'], sk.bb.ability_range_forward_extend],
|
||||
['DEFAULT', 'toggle', elite ? 8 : 9, 0, 3, elite ? 1.6 : 1.4, -1], `T${tier}`);
|
||||
const lone = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 12), `T${tier}: on once its SP is full and an enemy is in range`);
|
||||
const short = formOf(tier, elite).rangeGrid.filter(([, c]) => c < 3);
|
||||
assert.deepEqual(u.liveRangeGrid, short, `T${tier}: 攻击距离-1`);
|
||||
h.run(3);
|
||||
const one = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||||
assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one hit per attack on a lone enemy`);
|
||||
assert.equal(new Set(one.map((c) => c.dmg.attackId)).size, one.length, `T${tier}: no bounce`);
|
||||
const before = atkHits(h, u).filter((c) => c.t < one[0].t - 1e-9).length; // her normal attacks' steals so far
|
||||
approx(one[0].amount, (u.base.atk + Math.min(250, 25 * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % (+ the ATK stolen before)`);
|
||||
// A (lone) at 10,5; B at 11,5 (1 from A); C at 9,6 (1.41 from A, 2.24 from B)
|
||||
const b = h.spawn('enemy_dummy', { pos: [11, 5] }), c = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
const far = h.spawn('enemy_dummy', { pos: [12, 5] }); // 2 from A, 1 from B: reachable from B only
|
||||
h.run(0.1);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(4);
|
||||
const after = atkHits(h, u).slice(n0);
|
||||
const id = after[0].dmg.attackId;
|
||||
const chain = after.filter((x) => x.dmg.attackId === id);
|
||||
assert.equal(chain.length, 4, `T${tier}: the hit + 3 bounces`);
|
||||
assert.equal(chain[0].target, lone, `T${tier}: her target`);
|
||||
assert.equal(chain[1].target, b, `T${tier}: nearest not yet hit (B, 1 tile)`);
|
||||
assert.equal(chain[2].target, far, `T${tier}: from B the one not yet hit (12,5), before A`);
|
||||
approx(chain[1].t - chain[0].t, 1 / 6 + TICK, `T${tier}: bounce at 6 tiles/s`, 0.07);
|
||||
assert.ok(chain.every((x) => x.dmg.isAttack && x.dmg.isSkill && x.type === 'phys'), `T${tier}: hits of the attack`);
|
||||
assert.ok(!chain.some((x) => x.target === c) || chain[3].target === c, `T${tier}: C (2.24 from B) only from A`);
|
||||
// on until she leaves; redeployed: off, own range
|
||||
h.run(30);
|
||||
assert.ok(u.skill.active, `T${tier}: still on`);
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u);
|
||||
assert.ok(!u.skill.active, `T${tier}: off after a redeploy`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: y-7 again`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 恶魇 (30 s, attack SP 18 from 5 / 8): 210 % / 225 % ATK + 停顿 1 s; the boomerang flies on 0.5 s, then hits every enemy within 1.0 of its way back once for 155 % / 170 %; farther ones untouched', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@atk_scale_comeback'], sk.bb['attack@sluggish'], sk.bb['attack@move_ahead_time'], sk.bb['attack@projectile_range']],
|
||||
['DEFAULT', 'attack', 18, elite ? 8 : 5, 30, elite ? 2.25 : 2.1, elite ? 1.7 : 1.55, 1, 0.5, 1], `T${tier}`);
|
||||
const target = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const side = h.spawn('enemy_dummy', { pos: [11, 5] }); // 1.0 from the return line (row 10): hit on the way back
|
||||
const off = h.spawn('enemy_dummy', { pos: [12, 4] }); // 2 rows off: never
|
||||
h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target
|
||||
// attack SP: one per normal attack
|
||||
const sp0 = u.skill.sp;
|
||||
h.run(3.5);
|
||||
const normal = atkHits(h, u).filter((c) => !c.dmg.isSkill).length;
|
||||
approx(u.skill.sp, Math.min(18, sp0 + normal), `T${tier}: +1 SP an attack`);
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.1);
|
||||
h.run(3);
|
||||
const sk2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||||
const id = sk2[0].dmg.attackId;
|
||||
const main = sk2.find((c) => c.dmg.attackId === id && !c.dmg.tags?.includes('narant:comeback'));
|
||||
const back = sk2.filter((c) => c.dmg.attackId === id && c.dmg.tags?.includes('narant:comeback'));
|
||||
assert.equal(main.target, target, `T${tier}: her target`);
|
||||
assert.deepEqual(back.map((c) => c.target).sort((a, b) => a.id - b.id), [target, side].sort((a, b) => a.id - b.id), `T${tier}: once each within 1.0 of the way back`);
|
||||
assert.ok(back.every((c) => c.t - main.t >= 0.5), `T${tier}: after the 0.5 s dash`);
|
||||
assert.ok(!sk2.some((c) => c.target === off), `T${tier}: 2 tiles off the line`);
|
||||
assert.ok(back.every((c) => c.dmg.isAttack && c.type === 'phys'), `T${tier}: hits of the attack`);
|
||||
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
|
||||
assert.ok(slows.length && slows.every((c) => c.target === target && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 停顿 1 s on the hit target only`);
|
||||
// the hit's ATK scale: her ATK at that hit (base + the steals before it) × 225 %
|
||||
const before = atkHits(h, u).filter((c) => c.t < main.t - 1e-9).length;
|
||||
const sa = formOf(tier, elite).talents.find((t) => t.index === 0).bb['attack@steal_atk'];
|
||||
approx(main.amount, (u.base.atk + Math.min(250, sa * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 吞日 (20 s): 3 boomerangs per attack (3 hits of 145 % / 160 %, flying together); once they are caught she strikes at most 3 enemies within one tile for 130 % / 140 % and 停顿 1 s (no attack)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.cnt, sk.bb['attack@atk_scale'], sk.bb.atk_scale_aoe, sk.bb['attack@aoe.max_target'], sk.bb.sluggish],
|
||||
['DEFAULT', elite ? 36 : 39, elite ? 17 : 14, 20, 3, elite ? 1.6 : 1.45, elite ? 1.4 : 1.3, 3, 1], `T${tier}`);
|
||||
const target = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const around = [[9, 3], [11, 3], [9, 4], [11, 4]].map((pos) => h.spawn('enemy_dummy', { pos }));
|
||||
h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target: the far one
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.1);
|
||||
h.run(2.5);
|
||||
const sk3 = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||||
const id = sk3[0].dmg.attackId;
|
||||
const three = sk3.filter((c) => c.dmg.attackId === id);
|
||||
assert.equal(three.length, 3, `T${tier}: 3 boomerangs`);
|
||||
assert.ok(three.every((c) => c.target === target && c.t === three[0].t), `T${tier}: together, at the target`);
|
||||
const caught = h.hooksOf('boomerangCaught').find((c) => c.unit === u && c.attackId === id);
|
||||
assert.ok(caught?.isSkill, `T${tier}: caught`);
|
||||
const blast = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('narant:blast') && Math.abs(c.t - caught.t) < 1e-9);
|
||||
assert.equal(blast.length, 3, `T${tier}: at most 3 of the 4 around her`);
|
||||
assert.ok(blast.every((c) => around.includes(c.target) && !c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: skill hits within one tile`);
|
||||
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && Math.abs(c.t - caught.t) < 1e-9);
|
||||
assert.equal(slows.length, 3, `T${tier}: 停顿 on each`);
|
||||
for (const c of slows) approx(c.duration, 1, '1 s');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 “我见,我得”: every landing attack hit steals 25 ATK / 20 DEF — the enemy loses, she gains, each capped at 250 / 200 (LPS-X stage 3: 300 / 240, twice within one tile); two of her: the larger loss, not the sum; a dodged hit steals nothing; all given back when she leaves', () => {
|
||||
for (const f of [[5, false, null], [6, true, null], [5, true, LPSX], [6, true, LPSX], [6, true, LPSY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||||
const tc = mod === LPSX && tier === 6 ? modOf(6, LPSX).talentChanges.find((t) => t.talentIndex === 0) : null;
|
||||
const capA = tc?.bb['attack@steal_atk_max'] ?? t0['attack@steal_atk_max'], capD = tc?.bb['attack@steal_def_max'] ?? t0['attack@steal_def_max'];
|
||||
assert.deepEqual([t0['attack@steal_atk'], t0['attack@steal_def'], capA, capD], [25, 20, ...(tc ? [300, 240] : [250, 200])], label(f));
|
||||
const twice = !!tc;
|
||||
if (twice) assert.match(tc.desc, /攻击到周围8格的敌人时偷取触发2次/);
|
||||
// one hit at two tiles, then one at one tile
|
||||
{
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_brute', { pos: [10, 5] });
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3));
|
||||
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25, defFlat: 20 }, `${label(f)}: she gains`);
|
||||
assert.deepEqual(e.findBuff('narant:stolen')?.mods, { atkFlat: -25, defFlat: -20 }, `${label(f)}: the enemy loses`);
|
||||
assert.deepEqual([e.s.atk, e.s.def], [375, 280], `${label(f)}: its stats`);
|
||||
h.b.kill(e, null);
|
||||
const near = h.spawn('enemy_brute', { pos: [10, 4] });
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length === 2, 4));
|
||||
const k = twice ? 2 : 1;
|
||||
assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -25 * k, defFlat: -20 * k }, `${label(f)}: within one tile ×${k}`);
|
||||
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25 * (1 + k), defFlat: 20 * (1 + k) }, `${label(f)}: hers`);
|
||||
// the caps
|
||||
h.run(40);
|
||||
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: capA, defFlat: capD }, `${label(f)}: her cap`);
|
||||
assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -capA, defFlat: -capD }, `${label(f)}: its cap`);
|
||||
approx(u.s.atk, (u.base.atk + capA) * (1 + calmAtk(tier, elite, mod)), `${label(f)}: her ATK`);
|
||||
// she leaves: nothing kept, everything given back
|
||||
h.b.retreat(u);
|
||||
h.step();
|
||||
assert.equal(u.findBuff('talent:narant:loot'), null, `${label(f)}: her gains go`);
|
||||
assert.equal(near.findBuff('narant:stolen'), null, `${label(f)}: its losses come back`);
|
||||
assert.deepEqual([near.s.atk, near.s.def], [400, 300]);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// two of her (a T5 and a T6 piece) hitting one enemy: the larger loss, never the sum; a dodged hit steals nothing
|
||||
const { h, u } = field({ tier: 5, skill: 2, others: [{ uid: 2, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, row: 12, col: 3 }] });
|
||||
const v = h.unit(2);
|
||||
const e = h.spawn('enemy_brute', { pos: [11, 5] });
|
||||
h.run(8);
|
||||
const n1 = atkHits(h, u).filter((c) => c.target === e).length, n2 = atkHits(h, v).filter((c) => c.target === e).length;
|
||||
assert.ok(n1 >= 3 && n2 >= 3, `both hit it (${n1}, ${n2})`);
|
||||
assert.deepEqual(e.findBuff('narant:stolen')?.mods.atkFlat, -Math.min(250, 25 * Math.max(n1, n2)), 'the larger loss');
|
||||
const ghost = h.spawn('enemy_brute', { pos: [11, 4] });
|
||||
h.b.kill(e, null);
|
||||
h.b.addBuff(ghost, { key: 'test:dodge', mods: { dodgePhys: 1 } });
|
||||
h.run(4);
|
||||
assert.ok(h.hooksOf('damaged').every((c) => c.target !== ghost), 'every hit dodged');
|
||||
assert.equal(ghost.findBuff('narant:stolen'), null, 'a dodged hit steals nothing');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T2 婀娜虚影: enemies within one tile of her miss 20 % (LPS-Y stage 3: 30 %) of their physical / arts attacks — one roll per attack —, farther ones never; LPS-Y stage 3: ATK +15 % after 6 s without damage', () => {
|
||||
for (const f of [[5, false, null], [6, true, LPSY], [5, true, LPSY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
|
||||
const cut = mod === LPSY && tier === 6 ? -modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === 1).bb.damage_hitrate_physical : -t1.damage_hitrate_physical;
|
||||
assert.equal(cut, mod === LPSY && tier === 6 ? 0.3 : 0.2, label(f));
|
||||
for (const [pos, inside] of [[[10, 4], true], [[11, 5], false]]) {
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 });
|
||||
h.b.removeBuff(u, 'talent:narant:dodge'); // her own 闪避 aside: every lost shot is a miss
|
||||
h.b.addBuff(u, { key: 'test:tank', mods: { hpFlat: 1e9 } });
|
||||
const shooter = h.spawn('enemy_shooter', { pos });
|
||||
h.run(150);
|
||||
const shots = h.hooksOf('attack').filter((c) => c.attacker === shooter).length;
|
||||
const landed = h.hooksOf('damaged').filter((c) => c.source === shooter).length;
|
||||
assert.ok(shots > 200, `${label(f)}: ${shots} shots`);
|
||||
const rate = 1 - landed / shots;
|
||||
if (inside) assert.ok(Math.abs(rate - cut) < 0.07, `${label(f)}: ${(rate * 100).toFixed(1)} % missed ≈ ${cut * 100} %`);
|
||||
else assert.ok(landed >= shots - 1, `${label(f)}: two tiles away: no miss (${landed} / ${shots}, the last one may still fly)`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the calm ATK
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 });
|
||||
assert.deepEqual(modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === -1).bb, { atk: 0.15, interval: 6 });
|
||||
h.step();
|
||||
assert.deepEqual(u.findBuff('talent:narant:calm')?.mods, { atkPct: 0.15 }, 'unhurt since her deployment');
|
||||
h.b.dealDamage(null, u, { amount: 10, type: 'true' });
|
||||
h.step();
|
||||
assert.equal(u.findBuff('talent:narant:calm'), null, 'hurt: gone');
|
||||
h.run(5.5);
|
||||
assert.equal(u.findBuff('talent:narant:calm'), null, 'not yet');
|
||||
h.run(0.6);
|
||||
assert.ok(u.findBuff('talent:narant:calm'), '6 s later: back');
|
||||
h.b.loseHp(u, 10, {});
|
||||
h.step();
|
||||
assert.ok(u.findBuff('talent:narant:calm'), 'a 流失 is no damage');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('LPS-X “吹尽狂沙”: ×1.1 on her attack hits within one tile (stages 1 and 3), not beyond; none without it', () => {
|
||||
for (const [tier, mod] of [[5, LPSX], [6, LPSX], [6, LPSY], [5, null]]) {
|
||||
const near = mod === LPSX ? 1.1 : 1;
|
||||
if (mod === LPSX) assert.deepEqual(modOf(tier, LPSX).traitOverride.bb, { atk_scale: 1.1 });
|
||||
for (const [pos, d1] of [[[10, 4], true], [[10, 5], false], [[11, 4], true]]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 2 });
|
||||
const e = h.spawn('enemy_dummy', { pos });
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3));
|
||||
approx(atkHits(h, u)[0].amount, u.base.atk * (1 + calmAtk(tier, true, mod)) * (d1 ? near : 1), `T${tier} ${mod}: ${pos}`);
|
||||
assert.equal(atkHits(h, u)[0].target, e);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('LPS-Y “致我们的老大”: 1 SP every 5 boomerangs caught (an S3 throw carries 3), none while her skill runs; none without it', () => {
|
||||
for (const [tier, mod] of [[5, LPSY], [6, LPSY], [6, LPSX]]) {
|
||||
if (mod === LPSY) assert.deepEqual(modOf(tier, LPSY).traitOverride.bb, { come_back_cnt: 5, sp: 1 });
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 1 }); // S2: attack SP — the time SP stays out of it
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(9);
|
||||
const caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && !c.isSkill).length;
|
||||
const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module').length;
|
||||
assert.ok(caught >= 5, `T${tier} ${mod}: ${caught} caught`);
|
||||
assert.equal(gains, mod === LPSY ? Math.floor(caught / 5) : 0, `T${tier} ${mod}: ${gains} module SP`);
|
||||
done(h);
|
||||
}
|
||||
// S3: each throw is 3 boomerangs; while S3 runs no SP is gained (the catches still count)
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 });
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
h.run(6);
|
||||
const during = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && c.isSkill).length;
|
||||
assert.ok(during >= 3, `${during} S3 throws caught`);
|
||||
assert.equal(u.mem.narantCaught, (3 * during) % 5, 'the S3 catches count 3 each');
|
||||
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module' && c.amount > 0 && u.skill.active).length, 0, 'no SP during S3');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('engine: a profile `boomerangOnward` takes the flight over after the first hit — ctx.hit / ctx.comeBack once — and a content error there still brings the boomerang back', () => {
|
||||
const calls = [];
|
||||
const kits = {
|
||||
[NARANT]: () => ({
|
||||
skills: Object.fromEntries([S1, S2, S3].map((id) => [id, { kind: 'duration' }])),
|
||||
trait: {
|
||||
boomerangOnward(ctx) {
|
||||
calls.push(ctx.attackId);
|
||||
if (calls.length === 2) throw new Error('kit bug');
|
||||
ctx.battle.after(0.5, () => { assert.equal(typeof ctx.comeBack(ctx.x, ctx.y), 'object'); assert.equal(ctx.comeBack(ctx.x, ctx.y), null, 'once'); });
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 120, autoFinish: false, seed: 1, kits, verbose: false, hooks: ['boomerangCaught'],
|
||||
units: [{ uid: 1, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, elite: true, row: 10, col: 3 }] });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.run(6);
|
||||
assert.ok(calls.length >= 3, `${calls.length} flights taken over`);
|
||||
assert.ok(h.hooksOf('boomerangCaught').length >= 2, 'caught every time (the error one too)');
|
||||
assert.ok(h.b.errors.some((e) => /boomerang\.onward/.test(e.label ?? e.where ?? JSON.stringify(e))), 'the error is logged');
|
||||
assert.equal(u.trait.boomerangsOut <= 1, true);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
// test/content/op_poca.test.js — the 自选 operator kit of 早露 (char_197_poca, 6★ 攻城手; kit
|
||||
// server/sim/content/kits/ops/op-poca.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
|
||||
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X
|
||||
// 攻城器械训练装置 or SIE-Y “心” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form
|
||||
// of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_poca.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 POCA = 'char_197_poca';
|
||||
const FORMS = BACKUPS.units[POCA].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SIEX = 'uniequip_002_poca', SIEY = 'uniequip_003_poca';
|
||||
const S1 = 'skcom_atk_up[3]', S2 = 'skchr_poca_2', S3 = 'skchr_poca_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
|
||||
enemy_armor: dummy('enemy_armor', { mass: 3, def: 600 }), enemy_plate: dummy('enemy_plate', { mass: 2, def: 600 }),
|
||||
enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }), enemy_hfly: dummy('enemy_hfly', { mass: 4, motion: 'FLY' }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY].map((m) => [t, true, m]))];
|
||||
/** 学生楷模: she is a 【乌萨斯学生自治团】 operator herself — her ATK always carries the talent's +atk. */
|
||||
const studentAtk = (tier, elite, mod) => (elite && mod === SIEY && tier === 6 ? 0.12 : 0.08);
|
||||
|
||||
/** A battle with 早露 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: POCA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
test('早露 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 4-3 range without her tile, ranged physical arrows that hit air, heaviest first, block 1, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[POCA], KITS[POCA]);
|
||||
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], [POCA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.profile.priority, u.base.bat],
|
||||
[1, 'ranged', 'phys', true, false, 'heaviest', 2.4], `${label(f)}: 攻城手`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
|
||||
assert.ok(!u.liveRangeGrid.some(([r, c]) => r === 0 && c === 0), `${label(f)}: PRTS 攻城手 "攻击范围不包含自身所在地块"`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[POCA].bonds, []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 1351 / 856 / 100, E2 Lv60 1619 / 973 / 115; SIE-X +60 / +30 → +90 / +45,
|
||||
// SIE-Y +70 / +20 → +100 / +30 ATK / DEF
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1351, 856, 1619, 973]);
|
||||
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr], [{ atk: 60, def: 30 }, { atk: 90, def: 45 }, { atk: 70, def: 20 }, { atk: 100, def: 30 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 早露 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(POCA));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(POCA), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } } });
|
||||
});
|
||||
|
||||
test('trait 攻城手 "优先攻击重量最重的敌人": the heaviest enemy of her range first, wherever it stands; a lighter one only once it is heavier (失重 moves it down); flyers are targets too', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [11, 7] });
|
||||
const n0 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 4), `T${tier}: an attack`);
|
||||
assert.equal(atkHits(h, u).at(-1).target, heavy, `T${tier}: weight 3 before weight 1 (the nearer one)`);
|
||||
h.b.addBuff(heavy, { key: 'test:weightless', mods: { massFlat: -3 } });
|
||||
const n1 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length > n1, 4));
|
||||
assert.equal(atkHits(h, u).at(-1).target, light, `T${tier}: 失重 to 0 — now the light one first`);
|
||||
const fly = h.spawn('enemy_hfly', { pos: [9, 6] });
|
||||
const n2 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => atkHits(h, u).length > n2, 4));
|
||||
assert.equal(atkHits(h, u).at(-1).target, fly, `T${tier}: a weight-4 flyer first (可对空)`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +45 % / +60 % for 30 s, SP 37 / 35 from 5 / 10; cast with an enemy in her range at her attack', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk],
|
||||
['DEFAULT', 'duration', 'time', elite ? 35 : 37, elite ? 10 : 5, 30, elite ? 0.6 : 0.45], `T${tier}`);
|
||||
const st = studentAtk(tier, elite, null);
|
||||
approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: 学生楷模 only`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
h.spawn('enemy_light', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
|
||||
approx(u.s.atk, u.base.atk * (1 + st + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`);
|
||||
h.runUntil(() => !u.skill.active, 31);
|
||||
approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 分裂射击 (60 s): ATK +45 % / +60 % and two targets per attack (attack@max_target 2) — the two heaviest; a lone enemy gets one arrow', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], u.skill.spCost, u.skill.initSp], ['DEFAULT', 60, elite ? 0.6 : 0.45, 2, 80, elite ? 45 : 42], `T${tier}`);
|
||||
const lone = h.spawn('enemy_light', { pos: [10, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
approx(u.skill.timeLeft, 60, `T${tier}: 60 s`, 0.05);
|
||||
approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.run(0.6);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(2.5);
|
||||
const one = atkHits(h, u).slice(n0);
|
||||
assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one target, one arrow per attack`);
|
||||
const mid = h.spawn('enemy_mid', { pos: [11, 6] }), heavy = h.spawn('enemy_heavy', { pos: [9, 5] });
|
||||
h.run(0.5);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(2.5);
|
||||
const ids = atkHits(h, u).slice(n1).map((c) => c.dmg.attackId);
|
||||
const first = atkHits(h, u).slice(n1).filter((c) => c.dmg.attackId === ids[0]);
|
||||
assert.deepEqual(first.map((c) => c.target).sort((a, b) => a.id - b.id), [mid, heavy].sort((a, b) => a.id - b.id), `T${tier}: the two heaviest (weights 3, 2), not the light one`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 雪崩击 (6 / 7 s): harpoons link the 3 heaviest enemies of her range (a flyer too), 束缚 them, and strike each every second — 6 / 7 strikes at ATK +10 % (attacks), no other attack; the binds end with it', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.max_target, sk.bb.hit_interval, sk.bb.hit_duration, sk.bb.atk, u.skill.spCost, u.skill.initSp],
|
||||
['DEFAULT', elite ? 7 : 6, 3, 1, elite ? 7 : 6, 0.1, elite ? 34 : 37, elite ? 12 : 11], `T${tier}`);
|
||||
const light = h.spawn('enemy_light', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] });
|
||||
const heavy = h.spawn('enemy_heavy', { pos: [11, 6] }), hfly = h.spawn('enemy_hfly', { pos: [9, 6] });
|
||||
const outside = h.spawn('enemy_heavy', { pos: [10, 9] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
const t0 = h.b.time;
|
||||
const linked = [mid, heavy, hfly];
|
||||
for (const e of linked) assert.equal(e.findBuff('bind')?.source, u, `T${tier}: ${e.defId} bound`);
|
||||
assert.equal(light.findBuff('bind'), null, `T${tier}: the 4th heaviest is not linked`);
|
||||
assert.equal(outside.findBuff('bind'), null, `T${tier}: out of range`);
|
||||
approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + 0.1), `T${tier}: ATK +10 %`);
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: ends`);
|
||||
approx(h.b.time - t0, sk.duration, `T${tier}: ${sk.duration} s`, 0.02);
|
||||
const hits = hitsBy(h, u);
|
||||
for (const e of linked) {
|
||||
const on = hits.filter((c) => c.target === e);
|
||||
assert.equal(on.length, sk.duration, `T${tier}: ${on.length} strikes on ${e.defId}`);
|
||||
assert.ok(on.every((c) => c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: physical skill attacks`);
|
||||
on.forEach((c, i) => i && approx(c.t - on[i - 1].t, 1, `T${tier}: one a second`, 0.02));
|
||||
}
|
||||
assert.ok(!hits.some((c) => c.target === light || c.target === outside), `T${tier}: nobody else is hit`);
|
||||
for (const e of linked) assert.equal(e.findBuff('bind'), null, `T${tier}: unbound at the end`);
|
||||
// the strikes are instant along the links
|
||||
assert.ok(h.eventsOf('atk').filter((ev) => ev[1] === u.id).some((ev) => ev[3] === 'beam'));
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 雪崩击 (PRTS 备注): a link breaks when its enemy is knocked out or vanishes, the skill ends at once when none is left; a stun / silence on her breaks every link', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
// all links lost ⇒ the skill ends early
|
||||
{
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
const a = h.spawn('enemy_heavy', { pos: [10, 6] }), b = h.spawn('enemy_mid', { pos: [11, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
h.run(1.2);
|
||||
h.b.kill(a, null);
|
||||
h.step();
|
||||
assert.ok(u.skill.active, `T${tier}: one link left`);
|
||||
b.hidden = true; // 消失
|
||||
h.step();
|
||||
assert.ok(!u.skill.active, `T${tier}: no link left ⇒ ends at once`);
|
||||
b.hidden = false;
|
||||
assert.equal(b.findBuff('bind'), null, `T${tier}: its bind went with the link`);
|
||||
done(h);
|
||||
}
|
||||
for (const status of ['stun', 'silence']) {
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
const a = h.spawn('enemy_heavy', { pos: [10, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
h.run(1.5);
|
||||
h.b.applyStatus(u, status, { duration: 2, source: null });
|
||||
h.step();
|
||||
assert.ok(!u.skill.active, `T${tier}: ${status} breaks the links`);
|
||||
assert.equal(a.findBuff('bind'), null, `T${tier}: ${status}: unbound`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 深入骨髓: her damage ignores 60 % of the DEF of an enemy of 重量等级 ≥ 3 (exact numbers), none below; the S3 strikes too', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0);
|
||||
assert.deepEqual([t0.bb.value, t0.bb.def_penetrate], [3, 0.6], `T${tier}: data`);
|
||||
for (const skill of [0, 2]) {
|
||||
const { h, u } = field({ tier, elite, skill });
|
||||
const armor = h.spawn('enemy_armor', { pos: [10, 6] }), plate = h.spawn('enemy_plate', { pos: [11, 6] });
|
||||
if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12);
|
||||
const on = (e) => atkHits(h, u).filter((c) => c.target === e);
|
||||
assert.ok(on(armor).length, `T${tier} S${skill + 1}: hit the weight-3 one`);
|
||||
for (const c of on(armor)) approx(c.amount, u.s.atk - 600 * (1 - 0.6), `T${tier} S${skill + 1}: weight 3, DEF 600 × 40 %`);
|
||||
if (skill === 2) {
|
||||
assert.ok(on(plate).length, `T${tier}: S3 links the weight-2 one too`);
|
||||
for (const c of on(plate)) approx(c.amount, u.s.atk - 600, `T${tier} S3: weight 2, full DEF`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 学生楷模: every 【乌萨斯学生自治团】 operator of her team — her and 古米, deployed or not — ATK +8 %, nobody else; SIE-Y stage 3: +12 % and +15 % per student in skill (≤ +45 %), re-read every 0.3 s', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 6 }];
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, others });
|
||||
const gummy = h.unit(2), yak = h.unit(3);
|
||||
const v = studentAtk(tier, elite, mod);
|
||||
for (const a of [u, gummy]) assert.deepEqual(a.findBuff('talent:poca:students')?.mods, { atkPct: v }, `${label(f)}: ${a.def.charId}`);
|
||||
assert.equal(yak.findBuff('talent:poca:students'), null, `${label(f)}: 角峰 is no student`);
|
||||
h.b.retreat(u);
|
||||
h.run(0.5);
|
||||
assert.ok(gummy.findBuff('talent:poca:students'), `${label(f)}: 编入队伍时 — still there with her off the field`);
|
||||
assert.ok(u.findBuff('talent:poca:students'), `${label(f)}: kept through her retreat`);
|
||||
assert.equal(!!gummy.findBuff('talent:poca:studentSkills'), false, `${label(f)}: nobody in skill`);
|
||||
done(h);
|
||||
}
|
||||
// SIE-Y stage 3: the extra +15 % per student operator on the field whose skill runs, at most +45 %
|
||||
const t6 = formOf(6, true).modules.find((m) => m.uniEquipId === SIEY).talentChanges;
|
||||
assert.deepEqual([t6.find((t) => t.talentIndex === 1).bb.atk, t6.find((t) => t.talentIndex === -1).bb], [0.12, { init_atk: 0.15, max_atk: 0.45 }]);
|
||||
const others = [
|
||||
{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3, skillIndex: 1 }, { uid: 3, chessId: 'chess_char_1_10_b', row: 12, col: 5, skillIndex: 1 },
|
||||
{ uid: 4, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_1051_headb2', skillIndex: 0 }, row: 11, col: 8 },
|
||||
{ uid: 5, chessId: 'chess_char_1_02_a', row: 12, col: 7 },
|
||||
];
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 0, others });
|
||||
const team = [u, h.unit(2), h.unit(3), h.unit(4)];
|
||||
assert.ok(team.every((a) => a && a.deployed), 'four students on the field');
|
||||
const drive = (a) => a.findBuff('talent:poca:studentSkills')?.mods.atkPct ?? 0;
|
||||
const cast = (a) => { a.skill.gainSp(999); a.skill.activate('test'); assert.ok(a.skill.active, `${a.def.charId} in skill`); };
|
||||
cast(u);
|
||||
h.step(); // at once: 每次开启技能时
|
||||
for (const a of team) approx(drive(a), 0.15, `one in skill: ${a.def.charId}`);
|
||||
assert.equal(drive(h.unit(5)), 0, '角峰 gets nothing');
|
||||
approx(u.s.atk, u.base.atk * (1 + 0.12 + 0.15 + skillOf(6, true, S1).bb.atk), 'her ATK: +12 % +15 % (and her S1)');
|
||||
cast(h.unit(2));
|
||||
h.step();
|
||||
approx(drive(h.unit(4)), 0.3, 'two in skill');
|
||||
cast(h.unit(3));
|
||||
cast(h.unit(4));
|
||||
h.step();
|
||||
approx(drive(u), 0.45, 'four in skill: capped at +45 %');
|
||||
u.skill.end('test');
|
||||
h.run(0.35); // the 0.3 s re-read
|
||||
approx(drive(h.unit(2)), 0.45, 'three in skill');
|
||||
for (const a of team.slice(1)) a.skill.end('test');
|
||||
h.run(0.35);
|
||||
assert.equal(drive(u), 0, 'nobody in skill');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('SIE-X 攻城器械训练装置: ×1.15 on her attacks against 重量等级 ≥ 3 (normal attacks, S3 strikes), nothing below; stage 3 深入骨髓 adds a 60 % ATK physical hit on such enemies (with the DEF ignore)', () => {
|
||||
for (const [tier, mod] of [[5, null], [5, SIEX], [6, SIEX], [6, SIEY], [6, null]]) {
|
||||
const xs = mod === SIEX ? 1.15 : 1;
|
||||
const extra = mod === SIEX && tier === 6 ? 0.6 : 0;
|
||||
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 }, `T${tier}: data`);
|
||||
for (const skill of [0, 2]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill });
|
||||
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] });
|
||||
if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12);
|
||||
const main = atkHits(h, u);
|
||||
const onH = main.filter((c) => c.target === heavy), onM = main.filter((c) => c.target === mid);
|
||||
// (SIE-Y's own distance factor: 2 / 3 tiles)
|
||||
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
|
||||
assert.ok(onH.length, `T${tier} ${mod} S${skill + 1}: hits the heavy one`);
|
||||
for (const c of onH) approx(c.amount, u.s.atk * xs * far(2), `T${tier} ${mod} S${skill + 1}: ×${xs} on weight 3`);
|
||||
for (const c of onM) approx(c.amount, u.s.atk * far(3), `T${tier} ${mod} S${skill + 1}: ×1 on weight 2`);
|
||||
const ex = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('poca:extra'));
|
||||
if (extra) {
|
||||
assert.equal(ex.length, onH.length, `T${tier}: one extra hit per attack hit on the heavy one`);
|
||||
assert.ok(ex.every((c) => c.target === heavy && c.type === 'phys' && !c.dmg.isAttack), `T${tier}: physical, not an attack`);
|
||||
for (const c of ex) approx(c.amount, u.s.atk * extra, `T${tier}: 60 % ATK`);
|
||||
} else assert.equal(ex.length, 0, `T${tier} ${mod}: no extra hit`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the extra hit gets 深入骨髓's 物理穿透 too: weight 3, DEF 600
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 0 });
|
||||
const armor = h.spawn('enemy_armor', { pos: [10, 6] });
|
||||
h.run(6);
|
||||
const ex = hitsBy(h, u).filter((c) => c.target === armor && c.dmg.tags?.includes('poca:extra'));
|
||||
assert.ok(ex.length);
|
||||
for (const c of ex) approx(c.amount, u.s.atk * 0.6 - 600 * 0.4, 'extra hit: DEF 600 × 40 %');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('SIE-Y “心”: her attack damage rises with the distance, linearly from 1 tile to 4.5 (at most +12 %), stages 1 and 3; nothing without it', () => {
|
||||
for (const [tier, mod] of [[5, SIEY], [6, SIEY], [6, SIEX], [5, null]]) {
|
||||
const tb = mod === SIEY ? modOf(tier, SIEY).traitOverride.bb : {};
|
||||
if (mod === SIEY) assert.deepEqual(tb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 }, `T${tier}: data`);
|
||||
for (const [r, c] of [[10, 5], [10, 6], [10, 7], [11, 6], [12, 5]]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
|
||||
const e = h.spawn('enemy_light', { pos: [r, c] });
|
||||
h.run(3);
|
||||
const hit = atkHits(h, u).find((x) => x.target === e);
|
||||
assert.ok(hit, `T${tier} ${mod}: hit at ${r},${c}`);
|
||||
const d = Math.hypot(c - 3, r - 10);
|
||||
const want = mod === SIEY ? 1 + 0.12 * Math.max(0, Math.min(1, (d - 1) / 3.5)) : 1;
|
||||
approx(hit.amount, u.s.atk * want, `T${tier} ${mod}: ${d.toFixed(2)} tiles ×${want.toFixed(4)}`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// test/content/op_saga.test.js — the 自选 operator kit of 嵯峨 (char_362_saga, 6★ 尖兵; kit
|
||||
// server/sim/content/kits/ops/op-saga.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
|
||||
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SOL-Y 身向云泥 or
|
||||
// SOL-X 何处寻驮? at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity
|
||||
// checklist of kits/README.md item by item (劝善's 重伤 per PRTS 术语释义 重伤 / the talent and S2 备注).
|
||||
// Run: node --test test/content/op_saga.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
|
||||
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
|
||||
import { DYING, S2_CHECK, isDying } from '../../server/sim/content/kits/ops/op-saga.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 SAGA = 'char_362_saga';
|
||||
const FORMS = BACKUPS.units[SAGA].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SOLY = 'uniequip_002_saga', SOLX = 'uniequip_003_saga';
|
||||
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_saga_2', S3 = 'skchr_saga_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_weak: dummy('enemy_weak', { hp: 100 }),
|
||||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
|
||||
};
|
||||
// a regenerating enemy (data/enemies.json has two: 200 HP/s for enemy_1061_zomshd)
|
||||
ENEMIES.enemy_regen = { ...dummy('enemy_regen', { hp: 100 }), stats: { ...dummy('enemy_regen', { hp: 100 }).stats, hpRecoveryPerSec: 200 } };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOLY, SOLX].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 嵯峨 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, dp = 0 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'skillStart', 'skillEnd', 'kill', 'death', 'spGain', 'attack'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SAGA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy');
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('嵯峨 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 2, melee ground-only, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[SAGA], KITS[SAGA]);
|
||||
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], [SAGA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
|
||||
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, typeof u.profile.skipEnemy], [2, 'melee', 'phys', false, 1.05, 'function'], `${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.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1609 / 434 / 297, E2 Lv60 2004 / 498 / 347; SOL-Y +75 / +45 / +15 → +125 / +55 / +20, SOL-X +50 / +28 → +70 / +44
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1609, 434, 2004, 498]);
|
||||
assert.deepEqual([modOf(5, SOLY).attr, modOf(6, SOLY).attr, modOf(5, SOLX).attr, modOf(6, SOLX).attr],
|
||||
[{ maxHp: 75, atk: 45, def: 15 }, { maxHp: 125, atk: 55, def: 20 }, { atk: 50, def: 28 }, { atk: 70, def: 44 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 嵯峨 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(SAGA));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SAGA), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } } });
|
||||
});
|
||||
|
||||
test('melee, ground-only: no attack on a flyer in her range; a ground enemy there is attacked (physical, 100 % ATK)', () => {
|
||||
const { h, u } = field({ tier: 5, skill: 0 });
|
||||
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
|
||||
h.run(3);
|
||||
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'flyer untouched');
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === e), 3));
|
||||
const c = hitsBy(h, u).find((x) => x.target === e);
|
||||
assert.deepEqual([c.type, c.dmg.isAttack], ['phys', true]);
|
||||
approx(c.amount, u.s.atk, '100 % ATK');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (SP_FULL); 41 / 38 SP from 12 / 14', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, mod: elite ? SOLX : null, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.cost], ['SP_FULL', elite ? 38 : 41, elite ? 14 : 12, 12], `T${tier}`);
|
||||
h.b.players[0].dp = 0;
|
||||
h.run(sk.spCost - sk.initSp - 0.5);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: at full SP, no enemy`);
|
||||
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 除恶 (MANUAL, data SKILL_RANGE on x-6, 2 charges): +4 DP; the cross\'s ground enemies take 280 % / 320 % ATK physical — at most 6, flyers and other tiles untouched', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, mod: elite ? SOLY : null, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.rangeId, sk.bb.atk_scale, sk.bb.cost, sk.spCost, sk.initSp], ['SKILL_RANGE', 2, 'x-6', elite ? 3.2 : 2.8, 4, elite ? 16 : 18, elite ? 10 : 9], `T${tier}`);
|
||||
assert.match(sk.desc, /最多6名地面敌人/);
|
||||
const cross = [[12, 5], [11, 5], [10, 3], [10, 4], [10, 6], [10, 7], [9, 5]];
|
||||
const ground = cross.map((pos) => h.spawn('enemy_dummy', { pos }));
|
||||
const fly = h.spawn('enemy_fly', { pos: [8, 5] });
|
||||
const diag = h.spawn('enemy_dummy', { pos: [11, 6] });
|
||||
h.b.players[0].dp = 0;
|
||||
const n0 = hitsBy(h, u).length;
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast (enemies in the cross)`);
|
||||
const hit = hitsBy(h, u, n0).filter((c) => c.dmg.tags?.includes('saga:s2'));
|
||||
assert.equal(hit.length, 6, `T${tier}: at most 6`);
|
||||
assert.ok(hit.every((c) => ground.includes(c.target)), `T${tier}: ground enemies of the cross only`);
|
||||
assert.equal(new Set(hit.map((c) => c.target)).size, 6, `T${tier}: six different`);
|
||||
for (const c of hit) {
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
assert.deepEqual([c.type, c.dmg.isSkill, c.dmg.isAttack], ['phys', true, false], `T${tier}: a physical skill damage`);
|
||||
}
|
||||
assert.ok(!hit.some((c) => c.target === fly || c.target === diag), `T${tier}: flyer / diagonal untouched`);
|
||||
approx(h.b.players[0].dp, 4, `T${tier}: +4 DP`);
|
||||
assert.equal(u.skill.charges, 1, `T${tier}: one charge left`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 除恶: its trigger counts any enemy in the cross (a flyer too, 无视其不可选中) but hits ground ones only', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||||
const fly = h.spawn('enemy_fly', { pos: [9, 5] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'cast for a flyer alone in the cross');
|
||||
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'but not hit');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S2 除恶: a 重伤 enemy is selected (可选择重伤单位) and every hit enemy still 重伤 0.7 s later is killed by her (killer 嵯峨, +2 SP each)', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||||
const sk = skillOf(6, true, S2);
|
||||
const weak = h.spawn('enemy_weak', { pos: [10, 7] }); // in the cross, outside her 1-1
|
||||
const front = h.spawn('enemy_weak', { pos: [10, 6] }); // in her 1-1
|
||||
assert.ok(h.runUntil(() => isDying(front), 3), 'her attack: 重伤');
|
||||
assert.deepEqual([front.hp, weak.hp], [1, 100]);
|
||||
// 除恶 comes by itself once a charge is ready (SKILL_RANGE: enemies in the cross)
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, sk.spCost - sk.initSp + 1), 'cast');
|
||||
const tc = h.b.time;
|
||||
assert.ok(hitsBy(h, u).some((c) => c.target === front && c.dmg.tags?.includes('saga:s2')), '可选择重伤单位');
|
||||
assert.ok(isDying(weak) && weak.hp === 1, 'the weak one: 1 HP, 重伤');
|
||||
h.run(S2_CHECK - 0.1);
|
||||
assert.ok(weak.alive && front.alive, 'not before 0.7 s');
|
||||
assert.ok(h.runUntil(() => !weak.alive && !front.alive, 0.2), 'both killed at 0.7 s');
|
||||
const kills = h.hooksOf('kill').filter((c) => c.victim === weak || c.victim === front);
|
||||
assert.ok(kills.length === 2 && kills.every((c) => c.killer === u), 'killer: 嵯峨');
|
||||
assert.equal(u.stats.kills, 2);
|
||||
const talentSp = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent' && c.t >= tc).reduce((n, c) => n + c.amount, 0);
|
||||
approx(talentSp, 4, '2 × 2 SP');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 怒目 (MANUAL, data ACTIVE_RANGE on 1-1 + 1): 20 s, ATK +70 % / +100 %, interval 1.05 + 0.5 s, range +1, every blocked enemy at once, +20 DP over the skill; all back after', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2, row: 9, col: 5 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.ability_range_forward_extend, sk.bb.cost, sk.bb.interval, sk.bb.value, sk.bb['attack@hp_ratio']],
|
||||
['ACTIVE_RANGE', 20, elite ? 1 : 0.7, 0.5, 1, 1, 1, 20, 0.5], `T${tier}`);
|
||||
assert.deepEqual(sk.trigger.customRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: her 1-1 + 1`);
|
||||
u.skill.gainSp(999);
|
||||
const far = h.spawn('enemy_dummy', { pos: [9, 7] }); // two tiles ahead: the running range only
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy 2 tiles ahead`);
|
||||
h.b.players[0].dp = 0;
|
||||
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: range +1`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
approx(u.s.interval, 1.55, `T${tier}: +0.5 s`);
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === far), 3), `T${tier}: reaches 2 tiles ahead`);
|
||||
h.b.kill(far, null);
|
||||
// two walkers blocked: one attack hits both
|
||||
h.spawn('enemy_walk', { routeIndex: 0 });
|
||||
h.spawn('enemy_walk', { routeIndex: 0, pos: [9, 9] });
|
||||
assert.ok(h.runUntil(() => u.blocking.length === 2, 30), `T${tier}: blocking two`);
|
||||
const n0 = h.hooksOf('attack').length;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 3));
|
||||
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
|
||||
assert.equal(at.targets.length, 2, `T${tier}: every blocked enemy`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
approx(h.b.players[0].dp, 20, `T${tier}: +20 DP in all`, 1e-6);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
|
||||
approx(u.s.interval, 1.05, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 怒目: DP comes gradually (1 per second) and one more attack follows each attack on a target below half HP (checked before the hit), none above', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
const low = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
low.hp = low.s.maxHp * 0.4;
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1));
|
||||
h.b.players[0].dp = 0;
|
||||
h.run(5.5);
|
||||
approx(h.b.players[0].dp, 5, '5 DP after 5.5 s', 1e-6);
|
||||
// hits on the target, grouped by attack: an extra attack lands one tick after its attack
|
||||
const groups = (hits) => {
|
||||
const out = [];
|
||||
for (const c of hits) { const g = out.at(-1); if (g && c.t - g.at(-1).t < 0.1) g.push(c); else out.push([c]); }
|
||||
return out.map((g) => g.length);
|
||||
};
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.run(6);
|
||||
const low6 = groups(hitsBy(h, u, n0).filter((c) => c.target === low && c.dmg.isAttack));
|
||||
assert.ok(low6.length >= 3, `${low6.length} attacks in 6 s (1.55 s apart)`);
|
||||
assert.ok(low6.slice(0, -1).every((n) => n === 2), `every attack followed by one more: ${low6}`);
|
||||
// above half: no extra attack
|
||||
h.b.kill(low, null);
|
||||
const high = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
high.hp = high.s.maxHp * 0.6;
|
||||
const n1 = hitsBy(h, u).length;
|
||||
h.run(5);
|
||||
const high5 = groups(hitsBy(h, u, n1).filter((c) => c.target === high && c.dmg.isAttack));
|
||||
assert.ok(high5.length >= 2 && high5.every((n) => n === 1), `one hit per attack: ${high5}`);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 劝善: her damage leaves an enemy at 1 HP and 重伤 (move ×0.2, unblockable, 禁疗, no regeneration); she never attacks it; it dies 10 s later (无来源, no SP); a 重伤 enemy takes no second 重伤', () => {
|
||||
for (const f of [[5, false, null], [6, true, SOLY]]) {
|
||||
const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 2 });
|
||||
const t0 = BACKUPS.units[SAGA].forms[f[1] ? '2/60/7/3' : '2/1/4/0'].talents[0].bb;
|
||||
assert.deepEqual(t0, { move_speed: -0.8, sp: 2, interval: 10 });
|
||||
const e = h.spawn('enemy_weak', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => isDying(e), 3), `${label(f)}: 重伤`);
|
||||
const at = h.b.time;
|
||||
assert.deepEqual([e.alive, e.hp], [true, 1], `${label(f)}: 1 HP left`);
|
||||
const d = e.findBuff(DYING);
|
||||
assert.equal(d.source, u);
|
||||
approx(d.mods.moveMul, 0.2, `${label(f)}: move −80 %`);
|
||||
assert.equal(d.mods.hpRegen, -1e9, `${label(f)}: regeneration zeroed`);
|
||||
assert.deepEqual(d.flags, { unblockable: true, healFree: true, noHeal: true }, `${label(f)}: 禁用近战 / 禁疗`);
|
||||
approx(e.s.moveSpeed, e.base.moveSpeed * 0.2, `${label(f)}: move`);
|
||||
assert.ok(e.s.hpRegen <= 0, `${label(f)}: no regeneration (a cancelling hpRegen)`);
|
||||
assert.equal(h.b.heal(e, e, 50, { self: true }), 0, `${label(f)}: 禁疗`);
|
||||
// she never attacks it again
|
||||
const n0 = hitsBy(h, u).length, a0 = u.stats.attacks;
|
||||
h.run(4);
|
||||
assert.equal(hitsBy(h, u, n0).length, 0, `${label(f)}: no damage of hers`);
|
||||
assert.equal(u.stats.attacks, a0, `${label(f)}: no attack`);
|
||||
// no second 重伤
|
||||
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
|
||||
assert.equal(e.findBuff(DYING), d, `${label(f)}: the same 重伤`);
|
||||
assert.equal(e.hp, 1, `${label(f)}: still not lethal`);
|
||||
// 10 s: a forced, 无来源 death
|
||||
const sp0 = u.skill.sp;
|
||||
h.run(at + 10 - h.b.time - 0.1);
|
||||
assert.ok(e.alive, `${label(f)}: alive before 10 s`);
|
||||
assert.ok(h.runUntil(() => !e.alive, 0.2), `${label(f)}: dead at 10 s`);
|
||||
const k = h.hooksOf('kill').find((c) => c.victim === e);
|
||||
assert.deepEqual([k.killer, e.removeReason], [null, 'killed'], `${label(f)}: 无来源 kill`);
|
||||
assert.equal(h.b.killed, 1, `${label(f)}: counted`);
|
||||
assert.equal(h.hooksOf('spGain').filter((c) => c.reason === 'talent').length, 0, `${label(f)}: no SP for it`);
|
||||
assert.ok(u.skill.sp >= sp0, `${label(f)}: (time SP only)`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 劝善: a 重伤 enemy regenerates nothing (生命回复速度归零) — one with 200 HP/s stays at 1 HP', () => {
|
||||
const { h, u } = field({ tier: 5, skill: 2 });
|
||||
const e = h.spawn('enemy_regen', { pos: [10, 6] });
|
||||
assert.equal(e.s.hpRegen, 200, 'it regenerates');
|
||||
assert.ok(h.runUntil(() => isDying(e), 3));
|
||||
h.run(3);
|
||||
assert.deepEqual([e.alive, e.hp], [true, 1], 'no HP back while 重伤');
|
||||
assert.equal(hitsBy(h, u).filter((c) => c.target === e).length, 1, 'one hit of hers, then none');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 劝善: whoever kills a 重伤 enemy gets 2 SP; 重伤 never kills while 消失 and kills at once when it comes back', () => {
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
|
||||
const { h } = field({ tier: 5, skill: 2, others });
|
||||
const texas = h.unit(2);
|
||||
const e = h.spawn('enemy_weak', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => isDying(e), 3));
|
||||
const sp0 = texas.skill.sp;
|
||||
h.b.dealDamage(texas, e, { amount: 1e6, type: 'true' });
|
||||
assert.ok(!e.alive, 'another operator\'s damage kills it');
|
||||
approx(texas.skill.sp - sp0, 2, '+2 SP to 德克萨斯');
|
||||
// 消失
|
||||
const g = h.spawn('enemy_weak', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => isDying(g), 3));
|
||||
h.step();
|
||||
g.hidden = true;
|
||||
h.run(12);
|
||||
assert.ok(g.alive, 'no natural death while 消失');
|
||||
g.hidden = false;
|
||||
h.step();
|
||||
assert.ok(!g.alive, 'dead the moment it appears');
|
||||
assert.equal(h.hooksOf('kill').find((c) => c.victim === g).killer, null);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 劝善 SOL-X stage 3: her damage on a target below half HP ×1.15 (stage 1 and the other forms: none)', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const x3 = elite && tier === 6 && mod === SOLX;
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const hitAt = (ratio) => {
|
||||
e.hp = e.s.maxHp * ratio;
|
||||
const n0 = hitsBy(h, u).length;
|
||||
assert.ok(h.runUntil(() => hitsBy(h, u, n0).length > 0, 3), label(f));
|
||||
return hitsBy(h, u, n0)[0].amount;
|
||||
};
|
||||
const lo = hitAt(0.4), hi = hitAt(0.6);
|
||||
approx(lo / hi, x3 ? 1.15 : 1, `${label(f)}: below / above half`);
|
||||
if (x3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /对生命值低于一半的单位造成的伤害提升15%/);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 清明: once per deployment, the first time she drops below 40 %: 70 % physical dodge and 5 % max HP/s for 15 s; SOL-Y stage 3: 6.5 % (its blackboard) for 18 s', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, row: 12, col: 9 });
|
||||
const y3 = elite && tier === 6 && mod === SOLY;
|
||||
const tb = u.def.raw.talents.find((t) => t.index === 1).bb;
|
||||
assert.deepEqual(tb, { hp_ratio: 0.4, hp_recovery_per_sec_by_max_hp_ratio: y3 ? 0.065 : 0.05, prob: 0.7, duration: y3 ? 18 : 15 }, label(f));
|
||||
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.55, type: 'true' });
|
||||
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: at 45 % nothing`);
|
||||
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.1, type: 'true' });
|
||||
const b = u.findBuff('talent:saga:qingming');
|
||||
assert.ok(b, `${label(f)}: below 40 %`);
|
||||
assert.deepEqual(b.mods, { dodgePhys: 0.7, hpRegenRatio: y3 ? 0.065 : 0.05 }, `${label(f)}: mods`);
|
||||
approx(b.duration, y3 ? 18 : 15, `${label(f)}: duration`);
|
||||
approx(u.s.dodgePhys, 0.7, `${label(f)}: dodge`);
|
||||
const hp0 = u.hp;
|
||||
h.run(2.01);
|
||||
approx(u.hp - hp0, 2 * (y3 ? 0.065 : 0.05) * u.s.maxHp, `${label(f)}: regeneration`, 0.02);
|
||||
h.run(b.duration);
|
||||
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: over`);
|
||||
u.hp = u.s.maxHp;
|
||||
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
|
||||
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: only once`);
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u, { free: true });
|
||||
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
|
||||
assert.ok(u.findBuff('talent:saga:qingming'), `${label(f)}: again after a new deployment`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('SOL-X 何处寻驮? trait: ATK / DEF +8 % while she blocks an enemy (stages 1 and 3); none without it; SOL-Y\'s 首次部署时部署费用-4 changes nothing in battle', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 });
|
||||
const solx = elite && mod === SOLX;
|
||||
if (solx) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
|
||||
h.run(1);
|
||||
assert.equal(u.findBuff('trait:saga:block'), null, `${label(f)}: nothing blocked`);
|
||||
h.spawn('enemy_walk', { routeIndex: 0 });
|
||||
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`);
|
||||
h.step();
|
||||
assert.equal(!!u.findBuff('trait:saga:block'), solx, `${label(f)}: blocking`);
|
||||
if (solx) assert.deepEqual(u.findBuff('trait:saga:block').mods, { atkPct: 0.08, defPct: 0.08 });
|
||||
if (elite && mod === SOLY) {
|
||||
assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb.runtime_cost).filter((v) => v != null), [-4], `${label(f)}: the data carries it`);
|
||||
assert.equal(u.base.cost, formOf(tier, true).stats.cost, `${label(f)}: no cost change`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
// test/content/op_shining.test.js — the 自选 operator kit of 闪灵 (char_147_shining, 6★ 医师; kit
|
||||
// server/sim/content/kits/ops/op-shining.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, PHY-Y
|
||||
// 干枯剑鞘 or PHY-X “使徒” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
|
||||
// that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_shining.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, enemyRec, chessRec, 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 SHINING = 'char_147_shining';
|
||||
const FORMS = BACKUPS.units[SHINING].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const PHYY = 'uniequip_002_shining', PHYX = 'uniequip_003_shining';
|
||||
const S1 = 'skchr_shining_1', S2 = 'skchr_shining_2', S3 = 'skchr_shining_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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 }) };
|
||||
/** Synthetic allies with no skill: a melee one (地面 tiles) and a ranged one (高台). */
|
||||
const ALLIES = {
|
||||
test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, atk: 0 }, skill: null }),
|
||||
test_ranged_a: chessRec({ id: 'test_ranged_a', profession: 'SNIPER', stats: { maxHp: 10000, def: 100, atk: 0 }, skill: null }),
|
||||
};
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PHYY, PHYX].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/**
|
||||
* A battle with 闪灵 as uid 1 on the 高台 tile (11,2) facing RIGHT (her 3-3: rows 10–12, cols 2–5), a melee ally on the
|
||||
* 地面 tile (10,4) (uid 2) and a ranged ally on the 高台 tile (10,2) (uid 3) — both in her range —, a melee ally outside it
|
||||
* at (10,8) (uid 4).
|
||||
*/
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, extra = [] } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 600, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd'], captureNoisy: true,
|
||||
units: [
|
||||
{ uid: 1, diy: { slot: SLOT[tier], charId: SHINING, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
|
||||
{ uid: 2, chessId: 'test_melee_a', row: 10, col: 4 },
|
||||
{ uid: 3, chessId: 'test_ranged_a', row: 10, col: 2 },
|
||||
{ uid: 4, chessId: 'test_melee_a', row: 10, col: 8 },
|
||||
...extra,
|
||||
],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1), ground: h.unit(2), high: h.unit(3), out: h.unit(4) };
|
||||
}
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const healsOn = (h, u, t, from = 0) => h.hooksOf('heal').slice(from).filter((c) => c.source === u && c.target === t && !c.opts?.regen);
|
||||
|
||||
test('闪灵 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-3, one heal (医师), no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[SHINING], KITS[SHINING]);
|
||||
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], [SHINING, 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`);
|
||||
// a heal attack selects injured allies only (ai.js acquireTargets): no enemy, so no anti-air question
|
||||
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.attack, u.base.bat], ['heal', 'single', 'ranged', 2.85], `${label(f)}: 医师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-3`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 1451 / 424 / 110, E2 Lv60 1558 / 494 / 129; PHY-Y +35 ATK +5 ASPD → +60 / +7,
|
||||
// PHY-X +45 ATK +13 DEF → +63 / +20
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1451, 424, 1558, 494]);
|
||||
assert.deepEqual([modOf(5, PHYY).attr, modOf(6, PHYY).attr, modOf(5, PHYX).attr, modOf(6, PHYX).attr], [{ atk: 35, aspd: 5 }, { atk: 60, aspd: 7 }, { atk: 45, def: 13 }, { atk: 63, def: 20 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 闪灵 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(SHINING));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHINING), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SHINING, skillIndex: 2, uniEquipId: PHYX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: SHINING, skillIndex: 2, uniEquipId: PHYX } } });
|
||||
});
|
||||
|
||||
test('S1 信条 (MANUAL, data DEFAULT): cast on an injured ally in range only; 20 s of ATK +58 % / +67 % and ASPD +10 / +20; 47 / 44 SP from 10 / 14', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u, ground } = field({ tier, elite, mod: elite ? PHYY : null, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
|
||||
['DEFAULT', elite ? 44 : 47, elite ? 14 : 10, 20, elite ? 0.67 : 0.58, elite ? 20 : 10], `T${tier}`);
|
||||
const aspd0 = u.s.aspd;
|
||||
u.skill.gainSp(999);
|
||||
h.run(3);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured, no cast`);
|
||||
ground.hp = 100;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast as she heals`);
|
||||
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
approx(u.s.aspd, aspd0 + sk.bb.attack_speed, `T${tier}: ASPD`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
|
||||
approx(u.s.aspd, aspd0, `T${tier}: ASPD back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 自动掩护 (AUTO, data DEFAULT, 1 / 2 charges): the next heal gives its target a 屏障 of 28 % / 35 % ATK for 2 / 3 s (every damage type) and DEF +25 % / +35 % while it holds', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u, ground } = field({ tier, elite, mod: elite ? PHYX : null, skill: 1 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.def],
|
||||
['DEFAULT', elite ? 2 : 1, elite ? 'charges' : 'instant', 7, elite ? 0.35 : 0.28, elite ? 3 : 2, elite ? 0.35 : 0.25], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured, no cast (it waits for a heal)`);
|
||||
ground.hp = 100;
|
||||
const def0 = ground.s.def;
|
||||
assert.ok(h.runUntil(() => ground.findBuff('shining:barrier'), 5), `T${tier}: the barrier comes with the heal`);
|
||||
assert.equal(healsOn(h, u, ground).length, 1, `T${tier}: one heal so far`);
|
||||
const bar = ground.findBuff('shining:barrier');
|
||||
approx(bar.shield, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
|
||||
approx(bar.duration, sk.bb.duration, `T${tier}: ${sk.bb.duration} s`);
|
||||
assert.equal(bar.shieldType, null, `T${tier}: absorbs every damage type`);
|
||||
assert.deepEqual(ground.findBuff('shining:barrierDef')?.mods, { defPct: sk.bb.def }, `T${tier}: DEF bonus`);
|
||||
approx(ground.s.def, def0 * (1 + sk.bb.def), `T${tier}: DEF`, 1e-6);
|
||||
// the barrier takes physical and arts damage alike
|
||||
const hp0 = ground.hp, sh0 = bar.shield;
|
||||
h.b.dealDamage(null, ground, { amount: 10, type: 'arts', canDodge: false });
|
||||
approx(ground.hp, hp0, `T${tier}: arts absorbed`);
|
||||
approx(bar.shield, sh0 - 10, `T${tier}: shield −10`);
|
||||
h.b.dealDamage(null, ground, { amount: 1e6, type: 'true' });
|
||||
assert.equal(ground.findBuff('shining:barrier'), null, `T${tier}: broken`);
|
||||
assert.equal(ground.findBuff('shining:barrierDef'), null, `T${tier}: the DEF bonus goes with it`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 自动掩护: barriers stack with their own timers, the older absorbs first, the DEF bonus does not stack; all gone after their time', () => {
|
||||
const sk = skillOf(6, true, S2);
|
||||
const { h, u, ground } = field({ tier: 6, elite: true, mod: PHYY, skill: 1 });
|
||||
ground.hp = 100;
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => ground.buffs.filter((b) => b.key === 'shining:barrier').length === 1, 5));
|
||||
ground.hp = 100; // still the most injured
|
||||
assert.ok(h.runUntil(() => ground.buffs.filter((b) => b.key === 'shining:barrier').length === 2, 8), 'the second charge on the next heal');
|
||||
const [a, b] = ground.buffs.filter((x) => x.key === 'shining:barrier');
|
||||
assert.ok(a.seq < b.seq && a.timeLeft < b.timeLeft, 'own timers');
|
||||
assert.equal(ground.buffs.filter((x) => x.key === 'shining:barrierDef').length, 1, 'one DEF bonus');
|
||||
approx(ground.findBuff('shining:barrierDef').mods.defPct, sk.bb.def, 'not doubled');
|
||||
approx(ground.s.shield, a.shield + b.shield, 'the absorption adds up');
|
||||
const sa = a.shield, sb = b.shield;
|
||||
h.b.dealDamage(null, ground, { amount: sa / 2, type: 'true' });
|
||||
approx(a.shield, sa / 2, 'the older one first');
|
||||
approx(b.shield, sb, 'the newer one untouched');
|
||||
h.run(sk.bb.duration + 0.1);
|
||||
assert.equal(ground.findBuff('shining:barrier'), null, 'expired');
|
||||
assert.equal(ground.findBuff('shining:barrierDef'), null, 'the DEF bonus with them');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 教条力场 (MANUAL, data DEFAULT): 60 s, ATK +30 % / +40 %, every ally in her range DEF +45 % / +60 % (not outside it); gone after it ends', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u, ground, high, out } = field({ tier, elite, mod: elite ? PHYX : null, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.def], ['DEFAULT', 60, 120, 100, elite ? 0.4 : 0.3, elite ? 0.6 : 0.45], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
ground.hp = 100;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast on the injured ally`);
|
||||
h.run(0.3);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
for (const a of [ground, high, u]) assert.deepEqual(a.findBuff('skill:shining:def')?.mods, { defPct: sk.bb.def }, `T${tier}: ${a.defId} in range`);
|
||||
assert.equal(out.findBuff('skill:shining:def'), null, `T${tier}: outside her range`);
|
||||
u.skill.end('test');
|
||||
h.run(0.3);
|
||||
for (const a of [ground, high, u]) assert.equal(a.findBuff('skill:shining:def'), null, `T${tier}: gone once it ends`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 黑恶魔的庇护: allies in her range DEF +60 (herself too, not outside); PHY-Y stage 3: +100 and 地面 units +40 more (stage 1: +60 only)', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, ground, high, out } = field({ tier, elite, mod, skill: 0 });
|
||||
h.run(0.3);
|
||||
const y3 = elite && tier === 6 && mod === PHYY;
|
||||
const d = y3 ? 100 : 60, low = y3 ? 40 : 0;
|
||||
assert.deepEqual(ground.findBuff('talent:shining:def')?.mods, { defFlat: d + low }, `${label(f)}: 地面 ally`);
|
||||
assert.deepEqual(high.findBuff('talent:shining:def')?.mods, { defFlat: d }, `${label(f)}: 高台 ally`);
|
||||
assert.deepEqual(u.findBuff('talent:shining:def')?.mods, { defFlat: d }, `${label(f)}: herself (高台)`);
|
||||
assert.equal(out.findBuff('talent:shining:def'), null, `${label(f)}: outside her range`);
|
||||
approx(ground.s.def, 100 + d + low, `${label(f)}: DEF of the 地面 ally`);
|
||||
if (y3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /防御力\+100,地面单位防御力额外\+40/);
|
||||
h.b.retreat(u);
|
||||
h.run(0.3);
|
||||
assert.equal(ground.findBuff('talent:shining:def'), null, `${label(f)}: gone once she leaves`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 法典: ASPD +10; PHY-X stage 3: ASPD +15, ATK +25 % with 自动掩护 picked, SP +0.6/s with 教条力场 picked (stage 1: ASPD +10 only)', () => {
|
||||
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 x3 = elite && tier === 6 && mod === PHYX;
|
||||
approx(u.s.aspd, u.base.aspd + (x3 ? 15 : 10), `${label(f)} S${skill + 1}: ASPD`);
|
||||
approx(u.s.atk, u.base.atk * (1 + (x3 && skill === 1 ? 0.25 : 0)), `${label(f)} S${skill + 1}: ATK`);
|
||||
approx(u.s.spRecovery, 1 + (x3 && skill === 2 ? 0.6 : 0), `${label(f)} S${skill + 1}: SP/s`);
|
||||
if (x3 && skill === 2) {
|
||||
const sp0 = u.skill.sp;
|
||||
h.run(5);
|
||||
approx(u.skill.sp - sp0, 5 * 1.6, `${label(f)}: 1.6 SP/s`, 0.01);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('module traits: PHY-Y heals on 地面 units ×1.15 (not on 高台), PHY-X heals on allies below 50 % ×1.15 (stages 1 and 3); none without a module', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u, ground, high } = field({ tier, elite, mod, skill: 0 });
|
||||
const tb = u.def.raw.trait.bb;
|
||||
const y = elite && mod === PHYY, x = elite && mod === PHYX;
|
||||
if (y) assert.deepEqual(tb, { heal_scale: 1.15 }, label(f));
|
||||
if (x) assert.deepEqual(tb, { heal_scale: 1.15, hp_ratio: 0.5 }, label(f));
|
||||
const heal = (t, hp) => {
|
||||
t.hp = hp;
|
||||
const n0 = h.hooksOf('heal').length;
|
||||
assert.ok(h.runUntil(() => healsOn(h, u, t, n0).length > 0, 6), `${label(f)}: a heal on ${t.defId}`);
|
||||
return healsOn(h, u, t, n0)[0].amount;
|
||||
};
|
||||
// 地面 vs 高台, both below 50 %
|
||||
approx(heal(ground, 100), u.s.atk * (y || x ? 1.15 : 1), `${label(f)}: 地面 ally at 1 %`);
|
||||
ground.hp = ground.s.maxHp;
|
||||
approx(heal(high, 100), u.s.atk * (x ? 1.15 : 1), `${label(f)}: 高台 ally at 1 %`);
|
||||
high.hp = high.s.maxHp;
|
||||
// above 50 %
|
||||
approx(heal(ground, 6000), u.s.atk * (y ? 1.15 : 1), `${label(f)}: 地面 ally at 60 %`);
|
||||
ground.hp = ground.s.maxHp;
|
||||
approx(heal(high, 6000), u.s.atk, `${label(f)}: 高台 ally at 60 %`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// test/content/op_shwaz.test.js — the 自选 operator kit of 黑 (char_340_shwaz, 6★ 重射手; kit
|
||||
// server/sim/content/kits/ops/op-shwaz.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, ARC-X
|
||||
// “定制弩配件套装” or ARC-Y “老剃刀” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
|
||||
// form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_shwaz.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 SHWAZ = 'char_340_shwaz';
|
||||
const FORMS = BACKUPS.units[SHWAZ].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const X = 'uniequip_002_shwaz', Y = 'uniequip_003_shwaz';
|
||||
const S1 = 'skchr_shwaz_1', S2 = 'skchr_shwaz_2', S3 = 'skchr_shwaz_3';
|
||||
const PROVE = 'chess_char_1_07_a', YAK = 'chess_char_1_02_a';
|
||||
/** The unit form of a slot (tier, normal / elite). */
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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' }) };
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))];
|
||||
/** 破甲箭头 of a form: the base talent, upgraded by ARC-Y's NAMED change only (stage 3: 170 % / −25 %). */
|
||||
const pierce = (tier, elite, mod) => ({ ...formOf(tier, elite).talents[0].bb, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === 0 && t.name).reduce((o, t) => ({ ...o, ...t.bb }), {}) });
|
||||
|
||||
/** A battle with 黑 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, players = null } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'dodge'], captureNoisy: true,
|
||||
...(players ? { players } : { units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SHWAZ, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others] }),
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
test('黑 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-6, ranged physical, hits air, blocks 1, ground-targetable, data triggers', () => {
|
||||
assert.equal(OPERATOR_KITS[SHWAZ], KITS[SHWAZ]);
|
||||
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], [SHWAZ, 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`);
|
||||
// ARC-X “定制弩配件套装”: "再部署时间减少" — the attribute respawn_time −25 (70 → 45 s)
|
||||
assert.equal(u.base.respawnTime, form.stats.respawnTime + (m?.attr.respawnTime ?? 0), `${label(f)}: redeploy time`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'phys', 1.6], `${label(f)}: 重射手`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the data: all three DEFAULT (S3's 「攻击范围改为」 is an attack-range change, not a 技能范围 — tools/build-data.mjs
|
||||
// ATTACK_RANGE_CHANGE); E2 Lv1 1393 / 646 / 164, Lv60 1685 / 775 / 204
|
||||
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'DEFAULT']);
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk], [646, 775]);
|
||||
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr],
|
||||
[{ maxHp: 130, atk: 55, respawnTime: -25 }, { maxHp: 170, atk: 75, respawnTime: -25 }, { atk: 80, def: 10 }, { atk: 120, def: 16 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 黑 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(SHWAZ));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHWAZ), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SHWAZ, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: SHWAZ, skillIndex: 2, uniEquipId: Y } } });
|
||||
});
|
||||
|
||||
test('S1 强弩 (AUTO, attack SP 4, data DEFAULT): every 5th attack ×175 % / 190 % ATK, 破甲箭头 then at 60 % / 70 % (20 % otherwise); air units are hit', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1), t0 = pierce(tier, elite, null);
|
||||
const { h, u } = field({ tier, elite, skill: 0, seed: 21 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spCost, u.skill.spType, sk.bb.atk_scale, sk.bb['talent@prob']],
|
||||
['instant', 'DEFAULT', 4, 'attack', elite ? 1.9 : 1.75, elite ? 0.7 : 0.6], `T${tier}`);
|
||||
h.spawn('enemy_fly', { pos: [10, 6] }); // a flyer in front: she hits air units
|
||||
h.run(800);
|
||||
const hits = atkHits(h, u);
|
||||
const sk1 = hits.filter((c) => c.dmg.isSkill), plain = hits.filter((c) => !c.dmg.isSkill);
|
||||
assert.ok(Math.abs(sk1.length * 5 - hits.length) <= 5, `T${tier}: one S1 attack in 5 (${sk1.length} / ${hits.length})`);
|
||||
const base = u.s.atk;
|
||||
for (const c of sk1) assert.ok([base * sk.bb.atk_scale, base * sk.bb.atk_scale * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: S1 hit ${c.amount}`);
|
||||
for (const c of plain) assert.ok([base, base * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: plain hit ${c.amount}`);
|
||||
const rate = (list, scale) => list.filter((c) => c.amount > base * scale + 1e-6).length / list.length;
|
||||
const r1 = rate(sk1, sk.bb.atk_scale), r0 = rate(plain, 1);
|
||||
assert.ok(Math.abs(r1 - sk.bb['talent@prob']) < 0.12, `T${tier}: 破甲箭头 on S1 ${r1}`);
|
||||
assert.ok(Math.abs(r0 - t0.prob) < 0.07, `T${tier}: 破甲箭头 otherwise ${r0}`);
|
||||
assert.ok(hits.every((c) => c.type === 'phys' && c.target.motion === 'FLY'), `T${tier}: physical, on the flyer`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 暮眼锐瞳 (MANUAL, data DEFAULT): ATK +85 % / +100 % for 33 / 36 s, 破甲箭头 at 40 % / 45 % meanwhile; no cast without an enemy', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2), t0 = pierce(tier, elite, null);
|
||||
const { h, u } = field({ tier, elite, skill: 1, seed: 8 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule, sk.duration, sk.bb.atk, sk.bb['talent@prob']], ['duration', 'DEFAULT', elite ? 36 : 33, elite ? 1 : 0.85, elite ? 0.45 : 0.4], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`);
|
||||
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
u.skill.extend(600);
|
||||
h.runUntil(() => !u.skill.active, 700);
|
||||
const during = atkHits(h, u).slice(n0);
|
||||
const atk = u.base.atk * (1 + sk.bb.atk);
|
||||
assert.ok(during.length > 300 && during.every((c) => c.dmg.isSkill), `T${tier}: ${during.length} attacks, marked`);
|
||||
const r = during.filter((c) => c.amount > atk + 1e-6).length / during.length;
|
||||
assert.ok(Math.abs(r - sk.bb['talent@prob']) < 0.07, `T${tier}: 破甲箭头 ${r}`);
|
||||
for (const c of during.slice(0, 20)) assert.ok([atk, atk * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: ${c.amount}`);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 战术的终结: range 3-2 while it runs (and back), interval 1.6 + 0.4 s, ATK +120 % / +140 %, 破甲箭头 on every attack; the basic strategy (DEFAULT): an enemy she is about to attack casts it, then only the 3-2 line is attacked', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3), t0 = pierce(tier, elite, null);
|
||||
assert.deepEqual(sk.rangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3]]);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 4 });
|
||||
assert.deepEqual([u.skill.kind, u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['talent@prob']],
|
||||
['duration', 'DEFAULT', elite ? 21 : 18, elite ? 1.4 : 1.2, 0.4, 1], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
const side = h.spawn('enemy_dummy', { pos: [11, 6] }); // in 3-6, not in 3-2
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast as she is about to attack the 3-6 enemy (DEFAULT)`);
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 8] }); // 3 tiles ahead: 3-2, outside 3-6
|
||||
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: range 3-2 while it runs`);
|
||||
approx(u.s.interval, 2.0, `T${tier}: interval +0.4 s`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.runUntil(() => !u.skill.active, 30);
|
||||
const during = atkHits(h, u).slice(n0);
|
||||
assert.ok(during.length >= 8 && during.every((c) => c.target === far), `T${tier}: only the 3-2 enemy, not the side one (${during.length})`);
|
||||
for (const c of during) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * t0.atk_scale, `T${tier}: every attack procs`);
|
||||
const cuts = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown' && c.target === far);
|
||||
assert.ok(cuts.length >= during.length - 1, `T${tier}: DEF cut each time`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-6`);
|
||||
approx(u.s.interval, 1.6, `T${tier}: interval back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 破甲箭头: a proc ×160 % (ARC-Y stage 3: 170 %) and, once it hit, DEF ×0.8 (×0.75) for 5 s (defDown); ARC-Y stage 1 keeps 160 % (its hidden trait part is not 破甲箭头); a dodged attack cuts nothing', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t0 = pierce(tier, elite, mod);
|
||||
assert.deepEqual([t0.atk_scale, t0.def, t0.prob, t0.defdown_duration], tier === 6 && mod === Y ? [1.7, -0.25, 0.2, 5] : [1.6, -0.2, 0.2, 5], label(f));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [11, 6] }); // off her front line: no ARC-Y bonus
|
||||
h.run(120);
|
||||
const hits = atkHits(h, u).filter((c) => c.target === e);
|
||||
const procs = hits.filter((c) => c.amount > u.s.atk + 1e-6);
|
||||
assert.ok(procs.length > 5 && procs.length < hits.length * 0.4, `${label(f)}: ${procs.length} / ${hits.length}`);
|
||||
for (const c of procs) approx(c.amount, u.s.atk * t0.atk_scale, `${label(f)}: ×${t0.atk_scale}`);
|
||||
const cuts = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown');
|
||||
assert.equal(cuts.length, procs.length, `${label(f)}: one DEF cut per proc`);
|
||||
for (const c of cuts) assert.deepEqual([c.value, c.duration], [-t0.def, t0.defdown_duration], label(f));
|
||||
// a dodged attack: no hit, no cut
|
||||
const d = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.b.addBuff(d, { key: 'test:dodge', mods: { dodgePhys: 1 } });
|
||||
h.b.kill(e, null);
|
||||
const c0 = cuts.length;
|
||||
h.run(60);
|
||||
assert.ok(h.hooksOf('dodge').filter((c) => c.source === u).length > 20, `${label(f)}: dodged`);
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown').length, c0, `${label(f)}: nothing cut`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 交叉火力: with another 【狙击】 operator on the field every sniper (her too, a partner\'s too) ATK +8 % — not 角峰, not alone, not once she leaves; ARC-X stage 3 "携带": +13 % on her player\'s snipers for the whole battle', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const squad = tier === 6 && mod === X;
|
||||
const v = squad ? 0.13 : 0.08;
|
||||
const t1 = { ...formOf(tier, elite).talents[1].bb, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === 1).reduce((o, t) => ({ ...o, ...t.bb }), {}) };
|
||||
assert.equal(t1.atk, v, label(f));
|
||||
// alone: nothing
|
||||
const solo = field({ tier, elite, mod, skill: 0 });
|
||||
solo.h.run(1);
|
||||
assert.equal(solo.u.findBuff('talent:shwaz:crossfire'), null, `${label(f)}: alone`);
|
||||
done(solo.h);
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 2, chessId: PROVE, row: 12, col: 3 }, { uid: 3, chessId: YAK, row: 9, col: 3 }] });
|
||||
h.run(1);
|
||||
const prove = h.unit(2), yak = h.unit(3);
|
||||
for (const a of [u, prove]) assert.deepEqual(a.findBuff('talent:shwaz:crossfire')?.mods, { atkPct: v }, `${label(f)}: ${a.def.name}`);
|
||||
assert.equal(yak.findBuff('talent:shwaz:crossfire'), null, `${label(f)}: 角峰 (重装)`);
|
||||
approx(u.s.atk, u.base.atk * (1 + v), `${label(f)}: her ATK`);
|
||||
h.b.retreat(prove);
|
||||
h.run(1);
|
||||
assert.equal(!!u.findBuff('talent:shwaz:crossfire'), squad, `${label(f)}: 普罗旺斯 off the field (携带: still in the squad)`);
|
||||
h.b.redeploy(prove);
|
||||
h.run(1);
|
||||
h.b.retreat(u);
|
||||
h.run(1);
|
||||
assert.equal(!!prove.findBuff('talent:shwaz:crossfire'), squad, `${label(f)}: 黑 off the field`);
|
||||
done(h);
|
||||
}
|
||||
// a partner's sniper on a shared field: the field version counts it and buffs it; the squad version does not
|
||||
const pair = (mod, tier) => field({ players: [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: tier === 6 ? 'chess_char_6_diy1_b' : 'chess_char_5_diy1_b', diy: { charId: SHWAZ, skillIndex: 0, uniEquipId: mod }, row: 10, col: 5 }], bonds: {}, bandId: null, playerEffects: [] },
|
||||
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: PROVE, row: 12, col: 3 }], bonds: {}, bandId: null, playerEffects: [] },
|
||||
] });
|
||||
for (const [mod, tier, want] of [[null, 6, 0.08], [X, 6, null]]) {
|
||||
const { h } = pair(mod, tier);
|
||||
h.run(1);
|
||||
const mine = h.b.allyUnits.find((a) => a.ownerId === 'p1'), theirs = h.b.allyUnits.find((a) => a.ownerId === 'p2');
|
||||
assert.equal(mine.findBuff('talent:shwaz:crossfire')?.mods.atkPct ?? null, want, `${mod ?? 'none'}: 黑`);
|
||||
assert.equal(theirs.findBuff('talent:shwaz:crossfire')?.mods.atkPct ?? null, want, `${mod ?? 'none'}: the partner's 普罗旺斯`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('ARC-Y 老剃刀 trait: an enemy on her straight line ahead takes ×105 % and cannot dodge (stages 1 and 3); off the line it dodges; ARC-X / no module change nothing; facing UP turns the line', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y = elite && mod === Y;
|
||||
if (y) assert.deepEqual(formOf(tier, true).modules.find((m) => m.uniEquipId === Y).traitOverride.bb, { atk_scale: 1.05 }, label(f));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 3 });
|
||||
u.skill.sp = 0; u.skill.charges = 0;
|
||||
const front = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
|
||||
for (const e of [front, side]) h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
|
||||
h.run(40);
|
||||
const onFront = atkHits(h, u).filter((c) => c.target === front);
|
||||
const dodged = h.hooksOf('dodge').filter((c) => c.source === u);
|
||||
if (y) {
|
||||
assert.ok(onFront.length > 10, `${label(f)}: the front enemy is hit (${onFront.length})`);
|
||||
for (const c of onFront.filter((x) => !x.dmg.isSkill)) assert.ok([u.s.atk * 1.05, u.s.atk * 1.05 * pierce(tier, elite, mod).atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `${label(f)}: ×1.05 (${c.amount})`);
|
||||
h.b.kill(front, null);
|
||||
h.run(10);
|
||||
assert.ok(h.hooksOf('dodge').filter((c) => c.source === u && c.target === side).length > 3, `${label(f)}: off the line it dodges`);
|
||||
} else {
|
||||
assert.equal(onFront.length, 0, `${label(f)}: no bonus — every attack dodged`);
|
||||
assert.ok(dodged.length > 10, label(f));
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
// facing UP: the line is the column above her
|
||||
const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 60, autoFinish: false, seed: 2, flags: { dpPerSec: 0 }, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: 6, charId: SHWAZ, skillIndex: 0, uniEquipId: Y }, elite: true, row: 10, col: 5, dir: 'UP' }] });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
const e = h.spawn('enemy_dummy', { pos: [12, 5] });
|
||||
h.run(20);
|
||||
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !c.dmg.isSkill);
|
||||
assert.ok(plain.length > 5);
|
||||
for (const c of plain) assert.ok([1.05, 1.05 * pierce(6, true, Y).atk_scale].some((k) => Math.abs(c.amount - u.base.atk * k) < 1e-6), `UP: two tiles above her is her line (${c.amount})`);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
// test/content/op_typhon.test.js — the 自选 operator kit of 提丰 (char_2012_typhon, 6★ 攻城手; kit
|
||||
// server/sim/content/kits/ops/op-typhon.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X
|
||||
// 自然的包容, SIE-Y 冰原的影子 or ISW-A 提丰特限证章 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
|
||||
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_typhon.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 TYPHON = 'char_2012_typhon';
|
||||
const FORMS = BACKUPS.units[TYPHON].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SIEX = 'uniequip_002_typhon', SIEY = 'uniequip_003_typhon', ISWA = 'uniequip_004_typhon';
|
||||
const S1 = 'skcom_quickattack[3]', S2 = 'skchr_typhon_2', S3 = 'skchr_typhon_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
|
||||
enemy_plate: dummy('enemy_plate', { mass: 1, def: 600 }), enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }),
|
||||
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.8, mass: 3 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY, ISWA].map((m) => [t, true, m]))];
|
||||
|
||||
/** A battle with 提丰 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
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', 'statusApplied'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: TYPHON, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
|
||||
const rainHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.tags?.includes('typhon:rain'));
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
test('提丰 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (SIE-Y ASPD), 4-3 range, ranged physical arrows that hit air, heaviest first, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[TYPHON], KITS[TYPHON]);
|
||||
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], [TYPHON, 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), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.priority, u.base.bat], [1, 'ranged', 'phys', true, 'heaviest', 2.4], `${label(f)}: 攻城手`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[TYPHON].bonds, []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// E2 Lv1 1310 / 867 / 92, E2 Lv60 1570 / 985 / 106; SIE-X +120 HP / +60 ATK → +210 / +90, SIE-Y +70 ATK / +5 ASPD → +94 / +7,
|
||||
// ISW-A +100 / +70 → +150 / +90
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1310, 867, 1570, 985]);
|
||||
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
|
||||
[{ maxHp: 120, atk: 60 }, { maxHp: 210, atk: 90 }, { atk: 70, aspd: 5 }, { atk: 94, aspd: 7 }, { maxHp: 100, atk: 70 }, { maxHp: 150, atk: 90 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 提丰 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(TYPHON));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(TYPHON), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } } });
|
||||
});
|
||||
|
||||
test('trait 攻城手: the heaviest enemy of her range first; a flyer is a target', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true });
|
||||
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] });
|
||||
h.run(3);
|
||||
assert.equal(atkHits(h, u)[0].target, heavy);
|
||||
h.b.kill(heavy, null);
|
||||
h.b.kill(light, null);
|
||||
const fly = h.spawn('enemy_fly', { pos: [11, 5] });
|
||||
h.run(3);
|
||||
assert.ok(atkHits(h, u).some((c) => c.target === fly), '可对空');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S1 迅捷打击·γ型 (MANUAL, data DEFAULT): ATK +27 % / +34 %, ASPD +25 / +35 for 35 s; SP 42 / 39 from 5 / 10', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
|
||||
['DEFAULT', 'duration', elite ? 39 : 42, elite ? 10 : 5, 35, elite ? 0.34 : 0.27, elite ? 35 : 25], `T${tier}`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
h.spawn('enemy_light', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
approx(u.skill.timeLeft, 35, `T${tier}: 35 s`, 0.01);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
|
||||
h.runUntil(() => !u.skill.active, 36);
|
||||
assert.deepEqual([u.s.atk, u.s.aspd], [u.base.atk, 100], `T${tier}: back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 冰原秩序: ATK +26 % / +35 %, two arrows per attack — two enemies when there are two, both at a lone one — each with 40 % to stun 1 s; 20 s on the first cast of a deployment, then 持续时间无限 until she leaves (a redeploy counts again)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1, seed: 9 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.first_duration, sk.bb.atk, sk.bb['attack@prob'], sk.bb['attack@stun'], u.skill.spCost, u.skill.initSp],
|
||||
['DEFAULT', 'toggle', 20, 20, elite ? 0.35 : 0.26, 0.4, 1, elite ? 51 : 53, elite ? 40 : 37], `T${tier}`);
|
||||
const lone = h.spawn('enemy_light', { pos: [10, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
const t0 = h.b.time;
|
||||
approx(u.skill.timeLeft, 20, `T${tier}: first cast 20 s`, 0.05);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.run(5);
|
||||
const s2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||||
const byAttack = Map.groupBy(s2, (c) => c.dmg.attackId);
|
||||
assert.ok(byAttack.size >= 2, `T${tier}: attacks`);
|
||||
for (const list of byAttack.values()) assert.deepEqual(list.map((c) => c.target), [lone, lone], `T${tier}: both arrows at the lone enemy`);
|
||||
const other = h.spawn('enemy_mid', { pos: [11, 6] });
|
||||
h.run(5);
|
||||
const later = atkHits(h, u).filter((c) => c.dmg.isSkill && c.t > h.b.time - 4.5);
|
||||
for (const list of Map.groupBy(later, (c) => c.dmg.attackId).values()) {
|
||||
assert.deepEqual(list.map((c) => c.target).sort((a, b) => a.id - b.id), [lone, other].sort((a, b) => a.id - b.id), `T${tier}: two different enemies`);
|
||||
}
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 15), `T${tier}: the first cast ends`);
|
||||
approx(h.b.time - t0, 20, `T${tier}: after 20 s`, 0.05);
|
||||
// the second cast never ends
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: second cast`);
|
||||
assert.equal(u.skill.timeLeft, Infinity, `T${tier}: 持续时间无限`);
|
||||
h.run(120);
|
||||
assert.ok(u.skill.active, `T${tier}: still on after 120 s`);
|
||||
// the stun: 40 % of the arrows, 1 s
|
||||
const all = atkHits(h, u).filter((c) => c.dmg.isSkill);
|
||||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||||
assert.ok(all.length > 100, `T${tier}: ${all.length} arrows`);
|
||||
assert.ok(stuns.length / all.length > 0.32 && stuns.length / all.length < 0.48, `T${tier}: ${stuns.length} / ${all.length} stuns ≈ 40 %`);
|
||||
for (const c of stuns) approx(c.duration, 1, `T${tier}: 1 s`);
|
||||
// a redeploy counts its casts again
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u);
|
||||
assert.ok(!u.skill.active && u.deployed, `T${tier}: back on the field, skill off`);
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast after the redeploy`);
|
||||
approx(u.skill.timeLeft, 20, `T${tier}: the first cast of this deployment — 20 s again`, 0.05);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 “永恒狩猎”: 7 / 8 rounds, attack interval 2.4 + 3.1 s; the mark on the heaviest enemy of her own range; range 全场 but attacks only with an enemy in the mark area (radius 1.3); each round 4 arrows 0.1 s apart on random enemies of the area, 145 % / 160 % ATK physical skill hits (no attack), 0.4 s stun once they land', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.kind, sk.durationType, sk.bb['attack@s3_trigger_time'], sk.bb.base_attack_time, sk.bb['attack@s3_atk_scale'], sk.bb['attack@s3_max_hit_num'], sk.bb['attack@s3_stun'], u.skill.spCost, u.skill.initSp],
|
||||
['DEFAULT', 'ammo', 'AMMO', elite ? 8 : 7, 3.1, elite ? 1.6 : 1.45, 4, 0.4, elite ? 43 : 47, 25], `T${tier}`);
|
||||
const marked = h.spawn('enemy_heavy', { pos: [10, 6] }), near = h.spawn('enemy_light', { pos: [11, 6] });
|
||||
const away = h.spawn('enemy_mid', { pos: [9, 5] }); // in her range, 1.4+ tiles from the mark: never hit
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
|
||||
const t0 = h.b.time;
|
||||
assert.equal(u.mem.typhonMark.target, marked, `T${tier}: the heaviest of her range is marked`);
|
||||
assert.ok(u.liveRangeGrid.length >= 400, `T${tier}: the card shows 全场`);
|
||||
approx(u.s.interval, 2.4 + 3.1, `T${tier}: interval 5.5 s`);
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: ammo spent`);
|
||||
h.run(0.5);
|
||||
const rain = rainHits(h, u);
|
||||
assert.equal(atkHits(h, u).filter((c) => c.t >= t0 - 0.05).length, 0, `T${tier}: the round itself deals nothing`);
|
||||
assert.equal(rain.length, sk.bb['attack@s3_trigger_time'] * 4, `T${tier}: ${rain.length} arrows (rounds × 4)`);
|
||||
assert.ok(rain.every((c) => c.target === marked || c.target === near), `T${tier}: the area only`);
|
||||
assert.ok(rain.some((c) => c.target === near) && rain.some((c) => c.target === marked), `T${tier}: random among the area`);
|
||||
assert.ok(rain.every((c) => c.type === 'phys' && c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: physical skill hits, no attack`);
|
||||
const rounds = Map.groupBy(rain, (c) => Math.round((c.t - t0) / 5.5));
|
||||
assert.equal(rounds.size, sk.bb['attack@s3_trigger_time'], `T${tier}: one round per bullet`);
|
||||
for (const list of rounds.values()) list.forEach((c, i) => i && approx(c.t - list[i - 1].t, 0.1, `T${tier}: 0.1 s apart`, 0.05));
|
||||
// amounts: ATK × s3_atk_scale (DEF 0), the first hit on each enemy ×1.6 (重如沼泥)
|
||||
const firsts = new Set([marked, near].map((e) => rain.find((c) => c.target === e)));
|
||||
for (const c of rain) approx(c.amount, u.s.atk * sk.bb['attack@s3_atk_scale'] * (firsts.has(c) ? 1.6 : 1), `T${tier}: s3_atk_scale`);
|
||||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||||
assert.equal(stuns.length, rain.length, `T${tier}: a stun per landed arrow`);
|
||||
for (const c of stuns) approx(c.duration, 0.4, `T${tier}: 0.4 s`);
|
||||
assert.ok(!hitsBy(h, u).some((c) => c.target === away), `T${tier}: outside the mark area`);
|
||||
assert.ok(u.liveRangeGrid.length < 20, `T${tier}: own range back`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 “永恒狩猎”: no enemy in the mark area ⇒ no attack and no bullet spent; the mark follows its enemy and stays where it left the field', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 3 });
|
||||
const walker = h.spawn('enemy_walk', { routeIndex: 0 });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 30), 'cast once the walker is in her range');
|
||||
assert.equal(u.mem.typhonMark.target, walker);
|
||||
const x0 = u.mem.typhonMark.x;
|
||||
h.run(1);
|
||||
assert.notEqual(u.mem.typhonMark.x, x0, 'the mark moves with its enemy');
|
||||
const ammo = u.skill.ammoLeft;
|
||||
h.b.kill(walker, null);
|
||||
const spot = [u.mem.typhonMark.x, u.mem.typhonMark.y];
|
||||
h.run(12);
|
||||
assert.equal(u.skill.ammoLeft, ammo, 'nothing in the area: no round');
|
||||
assert.deepEqual([u.mem.typhonMark.x, u.mem.typhonMark.y], spot, 'the mark stays');
|
||||
const later = h.spawn('enemy_light', { pos: [Math.round(spot[1]), Math.round(spot[0])] });
|
||||
assert.ok(h.runUntil(() => rainHits(h, u).some((c) => c.target === later), 12), 'an enemy at the old spot is hit');
|
||||
assert.equal(u.skill.ammoLeft, ammo - 1);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 锐如兽牙: each attack adds 10 % (SIE-X stage 3: 12 %) DEF ignore up to 5 stacks, applied to that attack already; all gone 8 s (10 s) after her last attack', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEX], [6, true, SIEX]]) {
|
||||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||||
const tc = mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0 && !t.hidden)?.bb : null;
|
||||
const per = tc?.def_penetrate ?? t0.def_penetrate, dur = tc?.duration ?? t0.duration;
|
||||
assert.deepEqual([per, dur, tc?.max_stack_cnt ?? t0.max_stack_cnt], tier === 6 && mod === SIEX ? [0.12, 10, 5] : [0.1, 8, 5], label([tier, elite, mod]));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const e = h.spawn('enemy_plate', { pos: [10, 6] });
|
||||
h.run(16);
|
||||
const list = atkHits(h, u).filter((c) => c.target === e);
|
||||
assert.ok(list.length >= 6, `${label([tier, elite, mod])}: ${list.length} attacks`);
|
||||
list.forEach((c, i) => approx(c.amount, u.s.atk - 600 * (1 - per * Math.min(5, i + 1)), `${label([tier, elite, mod])}: attack ${i + 1}`));
|
||||
h.b.kill(e, null);
|
||||
const last = list.at(-1).t;
|
||||
h.run(last + dur - 1 - h.b.time);
|
||||
assert.equal(u.findBuff('talent:typhon:fang')?.stacks, 5, `${label([tier, elite, mod])}: still there ${dur - 1} s after the last attack`);
|
||||
h.run(1.5);
|
||||
assert.equal(u.findBuff('talent:typhon:fang'), null, `${label([tier, elite, mod])}: lost after ${dur} s without an attack`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 重如沼泥: while her skill runs, the first damage on each enemy deals ×1.6 and 停顿 3 s (SIE-Y stage 3: the first two, ×2.2); not outside a skill; a new cast clears the marks', () => {
|
||||
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEY], [6, true, SIEY], [6, true, ISWA]]) {
|
||||
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
|
||||
const tc = mod === SIEY && tier === 6 ? modOf(tier, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb : null;
|
||||
const n = tc?.max_stack_cnt ?? 1, scale = tc?.atk_scale ?? t1.atk_scale;
|
||||
assert.deepEqual([scale, tc?.sluggish ?? t1.sluggish], [tier === 6 && mod === SIEY ? 2.2 : 1.6, 3], label([tier, elite, mod]));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
const a = h.spawn('enemy_light', { pos: [10, 5] });
|
||||
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
|
||||
h.run(5);
|
||||
assert.ok(atkHits(h, u).length && atkHits(h, u).every((c) => c.target === a), `${label([tier, elite, mod])}: attacks`);
|
||||
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * far(2), `${label([tier, elite, mod])}: no skill, no bonus`);
|
||||
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish').length, 0);
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
const t0 = h.b.time;
|
||||
h.run(8);
|
||||
const during = atkHits(h, u).filter((c) => c.t >= t0 - 0.05);
|
||||
assert.ok(during.length > n + 1, `${label([tier, elite, mod])}: attacks during S1`);
|
||||
during.forEach((c, i) => approx(c.amount, u.s.atk * far(2) * (i < n ? scale : 1), `${label([tier, elite, mod])}: hit ${i + 1}`));
|
||||
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
|
||||
assert.equal(slows.length, n, `${label([tier, elite, mod])}: 停顿 with the marked hits`);
|
||||
for (const c of slows) approx(c.duration, 3, '3 s');
|
||||
// a second enemy gets its own first hit; a new cast clears the marks
|
||||
u.skill.end('test');
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const t1s = h.b.time;
|
||||
h.run(3);
|
||||
const again = atkHits(h, u).filter((c) => c.t >= t1s - 0.05);
|
||||
approx(again[0].amount, u.s.atk * far(2) * scale, `${label([tier, elite, mod])}: marks cleared by the new cast`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('SIE-X 自然的包容: ×1.15 on her attacks against 重量等级 ≥ 3, nothing below; the S3 arrow rain (no attack) is not raised', () => {
|
||||
for (const [tier, mod] of [[5, SIEX], [6, SIEX], [6, null], [6, ISWA]]) {
|
||||
const xs = mod === SIEX ? 1.15 : 1;
|
||||
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 });
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
|
||||
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
|
||||
h.run(4);
|
||||
const mid = h.spawn('enemy_mid', { pos: [10, 6] });
|
||||
h.b.kill(heavy, null);
|
||||
h.run(4);
|
||||
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * (c.target.weight >= 3 ? xs : 1), `T${tier} ${mod}: ${c.target.defId}`);
|
||||
assert.ok(atkHits(h, u).some((c) => c.target === mid) && atkHits(h, u).some((c) => c.target.weight >= 3));
|
||||
done(h);
|
||||
}
|
||||
// S3: rain hits on a weight-3 enemy are s3_atk_scale × ATK (× 1.6 the first)
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 2 });
|
||||
h.spawn('enemy_heavy', { pos: [10, 6] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
h.run(7);
|
||||
const rain = rainHits(h, u);
|
||||
rain.forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i === 0 ? 1.6 : 1), `rain ${i + 1}: no ×1.15`));
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('SIE-Y 冰原的影子: her attack damage rises with the distance, linearly 1 → 4.5 tiles, at most +12 % (stages 1 and 3); the S3 rain is not raised', () => {
|
||||
for (const tier of [5, 6]) {
|
||||
assert.deepEqual(modOf(tier, SIEY).traitOverride.bb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 });
|
||||
for (const [r, c] of [[10, 5], [10, 7], [11, 6]]) {
|
||||
const { h, u } = field({ tier, elite: true, mod: SIEY, skill: 0 });
|
||||
const e = h.spawn('enemy_light', { pos: [r, c] });
|
||||
h.run(3);
|
||||
const d = Math.hypot(c - 3, r - 10);
|
||||
approx(atkHits(h, u).find((x) => x.target === e).amount, u.s.atk * (1 + 0.12 * (d - 1) / 3.5), `T${tier}: ${d.toFixed(2)} tiles`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 2 });
|
||||
h.spawn('enemy_light', { pos: [10, 7] });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4));
|
||||
h.run(7);
|
||||
const n = modOf(6, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb.max_stack_cnt;
|
||||
rainHits(h, u).forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i < n ? 2.2 : 1), `rain ${i + 1}: no distance bonus`));
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('ISW-A 提丰特限证章: only its HP / ATK — the 集成战略 trait (damage_scale 1.4 on heavy enemies) and arrow-rain mark do nothing here', () => {
|
||||
for (const tier of [5, 6]) {
|
||||
const m = modOf(tier, ISWA);
|
||||
assert.match(m.traitOverride.moduleDesc, /^在集成战略中/);
|
||||
assert.ok(m.talentChanges.some((t) => t.bb.damage_scale === 1.4), 'the data carries the IS part');
|
||||
const { h, u } = field({ tier, elite: true, mod: ISWA, skill: 0 });
|
||||
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
|
||||
h.run(8);
|
||||
for (const c of atkHits(h, u).filter((x) => x.target === heavy)) approx(c.amount, u.s.atk, `T${tier}: ×1 on a weight-3 enemy`);
|
||||
h.run(30);
|
||||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && !c.dmg.isAttack).length, 0, `T${tier}: no arrow rain of its own`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// test/content/op_veen.test.js — the 自选 operator kit of 维伊 (char_4226_veen, 6★ 秘术师; kit
|
||||
// server/sim/content/kits/ops/op-veen.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or MSC-Y
|
||||
// “军械库” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot
|
||||
// status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_veen.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 VEEN = 'char_4226_veen';
|
||||
const FORMS = BACKUPS.units[VEEN].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const MSCY = 'uniequip_002_veen';
|
||||
const S1 = 'skchr_veen_1', S2 = 'skchr_veen_2', S3 = 'skchr_veen_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
|
||||
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
|
||||
const talentOf = (tier, elite, mod, i) => {
|
||||
const m = elite ? modOf(tier, mod) : null;
|
||||
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
|
||||
};
|
||||
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' }) };
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, MSCY].map((m) => [t, true, m]))];
|
||||
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, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack', 'ammoUsed'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: VEEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
const warTicks = (h, u, e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && (c.dmg?.tags || []).includes('veenWar'));
|
||||
const stored = (u) => ({ s: u.trait.veen?.s ?? 0, t: u.trait.veen?.t ?? 0 });
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
/** Run until she holds `t` 转置能量 and `s` stored energies (no enemy on the field). */
|
||||
function storeUp(h, u, t, s) {
|
||||
assert.ok(h.runUntil(() => stored(u).t === t && stored(u).s === s, 60), `stores up to ${t}/${s}`);
|
||||
}
|
||||
|
||||
test('维伊 in every 自选 form: her operator kit (all three skills authored), stats + MSC-Y attributes, 3-14 range, ranged arts every 3 s that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[VEEN], KITS[VEEN]);
|
||||
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], [VEEN, 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], `${label(f)}: stats`);
|
||||
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.base.cost], [1, 'ranged', 'arts', true, 3, 27], `${label(f)}: 秘术师`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].trait.bb], [1142, 1320, { times: 9, merge_cnt: 3 }]);
|
||||
assert.deepEqual([modOf(5, MSCY).attr, modOf(6, MSCY).attr], [{ maxHp: 80, atk: 85 }, { maxHp: 190, atk: 120 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 维伊 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(VEEN));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(VEEN), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } } });
|
||||
});
|
||||
|
||||
test('trait + T1: with no target her attack check stores one energy (an attack action: the interval restarts — PRTS 分支特性信息), 3 merge into a 转置能量, at most 3 of those (then she stops); ATK +10 % while she holds one, ASPD +15 while she holds none; MSC-Y: ASPD +30 while she holds any energy', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t0 = talentOf(tier, elite, mod, 0);
|
||||
assert.deepEqual(t0, { atk: 0.1, attack_speed: 15 }, label(f));
|
||||
const msc = elite && mod === MSCY ? 30 : 0;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
u.skill.sp = 0;
|
||||
assert.deepEqual(stored(u), { s: 1, t: 0 }, `${label(f)}: her first attack check (at deployment) stores at once`);
|
||||
approx(u.s.aspd, 100 + 15 + msc, `${label(f)}: no 转置能量: ASPD +15 (MSC-Y +30 while holding)`);
|
||||
approx(u.s.atk, u.base.atk, `${label(f)}: no ATK bonus`);
|
||||
const t1 = h.b.time;
|
||||
h.runUntil(() => stored(u).s === 2, 10);
|
||||
approx(h.b.time - t1, 3 * 100 / 115, `${label(f)}: the next one an attack interval later (set at the store, before MSC-Y)`, 0.03);
|
||||
const t2 = h.b.time;
|
||||
h.runUntil(() => stored(u).t === 1, 10);
|
||||
approx(h.b.time - t2, 3 * 100 / (115 + msc), `${label(f)}: then at the interval of ASPD ${115 + msc}`, 0.03);
|
||||
storeUp(h, u, 1, 0);
|
||||
approx(u.s.aspd, 100 + msc, `${label(f)}: a 转置能量: the ASPD +15 goes`);
|
||||
approx(u.s.atk, u.base.atk * 1.1, `${label(f)}: ATK +10 %`);
|
||||
storeUp(h, u, 3, 0);
|
||||
h.run(30);
|
||||
assert.deepEqual(stored(u), { s: 0, t: 3 }, `${label(f)}: no more storing at 3 转置能量`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('the volley: 转置能量 first (300 % ATK each), the main attack (100 %), then the stored energies (100 %) — one target, all landing together, each with the ATK it left with (+10 % while a 转置能量 was held)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 1, 2);
|
||||
const atk = u.s.atk;
|
||||
approx(atk, u.base.atk * 1.1, `T${tier}: holding a 转置能量`);
|
||||
const e = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
const a0 = atkHits(h, u).length;
|
||||
h.runUntil(() => atkHits(h, u).length > a0, 4);
|
||||
h.step();
|
||||
const hits = atkHits(h, u).slice(a0);
|
||||
assert.deepEqual(hits.map((c) => Math.round(c.amount / atk * 100) / 100), [3, 1, 1, 1], `T${tier}: 300 % + 100 % + 2 × 100 % at the cached ATK`);
|
||||
assert.ok(hits.every((c) => c.target === e && c.t === hits[0].t && c.dmg.attackId === hits[0].dmg.attackId && c.type === 'arts'), `T${tier}: one attack, one target (a flyer), together`);
|
||||
assert.deepEqual(stored(u), { s: 0, t: 0 }, `T${tier}: spent`);
|
||||
h.run(0.2);
|
||||
approx(u.s.atk, u.base.atk, `T${tier}: the ATK bonus gone after the volley`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 战争技艺: each hit adds a stack (a 转置能量 3) of 90 arts / s for 5 s, at most 3 (MSC-Y stage 3: 120 / s, at most 4); she never targets a marked enemy — with only marked ones in range she stores energy', () => {
|
||||
for (const f of [[5, false, null], [5, true, MSCY], [6, true, MSCY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const t1 = talentOf(tier, elite, mod, 1);
|
||||
const s3m = elite && mod === MSCY && tier === 6;
|
||||
assert.deepEqual(t1, s3m ? { 'attack@duration': 5, 'attack@value': 120, 'attack@max_stack_cnt': 4 } : { 'attack@duration': 5, 'attack@value': 90, 'attack@max_stack_cnt': 3 }, label(f));
|
||||
const { h, u } = field({ tier, elite, mod, skill: 0 });
|
||||
u.skill.sp = 0;
|
||||
h.run(3.1);
|
||||
u.trait.veen = null; // (start the check with nothing stored)
|
||||
const a = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.runUntil(() => atkHits(h, u).length > 0, 4);
|
||||
h.step();
|
||||
const war = a.findBuff(`veen:war:${u.id}`);
|
||||
assert.ok(war && war.stacks === 1, `${label(f)}: 1 stack`);
|
||||
h.run(1.02);
|
||||
const ticks = warTicks(h, u, a);
|
||||
assert.equal(ticks.length, 1, `${label(f)}: a tick per second`);
|
||||
approx(ticks[0].amount, t1['attack@value'], `${label(f)}: ${t1['attack@value']} arts`);
|
||||
// marked: not targeted — she stores instead while it is her only enemy in range
|
||||
const n0 = atkHits(h, u).length;
|
||||
h.run(2.9);
|
||||
assert.equal(atkHits(h, u).length, n0, `${label(f)}: no attack on a marked enemy`);
|
||||
assert.ok(stored(u).s >= 1, `${label(f)}: her next attack check stored an energy instead`);
|
||||
// another enemy: targeted at once; the mark lapses 5 s after the last stack
|
||||
const b = h.spawn('enemy_dummy', { pos: [9, 7] });
|
||||
h.runUntil(() => atkHits(h, u).length > n0, 4);
|
||||
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === b), `${label(f)}: the unmarked one`);
|
||||
assert.ok(h.runUntil(() => !a.findBuff(`veen:war:${u.id}`), 6), `${label(f)}: 5 s`);
|
||||
assert.equal(warTicks(h, u, a).length, 5, `${label(f)}: 5 ticks`);
|
||||
done(h);
|
||||
}
|
||||
// a 转置能量 gives 3 stacks; the cap
|
||||
for (const [tier, mod, cap] of [[6, null, 3], [6, MSCY, 4]]) {
|
||||
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 2, 0);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
h.runUntil(() => atkHits(h, u).length >= 3, 4);
|
||||
h.step();
|
||||
assert.equal(e.findBuff(`veen:war:${u.id}`).stacks, cap, `T6 ${mod ?? 'none'}: 2 × 3 + 1 stacks capped at ${cap}`);
|
||||
h.run(1.02);
|
||||
approx(warTicks(h, u, e)[0].amount, talentOf(6, true, mod, 1)['attack@value'] * cap, `${cap} stacks`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 “自呼号生发” (AUTO): ASPD +60 / +75 for 18 s, cast as soon as its SP is full (31 / 29 from 7 / 9) — no enemy needed', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.attack_speed], ['SP_FULL', elite ? 29 : 31, elite ? 9 : 7, 18, elite ? 75 : 60], `T${tier}`);
|
||||
h.run(sk.spCost - sk.initSp - 0.5);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: at full SP, no enemy`);
|
||||
const base = u.s.aspd - sk.bb.attack_speed;
|
||||
assert.ok(base === 115 || base === 100, `T${tier}: +${sk.bb.attack_speed} on top of ${base}`);
|
||||
approx(u.skill.timeLeft, 18, `T${tier}: 18 s`, 0.01);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 “以鲜血洗去” (MANUAL, data DEFAULT): 28 s, attack interval 3.0 − 0.5 s; each released shot +9 % / +11 % ATK and +6 / +7 ASPD (a 转置能量 3 stacks), at most 8 / 9 — later shots of a volley carry the stacks of the earlier ones', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const b = sk.bb;
|
||||
const per = b['attack@veen_s_2_buff[stack].atk'], perAs = b['attack@veen_s_2_buff[stack].attack_speed'], max = b['attack@veen_s_2_buff[stack].max_stack_cnt'];
|
||||
assert.deepEqual([sk.duration, b.base_attack_time, per, perAs, max], [28, -0.5, elite ? 0.11 : 0.09, elite ? 7 : 6, elite ? 9 : 8], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 1 });
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 1, 1);
|
||||
const base = u.base.atk;
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
|
||||
h.step(2);
|
||||
approx(u.s.interval, 2.5 * 100 / u.s.aspd, `T${tier}: base interval 2.5 s`);
|
||||
const hits = atkHits(h, u);
|
||||
h.runUntil(() => atkHits(h, u).length >= 3, 4);
|
||||
const v = atkHits(h, u).slice(0, 3);
|
||||
// 转置能量 (0 stacks, +10 %), main (3 stacks), stored (4 stacks) — every one +10 % (a 转置能量 was held)
|
||||
approx(v[0].amount, base * (1 + 0.1) * 3, `T${tier}: 转置能量 before any stack`);
|
||||
approx(v[1].amount, base * (1 + 0.1 + 3 * per), `T${tier}: the main shot after 3`);
|
||||
approx(v[2].amount, base * (1 + 0.1 + 4 * per), `T${tier}: the stored one after 4`);
|
||||
assert.ok(hits.length <= 3);
|
||||
approx(u.s.aspd, 100 + 15 + 5 * perAs, `T${tier}: 5 stacks of ASPD (and T1's +15 again)`);
|
||||
h.run(25);
|
||||
assert.equal(u.findBuff('veen:s2stack').mods.atkPct, per * max, `T${tier}: at most ${max}`);
|
||||
h.runUntil(() => !u.skill.active, 10);
|
||||
assert.equal(u.findBuff('veen:s2stack'), null, `T${tier}: gone with the skill`);
|
||||
void e;
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 “用赤铁铭记” (MANUAL, data DEFAULT): 18 / 21 bullets, ASPD +110 / +140; an attack spends 1 bullet per stored energy and 3 per 转置能量 (none without), its stored shots deal nothing and its main hit bounces that many times — every 0.5 s within 1.7 tiles, a new enemy first — for 130 % / 140 % of the cached ATK, each with a 战争技艺 stack', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const b = sk.bb;
|
||||
assert.deepEqual([b['attack@trigger_time'], b.attack_speed, b['attack@bounce_atk_scale'], b['attack@base_atk_scale']], [elite ? 21 : 18, elite ? 140 : 110, elite ? 1.4 : 1.3, 1], `T${tier}`);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
u.skill.sp = 0;
|
||||
storeUp(h, u, 1, 1);
|
||||
const atk = u.s.atk;
|
||||
u.skill.gainSp(999);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
|
||||
const near = h.spawn('enemy_fly', { pos: [10, 8.2] });
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 12] }); // out of her range and 3.8 from the target
|
||||
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
|
||||
assert.deepEqual([u.skill.kind, u.skill.ammoMax], ['ammo', b['attack@trigger_time']], `T${tier}`);
|
||||
approx(u.s.aspd, 100 + b.attack_speed + 15, `T${tier}: ASPD (+ T1's 15: the volley spent the 转置能量)`, 1e-9);
|
||||
h.run(2.2);
|
||||
// the first attack: 1 + 3 = 4 bullets, the main hit then 4 bounces (e ↔ near: a new enemy first, then back)
|
||||
assert.equal(u.skill.ammoLeft, b['attack@trigger_time'] - 4, `T${tier}: 4 bullets`);
|
||||
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 4);
|
||||
const hits = atkHits(h, u);
|
||||
const id = hits[0].dmg.attackId;
|
||||
const of = hits.filter((c) => c.dmg.attackId === id);
|
||||
assert.equal(of.length, 5, `T${tier}: main + 4 bounces, no stored-shot damage`);
|
||||
approx(of[0].amount, atk * b['attack@base_atk_scale'], `T${tier}: the main hit`);
|
||||
assert.deepEqual(of.map((c) => c.target.id), [e.id, near.id, e.id, near.id, e.id], `T${tier}: a new enemy first, then between them`);
|
||||
for (const c of of.slice(1)) approx(c.amount, atk * b['attack@bounce_atk_scale'], `T${tier}: bounce at the cached ATK`);
|
||||
for (let i = 2; i < of.length; i++) approx(of[i].t - of[i - 1].t, 0.5, `T${tier}: 0.5 s apart`, 0.08);
|
||||
assert.ok(!of.some((c) => c.target === far), `T${tier}: 1.7 tiles at most`);
|
||||
assert.equal(near.findBuff(`veen:war:${u.id}`).stacks, 2, `T${tier}: a 战争技艺 stack per bounce`);
|
||||
// attacks without stored energy spend nothing; the skill ends when the bullets run out
|
||||
const left = u.skill.ammoLeft;
|
||||
h.run(1.5);
|
||||
assert.ok(u.skill.ammoLeft <= left);
|
||||
h.runUntil(() => !u.skill.active, 300);
|
||||
assert.equal(u.skill.ammoLeft, 0, `T${tier}: ended out of bullets`);
|
||||
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u)[0].reason, 'ammo');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
// test/content/op_zuole.test.js — the 自选 operator kit of 左乐 (char_4121_zuole, 6★ 武者; kit
|
||||
// server/sim/content/kits/ops/op-zuole.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
|
||||
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SBL-X
|
||||
// “岂苦夜长” or SBL-Y “秉烛人的记录” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json
|
||||
// (the form of that slot status); the fidelity checklist of kits/README.md item by item.
|
||||
// Run: node --test test/content/op_zuole.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 ZUOLE = 'char_4121_zuole';
|
||||
const FORMS = BACKUPS.units[ZUOLE].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SBLX = 'uniequip_002_zuole', SBLY = 'uniequip_003_zuole';
|
||||
const S1 = 'skchr_zuole_1', S2 = 'skchr_zuole_2', S3 = 'skchr_zuole_3';
|
||||
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
|
||||
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 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 }),
|
||||
};
|
||||
/** Every 自选 form: [tier, elite, module]. */
|
||||
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SBLX, SBLY].map((m) => [t, true, m]))];
|
||||
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
|
||||
|
||||
/** A battle with 左乐 as uid 1 at (row, col) facing RIGHT, plus `others`. */
|
||||
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ZUOLE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target?.side === 'enemy');
|
||||
const healsOf = (h, u) => h.hooksOf('heal').filter((c) => c.target === u);
|
||||
/** Keep 守正自明 / 坚忍 SP out of the way: no SP of any kind (阻回). */
|
||||
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
|
||||
function done(h) {
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||
}
|
||||
|
||||
test('左乐 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 炎, no 特质; 武者: no heal from others, 70 HP per enemy hit', () => {
|
||||
assert.equal(OPERATOR_KITS[ZUOLE], KITS[ZUOLE]);
|
||||
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], [ZUOLE, 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.noHeal, u.profile.selfHeal, u.base.bat], [1, 'melee', false, 'phys', true, 70, 1.2], `${label(f)}: 武者`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target him`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
// the numbers of the forms (zh_CN): E2 Lv1 2893 / 647 / 267, E2 Lv60 3533 / 735 / 299; SBL-X +55 / +40 → +90 / +60
|
||||
// ATK / DEF, SBL-Y +300 / +60 → +380 / +84 HP / ATK
|
||||
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2893, 647, 3533, 735]);
|
||||
assert.deepEqual([modOf(5, SBLX).attr, modOf(6, SBLX).attr, modOf(5, SBLY).attr, modOf(6, SBLY).attr], [{ atk: 55, def: 40 }, { atk: 90, def: 60 }, { maxHp: 300, atk: 60 }, { maxHp: 380, atk: 84 }]);
|
||||
});
|
||||
|
||||
test('a 自选 pick: 左乐 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
|
||||
const data = { chess: CHESS, backups: BACKUPS };
|
||||
assert.ok(KITTED_CHARS.includes(ZUOLE));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ZUOLE), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: ZUOLE, skillIndex: 1, uniEquipId: SBLX } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[5]]: { charId: ZUOLE, skillIndex: 1, uniEquipId: SBLX } } });
|
||||
});
|
||||
|
||||
test('trait (武者): +70 HP on every enemy hit, none from another unit\'s heal', () => {
|
||||
const { h, u } = field({ tier: 5, elite: true, skill: 1 });
|
||||
noSp(h, u);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
u.hp = u.s.maxHp * 0.6;
|
||||
const hp0 = u.hp;
|
||||
h.runUntil(() => hitsBy(h, u).length >= 3, 6);
|
||||
approx(u.hp - hp0, 70 * hitsBy(h, u).length, 'one heal of 70 per hit');
|
||||
assert.equal(h.b.heal(e, u, 500), 0, 'no heal from another unit');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S1 破虏 (AUTO, 2 charges, data DEFAULT): the next attack at 145 % / 160 %; below 80 % HP one more strike, below 50 % two more — each a damage instance of its own (and a trait heal)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S1);
|
||||
assert.deepEqual([sk.skillType, sk.spType, sk.spCost, sk.initSp, sk.maxChargeTime, sk.bb.atk_scale, sk.bb.hp_ratio_double, sk.bb.hp_ratio_tripple],
|
||||
['AUTO', 'INCREASE_WITH_TIME', elite ? 5 : 6, elite ? 4 : 2, 2, elite ? 1.6 : 1.45, 0.8, 0.5], `T${tier}`);
|
||||
for (const [ratio, strikes] of [[1, 1], [0.79, 2], [0.49, 3]]) {
|
||||
const { h, u } = field({ tier, elite, skill: 0 });
|
||||
assert.deepEqual([u.skill.rule, u.skill.maxCharges], ['DEFAULT', 2], `T${tier}`);
|
||||
noSp(h, u);
|
||||
u.skill.sp = 0;
|
||||
u.skill.charges = 1;
|
||||
u.hp = u.s.maxHp * ratio;
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), `T${tier}: cast`);
|
||||
h.step();
|
||||
const sk1 = hitsBy(h, u).filter((c) => c.dmg.isSkill);
|
||||
assert.equal(sk1.length, strikes, `T${tier} at ${ratio * 100} % HP: ${strikes} strike(s)`);
|
||||
for (const c of sk1) {
|
||||
assert.equal(c.target, e);
|
||||
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
|
||||
assert.equal(c.type, 'phys');
|
||||
}
|
||||
assert.equal(healsOf(h, u).filter((c) => c.opts?.self && !c.opts?.tags).length, strikes, `T${tier}: a trait heal per strike`);
|
||||
done(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 行险 (MANUAL, 12 s, data DEFAULT): −50 % current HP (never below 1), a barrier of 75 % / 90 % max HP stacking to 2× max HP, ATK +110 % / +140 %, block 2, every blocked enemy struck; once over the barrier loses 200 per second', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S2);
|
||||
const { h, u } = field({ tier, elite, skill: 1, row: 9 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.hp_ratio, sk.bb.scale, sk.bb.block_cnt, sk.bb.max_scale, sk.bb.shield_decrease],
|
||||
['DEFAULT', 12, elite ? 24 : 27, elite ? 10 : 5, elite ? 1.4 : 1.1, 0.5, elite ? 0.9 : 0.75, 1, 2, -200], `T${tier}`);
|
||||
noSp(h, u);
|
||||
const max = u.s.maxHp;
|
||||
const a = h.spawn('enemy_dummy', { pos: [9, 5] }), b = h.spawn('enemy_dummy', { pos: [9, 5] });
|
||||
h.step();
|
||||
assert.equal(u.blocking.length, 1, `T${tier}: blocks 1`);
|
||||
const hpAtStart = [];
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) hpAtStart.push(u.hp); });
|
||||
u.skill.charges = 1;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
|
||||
approx(hpAtStart[0], max * 0.5, `T${tier}: −50 % of the current HP`);
|
||||
const bar = () => u.findBuff('skill:zuole:barrier2');
|
||||
approx(bar().shield, max * sk.bb.scale, `T${tier}: barrier ${sk.bb.scale * 100} % max HP`);
|
||||
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
|
||||
h.step();
|
||||
assert.deepEqual([u.s.blockCnt, u.blocking.length], [2, 2], `T${tier}: block +1`);
|
||||
const n0 = hitsBy(h, u).length;
|
||||
h.runUntil(() => hitsBy(h, u).length > n0, 3);
|
||||
const atk = hitsBy(h, u).slice(n0);
|
||||
assert.deepEqual(new Set(atk.map((c) => c.target)), new Set([a, b]), `T${tier}: both blocked enemies at once`);
|
||||
// the barrier absorbs damage; it stays put while the skill runs
|
||||
const hpBefore = u.hp;
|
||||
h.b.dealDamage(a, u, { amount: 1000, type: 'true' });
|
||||
approx(bar().shield, max * sk.bb.scale - 1000, `T${tier}: absorbed`);
|
||||
approx(u.hp, hpBefore, `T${tier}: HP untouched`);
|
||||
const left = bar().shield;
|
||||
h.runUntil(() => !u.skill.active, 13);
|
||||
approx(bar().shield, left, `T${tier}: no decay during the skill`);
|
||||
h.run(3.05);
|
||||
approx(bar().shield, left - 600, `T${tier}: −200 per second once over`, 1e-3);
|
||||
// a second cast stacks onto what is left, capped at 2× max HP; the 流失 never goes below 1 HP
|
||||
u.hp = 1;
|
||||
const before = bar().shield;
|
||||
u.skill.charges = 1;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: second cast`);
|
||||
assert.equal(hpAtStart[1], 1, `T${tier}: at 1 HP nothing is lost`);
|
||||
const diff = bar().shield - before, add = max * sk.bb.scale;
|
||||
assert.ok(Math.abs(diff - add) < 1e-6 || Math.abs(diff - (add - 200)) < 1e-6, `T${tier}: stacked onto what was left (${diff} vs ${add}, a decay step at most in between)`);
|
||||
done(h);
|
||||
}
|
||||
// the cap: a cast on a barrier of 1.6× max HP stops at 2×
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
|
||||
noSp(h, u);
|
||||
h.b.addBuff(u, { key: 'skill:zuole:barrier2', shield: u.s.maxHp * 1.6, data: { createdAt: 0 } });
|
||||
u.skill.charges = 1;
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||||
approx(u.findBuff('skill:zuole:barrier2').shield, u.s.maxHp * 2, 'capped at 2× max HP');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('S3 佑序有炎 (MANUAL, data SKILL_RANGE on 3-2): 7 slashes on ≤ 3 enemies of the 3-2 (air too) at 205 % / 220 %, the last ×2 and 晕眩 3.5 / 4 s; meanwhile no trait heal — every hit adds 70 × 2.5 / 3 to a barrier (≤ 2× max HP, 15 s from the last gain)', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const sk = skillOf(tier, elite, S3);
|
||||
const { h, u } = field({ tier, elite, skill: 2 });
|
||||
assert.deepEqual([u.skill.rule, sk.rangeId, sk.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.times, sk.bb.stun, sk.bb.last_atk_bonus, sk.bb.shield_scale, sk.bb.shield_duration, sk.bb.max_scale],
|
||||
['SKILL_RANGE', '3-2', elite ? 28 : 29, elite ? 6 : 3, elite ? 2.2 : 2.05, 7, elite ? 4 : 3.5, 2, elite ? 3 : 2.5, 15, 2], `T${tier}`);
|
||||
assert.deepEqual(u.skill.triggerGrid, [[0, 0], [0, 1], [0, 2], [0, 3]], `T${tier}: its 3-2`);
|
||||
noSp(h, u);
|
||||
u.hp = u.s.maxHp * 0.6;
|
||||
const hp0 = u.hp;
|
||||
let hpAtEnd = null;
|
||||
h.b.on('skillEnd', (c) => { if (c.unit === u && hpAtEnd == null) hpAtEnd = u.hp; });
|
||||
u.skill.charges = 1;
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy on the 3-2, no cast`);
|
||||
const near = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const mid = h.spawn('enemy_fly', { pos: [10, 7] });
|
||||
const tip = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
const fourth = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
const beyond = h.spawn('enemy_dummy', { pos: [10, 9] });
|
||||
const side = h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.step();
|
||||
assert.equal(u.skill.activations, 1, `T${tier}: cast as soon as an enemy is on its 3-2`);
|
||||
const slashes = hitsBy(h, u).filter((c) => c.dmg.isSkill);
|
||||
assert.equal(slashes.length, 21, `T${tier}: 7 × 3`);
|
||||
const struck = new Set(slashes.map((c) => c.target));
|
||||
assert.equal(struck.size, 3, `T${tier}: the same three`);
|
||||
assert.ok(struck.has(near) && struck.has(mid), `T${tier}: the flyer too`);
|
||||
assert.ok(!struck.has(beyond) && !struck.has(side), `T${tier}: nothing off the 3-2`);
|
||||
assert.ok(struck.has(tip) !== struck.has(fourth), `T${tier}: one of the two on the tip (at most 3)`);
|
||||
const amt = u.s.atk * sk.bb.atk_scale;
|
||||
for (const [i, c] of slashes.entries()) approx(c.amount, amt * (i >= 18 ? 2 : 1), `T${tier}: slash ${Math.floor(i / 3) + 1}`);
|
||||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
|
||||
assert.equal(stuns.length, 3, `T${tier}: the last slash stuns`);
|
||||
for (const c of stuns) approx(c.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
|
||||
approx(hpAtEnd, hp0, `T${tier}: no trait heal during the slashes`);
|
||||
const bar = u.findBuff('skill:zuole:barrier3');
|
||||
approx(bar.shield, 21 * 70 * sk.bb.shield_scale, `T${tier}: 21 × 70 × ${sk.bb.shield_scale}`);
|
||||
approx(bar.timeLeft, 15, `T${tier}: 15 s`, 0.01);
|
||||
// afterwards the trait heals again; the barrier lasts 15 s from its last gain
|
||||
h.runUntil(() => hitsBy(h, u).some((c) => !c.dmg.isSkill), 3);
|
||||
assert.ok(u.hp > hp0, `T${tier}: the trait heals again`);
|
||||
h.run(15);
|
||||
assert.equal(u.findBuff('skill:zuole:barrier3'), null, `T${tier}: gone 15 s later`);
|
||||
done(h);
|
||||
}
|
||||
// the cap: 2× max HP at each gain; each gain restarts the 15 s
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
|
||||
noSp(h, u);
|
||||
h.b.addBuff(u, { key: 'skill:zuole:barrier3', shield: u.s.maxHp * 1.99, duration: 3, data: { createdAt: 0 } });
|
||||
u.skill.charges = 1;
|
||||
h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.step();
|
||||
const b = u.findBuff('skill:zuole:barrier3');
|
||||
approx(b.shield, u.s.maxHp * 2, 'capped at 2× max HP');
|
||||
approx(b.timeLeft, 15, 'restarted', 0.01);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 秉烛照影 (坚忍): ASPD up to +50 and SP recovery up to +2/s, linear in the HP lost, full at 70 % lost — SBL-X stage 3: +70 / +2.3 at 50 % lost (stage 1: unchanged)', () => {
|
||||
for (const f of [[5, false, null], [6, true, null], [5, true, SBLX], [6, true, SBLX], [6, true, SBLY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const x3 = tier === 6 && mod === SBLX;
|
||||
const [as, sp, full] = x3 ? [70, 2.3, 0.5] : [50, 2, 0.3];
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2 });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
assert.deepEqual([t0.min_attack_speed, t0.min_sp_recovery_per_sec, t0.min_hp_ratio], [as, sp, full], label(f));
|
||||
assert.deepEqual([u.s.aspd, u.s.spRecovery], [100, 1], `${label(f)}: full HP`);
|
||||
for (const ratio of [0.8, 0.6, full, 0.1]) {
|
||||
u.hp = u.s.maxHp * ratio;
|
||||
h.step();
|
||||
const k = Math.min(1, (1 - ratio) / (1 - full));
|
||||
approx(u.s.aspd, 100 + as * k, `${label(f)}: ASPD at ${ratio * 100} %`);
|
||||
approx(u.s.spRecovery, 1 + sp * k, `${label(f)}: SP recovery at ${ratio * 100} %`);
|
||||
}
|
||||
u.hp = u.s.maxHp;
|
||||
h.step();
|
||||
assert.deepEqual([u.s.aspd, u.s.spRecovery], [100, 1], `${label(f)}: back at full HP`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 守正自明: every damage he outputs rolls 20 % (70 % below half HP) for 1 SP — SBL-Y stage 3: 80 % below half and the instance ×1.2 on a success (stage 1: unchanged)', () => {
|
||||
for (const f of [[5, false, null], [5, true, SBLY], [6, true, SBLY]]) {
|
||||
const [tier, elite, mod] = f;
|
||||
const y3 = tier === 6 && mod === SBLY;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 17 });
|
||||
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
|
||||
assert.deepEqual([t1.prob_1, t1.prob_2, t1.hp_ratio, t1.sp, t1.atk_scale ?? 1], [0.2, y3 ? 0.8 : 0.7, 0.5, 1, y3 ? 1.2 : 1], label(f));
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
u.skill.spCostMul = 1e5; // the skill never fills: every success is an spGain of its own
|
||||
for (const [ratio, prob] of [[0.9, 0.2], [0.3, y3 ? 0.8 : 0.7]]) {
|
||||
let gains = 0, rolls = 0, boosted = 0;
|
||||
const offG = h.b.on('spGain', (c) => { if (c.unit === u && c.reason === 'talent') gains++; });
|
||||
const offD = h.b.on('damaged', (c) => {
|
||||
if (c.source !== u || c.target !== e) return;
|
||||
rolls++;
|
||||
if (Math.abs(c.amount - u.s.atk * 1.2) < 1e-6) boosted++;
|
||||
u.hp = u.s.maxHp * ratio; // keep the HP band
|
||||
});
|
||||
u.hp = u.s.maxHp * ratio;
|
||||
h.run(150);
|
||||
h.b.off(offG);
|
||||
h.b.off(offD);
|
||||
const rate = gains / rolls;
|
||||
assert.ok(Math.abs(rate - prob) < 0.08, `${label(f)} at ${ratio * 100} %: ${gains} / ${rolls} ≈ ${prob}`);
|
||||
assert.equal(boosted, y3 ? gains : 0, `${label(f)}: ×1.2 on each success`);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('SBL-X (stages 1 and 3): 25 % 庇护 below 50 % HP — physical and arts, not true; none at or above 50 %; after 行险 the 庇护 comes back behind the barrier (it cuts only what passes it)', () => {
|
||||
for (const tier of [5, 6]) {
|
||||
const { h, u } = field({ tier, elite: true, mod: SBLX, skill: 1 });
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
|
||||
assert.deepEqual([t0.hp_ratio, t0.damage_resistance], [0.5, 0.25], `T${tier}`);
|
||||
noSp(h, u);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const take = (amount, type) => { const hp = u.hp; h.b.dealDamage(e, u, { amount, type }); return hp - u.hp; };
|
||||
u.hp = u.s.maxHp * 0.6;
|
||||
approx(take(300, 'true'), 300, `T${tier}: above 50 %: nothing`);
|
||||
u.hp = u.s.maxHp * 0.4;
|
||||
approx(take(1000, 'phys'), Math.max(1000 - u.s.def, 50) * 0.75, `T${tier}: physical ×0.75`);
|
||||
approx(take(400, 'arts'), 400 * 0.75, `T${tier}: arts ×0.75`);
|
||||
approx(take(400, 'true'), 400, `T${tier}: true untouched`);
|
||||
// 行险: 庇护 reset for 0.1 s, then judged again — after the barrier
|
||||
u.hp = u.s.maxHp * 0.8;
|
||||
let hpAtStart = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u && hpAtStart == null) hpAtStart = u.hp; });
|
||||
u.skill.charges = 1;
|
||||
assert.ok(h.runUntil(() => u.skill.active, 3));
|
||||
approx(hpAtStart, u.s.maxHp * 0.4, `T${tier}: −50 %`);
|
||||
const bar = u.findBuff('skill:zuole:barrier2');
|
||||
const B = bar.shield;
|
||||
approx(take(1000, 'true'), 0, `T${tier}: inside 0.1 s the barrier takes it whole`);
|
||||
approx(bar.shield, B - 1000, `T${tier}: no 庇护 inside the 0.1 s`);
|
||||
h.run(0.2);
|
||||
bar.shield = 500;
|
||||
const hp = u.hp;
|
||||
take(1300, 'arts');
|
||||
approx(bar.shield, 0, `T${tier}: the barrier took 500 of the full 1300`);
|
||||
approx(hp - u.hp, 800 * 0.75, `T${tier}: 庇护 cut the 800 that passed`);
|
||||
done(h);
|
||||
}
|
||||
// without the module: no 庇护
|
||||
const { h, u } = field({ tier: 6, elite: true, mod: SBLY, skill: 1 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
u.hp = u.s.maxHp * 0.3;
|
||||
const hp = u.hp;
|
||||
h.b.dealDamage(e, u, { amount: 400, type: 'arts' });
|
||||
approx(hp - u.hp, 400, 'SBL-Y: no 庇护');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('SBL-Y (stages 1 and 3): knocked out ⇒ he stays and heals 30 % max HP, once per deployment (again after a redeploy); not while a 不死 holds him; none without the module', () => {
|
||||
for (const tier of [5, 6]) {
|
||||
const { h, u } = field({ tier, elite: true, mod: SBLY, skill: 1 });
|
||||
assert.deepEqual(u.def.raw.trait.bb, { value: 70, hp_ratio: 0.3 }, `T${tier}`);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
|
||||
assert.ok(u.alive, `T${tier}: not withdrawn`);
|
||||
approx(u.hp, u.s.maxHp * 0.3, `T${tier}: 30 % max HP`);
|
||||
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
|
||||
assert.ok(!u.alive, `T${tier}: once per deployment`);
|
||||
h.b.redeploy(u);
|
||||
h.step();
|
||||
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
|
||||
assert.ok(u.alive, `T${tier}: again after the redeploy`);
|
||||
// a running 不死 (坚固维式重锤's window) keeps him up instead
|
||||
h.b.redeploy(u);
|
||||
const r = field({ tier, elite: true, mod: SBLY, skill: 1 });
|
||||
r.u.mem.undyingAt = `${r.u.deploySeq}:0`;
|
||||
r.u.mem.undyingUntil = r.h.b.time + 5;
|
||||
const hammer = r.h.b.on('fatal', (c) => { if (c.unit === r.u && !c.prevented) c.prevented = true; }, { priority: -99 });
|
||||
const f = r.h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
r.h.b.dealDamage(f, r.u, { amount: 1e6, type: 'true' });
|
||||
assert.ok(r.u.alive && r.u.hp < 2, `T${tier}: held by the 不死, the module untouched`);
|
||||
r.h.b.off(hammer);
|
||||
r.u.mem.undyingUntil = 0;
|
||||
r.h.b.dealDamage(f, r.u, { amount: 1e6, type: 'true' });
|
||||
assert.ok(r.u.alive, `T${tier}: the module still there afterwards`);
|
||||
approx(r.u.hp, r.u.s.maxHp * 0.3, `T${tier}: 30 % max HP`);
|
||||
done(h);
|
||||
done(r.h);
|
||||
}
|
||||
for (const mod of [null, SBLX]) {
|
||||
const { h, u } = field({ tier: 6, elite: true, mod, skill: 1 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
|
||||
assert.ok(!u.alive, `${mod ?? 'no module'}: withdrawn`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
+2
-2
@@ -35,11 +35,11 @@ test('slots: two per tier (5, 6), each with its elite twin', () => {
|
||||
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
|
||||
});
|
||||
|
||||
test('diyPool: tier 5 = 15 prototypes + 72 owned 6★, tier 6 = 9 + 72; no preset, no collab; with the kit registry only kitted operators', () => {
|
||||
test('diyPool: tier 5 = 15 prototypes + 71 owned 6★, tier 6 = 9 + 71; no preset, no collab; with the kit registry only kitted operators', () => {
|
||||
const p5 = diyPool(5, { data: DATA }), p6 = diyPool(6, { data: DATA });
|
||||
assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]);
|
||||
assert.deepEqual(p6, [...ELITES, ...ownedPool]);
|
||||
assert.deepEqual([p5.length, p6.length], [87, 81]);
|
||||
assert.deepEqual([p5.length, p6.length], [86, 80]);
|
||||
const roster = new Set(Object.values(chess).map((c) => c.charId).filter(Boolean));
|
||||
for (const id of p5) assert.ok(!roster.has(id) && !COLLAB.includes(id), id);
|
||||
assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4');
|
||||
|
||||
@@ -19,7 +19,7 @@ through.
|
||||
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on its own map (carried HP / SP, a knocked-out operator, two leakers' enemies) |
|
||||
| `matches` | 18 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 7 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
|
||||
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
|
||||
| `diy` | 6 battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS` — 推进之王 so far) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios |
|
||||
| `diy` | battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS`) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
|
||||
|
||||
Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields
|
||||
run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a
|
||||
|
||||
+1987
-100
File diff suppressed because it is too large.
Load diff
@@ -95,7 +95,7 @@ test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and
|
||||
assert.equal(OVERLAY.version, 1);
|
||||
assert.equal(OVERLAY.lang, 'en');
|
||||
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
|
||||
// the 自选 data (0.2.0) brings 72 owned 6★, some of them (and some newer modules) not in the EN client yet: their
|
||||
// the 自选 data (0.2.0) brings 71 owned 6★, some of them (and some newer modules) not in the EN client yet: their
|
||||
// names, skills, talents and modules stay Chinese — measured 97.6 % in all, skills 95.9 / talents 95.1 % (2026-10-05)
|
||||
assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`);
|
||||
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// test/sim/feedback5-hpdamage-hook.test.js — the `hpDamage` hook of damage.js dealDamage (0.2.0, the 自选 operator kits):
|
||||
// what passed the shields of a damage instance, before the HP loss; a handler may only lower it — the 伤判效果 that act
|
||||
// after a barrier (煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied behind his 行险 barrier). A 流失 (Battle.loseHp) never
|
||||
// reaches it, a hit the shields absorb whole neither. docs/SIM.md §4 / §5.
|
||||
// Run: node --test test/sim/feedback5-hpdamage-hook.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
function arena() {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, mass: 0 }) } },
|
||||
units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }], timeLimit: 60, autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1), e = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
return { h, u, e };
|
||||
}
|
||||
|
||||
test('hpDamage: after the shields, before the HP loss — a handler lowers what reaches the HP; a raise is ignored', () => {
|
||||
const { h, u, e } = arena();
|
||||
const seen = [];
|
||||
const off = h.b.on('hpDamage', (c) => { if (c.target === u) { seen.push([c.amount, c.source, c.dmg.type]); c.amount = Math.min(c.amount, 100); } });
|
||||
h.b.addBuff(u, { key: 'test:shield', shield: 300 });
|
||||
const hp = u.hp;
|
||||
const dealt = h.b.dealDamage(e, u, { amount: 1000, type: 'true' });
|
||||
assert.deepEqual(seen, [[700, e, 'true']], 'the 300 shield first, then the hook sees the 700 that passed');
|
||||
assert.equal(u.findBuff('test:shield'), null, 'the shield was spent');
|
||||
assert.deepEqual([hp - u.hp, dealt], [100, 100], 'HP loss and the returned amount follow the lowered value');
|
||||
h.b.off(off);
|
||||
h.b.on('hpDamage', (c) => { if (c.target === u) c.amount = 1e9; });
|
||||
const hp2 = u.hp;
|
||||
h.b.dealDamage(e, u, { amount: 50, type: 'true' });
|
||||
assert.equal(hp2 - u.hp, 50, 'a raise is ignored');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('hpDamage: no call for a 流失 nor for a hit the shields take whole; without a handler nothing changes', () => {
|
||||
const { h, u, e } = arena();
|
||||
let n = 0;
|
||||
h.b.on('hpDamage', (c) => { if (c.target === u) n++; });
|
||||
h.b.loseHp(u, 100, { source: e });
|
||||
assert.equal(n, 0, 'a 流失 skips it (no 伤判效果)');
|
||||
h.b.addBuff(u, { key: 'test:shield', shield: 500 });
|
||||
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
|
||||
assert.equal(n, 0, 'absorbed whole: nothing reaches the HP');
|
||||
h.b.dealDamage(e, u, { amount: 400, type: 'true' });
|
||||
assert.equal(n, 1, 'the 100 that passed');
|
||||
const r = arena();
|
||||
const hp = r.u.hp;
|
||||
r.h.b.dealDamage(r.e, r.u, { amount: 250, type: 'true' });
|
||||
assert.equal(hp - r.u.hp, 250, 'no handler: the plain pipeline');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// test/sim/feedback5-o8-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 夜莺 and 嵯峨 need:
|
||||
// - a 屏障 of one damage type (buff `shieldType`, damage.js absorbShields): 夜莺 S2 法术护盾 "能吸收…法术伤害"; a shield
|
||||
// without a type absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"); older shields first;
|
||||
// - a profile's own target exclusion (`skipEnemy(e)`, targeting.js canTargetEnemy): 嵯峨 "不攻击重伤单位" — no normal
|
||||
// attack on such an enemy, not as a block target, and the DEFAULT trigger condition ignores it.
|
||||
// Run: node --test test/sim/feedback5-o8-engine.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { canTargetEnemy } from '../../server/sim/targeting.js';
|
||||
|
||||
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
|
||||
const CHESS = { test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 300, def: 0, res: 0 }, skill: { duration: 5, spCost: 1, initSp: 0, trigger: { rule: 'DEFAULT' } } }) };
|
||||
|
||||
function field(kits = undefined) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits,
|
||||
hooks: ['damaged', 'skillStart'], captureNoisy: true,
|
||||
units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }],
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
|
||||
test('typed shield: an arts 屏障 absorbs arts damage only; an untyped one every type; the older shield is spent first', () => {
|
||||
const { h, u } = field();
|
||||
h.b.addBuff(u, { key: 'test:artsBarrier', shield: 100, shieldType: 'arts', duration: 30 });
|
||||
for (const type of ['phys', 'true', 'elemental']) {
|
||||
const hp = u.hp;
|
||||
h.b.dealDamage(null, u, { amount: 10, type, canDodge: false });
|
||||
assert.equal(u.hp, hp - 10, `${type} passes the arts barrier`);
|
||||
}
|
||||
assert.equal(u.findBuff('test:artsBarrier').shield, 100);
|
||||
assert.equal(u.s.shield, 100, 'it counts in the shield bar');
|
||||
let hp = u.hp;
|
||||
h.b.dealDamage(null, u, { amount: 40, type: 'arts', canDodge: false });
|
||||
assert.deepEqual([u.hp, u.findBuff('test:artsBarrier').shield], [hp, 60], 'arts absorbed');
|
||||
// an untyped shield added later: arts goes to the older (typed) one first, physical to the untyped one
|
||||
h.b.addBuff(u, { key: 'test:barrier', shield: 50, duration: 30 });
|
||||
h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false });
|
||||
assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 50], 'older first');
|
||||
h.b.dealDamage(null, u, { amount: 20, type: 'phys', canDodge: false });
|
||||
assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 30], 'physical: the untyped one only');
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { amount: 100, type: 'arts', canDodge: false });
|
||||
assert.equal(u.findBuff('test:artsBarrier'), null, 'spent and removed');
|
||||
assert.equal(u.findBuff('test:barrier'), null, 'the rest on the untyped one, spent too');
|
||||
assert.equal(u.hp, hp - 40, '100 − 30 − 30 reaches HP');
|
||||
// a typed hit-negating barrier
|
||||
h.b.addBuff(u, { key: 'test:physBlock', shieldHits: 1, shieldType: 'phys', duration: 30 });
|
||||
hp = u.hp;
|
||||
h.b.dealDamage(null, u, { amount: 25, type: 'arts', canDodge: false });
|
||||
assert.equal(u.hp, hp - 25, 'arts passes a physical block');
|
||||
h.b.dealDamage(null, u, { amount: 25, type: 'phys', canDodge: false });
|
||||
assert.equal(u.hp, hp - 25, 'the physical hit is negated');
|
||||
assert.equal(u.findBuff('test:physBlock'), null);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('profile skipEnemy: the unit never selects such an enemy — no attack, no block target, no DEFAULT trigger — and other enemies stay its targets', () => {
|
||||
const skip = new Set();
|
||||
const { h, u } = field({ test_guard_a: () => ({ skill: { kind: 'duration' }, trait: { skipEnemy: (e) => skip.has(e) } }) });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
skip.add(e);
|
||||
assert.equal(canTargetEnemy(u, e, u.profile), false);
|
||||
assert.equal(canTargetEnemy(u, e, { canHitFly: false }), true, 'another profile still may');
|
||||
h.run(3);
|
||||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0, 'never attacked');
|
||||
assert.equal(u.skill.activations, 0, 'DEFAULT: no target');
|
||||
// blocked: still not a target
|
||||
const w = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
skip.add(w);
|
||||
h.run(1);
|
||||
assert.ok(u.blocking.includes(w), 'she blocks it');
|
||||
assert.equal(h.b.blockedTargets(u, u.profile).length, 0, 'not a block target');
|
||||
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0);
|
||||
// an ordinary enemy is attacked
|
||||
const o = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === o), 3), 'attacks the other one');
|
||||
assert.ok(h.hooksOf('damaged').filter((c) => c.source === u).every((c) => c.target === o), 'only that one');
|
||||
assert.ok(u.skill.activations >= 1, 'DEFAULT fires for it');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// test/sim/palsy-trigger.test.js — the engine hook `palsyTrigger` (server/sim/ai.js enemyAttack): fired when a 麻痹 stack
|
||||
// interrupts an enemy's normal attack (PRTS 真言 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时"), once per interrupt; a
|
||||
// handler may keep the stack (`keep`) or kill the enemy. 真言's 噤声限域 (kits/ops/op-mantra.js) is its user.
|
||||
// Run: node --test test/sim/palsy-trigger.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
|
||||
const shooter = enemyRec({ key: 'enemy_shooter', hp: 1e6, atk: 100, bat: 1, range: 3, speed: 0, mass: 0 });
|
||||
|
||||
function field(setup) {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_guard: guard }, enemies: { enemy_shooter: shooter } },
|
||||
units: [{ chessId: 't_guard', row: 10, col: 5 }], content: 'none', autoFinish: false, setup,
|
||||
});
|
||||
h.step();
|
||||
return h;
|
||||
}
|
||||
|
||||
test('palsyTrigger: one event per interrupted attack, before the stack is used; no event without 麻痹', () => {
|
||||
const seen = [];
|
||||
const h = field((b) => b.on('palsyTrigger', (c) => seen.push({ t: b.time, stacks: c.buff.stacks, enemy: c.enemy })));
|
||||
const e = h.spawn('enemy_shooter', { pos: [10, 7] });
|
||||
h.run(3.05);
|
||||
assert.equal(seen.length, 0, 'no 麻痹, no event');
|
||||
const a0 = e.stats.attacks;
|
||||
h.b.applyStatus(e, 'palsy', { value: 2 });
|
||||
h.run(2.05);
|
||||
assert.deepEqual(seen.map((x) => [x.enemy, x.stacks]), [[e, 2], [e, 1]], 'each interrupt, the stack count before it is used');
|
||||
assert.equal(e.stats.attacks, a0, 'both attacks interrupted');
|
||||
assert.equal(e.findBuff('palsy'), null);
|
||||
h.run(1.1);
|
||||
assert.ok(e.stats.attacks > a0, 'attacks again');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('palsyTrigger: `keep` leaves the stack; a handler that kills the enemy ends its turn', () => {
|
||||
let keepNext = true;
|
||||
const h = field((b) => b.on('palsyTrigger', (c) => { if (keepNext) { c.keep = true; keepNext = false; } }));
|
||||
const e = h.spawn('enemy_shooter', { pos: [10, 7] });
|
||||
h.step();
|
||||
h.b.applyStatus(e, 'palsy', { value: 1 });
|
||||
const a0 = e.stats.attacks;
|
||||
h.run(1.1);
|
||||
assert.equal(e.findBuff('palsy')?.stacks, 1, 'kept');
|
||||
assert.equal(e.stats.attacks, a0, 'still interrupted');
|
||||
h.run(1.05);
|
||||
assert.equal(e.findBuff('palsy'), null, 'used by the next interrupt');
|
||||
assert.equal(e.stats.attacks, a0);
|
||||
// a lethal handler
|
||||
const h2 = field((b) => b.on('palsyTrigger', (c) => b.kill(c.enemy, null)));
|
||||
const e2 = h2.spawn('enemy_shooter', { pos: [10, 7] });
|
||||
h2.step();
|
||||
h2.b.applyStatus(e2, 'palsy', { value: 3 });
|
||||
h2.run(1.2);
|
||||
assert.ok(!e2.alive);
|
||||
assert.equal(h2.b.errors.length, 0, JSON.stringify(h2.b.errors[0]));
|
||||
checkInvariants(h.b);
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
Reference in new issue
Block a user