Files
ark-stronghold-protocol/test/content/standin_acpion.test.js
T

253 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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]);
});