Files
ark-stronghold-protocol/test/sim/combat.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

517 lines
27 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.
// Combat rules: statuses & immunities, cold→freeze, element bursts, blocking, melee vs FLY, stealth, priorities, healers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { ELEMENT } from '../../server/sim/constants.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const guard = (o = {}) => chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 300, blockCnt: 2 }, skill: null, ...o });
const sniper = (o = {}) => chessRec({ id: 't_sniper', profession: 'SNIPER', subProfessionId: 'closerange', stats: { atk: 300 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [1, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]], skill: null, ...o });
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, ...o });
const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o });
test('stun stops enemy movement and attacks; immunity honoured; statusApplied fires', () => {
const h = makeBattle({
defs: { enemies: { enemy_walker: walker({ atk: 100 }), enemy_boss: enemyRec({ key: 'enemy_boss', hp: 1e6, speed: 1, immunities: { stun: true } }) } },
enemies: [{ key: 'enemy_walker' }, { key: 'enemy_boss', route: 1 }],
content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_walker');
const boss = h.enemy('enemy_boss');
assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), true);
assert.equal(h.b.applyStatus(boss, 'stun', { duration: 1 }), false, 'immune');
const x0 = e.x, bx0 = boss.x;
h.run(0.9);
approx(e.x, x0);
assert.ok(boss.x < bx0, 'boss keeps walking');
h.run(0.3);
assert.ok(e.x < x0, 'moves again after stun');
assert.equal(h.hooksOf('statusApplied').length, 1);
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 1));
assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 0));
});
test('stunned operator neither attacks nor gains SP; silence blocks skill activation', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ skill: { spCost: 5, initSp: 0, duration: 3, bb: { atk: 0.5 } } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic',
});
h.step();
const u = h.unit('t_guard');
h.b.applyStatus(u, 'stun', { duration: 2 });
const atk0 = u.stats.attacks;
h.run(1.9);
assert.equal(u.stats.attacks, atk0);
assert.ok(u.skill.sp < 0.2);
h.b.applyStatus(u, 'silence', { duration: 20 });
h.run(8);
assert.ok(u.stats.attacks > atk0, 'attacks after stun');
assert.equal(u.skill.ready, true);
assert.equal(u.skill.activations, 0, 'silenced: no cast');
});
test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get cold', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy({ res: 30 }), enemy_icy: enemyRec({ key: 'enemy_icy', speed: 0, hp: 1e6, immunities: { frozen: true } }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_icy', pos: [10, 8] }], content: 'none',
});
h.step();
const e = h.enemy('enemy_dummy');
const icy = h.enemy('enemy_icy');
h.b.applyStatus(e, 'cold', { duration: 5 });
assert.equal(e.s.aspd, 70);
assert.equal(e.s.flags.freeze, undefined);
h.b.applyStatus(e, 'cold', { duration: 5 });
assert.equal(e.s.flags.freeze, true);
assert.equal(e.s.flags.stun, true);
assert.equal(e.s.res, 15);
h.run(3.1);
assert.ok(!e.s.flags.freeze, 'freeze lasts 3 s');
h.b.applyStatus(icy, 'cold', { duration: 5 });
h.b.applyStatus(icy, 'cold', { duration: 5 });
assert.ok(!icy.s.flags.freeze);
assert.ok(icy.s.flags.cold);
});
test('sleep: target is untargetable and inactive; fear stops enemy attacks', () => {
const h = makeBattle({
defs: { chess: { t_sniper: sniper() }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sniper', row: 11, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 7] }], content: 'none',
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'sleep', { duration: 2 });
const u = h.unit('t_sniper');
const a0 = u.stats.attacks;
h.run(1.5);
assert.equal(u.stats.attacks, a0, 'sleeping enemy not targeted');
h.run(1.5);
assert.ok(u.stats.attacks > a0);
});
test('element bursts on enemies ("·我方" terms): burn 7000 元素伤害 + RES −20 (10 s lock), neural 6000 + 3 麻痹, leaders hold 2000', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy({ res: 50 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_lead', pos: [10, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy');
const hp0 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 600 });
approx(e.elem.burn, 600);
assert.equal(e.hp, hp0, 'element damage fills the gauge only');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 600 });
assert.ok(e.findBuff('burnBurst'));
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-6, '元素伤害 ignores RES');
assert.equal(e.s.res, 50 - ELEMENT.burn.enemy.resDown);
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
assert.equal(e.elem.burn, 1000, 'locked during burst');
h.run(10.1);
assert.equal(e.elem.burn, 0);
assert.equal(e.s.res, 50);
// neural: 6000 元素伤害 + 3 麻痹 stacks
const hp1 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 });
approx(hp1 - e.hp, ELEMENT.neural.enemy.elemDamage);
assert.equal(e.findBuff('palsy').stacks, ELEMENT.neural.enemy.palsy);
assert.ok(!e.s.flags.stun, 'enemies are not stunned by a neural burst');
assert.equal(h.hooksOf('elementBurst').length, 2);
// element fragility multiplies 元素伤害
h.b.addBuff(e, { key: 'test:ef', mods: { elemTakenMul: 1.5 } });
const hp2 = e.hp;
h.b.dealDamage(null, e, { amount: 100, type: 'elemental', element: 'burn' });
approx(hp2 - e.hp, 150);
// leaders need 2000
const L = h.enemy('enemy_lead');
assert.equal(L.gaugeMax, 2000);
h.b.dealDamage(null, L, { type: 'element', element: 'burn', amount: 1500 });
assert.ok(!L.findBuff('burnBurst'));
h.b.dealDamage(null, L, { type: 'element', element: 'burn', amount: 600 });
assert.ok(L.findBuff('burnBurst'));
});
test('element bursts on enemies: apoptosis = 800 元素伤害/s + 50 % weaken recovering; erosion = 5000 + permanent DEF −120 (8 s lock)', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ atk: 1000, def: 500 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_dummy');
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
approx(e.s.atk, 500, 1e-6, '50 % weaken at the start');
const hp0 = e.hp;
h.run(5.02);
approx(hp0 - e.hp, 5 * ELEMENT.apoptosis.enemy.elemDps, 1e-6);
assert.ok(e.s.atk > 500 && e.s.atk < 1000, `weaken recovers (${e.s.atk})`);
h.run(10.1);
assert.ok(!e.findBuff('apoptosisBurst'));
approx(e.s.atk, 1000);
const hp1 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 });
approx(hp1 - e.hp, ELEMENT.erosion.enemy.elemDamage);
assert.equal(e.s.def, 500 - ELEMENT.erosion.enemy.defDown);
assert.equal(e.elem.erosion, 1000, 'locked for 8 s');
h.run(8.1);
assert.equal(e.elem.erosion, 0);
h.b.dealDamage(null, e, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(e.s.def, 500 - 2 * ELEMENT.erosion.enemy.defDown, 'stacks, permanent');
});
test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 true + stun 10 s, apoptosis no skill / −1 SP/s / 100 arts/s, erosion 800 phys + DEF −100', () => {
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: { spCost: 30, initSp: 20, duration: 5, bb: { atk: 0.5 } } });
const h = makeBattle({ defs: { chess: { t_op: op } }, units: [{ chessId: 't_op', row: 9, col: 5 }], content: 'generic', autoFinish: false, timeLimit: 120 });
h.step();
const u = h.unit('t_op');
let hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 1000 });
approx(hp - u.hp, ELEMENT.burn.ally.damage);
hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1000 });
approx(hp - u.hp, ELEMENT.neural.ally.damage);
assert.ok(u.findBuff('stun'));
approx(u.findBuff('stun').timeLeft, ELEMENT.neural.ally.stun);
h.run(10.1);
const sp0 = u.skill.sp;
hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(u.s.flags.silence, 'no skill activation');
h.run(3.02);
approx(hp - u.hp, 3 * ELEMENT.apoptosis.ally.dps);
approx(u.skill.sp, sp0 + 3 * u.s.spRecovery - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
h.run(12.1);
assert.ok(!u.s.flags.silence);
hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
approx(hp - u.hp, ELEMENT.erosion.ally.damage - (300 - ELEMENT.erosion.ally.defDown), 1e-6, 'DEF cut first, then 800 phys');
assert.equal(u.s.def, 300 - ELEMENT.erosion.ally.defDown);
assert.equal(u.elem.erosion, 0, 'no lock: accumulates again at once');
});
test('a resolving burst already locks its gauge: an elementBurst handler spreading the element back cannot re-burst (no ping-pong recursion)', () => {
// two operators that spread every burst to each other (the 淤困 parasite pattern) — burn has a lock, erosion on
// operators has none (duration 0), apoptosis locks with a ticker: each must burst exactly once per fill
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: null });
for (const el of ['burn', 'erosion', 'apoptosis', 'neural']) {
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } },
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 9, col: 6 }], content: 'none', autoFinish: false,
extraContent: [{ install(battle) {
battle.on('elementBurst', (c) => {
for (const o of battle.units) if (o.side === 'ally' && o.alive && o.deployed) battle.dealDamage(null, o, { type: 'element', element: c.element, amount: 5000 });
});
} }],
});
h.step();
const A = h.unit('t_a'), B = h.unit('t_b');
const hpA = A.hp, hpB = B.hp;
h.b.dealDamage(null, A, { type: 'element', element: el, amount: 1000 });
assert.deepEqual(h.b.errors.map((e) => e.label), [], `${el}: no hook-depth errors`);
assert.deepEqual(h.hooksOf('elementBurst').map((c) => c.target.defId).sort(), ['t_a', 't_b'], `${el}: one burst per unit`);
assert.ok(A.alive && B.alive);
if (el === 'burn') { approx(hpA - A.hp, ELEMENT.burn.ally.damage); approx(hpB - B.hp, ELEMENT.burn.ally.damage); }
if (el === 'erosion') {
for (const u of [A, B]) assert.equal(u.findBuff('erosionDown').stacks, 1, 'one permanent DEF cut');
assert.equal(A.elem.erosion, 0, 'no lock once resolved: accumulates again');
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(A.findBuff('erosionDown').stacks, 2, 'a later fill bursts again');
}
assert.ok(!A.burstPending?.[el] && !B.burstPending?.[el], 'pending marker cleared');
}
});
test('淤困 (enemy_9007_acelem) parasite hosts side by side: one burst spreads once, no ping-pong', () => {
const h = makeBattle({
seed: 3,
units: [{ chessId: 'chess_char_4_17_a', row: 9, col: 5 }, { chessId: 'chess_char_2_08_a', row: 10, col: 5 }],
enemies: [{ key: 'enemy_9007_acelem', pos: [9, 7], route: { motion: 'WALK', start: [9, 7], end: [9, 2], checkpoints: [] } },
{ key: 'enemy_9007_acelem', pos: [10, 7], route: { motion: 'WALK', start: [10, 7], end: [9, 2], checkpoints: [[10, 3]] } }],
hooks: ['elementBurst'],
});
const A = h.unit('chess_char_4_17_a'), B = h.unit('chess_char_2_08_a');
assert.ok(h.runUntil(() => A.findBuff('ab:parasite') && B.findBuff('ab:parasite'), 20), 'both defenders parasitised');
const hpA = A.hp, hpB = B.hp;
h.eventsOf('dmg'); // drain into h.events
const ev0 = h.events.length;
h.b.dealDamage(null, A, { type: 'element', element: 'burn', amount: 1000 });
assert.deepEqual(h.b.errors.map((e) => e.label), []);
assert.equal(h.hooksOf('elementBurst').length, 2, 'A bursts, the spread bursts B, B\'s spread back to A is locked out');
assert.ok(A.alive && B.alive, `defenders survive (${Math.round(A.hp)}/${Math.round(B.hp)})`);
assert.ok(A.findBuff('burnBurst') && B.findBuff('burnBurst'));
h.eventsOf('dmg');
const burstHit = (u) => h.events.slice(ev0).filter((e) => e[0] === 'dmg' && e[1] === u.id && e[3] === 'arts').length;
assert.equal(burstHit(A), 1, 'one 灼燃 burst hit on A');
assert.equal(burstHit(B), 1, 'one 灼燃 burst hit on B');
assert.ok(hpA - A.hp < 2 * ELEMENT.burn.ally.damage && hpB - B.hp < 2 * ELEMENT.burn.ally.damage);
});
test('statuses (official terms): tremble only stops attacks while blocked; fear = unblockable + no attack + no advance; stunned blocker lets go', () => {
const g = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
const h = makeBattle({
defs: { chess: { t_guard: g }, enemies: { enemy_walker: walker({ atk: 100, bat: 1 }) } },
units: [{ chessId: 't_guard', row: 9, col: 6 }], enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false, timeLimit: 120,
});
const u = h.unit('t_guard');
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 20));
const e = h.enemies()[0];
h.b.applyStatus(e, 'tremble', { duration: 3 });
const a0 = e.stats.attacks;
h.run(2.5);
assert.equal(e.stats.attacks, a0, 'trembling + blocked: no normal attack');
h.run(1);
assert.ok(e.stats.attacks > a0, 'attacks again');
// stun on the blocker: releases the enemy; it is re-blocked once the stun ends
h.b.applyStatus(u, 'stun', { duration: 1 });
assert.equal(e.blockedBy, null, 'a stunned operator blocks nothing');
assert.equal(u.blocking.length, 0);
h.run(1.2);
// fear: released, no attacks, does not advance
if (!e.blockedBy) assert.ok(h.runUntil(() => e.blockedBy === u, 20));
h.b.applyStatus(e, 'fear', { duration: 2 });
assert.equal(e.blockedBy, null);
const x0 = e.x, a1 = e.stats.attacks;
h.run(1.9);
approx(e.x, x0);
assert.equal(e.stats.attacks, a1);
h.run(0.5);
assert.ok(e.blockedBy === u || e.x < x0, 'walks / is blocked again after the fear');
});
test('statuses: sleep = invulnerable except `hitSleep` attackers; levitate halves on heavy units; frozen RES cut only on enemies', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_heavy: enemyRec({ key: 'enemy_heavy', hp: 1e6, speed: 0, mass: 4 }) } },
units: [{ chessId: 'chess_char_1_18_a', row: 9, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_heavy', pos: [10, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'sleep', { duration: 5 });
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'true' }), 0, 'sleepers take no damage');
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'true', ignoreSleep: true }), 100);
const fake = { side: 'ally', s: { dmgDealtMul: 1, physDealtMul: 1, artsDealtMul: 1, defIgnorePct: 0, defIgnoreFlat: 0, resIgnorePct: 0, resIgnoreFlat: 0 }, profile: { hitSleep: true }, stats: { dmg: 0 }, ownerId: 'p1' };
assert.equal(h.b.dealDamage(fake, e, { amount: 100, type: 'true' }), 100, 'hitSleep attacker');
const heavy = h.enemy('enemy_heavy');
h.b.applyStatus(heavy, 'levitate', { duration: 4 });
approx(heavy.findBuff('levitate').timeLeft, 2);
h.b.applyStatus(e, 'levitate', { duration: 4 });
approx(e.findBuff('levitate').timeLeft, 4);
const op = h.unit('chess_char_1_18_a');
const r0 = op.s.res;
h.b.applyStatus(op, 'freeze', { duration: 2 });
assert.equal(op.s.res, r0, 'operators keep their RES when frozen');
assert.ok(op.s.flags.freeze);
});
test('statuses: "同名效果取最高" — a weaker fragile never overrides a stronger one and resumes after it; palsy cancels attacks', () => {
const g = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
const h = makeBattle({
defs: { chess: { t_guard: g }, enemies: { enemy_dummy: dummy({ atk: 100, bat: 1 }) } },
units: [{ chessId: 't_guard', row: 11, col: 8 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'fragile', { duration: 2, value: 0.3 });
h.b.applyStatus(e, 'fragile', { duration: 5, value: 0.1 });
approx(e.s.dmgTakenMul, 1.3, 1e-9, 'the weaker one does not override');
h.run(2.1);
approx(e.s.dmgTakenMul, 1.1, 1e-9, 'the weaker one resumes after the stronger ends');
h.run(3.1);
approx(e.s.dmgTakenMul, 1);
h.b.applyStatus(e, 'fragile', { duration: 5, value: 0.1 });
h.b.applyStatus(e, 'fragile', { duration: 1, value: 0.4 });
approx(e.s.dmgTakenMul, 1.4, 1e-9, 'a stronger one takes over');
h.run(1.1);
approx(e.s.dmgTakenMul, 1.1, 1e-9, 'the longer weaker one comes back');
// palsy: 2 stacks cancel 2 attacks
const u = h.unit('t_guard');
assert.ok(h.runUntil(() => e.blockedBy === u, 10));
h.b.applyStatus(e, 'palsy', { value: 2 });
const a0 = e.stats.attacks;
h.run(2.05);
assert.equal(e.stats.attacks, a0, 'two attacks interrupted');
assert.ok(!e.findBuff('palsy'));
h.run(1.1);
assert.ok(e.stats.attacks > a0);
});
test('beforeStatus may change the duration / value; NEVER trigger never auto-casts; dodge sources roll independently; no SP while a timed skill runs', () => {
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 100, blockCnt: 1 }, skill: { spCost: 2, initSp: 2, duration: 5, bb: { atk: 0.5 } } });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_op', row: 11, col: 7 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'generic', autoFinish: false,
kits: { t_op: () => ({ skill: { kind: 'duration', trigger: 'NEVER', mods: { atkPct: 0.5 } } }) },
setup: (b) => b.on('beforeStatus', (c) => { if (c.status === 'slow') { c.duration *= 2; c.value = 0.9; } }),
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'slow', { duration: 1.5, value: 0.2 });
approx(e.findBuff('slow').timeLeft, 3);
approx(e.s.moveSpeed, e.base.moveSpeed * 0.1);
const u = h.unit('t_op');
h.run(4);
assert.ok(u.skill.ready && u.stats.attacks > 0);
assert.equal(u.skill.activations, 0, 'NEVER: no auto-cast');
assert.equal(u.skill.activate('manual'), true);
assert.equal(u.skill.gainSp(5, 'talent'), 0, 'no SP while the timed skill runs');
h.b.addBuff(u, { key: 'd1', mods: { dodgePhys: 0.5 } });
h.b.addBuff(u, { key: 'd2', mods: { dodgePhys: 0.5 } });
approx(u.s.dodgePhys, 0.75, 1e-9, '1 − 0.5 × 0.5');
});
test('blocking: capacity, heavy enemies, release on blocker death', () => {
const h = makeBattle({
defs: {
chess: { t_guard: guard({ stats: { atk: 0, blockCnt: 2, maxHp: 1e6 } }) },
enemies: { enemy_walker: walker(), enemy_heavy: enemyRec({ key: 'enemy_heavy', hp: 1e6, speed: 1, blockCnt: 3 }) },
},
units: [{ chessId: 't_guard', row: 9, col: 6 }],
enemies: [{ key: 'enemy_walker', count: 3, interval: 0.5 }, { key: 'enemy_heavy', time: 0.2 }],
content: 'none', autoFinish: false,
});
const g = h.unit('t_guard');
h.run(12);
assert.equal(g.blocking.length, 2, 'blocks exactly 2');
const blocked = h.enemies().filter((e) => e.blockedBy === g);
assert.equal(blocked.length, 2);
const passed = h.enemies().filter((e) => !e.blockedBy);
assert.ok(passed.length === 0 || passed.every((e) => e.x < 6), 'others walked through');
assert.ok(h.hooksOf('blocked').length >= 2);
const heavy = h.b.units.find((u) => u.defId === 'enemy_heavy');
assert.notEqual(heavy.blockedBy, g, 'blockCnt 3 enemy cannot be blocked by capacity 2');
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
assert.equal(g.alive, false);
assert.equal(g.blocking.length, 0);
for (const e of blocked) assert.equal(e.blockedBy, null);
const bx = blocked[0].x;
h.run(1);
assert.ok(blocked[0].x < bx || !blocked[0].alive, 'released enemy moves on');
});
test('blocked melee enemy attacks its blocker', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { atk: 0, maxHp: 1e6 } }) }, enemies: { enemy_walker: walker({ atk: 500 }) } },
units: [{ chessId: 't_guard', row: 9, col: 7 }], enemies: [{ key: 'enemy_walker' }], content: 'none', autoFinish: false,
});
h.run(10);
const g = h.unit('t_guard');
assert.ok(g.stats.taken > 0);
assert.ok(g.hp < g.s.maxHp);
});
test('melee operators cannot hit FLY enemies; ranged ones can', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ rangeGrid: [[0, 0], [0, 1], [0, 2]] }), t_sniper: sniper() }, enemies: { enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 0, motion: 'FLY' }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }, { chessId: 't_sniper', row: 10, col: 4 }],
enemies: [{ key: 'enemy_fly', pos: [9, 6], route: 2 }], content: 'none',
});
h.run(3);
assert.equal(h.unit('t_guard').stats.attacks, 0);
assert.ok(h.unit('t_sniper').stats.attacks > 0);
assert.equal(h.enemy('enemy_fly').blockedBy, null, 'FLY never blocked');
});
test('stealth enemies are untargetable unless blocked or revealed', () => {
const h = makeBattle({
defs: { chess: { t_sniper: sniper() }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sniper', row: 11, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 7] }], content: 'none',
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'stealth', { duration: 100 });
const u = h.unit('t_sniper');
const a0 = u.stats.attacks;
h.run(3);
assert.equal(u.stats.attacks, a0);
h.b.applyStatus(e, 'reveal', { duration: 100 });
h.run(2);
assert.ok(u.stats.attacks > a0);
});
test('target priority: least remaining distance to goal, then earliest spawned; fly-first profiles', () => {
const h = makeBattle({
defs: {
chess: { t_sniper: sniper({ rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [1, 1], [1, 2], [1, 3], [1, 4], [2, 1], [2, 2], [2, 3], [2, 4], [3, 2], [3, 3]] }) },
enemies: { enemy_dummy: dummy(), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 0, motion: 'FLY' }) },
},
units: [{ chessId: 't_sniper', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 7] }, { key: 'enemy_dummy', pos: [9, 5] }, { key: 'enemy_dummy', pos: [12, 6] }],
content: 'none',
});
h.run(1.2);
const [far, near, high] = h.b.units.filter((u) => u.defId === 'enemy_dummy');
assert.ok(near.stats.taken > 0, 'closest to goal attacked');
assert.equal(far.stats.taken, 0);
assert.equal(high.stats.taken, 0);
// fly-first
const h2 = makeBattle({
defs: {
chess: { t_fast: sniper({ id: 't_fast', subProfessionId: 'fastshot', targetPriority: 'fly' }) },
enemies: { enemy_dummy: dummy(), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e6, speed: 0, motion: 'FLY' }) },
},
units: [{ chessId: 't_fast', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 4] }, { key: 'enemy_fly', pos: [9, 6], route: 2 }], content: 'none',
});
h2.run(1.2);
assert.ok(h2.enemy('enemy_fly').stats.taken > 0);
assert.equal(h2.enemy('enemy_dummy').stats.taken, 0);
});
test('operators prioritise enemies they block', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100, blockCnt: 1, maxHp: 1e6 } }) }, enemies: { enemy_walker: walker(), enemy_dummy: dummy() } },
units: [{ chessId: 't_guard', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_walker', pos: [9, 5.4] }],
content: 'none', autoFinish: false,
});
h.run(0.2);
const w = h.enemy('enemy_walker');
assert.equal(w.blockedBy, h.unit('t_guard'));
h.run(3);
assert.ok(w.stats.taken > 0);
assert.equal(h.enemy('enemy_dummy').stats.taken, 0);
});
test('healers target the lowest HP% ally in range; idle when everyone is full; noHeal allies skipped', () => {
const medic = chessRec({ id: 't_medic', profession: 'MEDIC', subProfessionId: 'physician', dmgType: 'heal', attackKind: 'heal', stats: { atk: 200 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [-1, 0], [-1, 1], [-1, 2]], skill: null });
const reaper = chessRec({ id: 't_reaper', profession: 'WARRIOR', subProfessionId: 'reaper', skill: null, stats: { maxHp: 5000 } });
const h = makeBattle({
defs: { chess: { t_medic: medic, t_a: guard({ id: 't_a', stats: { maxHp: 5000 } }), t_b: guard({ id: 't_b', stats: { maxHp: 5000 } }), t_reaper: reaper } },
units: [{ chessId: 't_medic', row: 10, col: 3 }, { chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 11, col: 4 }, { chessId: 't_reaper', row: 9, col: 4 }],
content: 'none',
});
h.step();
const m = h.unit('t_medic'), a = h.unit('t_a'), b = h.unit('t_b'), r = h.unit('t_reaper');
h.run(2);
assert.equal(m.stats.attacks, 0, 'no injured ally: no heal');
a.hp = 4000; b.hp = 2000; r.hp = 100;
h.run(1.05);
assert.ok(b.hp > 2000, 'lowest ratio (non-reaper) healed first');
assert.equal(a.hp, 4000);
assert.equal(r.hp, 100, 'reaper cannot be healed by others');
});
test('ranged enemies attack allies in radius: blocker → taunt → latest deployed, then pause', () => {
const mk = (taunt) => makeBattle({
defs: {
chess: { t_a: guard({ id: 't_a', stats: { maxHp: 1e6, atk: 0 } }), t_b: guard({ id: 't_b', stats: { maxHp: 1e6, atk: 0, tauntLevel: taunt } }) },
enemies: { enemy_caster: enemyRec({ key: 'enemy_caster', hp: 1e6, speed: 1, atk: 100, range: 2.5, dmgType: 'arts' }) },
},
// deploy order: top→bottom, so t_b (row 11) deploys before t_a (row 10) ⇒ t_a is the latest deployed
units: [{ chessId: 't_a', row: 10, col: 8 }, { chessId: 't_b', row: 11, col: 8 }],
enemies: [{ key: 'enemy_caster', pos: [10, 10] }], content: 'none', autoFinish: false,
});
const h = mk(1);
h.step(1);
const e = h.enemy('enemy_caster');
assert.ok(e.lastAttackAt >= 0, 'attacked on the first tick');
// attack pause: no movement for ATTACK_PAUSE (0.35 s) after the attack
const x = e.x;
h.step(9);
assert.equal(e.x, x);
h.run(0.5);
assert.ok(e.x < x, 'moves after the pause');
assert.ok(h.unit('t_b').stats.taken > 0, 'highest taunt chosen');
assert.equal(h.unit('t_a').stats.taken, 0);
const h2 = mk(0);
h2.run(0.8);
assert.ok(h2.unit('t_a').stats.taken > 0, 'latest deployed chosen');
assert.equal(h2.unit('t_b').stats.taken, 0);
});