feedback5: merge K3 — stand-in kits 郁金香, Mechanist, Touch (Touch shares the 外勤医疗 code; every skill on every form)
# Conflicts: # server/sim/content/kits/index.js
This commit is contained in:
commit
acbb70dc6b
9 files changed
+1058
-11
No files matched your search
@@ -0,0 +1,205 @@
|
||||
// test/content/standin_acfend.test.js — the Mechanist (char_610_acfend) 补位 stand-in kit (kits/ops/standin-acfend.js) on
|
||||
// every chess she replaces: 4 星熊 (S2), 5 号角 / 6 余 (S3, module PRO-X on the elite); S1 through the harness
|
||||
// `standIn: { skillIndex: 0 }`. Numbers: data/backups.json (skill_table, PRTS Mechanist(卫戍协议) and its 备注: the 0.1 s
|
||||
// checks of both talents, S2's swap of the talent effect). Her triggers: the owner's 重装 exception for the stand-ins.
|
||||
// Run: node --test test/content/standin_acfend.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_610_acfend';
|
||||
const S1 = 'skchr_acfend_1', S2 = 'skchr_acfend_2';
|
||||
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_res: dummy({ key: 'enemy_res', res: 20 }),
|
||||
enemy_fly: dummy({ key: 'enemy_fly', motion: 'FLY' }),
|
||||
},
|
||||
};
|
||||
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);
|
||||
|
||||
function battle(opts) {
|
||||
const log = [];
|
||||
const h = makeBattle({
|
||||
defs: DEFS, timeLimit: 300, 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 }), { priority: -999 });
|
||||
if (opts.setup) opts.setup(b);
|
||||
},
|
||||
});
|
||||
h.dmg = log;
|
||||
return h;
|
||||
}
|
||||
/** DEF % of her talent and module at this moment (精研材料 +10 %, its extra below half HP, PRO-X while blocking). */
|
||||
const passiveDefPct = (id, u) => {
|
||||
const t0 = u.def.talents[0].bb;
|
||||
const mod = moduleOf(id);
|
||||
return t0.def + (u.hpRatio < t0.hp_ratio ? t0['acfend_t_1[extra].def'] : 0) + (mod && u.blocking.length ? mod.traitOverride.bb.def : 0);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('Mechanist fields on all 6 records she replaces: her body (+ PRO-X attributes on the 5 / 6 elites), the chess\'s backup skill, her own kit, the 重装 trigger', () => {
|
||||
assert.equal(RECORDS.length, 6);
|
||||
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.def, u.base.maxHp, u.base.blockCnt], [form.stats.def + (mod?.attr.def ?? 0), form.stats.maxHp + (mod?.attr.maxHp ?? 0), 3], `${id}: stats`);
|
||||
// STANDIN_TRIGGER_DEVIATIONS: DEFAULT (the official TAKE_DAMAGE kept as rawRule)
|
||||
assert.deepEqual([u.skill.rule, sk.trigger.rawRule], ['DEFAULT', 'TAKE_DAMAGE'], `${id}: trigger`);
|
||||
assert.equal(u.profile.canHitFly, false, `${id}: ground-only`);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
}
|
||||
assert.deepEqual([CHESS.chess_char_4_17_a, CHESS.chess_char_5_08_a, CHESS.chess_char_6_03_a].map((c) => c.backup.skillIndex), [1, 2, 2]);
|
||||
});
|
||||
|
||||
test('her skills cast with an enemy she blocks before anything hurts her (the owner\'s 重装 exception) — never on an air unit she cannot reach', () => {
|
||||
for (const id of ['chess_char_4_17_a', 'chess_char_5_08_b', 'chess_char_6_03_a']) {
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], enemies: [{ key: 'enemy_fly', pos: [10, 4] }] });
|
||||
const u = h.unit(1);
|
||||
h.run(2);
|
||||
assert.equal(u.skill.activations, 0, `${id}: an air unit on her tile is no target`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 4] }); // ATK 0: it never damages her
|
||||
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `${id}: cast`);
|
||||
assert.equal(h.dmg.filter((d) => d.tgt === u.id).length, 0, `${id}: untouched`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('精研材料: DEF +10 %; below 50 % HP another +10 % (+20 % with PRO-X Lv3), checked every 0.1 s', () => {
|
||||
for (const [id, extra] of [['chess_char_4_17_a', 0.1], ['chess_char_5_08_b', 0.1], ['chess_char_6_03_b', 0.2]]) {
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] });
|
||||
const u = h.unit(1);
|
||||
h.step();
|
||||
assert.equal(u.def.talents[0].bb['acfend_t_1[extra].def'], extra, id);
|
||||
close(u.s.def, u.base.def * 1.1, `${id}: +10 %`);
|
||||
h.b.loseHp(u, u.hp * 0.55);
|
||||
h.run(0.1 + TICK);
|
||||
close(u.s.def, u.base.def * (1.1 + extra), `${id}: below half HP`);
|
||||
h.b.heal(u, u, u.s.maxHp);
|
||||
h.run(0.1 + TICK);
|
||||
close(u.s.def, u.base.def * 1.1, `${id}: healed`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('反馈装甲: enemies she blocks ASPD −7 within 0.1 s; it ends once nobody blocks them — at her retreat too', () => {
|
||||
const h = battle({ units: [{ chessId: 'chess_char_4_17_a', row: 10, col: 4, standIn: true }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_fly', pos: [10, 5] }] });
|
||||
const u = h.unit(1);
|
||||
h.run(0.1 + TICK);
|
||||
const [a, b, f] = h.b.enemies;
|
||||
assert.deepEqual([a.s.aspd, b.s.aspd, f.s.aspd], [93, 93, 100], 'the two blocked enemies, not the air unit');
|
||||
assert.equal(a.findBuff('acfend:feedback').source, u);
|
||||
h.b.retreat(u);
|
||||
h.run(0.1 + TICK);
|
||||
assert.deepEqual([a.s.aspd, b.s.aspd], [100, 100], 'released: gone at the next check');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('S1 结构稳定: 40 s of max HP / DEF +26 % (+40 % elite)', () => {
|
||||
for (const [id, v] of [['chess_char_4_17_a', 0.26], ['chess_char_4_17_b', 0.4]]) {
|
||||
assert.deepEqual([skillOf(id, S1).bb.max_hp, skillOf(id, S1).bb.def, skillOf(id, S1).duration], [v, v, 40]);
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 }, carryState: { sp: 999 } }], enemies: [{ key: 'enemy_dummy', 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, 3));
|
||||
h.step();
|
||||
close(u.s.maxHp, u.base.maxHp * (1 + v), `${id}: max HP`);
|
||||
close(u.s.def, u.base.def * (1 + v + passiveDefPct(id, u)), `${id}: DEF`);
|
||||
h.runUntil(() => !u.skill.active, 45);
|
||||
close(h.b.time - cast, 40, `${id}: 40 s`, 0.01);
|
||||
close(u.s.maxHp, u.base.maxHp, `${id}: max HP back`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 不变性原理 (星熊\'s chess): 20 s of DEF +50 % / +65 %; 反馈装甲 ×2 / ×3 on the enemies she blocks, back to −7 after', () => {
|
||||
for (const [id, v, mult] of [['chess_char_4_17_a', 0.5, 2], ['chess_char_4_17_b', 0.65, 3]]) {
|
||||
const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }] });
|
||||
const u = h.unit(1);
|
||||
h.run(0.2);
|
||||
assert.deepEqual(h.b.enemies.map((e) => e.s.aspd), [93, 93]);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.ok(h.runUntil(() => cast != null, 60));
|
||||
h.step();
|
||||
assert.equal(u.skill.id, S2);
|
||||
close(u.s.def, u.base.def * (1 + v + passiveDefPct(id, u)), `${id}: DEF`);
|
||||
assert.deepEqual(h.b.enemies.map((e) => e.s.aspd), [100 - 7 * mult, 100 - 7 * mult], `${id}: ×${mult} at the cast`);
|
||||
assert.ok(h.b.enemies.every((e) => !e.findBuff('acfend:feedback') && e.findBuff('acfend:feedback2')), `${id}: the plain one replaced`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
close(h.b.time - cast, 20, `${id}: 20 s`, 0.01);
|
||||
h.run(0.1 + TICK);
|
||||
assert.deepEqual(h.b.enemies.map((e) => e.s.aspd), [93, 93], `${id}: plain again`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 应力倒置 (号角 / 余\'s chess): 30 s of DEF +50 % / +70 %, block +1, ATK × 40 % / 55 % arts to each enemy she blocks every second', () => {
|
||||
for (const [id, v, scale] of [['chess_char_5_08_a', 0.5, 0.4], ['chess_char_6_03_b', 0.7, 0.55]]) {
|
||||
const h = battle({
|
||||
units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_res', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }],
|
||||
});
|
||||
const u = h.unit(1);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
h.step();
|
||||
assert.equal(u.blocking.length, 3, 'blocks 3');
|
||||
assert.ok(h.runUntil(() => cast != null, 3));
|
||||
h.run(0.5);
|
||||
assert.equal(u.s.blockCnt, 4);
|
||||
assert.equal(u.blocking.length, 4, `${id}: blocks a 4th`);
|
||||
close(u.s.def, u.base.def * (1 + v + passiveDefPct(id, u)), `${id}: DEF`);
|
||||
h.runUntil(() => !u.skill.active, 35);
|
||||
close(h.b.time - cast, 30, `${id}: 30 s`, 0.01);
|
||||
const pulses = h.dmg.filter((d) => d.src === u.id && d.tags.includes('stress'));
|
||||
const times = [...new Set(pulses.map((d) => Math.round((d.t - cast) * 1000) / 1000))];
|
||||
assert.equal(times.length, 30, `${id}: 30 pulses`);
|
||||
times.forEach((t, i) => assert.ok(Math.abs(t - (i + 1)) <= TICK + 1e-6, `${id}: pulse ${i + 1} at ${t}`));
|
||||
assert.ok(pulses.every((d) => d.type === 'arts' && d.skill));
|
||||
const first = pulses.filter((d) => d.t === pulses[0].t);
|
||||
assert.equal(first.length, 4, `${id}: each blocked enemy`);
|
||||
for (const d of first) close(d.amount, u.s.atk * scale * (d.key === 'enemy_res' ? 0.8 : 1), `${id}: ${d.key}`);
|
||||
h.run(0.2);
|
||||
assert.equal(u.s.blockCnt, 3);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('module PRO-X (5 号角 / 6 余 elites): DEF +20 % exactly while she blocks; none without it', () => {
|
||||
for (const id of ['chess_char_4_17_b', 'chess_char_5_08_a', 'chess_char_5_08_b', 'chess_char_6_03_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.def, u.base.def * 1.1, `${id}: not blocking`);
|
||||
h.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
h.run(0.2);
|
||||
assert.equal(u.blocking.length, 1);
|
||||
close(u.s.def, u.base.def * (1.1 + (mod ? 0.2 : 0)), `${id}: blocking`);
|
||||
assert.equal(!!mod, id === 'chess_char_5_08_b' || id === 'chess_char_6_03_b', id);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// test/content/standin_acmedc.test.js — the Touch (char_613_acmedc) 补位 stand-in kit (kits/ops/standin-acmedc.js) on every
|
||||
// chess she replaces: 5 塞雷娅, 6 塑心 / 纯烬艾雅法拉 (all S3, module PHY-X on the elite); S1 / S2 through the harness
|
||||
// `standIn: { skillIndex }`. S3 and the talents are content/tokens.js touchGospel / mapCharTalents, the implementation the
|
||||
// 外勤医疗 strategy's Touch runs (test/content/tokens_devices.test.js). Triggers: S1 DEFAULT, S2 / S3 ACTIVE_RANGE (the
|
||||
// owner's rule of 2026-10-05, a heal skill: an injured ally inside the running range).
|
||||
// Run: node --test test/content/standin_acmedc.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { unitForm } from '../../shared/standIn.js';
|
||||
import { touchGospel } from '../../server/sim/content/tokens.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_613_acmedc';
|
||||
const S1 = 'skchr_acmedc_1', S2 = 'skchr_acmedc_2', S3 = 'skchr_acmedc_3';
|
||||
const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
|
||||
const DEFS = {
|
||||
chess: {
|
||||
test_tank_a: chessRec({ id: 'test_tank_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }),
|
||||
test_tank2_a: chessRec({ id: 'test_tank2_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }),
|
||||
// SP only from 攫升: an attack-SP skill and no enemy to attack
|
||||
test_sp_a: chessRec({ id: 'test_sp_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 1000, initSp: 0 } }),
|
||||
test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', stats: { atk: 300 }, skill: null, rangeGrid: [[0, 0]] }),
|
||||
},
|
||||
};
|
||||
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);
|
||||
|
||||
/**
|
||||
* Touch (unit 1) on (10,3) facing right — her 3-3 covers columns 3–6, S2's 3-10 columns 3–7, S3's 5-2 columns 3–8 — and
|
||||
* `allies` [[chessId, row, col, hpRatio]] (injured at once). h.heals: { t, tgt, uid, amount (final, before the HP cap), ratio }.
|
||||
*/
|
||||
function battle(touch, allies, opts = {}) {
|
||||
const heals = [];
|
||||
const h = makeBattle({
|
||||
defs: DEFS, timeLimit: 300, autoFinish: false, seed: opts.seed ?? 5, flags: { dpPerSec: 0 },
|
||||
units: [{ chessId: touch.id, row: 10, col: 3, standIn: touch.standIn ?? true, ...(touch.carry ? { carryState: touch.carry } : null) },
|
||||
...allies.map(([chessId, row, col]) => ({ chessId, row, col }))],
|
||||
setup(b) {
|
||||
b.on('heal', (c) => { if (c.source?.uid === 1) heals.push({ t: b.time, tgt: c.target.id, uid: c.target.uid, amount: c.amount, ratio: c.target.hpRatio }); }, { priority: -999 });
|
||||
},
|
||||
});
|
||||
h.heals = heals;
|
||||
h.step();
|
||||
allies.forEach(([, , , ratio], i) => { const a = h.unit(i + 2); if (ratio < 1) h.b.loseHp(a, a.hp * (1 - ratio)); });
|
||||
return h;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
test('Touch fields on all 6 records she replaces: her body (+ PHY-X attributes on the elites), S3 恳切福音 everywhere, her own kit, ACTIVE_RANGE on the 5-2', () => {
|
||||
assert.equal(RECORDS.length, 6);
|
||||
for (const id of RECORDS) {
|
||||
const c = CHESS[id];
|
||||
const form = formOf(id);
|
||||
const mod = moduleOf(id);
|
||||
const h = battle({ id }, []);
|
||||
const u = h.unit(1);
|
||||
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, c.backup.skillIndex], [CHAR, c.charId, S3, 2], 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.equal(!!mod, c.isGolden, `${id}: PHY-X on every elite`);
|
||||
assert.deepEqual([u.base.atk, u.base.maxHp], [form.stats.atk + (mod?.attr.atk ?? 0), form.stats.maxHp + (mod?.attr.maxHp ?? 0)], `${id}: stats`);
|
||||
assert.equal(u.skill.rule, 'ACTIVE_RANGE', `${id}: trigger`);
|
||||
assert.equal(u.skill.triggerGrid.length, 18, `${id}: the 5-2 running range`);
|
||||
assert.equal(u.profile.dmgType, 'heal');
|
||||
// S3 is the strategy's touchGospel spec
|
||||
const g = touchGospel(skillOf(id, S3).bb, skillOf(id, S3));
|
||||
const k = u.kit.skill;
|
||||
assert.deepEqual([k.kind, k.heal, k.mods, k.targeting], [g.skill.kind, g.skill.heal, g.skill.mods, g.skill.targeting], `${id}: touchGospel`);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3 恳切福音: cast with an injured ally inside the 5-2 only (no heal before); 40 s on the 5-2, 2 targets, ×1.35 / ×1.40 below half HP, +30 % extra heal', () => {
|
||||
for (const [id, atk, boost] of [['chess_char_5_11_a', 0.2, 1.35], ['chess_char_6_20_a', 0.2, 1.35], ['chess_char_6_09_b', 0.3, 1.4]]) {
|
||||
const phy = moduleOf(id) ? 1.15 : 1;
|
||||
const sk = skillOf(id, S3);
|
||||
assert.deepEqual([sk.bb.atk, sk.bb.heal_scale, sk.bb['attack@addition_heal_scale'], sk.duration], [atk, boost, 0.3, 40]);
|
||||
// (10,8): outside her 3-3 and S2's 3-10, inside the 5-2; (10,5) full HP
|
||||
const h = battle({ id }, [['test_tank_a', 10, 8, 0.3], ['test_tank2_a', 9, 5, 1]]);
|
||||
const u = h.unit(1), near = h.unit(3);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.equal(u.rangeKeys.length, 12);
|
||||
assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`);
|
||||
close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01);
|
||||
assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: no heal before the cast — it was out of her range`);
|
||||
assert.equal(u.rangeKeys.length, 18, `${id}: the 5-2`);
|
||||
close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
|
||||
h.run(0.5);
|
||||
const first = h.heals.filter((x) => x.t === h.heals[0].t);
|
||||
assert.ok(first[0].t >= cast, `${id}: the first heal comes with the cast`);
|
||||
assert.deepEqual(first.map((x) => x.uid), [2, 2], `${id}: the main heal on the far ally, then the extra on it (its neighbours are full)`);
|
||||
close(first[0].amount, u.s.atk * phy * boost, `${id}: main heal ×${boost}${phy > 1 ? ' ×1.15 PHY-X' : ''}`);
|
||||
close(first[1].amount, first[0].amount * 0.3, `${id}: extra heal = 30 % of the main one`);
|
||||
// two injured allies: both healed per action
|
||||
h.b.loseHp(near, near.hp * 0.2);
|
||||
const n0 = h.heals.length;
|
||||
h.runUntil(() => h.heals.length > n0, 5);
|
||||
h.step();
|
||||
const action = h.heals.slice(n0);
|
||||
assert.deepEqual(new Set(action.map((x) => x.uid)), new Set([2, 3]), `${id}: 2 targets`);
|
||||
close(action.find((x) => x.uid === 3).amount, u.s.atk * 1, `${id}: no bonus above half HP`);
|
||||
h.runUntil(() => !u.skill.active, 45);
|
||||
assert.equal(u.rangeKeys.length, 12, `${id}: back to the 3-3`);
|
||||
checkInvariants(h.b);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
}
|
||||
// none: no cast however long, and none for an injured ally beyond the 5-2
|
||||
for (const allies of [[['test_tank_a', 10, 8, 1]], [['test_tank_a', 10, 9, 0.3]]]) {
|
||||
const h = battle({ id: 'chess_char_5_11_a', carry: { sp: 999 } }, allies);
|
||||
h.run(5);
|
||||
assert.equal(h.unit(1).skill.activations, 0, JSON.stringify(allies));
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 慨赠: cast on an injured ally in her range; 20 s of ATK +40 % / +50 %, each heal a 40 % / 50 % chance of a second heal of the same ally', () => {
|
||||
for (const [id, atk, p] of [['chess_char_5_11_a', 0.4, 0.4], ['chess_char_5_11_b', 0.5, 0.5]]) {
|
||||
assert.deepEqual([skillOf(id, S1).bb.atk, skillOf(id, S1).bb['attack@prob'], skillOf(id, S1).duration], [atk, p, 20]);
|
||||
let actions = 0, doubles = 0;
|
||||
for (let seed = 1; seed <= 6; seed++) {
|
||||
// the ally stays above half HP (no PHY-X bonus), inside her 3-3
|
||||
const h = battle({ id, standIn: { skillIndex: 0 }, carry: { sp: 999 } }, [['test_tank_a', 10, 5, 0.7]], { seed });
|
||||
const u = h.unit(1);
|
||||
assert.equal(u.skill.id, S1);
|
||||
assert.equal(u.skill.rule, 'DEFAULT');
|
||||
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();
|
||||
close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
close(h.b.time - cast, 20, `${id}: 20 s`, 0.01);
|
||||
const during = h.heals.filter((x) => x.t >= cast && x.t < cast + 20);
|
||||
const byT = new Map();
|
||||
for (const x of during) byT.set(x.t, [...(byT.get(x.t) ?? []), x]);
|
||||
for (const list of byT.values()) {
|
||||
actions++;
|
||||
assert.ok(list.length <= 2 && list.every((x) => x.uid === 2), `${id}: at most one extra heal, on the same ally`);
|
||||
if (list.length === 2) { doubles++; close(list[1].amount, list[0].amount, `${id}: the same amount`); }
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
assert.ok(actions >= 30, `${id}: ${actions} heals`);
|
||||
assert.ok(Math.abs(doubles / actions - p) < 0.2, `${id}: ${doubles} / ${actions} extra heals for p = ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2 宛如天启: cast with an injured ally inside the 3-10 only; 30 s on the 3-10, 2 targets, every 医疗 operator of hers ATK +21 % / +30 % (herself too)', () => {
|
||||
for (const [id, v] of [['chess_char_6_09_a', 0.21], ['chess_char_6_09_b', 0.3]]) {
|
||||
const sk = skillOf(id, S2);
|
||||
assert.deepEqual([sk.bb['attack@atk'], sk.bb['attack@max_target'], sk.duration, sk.trigger.rule], [v, 2, 30, 'ACTIVE_RANGE']);
|
||||
const h = battle({ id, standIn: { skillIndex: 1 } }, [['test_tank_a', 10, 7, 0.6], ['test_medic_a', 12, 2, 1], ['test_tank2_a', 9, 4, 1]]);
|
||||
const u = h.unit(1), tank = h.unit(2), medic = h.unit(3), tank2 = h.unit(4);
|
||||
assert.equal(u.skill.id, S2);
|
||||
let cast = null;
|
||||
h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; });
|
||||
assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`);
|
||||
close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01);
|
||||
assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: (10,7) is outside her 3-3`);
|
||||
h.step();
|
||||
assert.equal(u.rangeKeys.length, 15, `${id}: the 3-10`);
|
||||
close(u.s.atk, u.base.atk * (1 + v), `${id}: her ATK`);
|
||||
close(medic.s.atk, medic.base.atk * (1 + v), `${id}: another medic's ATK`);
|
||||
close(tank.s.atk, tank.base.atk, `${id}: not a non-medic`);
|
||||
h.b.loseHp(tank2, tank2.hp * 0.3);
|
||||
const n0 = h.heals.length;
|
||||
h.runUntil(() => h.heals.length > n0, 5);
|
||||
assert.deepEqual(new Set(h.heals.slice(n0).map((x) => x.uid)), new Set([2, 4]), `${id}: 2 targets`);
|
||||
h.runUntil(() => !u.skill.active, 35);
|
||||
h.step();
|
||||
assert.equal(u.rangeKeys.length, 12);
|
||||
close(medic.s.atk, medic.base.atk, `${id}: the aura ends with the skill`);
|
||||
close(u.s.atk, u.base.atk, `${id}: hers too`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
// an injured ally on (10,8) is beyond the 3-10: no cast
|
||||
const h = battle({ id: 'chess_char_6_09_a', standIn: { skillIndex: 1 }, carry: { sp: 999 } }, [['test_tank_a', 10, 8, 0.3]]);
|
||||
h.run(5);
|
||||
assert.equal(h.unit(1).skill.activations, 0);
|
||||
});
|
||||
|
||||
test('攫升 / 超脱 (the strategy\'s mapCharTalents): a healed unit +3 SP; an operator knocked out in her range +5 SP (+8 with PHY-X Lv3)', () => {
|
||||
for (const [id, sp] of [['chess_char_5_11_a', 5], ['chess_char_5_11_b', 5], ['chess_char_6_20_b', 8]]) {
|
||||
const h = battle({ id }, [['test_sp_a', 10, 5, 0.6], ['test_tank_a', 9, 4, 1], ['test_tank2_a', 9, 10, 1]]);
|
||||
const u = h.unit(1), ally = h.unit(2);
|
||||
const gains = [];
|
||||
h.b.on('spGain', (c) => { if (c.unit === ally) gains.push([c.reason, c.amount]); });
|
||||
h.runUntil(() => h.heals.length > 0, 5);
|
||||
h.step();
|
||||
assert.deepEqual(gains, [['talent', 3]], `${id}: 攫升`);
|
||||
assert.equal(u.def.talents[1].bb.sp, sp);
|
||||
const before = u.skill.sp;
|
||||
h.b.kill(h.unit(3)); // (9,4): in her range
|
||||
close(u.skill.sp, before + sp, `${id}: 超脱`);
|
||||
const mid = u.skill.sp;
|
||||
h.b.kill(h.unit(4)); // (9,10): out of it
|
||||
close(u.skill.sp, mid, `${id}: not out of range`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('module PHY-X (elites): every heal of hers on an ally below 50 % HP ×1.15 — none at or above it, none on the normal chess', () => {
|
||||
for (const id of ['chess_char_5_11_a', 'chess_char_5_11_b', 'chess_char_6_09_b']) {
|
||||
const mod = moduleOf(id);
|
||||
if (mod) assert.deepEqual([mod.traitOverride.bb.heal_scale, mod.traitOverride.bb.hp_ratio], [1.15, 0.5]);
|
||||
for (const [ratio, mul] of [[0.3, mod ? 1.15 : 1], [0.5, 1], [0.8, 1]]) {
|
||||
const h = battle({ id }, [['test_tank_a', 10, 5, ratio]]);
|
||||
const u = h.unit(1);
|
||||
h.runUntil(() => h.heals.length >= 1, 10);
|
||||
assert.equal(u.skill.active, false, `${id}: a normal heal`);
|
||||
close(h.heals[0].amount, u.s.atk * mul, `${id}: at ${ratio} HP`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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