feedback5 K3: the 郁金香 stand-in kit (standin-acpion.js) — 无垠之心 / 浪潮之心, S1 钻心, S2 迅瞬 (1 DP every interval), S3 只余芬芳 (8 slashes on her Spine clip's events, 无敌 / 无法阻挡 / 眩晕冻结免疫, 可对空 — PRTS 备注 of the same skill), module SOL-X
This commit is contained in:
1 parent
d82511286c
commit
e2be2012e3
3 files changed
+397
-1
No files matched your search
@@ -64,7 +64,7 @@ export const KIT_FILES = Object.freeze([
|
||||
* A stand-in without a file fights with the generic kit (content/generic.js) plus its unconditional stat talents
|
||||
* (genericTalents) — the eight 预备干员 need no file.
|
||||
*/
|
||||
export const STANDIN_KIT_FILES = Object.freeze(['standin-acmedc.js', 'standin-acfend.js']);
|
||||
export const STANDIN_KIT_FILES = Object.freeze(['standin-acmedc.js', 'standin-acfend.js', 'standin-acpion.js']);
|
||||
|
||||
async function loadKitFile(file) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// server/sim/content/kits/ops/standin-acpion.js — 郁金香 (char_608_acpion) 补位 stand-in kit: 3 瑕光 (S3), 4 焰尾 (S2),
|
||||
// 5 凛御银灰 / 6 圣聆初雪 / 缪尔赛思 / 耀骑士临光 (S3 + module SOL-X on the elite). Stand-in contract: ../README.md.
|
||||
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
|
||||
|
||||
const S1 = 'skchr_acpion_1', S2 = 'skchr_acpion_2', S3 = 'skchr_acpion_3';
|
||||
/**
|
||||
* S3 只余芬芳's slashes: her Spine `Skill` clip (char_608_acpion.skel, 1.567 s) carries 8 OnAttack events — one per
|
||||
* slash, `times` 8 — at 0.400 s and then every 1/6 s (0.567 … 1.567 s); the `Skill_End` clip adds 0.233 s. The skill
|
||||
* holds its effects (below) from the cast to the end of `Skill_End` [ASSUMED: the clips play at 1×, the effects last the
|
||||
* two clips].
|
||||
*/
|
||||
const SLASH_FIRST = 0.4;
|
||||
const SLASH_GAP = 1 / 6;
|
||||
const SLASH_END_CLIP = 0.233;
|
||||
/** S3's buff while it plays: 无敌, 无法阻挡, no normal attack (the skill clip plays) — stun / freeze refused by a hook. */
|
||||
const FRAGRANCE_KEY = 'acpion:fragrance';
|
||||
|
||||
/** Enemies S3 slashes: every selectable enemy on the x-1 skill range, air units included (PRTS 备注 "※可对空"). */
|
||||
const SLASH_PROFILE = Object.freeze({ canHitFly: true });
|
||||
|
||||
export default {
|
||||
// 郁金香 (尖兵, blocks 2) — numbers from data/backups.json (normal E2 Lv1 rank 4, elite E2 Lv60 rank 7; skill_table and
|
||||
// PRTS 郁金香(卫戍协议) agree). DP (部署费用) is the player's redeploy currency here (DESIGN §5.5 "DP").
|
||||
// 无垠之心: from each deployment until her 2nd skill activation of it, SP recovery +sp_recovery_per_sec (module SOL-X Lv3:
|
||||
// +1/s) — `cnt` 2.
|
||||
// 浪潮之心: ATK +atk; each enemy she kills +cost DP.
|
||||
// S1 钻心 (instant): +cost DP, then ATK × atk_scale physical damage to up to max_target enemies of her attack range
|
||||
// (the enemies she blocks included, ground only like her attack) [ASSUMED: at the cast — her S1 clip `Skill_2`
|
||||
// carries no hit event].
|
||||
// S2 迅瞬 (10 s): ASPD +attack_speed, ignores def_penetrate of the target's DEF, and "逐渐获得" trig_cnt × cost DP: one
|
||||
// every `interval` s from the cast (6 × 1.6 s / 7 × 1.4 s, all inside the 10 s) [ASSUMED: none at the cast, and the
|
||||
// DP not yet given is lost if the skill ends early].
|
||||
// S3 只余芬芳 (instant): +cost DP, then `times` slashes on the enemies around her (the x-1 skill range), each ATK ×
|
||||
// atk_scale physical ignoring def_penetrate (50 %) of DEF, timed by her Spine clip (SLASH_*). PRTS 郁金香 (the 集成战略
|
||||
// character char_513_apionr, whose S1 只余芬芳 has the same text, blackboard and x-1 range) 备注: "※可对空" and
|
||||
// "※技能生效期间,持有效果:无敌,无法阻挡,眩晕免疫,冻结免疫" [ASSUMED: the 卫戍 copy skchr_acpion_3 behaves the same].
|
||||
// Module SOL-X (elite, trait override): "阻挡敌人时攻击力和防御力各+8%" — while she blocks (none during S3: 无法阻挡).
|
||||
// Triggers (data, nothing set here): S1 / S2 DEFAULT; S3 SKILL_RANGE on its x-1 (any enemy in it, air units too).
|
||||
char_608_acpion: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1), tb = traitBb(chess);
|
||||
const r1 = skillRec(chess, S1), r2 = skillRec(chess, S2), r3 = skillRec(chess, S3);
|
||||
const dp = (battle, unit, n) => {
|
||||
if (!(n > 0)) return;
|
||||
battle.addDp(unit.ownerId, n);
|
||||
battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n });
|
||||
};
|
||||
const skills = {};
|
||||
if (r1) {
|
||||
const b1 = r1.bb ?? {};
|
||||
skills[S1] = {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
dp(battle, unit, num(b1.cost));
|
||||
const prof = unit.profile;
|
||||
const foes = battle.enemiesInKeys(unit.rangeKeys || [], unit, prof);
|
||||
for (const e of battle.blockedTargets(unit, prof)) if (!foes.includes(e)) foes.push(e);
|
||||
sortEnemyTargets(battle, unit, foes, prof?.priority ?? null);
|
||||
const v = foes.slice(0, Math.max(1, Math.floor(num(b1.max_target, 2))));
|
||||
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
|
||||
for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
},
|
||||
};
|
||||
}
|
||||
if (r2) {
|
||||
const b2 = r2.bb ?? {};
|
||||
const total = Math.max(0, Math.floor(num(b2.trig_cnt, 0))), iv = Math.max(0.1, num(b2.interval, 1)), per = num(b2.cost, 1);
|
||||
skills[S2] = {
|
||||
kind: 'duration',
|
||||
mods: { aspd: num(b2.attack_speed), defIgnorePct: num(b2.def_penetrate) },
|
||||
onStart({ unit }) { unit.mem.acpionDp = { acc: 0, n: 0 }; },
|
||||
onTick({ battle, unit, dt }) {
|
||||
const m = unit.mem.acpionDp;
|
||||
if (!m) return;
|
||||
m.acc += dt;
|
||||
while (m.acc + 1e-9 >= iv && m.n < total) { m.acc -= iv; m.n++; dp(battle, unit, per); }
|
||||
},
|
||||
onEnd({ unit }) { unit.mem.acpionDp = null; },
|
||||
};
|
||||
}
|
||||
if (r3) {
|
||||
const b3 = r3.bb ?? {};
|
||||
const times = Math.max(1, Math.floor(num(b3.times, 8))), scale = num(b3.atk_scale, 1), pen = num(b3.def_penetrate, 0);
|
||||
const grid = r3.rangeGrid?.length ? r3.rangeGrid : null;
|
||||
const lastAt = SLASH_FIRST + (times - 1) * SLASH_GAP;
|
||||
const slash = (battle, unit) => {
|
||||
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys || [];
|
||||
const v = battle.enemiesInKeys(keys, unit, SLASH_PROFILE);
|
||||
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
|
||||
for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', defIgnorePct: pen, isSkill: true, tags: ['skill', 'slash'] });
|
||||
};
|
||||
skills[S3] = {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
dp(battle, unit, num(b3.cost));
|
||||
const seq = unit.deploySeq;
|
||||
battle.addBuff(unit, {
|
||||
key: FRAGRANCE_KEY, duration: lastAt + SLASH_END_CLIP,
|
||||
flags: { invulnerable: true, noBlock: true, disarm: true }, visible: true, source: unit, tags: ['skill'],
|
||||
});
|
||||
battle.releaseBlocked(unit);
|
||||
for (let i = 0; i < times; i++) {
|
||||
battle.after(SLASH_FIRST + i * SLASH_GAP, () => {
|
||||
if (up(unit) && unit.deploySeq === seq) slash(battle, unit);
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
skills,
|
||||
talents: [
|
||||
{ install(battle, unit) { // 无垠之心
|
||||
const per = num(t0.sp_recovery_per_sec), cnt = Math.max(1, Math.floor(num(t0.cnt, 2)));
|
||||
if (!(per > 0)) return;
|
||||
battle.on('deploy', ({ unit: u }) => {
|
||||
if (u !== unit) return;
|
||||
unit.mem.acpionCasts = 0;
|
||||
battle.addBuff(unit, { key: 'acpion:boundless', mods: { spRecoveryFlat: per }, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
battle.on('skillStart', ({ unit: u }) => {
|
||||
if (u !== unit) return;
|
||||
unit.mem.acpionCasts = (unit.mem.acpionCasts ?? 0) + 1;
|
||||
if (unit.mem.acpionCasts >= cnt) battle.removeBuff(unit, 'acpion:boundless');
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 浪潮之心
|
||||
statBuff(battle, unit, 'acpion:tide', { atkPct: num(t1.atk) });
|
||||
const n = num(t1.cost);
|
||||
if (n > 0) battle.on('kill', ({ killer, victim }) => { if (killer === unit && victim?.side === 'enemy') dp(battle, unit, n); }, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
const ma = num(tb.atk, 0), md = num(tb.def, 0); // module SOL-X: 阻挡敌人时攻击力和防御力各+8%
|
||||
if (ma || md) toggleBuff(battle, unit, 'acpion:sol', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
|
||||
// S3: 眩晕免疫, 冻结免疫 while its effects last
|
||||
if (r3) battle.on('beforeStatus', (c) => {
|
||||
if (c.target === unit && (c.status === 'stun' || c.status === 'freeze') && unit.findBuff(FRAGRANCE_KEY)) c.cancel = true;
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
// test/content/standin_acpion.test.js — the 郁金香 (char_608_acpion) 补位 stand-in kit (kits/ops/standin-acpion.js) on every
|
||||
// chess she replaces: 3 瑕光 (S3), 4 焰尾 (S2), 5 凛御银灰 / 6 圣聆初雪 / 缪尔赛思 / 耀骑士临光 (S3, module SOL-X on the
|
||||
// elite); S1 through the harness `standIn: { skillIndex: 0 }`. Numbers: data/backups.json (skill_table, PRTS
|
||||
// 郁金香(卫戍协议)); S3's slash timing: her Spine Skill clip (8 OnAttack events, 0.400 s + k/6 s).
|
||||
// Run: node --test test/content/standin_acpion.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 { unitForm } from '../../shared/standIn.js';
|
||||
import { TICK } 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 CHAR = 'char_608_acpion';
|
||||
const S1 = 'skchr_acpion_1', S3 = 'skchr_acpion_3';
|
||||
const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, ...o });
|
||||
const DEFS = {
|
||||
enemies: {
|
||||
enemy_dummy: dummy(),
|
||||
enemy_def: dummy({ key: 'enemy_def', def: 400 }),
|
||||
enemy_fly: dummy({ key: 'enemy_fly', motion: 'FLY' }),
|
||||
enemy_weak: dummy({ key: 'enemy_weak', hp: 10 }),
|
||||
enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 0.5 }),
|
||||
},
|
||||
};
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
|
||||
const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status);
|
||||
const skillOf = (id, skillId) => formOf(id).skills.find((s) => s.skillId === skillId);
|
||||
const moduleOf = (id) => (CHESS[id].backup.uniEquipId && CHESS[id].status.equipLevel > 0 ? formOf(id).modules.find((m) => m.uniEquipId === CHESS[id].backup.uniEquipId) : null);
|
||||
|
||||
/** A battle with the shared defs and a `damaged` log (h.dmg: { t, src, tgt, key, amount, type, tags, skill, attack }). */
|
||||
function battle(opts) {
|
||||
const log = [];
|
||||
const h = makeBattle({
|
||||
defs: DEFS, timeLimit: 200, autoFinish: false, seed: 5, flags: { dpPerSec: 0 }, ...opts,
|
||||
setup(b) {
|
||||
b.on('damaged', (c) => log.push({ t: b.time, src: c.source?.id ?? null, tgt: c.target.id, key: c.target.defId, amount: c.amount, type: c.type, tags: c.dmg?.tags ?? [], skill: !!c.dmg?.isSkill, attack: !!c.dmg?.isAttack }), { priority: -999 });
|
||||
if (opts.setup) opts.setup(b);
|
||||
},
|
||||
});
|
||||
h.dmg = log;
|
||||
return h;
|
||||
}
|
||||
const dpOf = (h) => h.b.players[0].dp;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('郁金香 fields on all 12 records she replaces: her body (+ SOL-X attributes on the 5 / 6 elites), the chess\'s backup skill, her own kit, the data triggers', () => {
|
||||
assert.equal(RECORDS.length, 12);
|
||||
for (const id of RECORDS) {
|
||||
const c = CHESS[id];
|
||||
const form = formOf(id);
|
||||
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
|
||||
const mod = moduleOf(id);
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id], [CHAR, c.charId, sk.skillId], id);
|
||||
assert.ok(!u.kit.generic && u.kit.skillSource === 'skills', `${id}: her kit's own spec`);
|
||||
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
|
||||
assert.deepEqual([u.base.atk, u.base.def, u.base.maxHp, u.base.blockCnt], [form.stats.atk + (mod?.attr.atk ?? 0), form.stats.def + (mod?.attr.def ?? 0), form.stats.maxHp, 2], `${id}: stats`);
|
||||
assert.equal(u.skill.rule, sk.skillId === S3 ? 'SKILL_RANGE' : 'DEFAULT', `${id}: trigger`);
|
||||
assert.equal(u.profile.canHitFly, false, `${id}: her attack is ground-only`);
|
||||
assert.deepEqual([u.skill.spCost, u.skill.initSp], [sk.spCost, sk.initSp], `${id}: SP`);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
}
|
||||
// the three tiers' skills: 3 瑕光 S3, 4 焰尾 S2, 5–6 S3
|
||||
assert.deepEqual([CHESS.chess_char_3_12_a, CHESS.chess_char_4_19_a, CHESS.chess_char_5_14_a].map((c) => c.backup.skillIndex), [2, 1, 2]);
|
||||
});
|
||||
|
||||
test('浪潮之心: ATK +10 %, +1 DP for each enemy she kills; SOL-X (5 / 6 elites only): ATK / DEF +8 % exactly while she blocks', () => {
|
||||
for (const id of ['chess_char_3_12_a', 'chess_char_4_19_b', 'chess_char_5_14_b', 'chess_char_6_17_b']) {
|
||||
const mod = moduleOf(id);
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] });
|
||||
const u = h.unit(1);
|
||||
h.step();
|
||||
close(u.s.atk, u.base.atk * 1.1, `${id}: ATK +10 %`);
|
||||
close(u.s.def, u.base.def, `${id}: DEF`);
|
||||
// a weak enemy walks into her block and dies to her first attack: +1 DP (no DP per second here)
|
||||
const dp0 = dpOf(h);
|
||||
const e = h.spawn('enemy_weak', { pos: [10, 4] });
|
||||
assert.ok(h.runUntil(() => !e.alive, 5), `${id}: she kills it`);
|
||||
assert.equal(dpOf(h), dp0 + 1, `${id}: +1 DP for the kill`);
|
||||
assert.equal(u.skill.activations, 0, `${id}: no skill DP mixed in`);
|
||||
// a lasting enemy on her tile: blocked ⇒ SOL-X
|
||||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
h.run(0.2);
|
||||
assert.equal(u.blocking.length, 1);
|
||||
close(u.s.atk, u.base.atk * (1.1 + (mod ? 0.08 : 0)), `${id}: ATK while blocking`);
|
||||
close(u.s.def, u.base.def * (1 + (mod ? 0.08 : 0)), `${id}: DEF while blocking`);
|
||||
if (mod) assert.deepEqual([mod.traitOverride.bb.atk, mod.traitOverride.bb.def], [0.08, 0.08]);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('无垠之心: SP recovery +0.6/s (+1/s with SOL-X Lv3) from each deployment until her 2nd skill activation of it', () => {
|
||||
for (const [id, per] of [['chess_char_3_12_a', 0.6], ['chess_char_5_14_b', 0.6], ['chess_char_6_02_b', 1]]) {
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 } }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }] });
|
||||
const u = h.unit(1);
|
||||
h.step();
|
||||
assert.equal(u.skill.id, S1);
|
||||
close(u.s.spRecovery, 1 + per, `${id}: before the 1st cast`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 40));
|
||||
h.step();
|
||||
close(u.s.spRecovery, 1 + per, `${id}: between the casts`);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 2, 40));
|
||||
h.step();
|
||||
close(u.s.spRecovery, 1, `${id}: after the 2nd cast`);
|
||||
// a new deployment starts it again
|
||||
h.b.retreat(u);
|
||||
assert.ok(h.b.redeploy(u));
|
||||
h.step();
|
||||
close(u.s.spRecovery, 1 + per, `${id}: redeployed`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
// the module's talent upgrade: +1/s at module Lv3 only (Lv1 keeps +0.6)
|
||||
assert.deepEqual(BACKUPS.units[CHAR].forms['2/60/7/3'].modules[0].talentChanges.map((t) => t.bb.sp_recovery_per_sec), [1]);
|
||||
});
|
||||
|
||||
test('S1 钻心: +6 DP and ATK × 170 % / 200 % physical to at most 2 enemies of her attack range (the blocked ones first, no air unit)', () => {
|
||||
for (const [id, scale] of [['chess_char_3_12_a', 1.7], ['chess_char_3_12_b', 2]]) {
|
||||
assert.equal(skillOf(id, S1).bb.atk_scale, scale);
|
||||
const h = battle({
|
||||
units: [{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 }, carryState: { sp: 999 } }],
|
||||
enemies: [{ key: 'enemy_def', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_def', pos: [10, 5] }, { key: 'enemy_fly', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit(1);
|
||||
const dp0 = dpOf(h);
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 5), `${id}: cast`);
|
||||
const atk = u.s.atk;
|
||||
const hits = h.dmg.filter((d) => d.src === u.id && d.skill);
|
||||
assert.equal(hits.length, 2, `${id}: two targets`);
|
||||
assert.deepEqual(hits.map((d) => d.key).sort(), ['enemy_def', 'enemy_dummy'], `${id}: the two she blocks`);
|
||||
for (const d of hits) close(d.amount, atk * scale - (d.key === 'enemy_def' ? 400 : 0), `${id}: ${d.key}`);
|
||||
assert.equal(dpOf(h), dp0 + 6, `${id}: +6 DP`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 迅瞬 (焰尾\'s chess): 10 s of ASPD +95 / +110 and 25 % / 30 % DEF ignored; 1 DP every 1.6 s / 1.4 s, 6 / 7 in all', () => {
|
||||
for (const [id, aspd, pen, iv, n] of [['chess_char_4_19_a', 95, 0.25, 1.6, 6], ['chess_char_4_19_b', 110, 0.3, 1.4, 7]]) {
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }] });
|
||||
const u = h.unit(1);
|
||||
h.run(1);
|
||||
assert.equal(u.skill.activations, 0, `${id}: DEFAULT — no enemy, no cast`);
|
||||
h.spawn('enemy_def', { pos: [10, 4] });
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
const gains = [];
|
||||
let last = dpOf(h);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5), `${id}: cast with an enemy`);
|
||||
assert.equal(u.s.aspd, 100 + aspd);
|
||||
close(u.s.defIgnorePct, pen, `${id}: DEF ignore`);
|
||||
while (u.skill.active) {
|
||||
h.step();
|
||||
const now = dpOf(h);
|
||||
if (now !== last) { gains.push([h.b.time - cast, now - last]); last = now; }
|
||||
}
|
||||
close(h.b.time - cast, 10, `${id}: 10 s`, 0.01);
|
||||
assert.equal(gains.length, n, `${id}: ${n} DP gains`);
|
||||
gains.forEach(([t, d], i) => { assert.equal(d, 1); assert.ok(Math.abs(t - (i + 1) * iv) <= TICK + 1e-6, `${id}: gain ${i + 1} at ${t}`); });
|
||||
// her attacks during the skill ignored the share of the 400 DEF
|
||||
const hit = h.dmg.find((d) => d.src === u.id && d.attack && d.t > cast + 0.1 && d.t < cast + 9.9);
|
||||
close(hit.amount, u.s.atk - 400 * (1 - pen), `${id}: attack vs DEF 400`);
|
||||
assert.equal(u.s.aspd, 100);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 只余芬芳: +6 / +7 DP, 8 slashes at 0.4 s + k/6 s on every enemy of the x-1 around her (air units too), each ATK × 155 % / 170 % ignoring 50 % DEF', () => {
|
||||
for (const [id, scale, cost] of [['chess_char_3_12_a', 1.55, 6], ['chess_char_3_12_b', 1.7, 7], ['chess_char_6_02_b', 1.7, 7]]) {
|
||||
const b3 = skillOf(id, S3).bb;
|
||||
assert.deepEqual([b3.atk_scale, b3.cost, b3.times, b3.def_penetrate], [scale, cost, 8, 0.5]);
|
||||
const h = battle({
|
||||
units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }],
|
||||
// x-1 = the diamond of radius 2: (10,4) blocked, (9,5) and (12,4) in it; (10,7) and (12,6) outside
|
||||
enemies: [{ key: 'enemy_def', pos: [10, 4] }, { key: 'enemy_fly', pos: [9, 5] }, { key: 'enemy_dummy', pos: [12, 4] }, { key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_dummy', pos: [12, 6] }],
|
||||
});
|
||||
const u = h.unit(1);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
const dp0 = dpOf(h);
|
||||
assert.ok(h.runUntil(() => cast != null, 5), `${id}: cast`);
|
||||
h.step();
|
||||
assert.equal(dpOf(h), dp0 + cost, `${id}: +${cost} DP`);
|
||||
h.run(2);
|
||||
const atk = u.base.atk * 1.1; // 浪潮之心; SOL-X is off: she blocks nothing while it plays
|
||||
const slashes = h.dmg.filter((d) => d.src === u.id && d.tags.includes('slash'));
|
||||
const byKey = (k) => slashes.filter((d) => d.key === k);
|
||||
assert.equal(byKey('enemy_def').length, 8, `${id}: 8 slashes on the blocked enemy`);
|
||||
assert.equal(byKey('enemy_fly').length, 8, `${id}: 8 on the air unit`);
|
||||
assert.equal(byKey('enemy_dummy').length, 8, `${id}: 8 on (12,4) only — not on (10,7) / (12,6)`);
|
||||
assert.equal(new Set(byKey('enemy_dummy').map((d) => d.tgt)).size, 1);
|
||||
byKey('enemy_def').forEach((d, k) => {
|
||||
close(d.amount, atk * scale - 400 * 0.5, `${id}: slash ${k} vs DEF 400`);
|
||||
assert.ok(Math.abs(d.t - cast - (0.4 + k / 6)) <= TICK + 1e-6, `${id}: slash ${k} at ${d.t - cast}`);
|
||||
assert.equal(d.type, 'phys');
|
||||
});
|
||||
for (const d of byKey('enemy_fly')) close(d.amount, atk * scale, `${id}: air unit`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 只余芬芳 while it plays (PRTS 备注): 无敌, 无法阻挡, 眩晕 / 冻结免疫, no normal attack — for the 1.8 s of her Skill + Skill_End clips', () => {
|
||||
const h = battle({
|
||||
units: [{ chessId: 'chess_char_6_11_b', row: 10, col: 4, standIn: true, carryState: { sp: 999 } }],
|
||||
enemies: [{ key: 'enemy_hitter', pos: [10, 4] }],
|
||||
});
|
||||
const u = h.unit(1);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.ok(h.runUntil(() => cast != null, 5));
|
||||
h.step();
|
||||
const hp = u.hp;
|
||||
const buff = u.findBuff('acpion:fragrance');
|
||||
assert.deepEqual(buff.flags, { invulnerable: true, noBlock: true, disarm: true });
|
||||
close(buff.duration, 0.4 + 7 / 6 + 0.233, 'the window');
|
||||
assert.equal(u.blocking.length, 0, 'lets go of the enemy');
|
||||
assert.equal(h.b.applyStatus(u, 'stun', { duration: 3, source: h.b.enemies[0] }), false, 'stun refused');
|
||||
assert.equal(h.b.applyStatus(u, 'freeze', { duration: 3, source: h.b.enemies[0] }), false, 'freeze refused');
|
||||
const attacks0 = h.hooksOf('attack').length;
|
||||
h.runUntil(() => h.b.time >= cast + 1.75, 3);
|
||||
assert.equal(u.hp, hp, 'no damage taken');
|
||||
assert.equal(u.blocking.length, 0, 'blocks nothing meanwhile');
|
||||
assert.equal(h.hooksOf('attack').length, attacks0, 'no normal attack meanwhile');
|
||||
h.runUntil(() => h.b.time >= cast + 2.2, 3);
|
||||
assert.equal(u.findBuff('acpion:fragrance'), null);
|
||||
assert.equal(u.blocking.length, 1, 'blocks again');
|
||||
assert.ok(h.b.applyStatus(u, 'stun', { duration: 0.5, source: h.b.enemies[0] }), 'stun lands again');
|
||||
h.run(1);
|
||||
assert.ok(u.hp < hp, 'takes damage again');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('S3 trigger SKILL_RANGE (data): any enemy on the x-1 casts it — an air unit she cannot attack too; none there, no cast', () => {
|
||||
const run = (pos) => {
|
||||
const h = battle({ units: [{ chessId: 'chess_char_3_12_a', row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], enemies: [{ key: 'enemy_fly', pos }] });
|
||||
const u = h.unit(1);
|
||||
h.run(3);
|
||||
return { casts: u.skill.activations, attacks: h.hooksOf('attack').filter((a) => a.attacker === u).length, slashes: h.dmg.filter((d) => d.tags.includes('slash')).length, trigger: u.def.skill.trigger };
|
||||
};
|
||||
const inside = run([10, 6]);
|
||||
assert.equal(inside.trigger.rule, 'SKILL_RANGE');
|
||||
assert.equal(inside.trigger.grid.length, 13, 'the x-1 diamond');
|
||||
assert.deepEqual([inside.casts, inside.attacks, inside.slashes], [1, 0, 8], 'cast with no attack; the slashes reach the air unit');
|
||||
const outside = run([10, 7]);
|
||||
assert.deepEqual([outside.casts, outside.slashes], [0, 0]);
|
||||
});
|
||||
Reference in new issue
Block a user