feedback5 WH: the first 自选 operator kit, 推进之王 (kits/ops/op-siege.js, registered in OPERATOR_KIT_FILES) — S1 冲锋号令·γ型 +12 DP at full SP, S2 跃空锤 next attack 250 / 280 % ATK on every ground enemy of her x-5 + 3 DP (2 / 3 charges), S3 碎颅击 interval +1.0 s with 290 / 320 % ATK and 40 % stun 0.8 / 1.1 s; T1 万兽之王 field 【先锋】 ATK / DEF +8 % (SOL-X stage 3: her own +8 % more), T2 粉碎 +1 SP per enemy falling on her x-5 (flyers, under 阻回; SOL-Y stage 3: +3 and +1 to another 先锋); SOL-X trait ATK / DEF +8 % while blocking; SOL-Y's 首次部署 −4 has no battle effect; tests in test/content/op_siege.test.js over every form (tiers 5 / 6, normal / elite × none / SOL-X / SOL-Y)
No golden scenario moved (none fields a 自选 piece).
This commit is contained in:
1 parent
2b72d4deb0
commit
ee74401901
3 files changed
+363
-1
No files matched your search
@@ -78,7 +78,7 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh
|
||||
* The 自选 operator kit files (`ops/op-<codename>.js`, registry key = the operator's charId, an owned-6★ pick of
|
||||
* data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS).
|
||||
*/
|
||||
export const OPERATOR_KIT_FILES = Object.freeze([]);
|
||||
export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']);
|
||||
|
||||
/**
|
||||
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// server/sim/content/kits/ops/op-siege.js — 推进之王 (char_112_siege) 自选 operator kit: 6★ 尖兵 (先锋), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_112_siege, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 推进之王
|
||||
// (talent 修正 "在场时"; 粉碎 备注 "本天赋无视自身的阻回,可响应飞行单位的死亡"; S2 备注 "技能固定动画不受攻击速度影响";
|
||||
// S3 "攻击间隔增大(+1.0)").
|
||||
// - Trait (尖兵) "能够阻挡两个敌人": the profession default (block 2). Module SOL-X “糖果盒” overrides it with
|
||||
// "阻挡敌人时攻击力和防御力各+8%" (trait bb atk / def): +atk / +def while she blocks at least one enemy.
|
||||
// - T1 万兽之王 "在场时,所有【先锋】职业干员的攻击力和防御力各+8%": an aura on every 【先锋】 operator of the field (her
|
||||
// included) while she is deployed (installAura: the kits' convention for field-wide talents — teammates' pioneers in a
|
||||
// shared field too; several sources keep the strongest). SOL-X stage 2+ adds "自身攻击力和防御力额外+N%" (the module
|
||||
// talent change's bb, +8 % at stage 3): a self buff on top; the aura value stays the base talent's (talentsBase).
|
||||
// - T2 粉碎 "周围四格内有敌人倒下时获得1点技力" (talent range x-5: her tile and the four next to it): +sp SP when an enemy
|
||||
// dies there — flying enemies too, and under her own 阻回 (PRTS 备注) — never while a timed skill runs (AK: no SP during
|
||||
// a skill). SOL-Y stage 2+ raises it to sp (3 at stage 3) "场上随机另一名【先锋】职业干员获得1点技力": the hidden module
|
||||
// talent's sp to one other 【先锋】 operator of the field, drawn uniformly [ASSUMED: any deployed other pioneer, a draw that
|
||||
// cannot take SP wastes it].
|
||||
// - Module SOL-Y “初诺” "首次部署时部署费用-4" (hidden runtime_cost −4): the first deployment of a battle costs nothing in
|
||||
// this mode ⇒ no effect (as 德克萨斯's 战术快递 elite note); its HP / ATK are in the stats.
|
||||
// - S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once; an AUTO skill acting on nobody fires as soon as its SP is full
|
||||
// (`trigger: 'SP_FULL'`, as 德克萨斯's kit casts the same skill — the owner's AUTO rule, kits/README.md checklist 5).
|
||||
// - S2 跃空锤 (AUTO, 可充能 2 / 3 次): the next attack hits every enemy around her — the skill range x-5 — for atk_scale × ATK
|
||||
// physical and gives cost DP; the data's DEFAULT trigger (an AUTO "next attack" waits for her attack). The AoE is the
|
||||
// attack itself, so it reaches the ground enemies a melee attack can [ASSUMED: no source says it reaches air]. The fixed
|
||||
// skill animation (PRTS 备注) does not change the sim's attack timing.
|
||||
// - S3 碎颅击 (MANUAL, 18 / 21 s): attack interval +base_attack_time s (a flat +1.0 on her 1.05 s), each attack at
|
||||
// attack@atk_scale × ATK with attack@buff_prob to stun the target attack@stun s.
|
||||
|
||||
import { num, talentBb, talentGrid, moduleBb, traitBb, moduleOn, skillRec, statBuff, toggleBuff, installAura, batMod,
|
||||
up, skillBusy, giveSp } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
|
||||
const S1 = 'skcom_charge_cost[3]';
|
||||
const S2 = 'skchr_siege_2';
|
||||
const S3 = 'skchr_siege_3';
|
||||
/** The four tiles around her and her own (talent / skill range x-5) when the data carries no grid. */
|
||||
const X5 = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** A 【先锋】 operator (summons are never 【先锋】 operators). */
|
||||
const isPioneer = (a) => !!a && a.kind === 'op' && a.def?.profession === 'PIONEER';
|
||||
|
||||
/** The module the record fights with (its active one), or null. */
|
||||
function activeModule(chess) {
|
||||
if (!moduleOn(chess)) return null;
|
||||
return (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null;
|
||||
}
|
||||
|
||||
export default {
|
||||
char_112_siege: (bb, chess) => {
|
||||
const base0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 万兽之王 without the module change
|
||||
const mod = activeModule(chess);
|
||||
const self0 = mod?.talentChanges?.find((t) => t && t.talentIndex === 0)?.bb ?? {}; // SOL-X stage 2+: 自身额外
|
||||
const t1 = talentBb(chess, 1);
|
||||
const grid1 = talentGrid(chess, 1) ?? X5;
|
||||
const hidden = moduleBb(chess); // SOL-Y: runtime_cost (no effect), sp to another pioneer (stage 2+)
|
||||
const tb = traitBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'instant', trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) {
|
||||
const n = num(b1.cost);
|
||||
battle.addDp(unit.ownerId, n);
|
||||
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
targeting: { rangeGrid: s2?.rangeGrid ?? X5, allInRange: true },
|
||||
attack: { atkScale: num(b2.atk_scale, 1) },
|
||||
onStart({ battle, unit }) {
|
||||
const n = num(b2.cost);
|
||||
battle.addDp(unit.ownerId, n);
|
||||
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'siege:hammer' });
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { batPct: batMod(b3.base_attack_time, chess) },
|
||||
attack: {
|
||||
atkScale: num(b3['attack@atk_scale'], 1),
|
||||
onHit({ battle, unit, target }) {
|
||||
if (target && target.alive && target.side === 'enemy' && battle.rng.chance(num(b3['attack@buff_prob']))) {
|
||||
battle.applyStatus(target, 'stun', { duration: num(b3['attack@stun']), source: unit });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 万兽之王: the field's 【先锋】 ATK / DEF +atk / +def; SOL-X stage 2+: her own extra
|
||||
const a = num(base0.atk), d = num(base0.def);
|
||||
if (a || d) installAura(battle, unit, { key: 'talent:siege:pioneers', value: a, select: isPioneer, mods: { atkPct: a, defPct: d } });
|
||||
statBuff(battle, unit, 'talent:siege:self', { atkPct: num(self0.atk), defPct: num(self0.def) });
|
||||
} },
|
||||
{ install(battle, unit) { // 粉碎: an enemy falls on her x-5 ⇒ +sp SP (阻回 ignored); SOL-Y: +1 SP to another pioneer
|
||||
const sp = num(t1.sp), other = num(hidden.sp);
|
||||
if (!(sp > 0)) return;
|
||||
battle.on('death', (ctx) => {
|
||||
const e = ctx.unit;
|
||||
if (e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit) || !unit.skill) return;
|
||||
if (!bodyInKeys(e, new Set(absoluteRangeKeys(grid1, unit.tileR, unit.tileC, unit.dir, 0)))) return;
|
||||
// "无视自身的阻回": the 'init' reason passes 阻回; a running timed skill still takes no SP
|
||||
if (!skillBusy(unit)) unit.skill.gainSp(sp, 'init');
|
||||
if (other > 0) {
|
||||
const mates = battle.alliesFor(unit).filter((a) => a !== unit && isPioneer(a));
|
||||
if (mates.length) giveSp(battle.rng.pick(mates), other);
|
||||
}
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// SOL-X “糖果盒”: 阻挡敌人时攻击力和防御力各+8%
|
||||
const ma = num(tb.atk), md = num(tb.def);
|
||||
if (ma || md) toggleBuff(battle, unit, 'trait:siege:block', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
// test/content/op_siege.test.js — the 自选 operator kit of 推进之王 (char_112_siege, 6★ 尖兵; kit
|
||||
// server/sim/content/kits/ops/op-siege.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-X
|
||||
// “糖果盒” or SOL-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_siege.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 SIEGE = 'char_112_siege';
|
||||
const FORMS = BACKUPS.units[SIEGE].forms;
|
||||
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
|
||||
const SOLX = 'uniequip_002_siege', SOLY = 'uniequip_003_siege';
|
||||
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_siege_2', S3 = 'skchr_siege_3';
|
||||
/** 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' }), 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, SOLX, SOLY].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 = 2, 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'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SIEGE, 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: its operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, melee ground-only, 维多利亚, no 特质', () => {
|
||||
assert.equal(OPERATOR_KITS[SIEGE], KITS[SIEGE]);
|
||||
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], [SIEGE, 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.base.bat], [2, 'melee', false, 1.05], `${label(f)}: 尖兵`);
|
||||
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
|
||||
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${label(f)}: bonds / 特质`);
|
||||
assert.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 1643 / 422 / 307, E2 Lv60 2046 / 484 / 358; SOL-X +60 / +40 → +75 / +50 ATK / DEF,
|
||||
// SOL-Y +200 / +65 → +260 / +82 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], [1643, 422, 2046, 484]);
|
||||
assert.deepEqual([modOf(5, SOLX).attr, modOf(6, SOLX).attr, modOf(5, SOLY).attr, modOf(6, SOLY).attr], [{ atk: 60, def: 40 }, { atk: 75, def: 50 }, { maxHp: 200, atk: 65 }, { maxHp: 260, atk: 82 }]);
|
||||
});
|
||||
|
||||
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(SIEGE));
|
||||
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SIEGE), `tier ${t}`);
|
||||
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SIEGE, skillIndex: 1, uniEquipId: SOLY } }, { data, kitted: KITTED_CHARS }),
|
||||
{ ok: true, picks: { [SLOT[6]]: { charId: SIEGE, skillIndex: 1, uniEquipId: SOLY } } });
|
||||
});
|
||||
|
||||
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (the kit\'s SP_FULL, as 德克萨斯 casts it); 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;
|
||||
const wait = sk.spCost - sk.initSp;
|
||||
h.run(wait - 0.5);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast at full SP (${wait} s), no enemy on the field`);
|
||||
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
|
||||
h.run(sk.spCost + 0.1);
|
||||
assert.equal(u.skill.activations, 2, `T${tier}: again ${sk.spCost} s later`);
|
||||
approx(h.b.players[0].dp, 24, `T${tier}: +12 DP each cast`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 跃空锤 (AUTO, 2 / 3 charges): the next attack hits every ground enemy on her x-5 for 250 % / 280 % ATK physical and gives 3 DP; one charge per attack; outside x-5 and flyers 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.bb.atk_scale, sk.bb.cost, sk.rangeId], ['DEFAULT', elite ? 3 : 2, elite ? 2.8 : 2.5, 3, 'x-5'], `T${tier}`);
|
||||
const at = (pos, key = 'enemy_dummy') => h.spawn(key, { pos });
|
||||
const front = at([10, 6]), back = at([10, 4]), upE = at([9, 5]);
|
||||
const fly = at([11, 5], 'enemy_fly');
|
||||
const diag = at([9, 6]), far = at([10, 7]);
|
||||
u.skill.gainSp(999);
|
||||
assert.equal(u.skill.charges, u.skill.maxCharges, `T${tier}: charges stored`);
|
||||
h.b.players[0].dp = 0;
|
||||
const n0 = atkHits(h, u).length;
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 3), `T${tier}: cast on her next attack`);
|
||||
const first = atkHits(h, u).slice(n0);
|
||||
const id = first[0].dmg.attackId;
|
||||
const hit = first.filter((c) => c.dmg.attackId === id);
|
||||
assert.deepEqual(hit.map((c) => c.target).sort((a, b) => a.id - b.id), [front, back, upE].sort((a, b) => a.id - b.id), `T${tier}: her x-5 ground enemies`);
|
||||
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], ['phys', true], `T${tier}: physical skill attack`);
|
||||
}
|
||||
for (const e of [fly, diag, far]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} untouched`);
|
||||
approx(h.b.players[0].dp, 3, `T${tier}: +3 DP`);
|
||||
assert.equal(u.skill.charges, u.skill.maxCharges - 1, `T${tier}: one charge spent`);
|
||||
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1 after the attack`);
|
||||
// the stored charges go on her next attacks, one each
|
||||
h.runUntil(() => u.skill.activations === u.skill.maxCharges, 10);
|
||||
assert.equal(u.skill.charges, 0);
|
||||
approx(h.b.players[0].dp, 3 * u.skill.maxCharges, `T${tier}: +3 DP per charge`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 碎颅击 (MANUAL, data DEFAULT): 18 / 21 s, attack interval 1.05 + 1.0 s, every attack 290 % / 320 % ATK with 40 % to stun 0.8 / 1.1 s; 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, seed: 11 });
|
||||
assert.deepEqual([u.skill.rule, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@buff_prob'], sk.bb['attack@stun'], sk.bb.base_attack_time],
|
||||
['DEFAULT', elite ? 21 : 18, elite ? 3.2 : 2.9, 0.4, elite ? 1.1 : 0.8, 1], `T${tier}`);
|
||||
approx(u.s.interval, 1.05, `T${tier}: own interval`);
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
|
||||
const e = 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, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
|
||||
approx(u.s.interval, 2.05, `T${tier}: +1.0 s`);
|
||||
const n0 = atkHits(h, u).length;
|
||||
u.skill.extend(400); // ≈ 200 attacks
|
||||
h.runUntil(() => !u.skill.active, 500);
|
||||
const hits = atkHits(h, u).slice(n0).filter((c) => c.dmg.isSkill);
|
||||
assert.ok(hits.length > 190, `T${tier}: ${hits.length} skill attacks`);
|
||||
for (const c of hits.slice(0, 5)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % ATK`);
|
||||
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === e);
|
||||
assert.ok(stuns.length / hits.length > 0.32 && stuns.length / hits.length < 0.48, `T${tier}: ${stuns.length} / ${hits.length} stuns ≈ 40 %`);
|
||||
for (const c of stuns) approx(c.duration, sk.bb['attack@stun'], `T${tier}: stun ${sk.bb['attack@stun']} s`);
|
||||
approx(u.s.interval, 1.05, `T${tier}: interval back`);
|
||||
const n1 = atkHits(h, u).length;
|
||||
h.run(10);
|
||||
assert.ok(atkHits(h, u).slice(n1).every((c) => !c.dmg.isSkill && Math.abs(c.amount - u.s.atk) < 1e-6), `T${tier}: plain attacks once it ends`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T1 万兽之王: every 【先锋】 operator of the field ATK / DEF +8 % while she is deployed (not other professions); SOL-X stage 3 adds her own +8 % / +8 %', () => {
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 7 }];
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, others });
|
||||
h.run(0.6);
|
||||
const texas = h.unit(2), yak = h.unit(3);
|
||||
const t0 = u.def.raw.talents.find((t) => t.index === 0);
|
||||
const extra = elite && mod === SOLX && tier === 6 ? 0.08 : 0;
|
||||
assert.deepEqual(texas.findBuff('talent:siege:pioneers')?.mods, { atkPct: 0.08, defPct: 0.08 }, `${label(f)}: 德克萨斯 (先锋)`);
|
||||
assert.equal(yak.findBuff('talent:siege:pioneers'), null, `${label(f)}: 角峰 (重装) gets nothing`);
|
||||
assert.deepEqual(u.findBuff('talent:siege:pioneers')?.mods, { atkPct: 0.08, defPct: 0.08 }, `${label(f)}: herself`);
|
||||
approx(u.s.atk, u.base.atk * (1 + 0.08 + extra), `${label(f)}: her ATK`);
|
||||
approx(u.s.def, u.base.def * (1 + 0.08 + extra), `${label(f)}: her DEF`);
|
||||
assert.equal(!!u.findBuff('talent:siege:self'), extra > 0, `${label(f)}: the SOL-X self extra`);
|
||||
if (extra) assert.match(t0.desc, /自身攻击力和防御力额外\+8%/, 'the module text');
|
||||
h.b.retreat(u);
|
||||
h.run(0.7);
|
||||
assert.equal(texas.findBuff('talent:siege:pioneers'), null, `${label(f)}: gone once she leaves the field`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('T2 粉碎: an enemy falling on her x-5 gives 1 SP (flyers too, under her own 阻回 too, never during a timed skill); SOL-Y stage 3: 3 SP and 1 SP to another 【先锋】 operator', () => {
|
||||
for (const f of FORMS_ALL) {
|
||||
const [tier, elite, mod] = f;
|
||||
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
|
||||
const { h, u } = field({ tier, elite, mod, skill: 2, others });
|
||||
const texas = h.unit(2);
|
||||
const y3 = elite && mod === SOLY && tier === 6;
|
||||
const sp = y3 ? 3 : 1;
|
||||
const kill = (pos, key = 'enemy_dummy') => {
|
||||
const e = h.spawn(key, { pos });
|
||||
const [a, b] = [u.skill.sp, texas.skill.sp];
|
||||
h.b.kill(e, null);
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
return [r6(u.skill.sp - a), r6(texas.skill.sp - b)];
|
||||
};
|
||||
assert.deepEqual(kill([10, 4]), [sp, y3 ? 1 : 0], `${label(f)}: behind her (x-5)`);
|
||||
assert.deepEqual(kill([9, 5], 'enemy_fly'), [sp, y3 ? 1 : 0], `${label(f)}: a flyer above her`);
|
||||
assert.deepEqual(kill([9, 6]), [0, 0], `${label(f)}: diagonal — outside x-5`);
|
||||
assert.deepEqual(kill([10, 7]), [0, 0], `${label(f)}: two tiles ahead`);
|
||||
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: 5 });
|
||||
assert.equal(kill([10, 6])[0], sp, `${label(f)}: 无视自身的阻回`);
|
||||
h.b.removeBuff(u, 'test:noSp');
|
||||
u.skill.gainSp(999);
|
||||
u.skill.activate('test');
|
||||
assert.ok(u.skill.active);
|
||||
assert.equal(kill([11, 5])[0], 0, `${label(f)}: no SP while 碎颅击 runs`);
|
||||
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: 2, 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:siege: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:siege:block'), solx, `${label(f)}: blocking`);
|
||||
if (solx) assert.deepEqual(u.findBuff('trait:siege: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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user