Files
ark-stronghold-protocol/test/sim/skills.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

288 lines
14 KiB
JavaScript

// Skills runtime: SP types, trigger rules, kinds (duration/ammo/instant/charges/passive/toggle), SkillSpec, generic kits.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { genericSkillSpec, genericKind } from '../../server/sim/content/generic.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const RANGE3 = [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]];
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
function sniperWith(skill, o = {}) {
return chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 100, bat: 1, ...(o.stats || {}) }, rangeGrid: RANGE3, skill, ...o });
}
test('SP: time regen, starts at initSp, capped at cost; DEFAULT waits for an enemy in the initial range', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ spCost: 5, initSp: 2, duration: 3, bb: { atk: 1 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic',
});
h.step();
const u = h.unit('t_sn');
approx(u.skill.sp, 2 + 1 / 30, 1e-6);
h.run(5);
assert.equal(u.skill.sp, 5);
assert.equal(u.skill.ready, true);
assert.equal(u.skill.activations, 0, 'no enemy in range: DEFAULT waits');
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(0.1);
assert.equal(u.skill.activations, 1);
assert.equal(u.skill.active, true);
approx(u.s.atk, 200);
const sp = u.skill.sp;
h.run(1);
assert.equal(u.skill.sp, sp, 'no SP gain during a duration skill');
h.run(2.1);
assert.equal(u.skill.active, false);
approx(u.s.atk, 100);
assert.equal(h.hooksOf('skillStart').length, 1);
assert.equal(h.hooksOf('skillEnd').length, 1);
assert.deepEqual(h.eventsOf('skill').map((e) => e[2]), [1, 0]);
});
test('SP types: attack (+1 per attack) and hurt (+1 per hit taken)', () => {
const h = makeBattle({
defs: {
chess: {
t_sn: sniperWith({ spType: 'INCREASE_WHEN_ATTACK', spCost: 100, initSp: 0, duration: 3 }),
t_tank: chessRec({ id: 't_tank', profession: 'TANK', stats: { atk: 0, maxHp: 1e6, blockCnt: 3 }, skill: { spType: 'INCREASE_WHEN_TAKEN_DAMAGE', spCost: 100, initSp: 0, duration: 3 } }),
},
enemies: { enemy_dummy: dummy({ atk: 10, bat: 1 }) },
},
units: [{ chessId: 't_sn', row: 10, col: 4 }, { chessId: 't_tank', row: 9, col: 6 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
});
h.run(0.2);
const e = h.enemy('enemy_dummy');
assert.equal(e.blockedBy, h.unit('t_tank'));
h.run(4.9);
const sn = h.unit('t_sn'), tank = h.unit('t_tank');
assert.equal(sn.skill.sp, sn.stats.attacks);
assert.ok(tank.skill.sp >= 4 && tank.skill.sp === Math.round(tank.skill.sp), `hurt sp ${tank.skill.sp}`);
});
test('trigger TAKE_DAMAGE fires on the first hit once ready; SP_FULL fires immediately; SEARCH needs an enemy in the initial range', () => {
const h = makeBattle({
defs: {
chess: {
t_tank: chessRec({ id: 't_tank', profession: 'TANK', stats: { atk: 0, maxHp: 1e6, blockCnt: 3 }, skill: { spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'TAKE_DAMAGE' } } }),
t_bard: chessRec({ id: 't_bard', profession: 'SUPPORT', subProfessionId: 'bard', skill: { spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'ALWAYS' } } }),
t_ph: chessRec({ id: 't_ph', profession: 'CASTER', subProfessionId: 'phalanx', attackKind: 'none', rangeGrid: [[0, 0], [0, 1], [0, 2]], skill: { spCost: 2, initSp: 0, duration: 2, trigger: { rule: 'SEARCH' } } }),
},
enemies: { enemy_dummy: dummy({ atk: 10, bat: 1 }) },
},
units: [{ chessId: 't_tank', row: 9, col: 6 }, { chessId: 't_bard', row: 12, col: 3 }, { chessId: 't_ph', row: 11, col: 3 }],
content: 'generic', autoFinish: false,
});
h.run(0.1);
assert.equal(h.unit('t_bard').skill.activations, 1, 'SP_FULL');
assert.equal(h.unit('t_tank').skill.activations, 0);
h.run(3);
assert.equal(h.unit('t_ph').skill.activations, 0, 'SEARCH: no enemy on field');
h.spawn('enemy_dummy', { pos: [9, 6] });
h.run(1.5);
assert.equal(h.unit('t_tank').skill.activations, 1, 'TAKE_DAMAGE');
// research 03 Addendum C1 (BWIKI): "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放" — an enemy
// elsewhere on the field is not enough (integration review: the old rule burnt the skill on enemies at the gate)
assert.equal(h.unit('t_ph').skill.activations, 0, 'SEARCH: an enemy outside the initial range does not trigger');
h.spawn('enemy_dummy', { pos: [11, 5] });
h.run(0.2);
assert.equal(h.unit('t_ph').skill.activations, 1, 'SEARCH: an enemy inside the initial range, without an attack');
});
test('trigger CUSTOM_RANGE uses the custom grid; unknown rules fall back to DEFAULT', () => {
const h = makeBattle({
defs: {
chess: {
t_cr: sniperWith({ spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'CUSTOM_RANGE', customRangeGrid: [[0, 0], [0, 1]] } }, { id: 't_cr' }),
t_unk: sniperWith({ spCost: 1, initSp: 1, duration: 2, trigger: { rule: 'MLYSS_WTRMAN' } }, { id: 't_unk' }),
},
enemies: { enemy_dummy: dummy() },
},
units: [{ chessId: 't_cr', row: 10, col: 4 }, { chessId: 't_unk', row: 12, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 7] }], content: 'generic',
});
h.run(1);
assert.equal(h.unit('t_cr').skill.activations, 0, 'enemy in attack range but outside the custom grid');
assert.ok(h.unit('t_cr').stats.attacks > 0);
h.b.units.find((u) => u.defId === 'enemy_dummy').x = 5;
h.run(0.2);
assert.equal(h.unit('t_cr').skill.activations, 1);
h.spawn('enemy_dummy', { pos: [12, 6] });
h.run(0.2);
assert.equal(h.unit('t_unk').skill.activations, 1, 'unknown rule behaves like DEFAULT');
});
test('ammo skills end when ammo runs out; ammoUsed fires per shot; no SP gain while active', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ durationType: 'AMMO', duration: 0, spCost: 5, initSp: 5, bb: { atk: 0.5, 'attack@trigger_time': 4 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }], content: 'generic',
hooks: ['ammoUsed', 'skillStart', 'skillEnd'],
});
h.run(0.1);
const u = h.unit('t_sn');
assert.equal(u.skill.kind, 'ammo');
assert.equal(u.skill.active, true);
h.run(3.1);
assert.equal(u.skill.active, false);
assert.deepEqual(h.hooksOf('ammoUsed').map((c) => c.left), [3, 2, 1, 0]);
assert.equal(h.hooksOf('skillEnd').length, 1);
assert.equal(h.hooksOf('skillEnd')[0].reason, 'ammo');
});
test('charges accumulate up to maxCharges and are spent one per cast, 3 s apart (the automatic operation cooldown)', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ duration: 0, spCost: 2, initSp: 0, maxChargeTime: 3, bb: { atk_scale: 3 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false,
});
h.run(7);
const u = h.unit('t_sn');
assert.equal(u.skill.kind, 'charges');
assert.equal(u.skill.charges, 3);
assert.equal(u.skill.sp, 2);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.step(1);
const t1 = h.b.time;
assert.equal(u.skill.charges, 2);
assert.equal(u.skill.sp, 0, 'SP bar restarts after using a full stack');
// PRTS 卫戍协议/帮助 技能操作 "自动操作具有3s冷却": a MANUAL skill's next automatic cast comes ≥ 3 s later
h.run(2.8);
assert.equal(u.skill.activations, 1, 'not within 3 s although she attacks every second');
assert.ok(h.runUntil(() => u.skill.activations >= 2, 2));
assert.ok(h.b.time - t1 >= 3 - 1e-6);
});
test('instant skills with an attack override apply to exactly one attack', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ duration: 0, spCost: 100, initSp: 100, bb: { atk_scale: 5 } }, { stats: { atk: 100 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], content: 'generic',
captureNoisy: true, hooks: ['damaged', 'skillStart', 'skillEnd'],
});
h.run(2.2);
const dmgs = h.hooksOf('damaged').map((c) => Math.round(c.amount));
assert.deepEqual(dmgs.slice(0, 3), [500, 100, 100]);
assert.equal(h.hooksOf('skillEnd').length, 1);
});
test('SkillSpec from a kit: mods, targeting override, attack override, onStart/onEnd/onHit', () => {
const log = [];
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ spCost: 1, initSp: 1, duration: 2 }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }, { key: 'enemy_dummy', pos: [11, 6] }, { key: 'enemy_dummy', pos: [9, 6] }],
kits: {
t_sn: (bb) => ({
skill: {
kind: 'duration', duration: 2,
mods: { aspd: 100 },
targeting: { maxTargets: 3 },
attack: { atkScale: 2, dmgType: 'arts', onHit: (c) => log.push(['hit', c.target.id]) },
onStart: (c) => log.push(['start', c.skill.timeLeft]),
onEnd: (c) => log.push(['end', c.reason]),
},
}),
},
});
h.run(0.6); // arrows are in flight for ~0.15 s
const u = h.unit('t_sn');
assert.equal(u.s.aspd, 200);
const hits = log.filter((x) => x[0] === 'hit');
assert.equal(new Set(hits.map((x) => x[1])).size, 3, 'hits 3 targets while active');
h.run(2);
assert.deepEqual(log.find((x) => x[0] === 'start'), ['start', 2]);
assert.deepEqual(log.find((x) => x[0] === 'end'), ['end', 'duration']);
assert.equal(u.s.aspd, 100);
});
test('passive and toggle kinds; carryState restores hp/sp/skill', () => {
const h = makeBattle({
defs: {
chess: {
t_p: sniperWith({ skillType: 'PASSIVE', durationType: 'NONE', duration: -1, spCost: 0, bb: { atk: 1 } }, { id: 't_p' }),
t_t: sniperWith({ duration: -1, spCost: 3, initSp: 3, bb: { attack_speed: 50 }, desc: '攻击速度+50;持续时间无限' }, { id: 't_t' }),
t_c: sniperWith({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }, { id: 't_c' }),
},
enemies: { enemy_dummy: dummy() },
},
units: [{ chessId: 't_p', row: 10, col: 4 }, { chessId: 't_t', row: 11, col: 4 }, { chessId: 't_c', row: 12, col: 4, carryState: { hpPct: 0.5, sp: 10, skillActive: true } }],
enemies: [{ key: 'enemy_dummy', pos: [11, 6] }], content: 'generic',
});
h.run(0.1);
assert.equal(h.unit('t_p').skill.kind, 'passive');
approx(h.unit('t_p').s.atk, 200);
const t = h.unit('t_t');
assert.equal(t.skill.kind, 'toggle');
h.run(30);
assert.equal(t.skill.active, true, 'toggle stays on');
const c = h.unit('t_c');
assert.ok(c.skill.activations >= 1);
const h2 = makeBattle({
defs: { chess: { t_c: sniperWith({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }, { id: 't_c' }) } },
units: [{ chessId: 't_c', row: 12, col: 4, carryState: { hpPct: 0.5, sp: 10, skillActive: true } }], content: 'generic',
});
h2.step();
const c2 = h2.unit('t_c');
approx(c2.hpRatio, 0.5, 1e-9);
assert.equal(c2.skill.active, true);
});
test('generic kit maps blackboards of every real chess to a sane SkillSpec', () => {
const ds = getDefaultSource();
const kinds = {};
for (const id of ds.chessIds()) {
const c = ds.getChess(id);
if (!c || !c.skill) continue;
const spec = genericSkillSpec(c.skill, c.skill.bb);
assert.ok(spec, id);
assert.ok(['duration', 'ammo', 'instant', 'charges', 'passive', 'toggle'].includes(spec.kind), `${id} kind ${spec.kind}`);
if (spec.kind === 'ammo') assert.ok(spec.ammo >= 1, `${id} ammo`);
if (spec.kind === 'duration') assert.ok(spec.duration > 0, `${id} duration`);
for (const [k, v] of Object.entries(spec.mods || {})) assert.ok(Number.isFinite(v), `${id} mod ${k}=${v}`);
kinds[spec.kind] = (kinds[spec.kind] || 0) + 1;
}
assert.ok(kinds.duration > 50 && kinds.ammo > 5, JSON.stringify(kinds));
// explicit mapping check
const spec = genericSkillSpec({ id: 's', skillType: 'MANUAL', durationType: 'AMMO', duration: 0, spCost: 20, maxCharges: 1, bb: {} }, { atk: 0.8, base_attack_time: -0.3, 'attack@trigger_time': 14 });
assert.equal(spec.kind, 'ammo');
assert.equal(spec.ammo, 14);
assert.deepEqual(spec.mods, { atkPct: 0.8, batPct: -0.3 });
assert.equal(genericKind({ skillType: 'MANUAL', durationType: 'NONE', duration: 0, spCost: 5, maxCharges: 2 }), 'charges');
assert.equal(genericKind({ skillType: 'AUTO', durationType: 'NONE', duration: 0, spCost: 0, maxCharges: 1 }), 'passive', 'free skill = always on');
});
test('generic kit heuristics: self-stun after skill, on-hit statuses, displacement direction', () => {
const ds = getDefaultSource();
const ghost = ds.getChess('chess_char_2_07_a'); // 幽灵鲨: "技能结束后干员晕眩10秒"
const g = genericSkillSpec(ghost.skill, ghost.skill.bb, ghost);
assert.equal(g.attack?.onHit, undefined, 'no stun on targets');
assert.equal(typeof g.onEnd, 'function', 'self stun on end');
const texas = ds.getChess('chess_char_1_08_a'); // 德克萨斯: instant, stun targets 2 s
const t = genericSkillSpec(texas.skill, texas.skill.bb, texas);
assert.equal(t.kind, 'instant');
assert.equal(typeof t.attack.onHit, 'function');
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_2_07_a', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
});
const u = h.unit('chess_char_2_07_a');
h.runUntil(() => u.skill.activations > 0, 60);
assert.ok(u.skill.active);
h.runUntil(() => !u.skill.active, 20);
h.step();
assert.ok(u.s.flags.stun, 'stunned after the skill ended');
});
test('spCostMul changes (绝技-style) keep SP within bounds and convert overflow into a charge', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ spCost: 10, initSp: 8, duration: 3 }) } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic',
setup: (b) => b.on('battleStart', () => { for (const u of b.allyUnits) if (u.skill) u.skill.spCostMul = 0.7; }),
});
h.step(2);
const u = h.unit('t_sn');
assert.equal(u.skill.spCost, 7);
assert.equal(u.skill.charges, 1);
assert.ok(u.skill.sp <= u.skill.spCost);
});