Files
ark-stronghold-protocol/test/sim/feedback2-enemy-attack-stand.test.js
T

129 lines
7.9 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/sim/feedback2-enemy-attack-stand.test.js — GitHub #58 「1.1中敌方远程攻击单位在攻击时任会移动」: an unblocked ranged enemy
// stands for each attack's clip — through its wind-up before the strike and the rest of the clip after it — and walks
// on between attacks (the owner's decision of 2026-10-04 from first-hand memory; the clip = data/enemies.json
// `attackAnim`, tools/build-data.mjs from the asset manifest; server/sim/ai.js attackStand). It used to stop only
// ATTACK_PAUSE (0.35 s) after each strike while the client played the attack clip over the whole walk. An enemy with no
// clip known keeps the old 0.35 s (test/sim/combat.test.js); a 「不停止移动」 attacker never stops; a stun cuts the stand.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeBattle, enemyRec, chessRec } from '../helpers/battleHarness.js';
import { TICK, ATTACK_PAUSE } from '../../server/sim/constants.js';
import { attackStand } from '../../server/sim/ai.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const readJson = (f) => JSON.parse(readFileSync(path.join(ROOT, f), 'utf8'));
/** Positions per tick and attack times of the first enemy, `seconds` long. */
function trace(h, seconds) {
const log = [], atks = [];
h.b.on('attack', (c) => { if (c.attacker.side === 'enemy') atks.push(h.b.time); });
for (let i = 0; i < Math.round(seconds / TICK); i++) {
h.step();
const e = h.enemies()[0];
if (!e) break;
log.push({ t: h.b.time, x: e.x, y: e.y, moving: e.moving });
}
return { log, atks };
}
/** Distance walked between game times t0 and t1 (ticks after t0 up to t1). */
function walked(log, t0, t1) {
let d = 0;
for (let i = 1; i < log.length; i++) if (log[i].t > t0 + 1e-9 && log[i].t <= t1 + 1e-9) d += Math.hypot(log[i].x - log[i - 1].x, log[i].y - log[i - 1].y);
return d;
}
test('data: every enemy with a real attack clip carries it as attackAnim (the clip the client plays, its length and strike frame)', () => {
const enemies = readJson('data/enemies.json'), assets = readJson('data/assets.json');
let n = 0;
for (const [k, e] of Object.entries(enemies)) {
const sp = assets.enemies[e.spine || k]?.spine;
const a = sp?.anims?.attack;
const real = !!a && typeof a.loop === 'string' && a.via !== 'idle' && sp.animations[a.loop] > 0;
assert.equal(!!e.attackAnim, real, `${k} ${e.name}`);
if (!real) continue;
n++;
assert.equal(e.attackAnim.clip, a.loop, k);
assert.equal(e.attackAnim.dur, sp.animations[a.loop], k);
const h = sp.hits?.[a.loop];
if (Array.isArray(h) && h.length) assert.equal(e.attackAnim.hit, h[0], k);
assert.equal(e.attackMoves, undefined, `${k}: no enemy of the mode attacks on the move`);
}
assert.ok(n > 200, `${n} enemies`);
// the ranged attackers of the report
assert.deepEqual(enemies.enemy_1019_jshoot.attackAnim, { clip: 'Attack', dur: 1, hit: 0.533 });
assert.deepEqual(enemies.enemy_1168_dumage.attackAnim, { clip: 'Attack', dur: 1.2, hit: 0.633 });
});
test('隐形弩手 with an operator in range stands for its 1.0 s Attack clip at every attack (0.533 s wind-up + 0.467 s), then walks', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_3_01_a', row: 10, col: 6, dir: 'RIGHT' }], enemies: [{ key: 'enemy_1019_jshoot', time: 0, route: 0 }],
seed: 3, timeLimit: 40, autoFinish: false, hooks: ['attack'] });
const { log, atks } = trace(h, 16);
assert.ok(atks.length >= 4, `${atks.length} attacks`);
const e = h.enemy('enemy_1019_jshoot');
const iv = e.s.interval;
assert.ok(Math.abs(iv - 2.7) < 1e-9);
for (const t of atks.slice(1, 3)) {
// the wind-up (the strike frame at 0.533 s of the clip) and the rest of the clip: no step
assert.equal(walked(log, t - 0.533 + 2 * TICK, t), 0, `stands through the wind-up before the attack at ${t.toFixed(2)}`);
assert.equal(walked(log, t, t + 0.467 - TICK), 0, `stands for the rest of the clip after ${t.toFixed(2)}`);
assert.ok(!log.find((r) => Math.abs(r.t - (t + 0.3)) < TICK / 2).moving, 'drawn standing (not on the move clip)');
// then it walks until the next wind-up: 2.7 − 1.0 = 1.7 s at 0.9 × 0.5 tiles/s
const d = walked(log, t + 0.467 + 2 * TICK, t + iv - 0.533 - TICK);
assert.ok(d > 0.6 && d < 0.8, `walks between the attacks (${d.toFixed(2)} tiles)`);
}
});
test('the stand shortens with the clip when the attacks come quicker than the clip: an enemy attacking every 1 s with a 2 s clip never walks while it has a target', () => {
const quick = { ...enemyRec({ key: 'enemy_quick', hp: 1e9, atk: 1, range: 2.5, bat: 1, speed: 1 }), attackAnim: { clip: 'Attack', dur: 2, hit: 1 } };
const st = attackStand({ def: { attackAnim: { dur: 2, hit: 1 } }, s: { interval: 1 }, profile: {} });
assert.deepEqual(st, { wind: 0.5, rest: 0.5 });
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_quick: quick } },
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_quick', pos: [9, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
const { log, atks } = trace(h, 6);
assert.ok(atks.length >= 5, `${atks.length} attacks`);
assert.equal(walked(log, atks[0], atks.at(-1)), 0, 'stands from the first attack on');
});
test('a 「不停止移动」 attacker (attackMoves) keeps walking through its attacks', () => {
const rec = (attackMoves) => ({ ...enemyRec({ key: 'enemy_mover', hp: 1e9, atk: 1, range: 2.5, bat: 2, speed: 1 }), attackAnim: { clip: 'Attack', dur: 1.5, hit: 0.5 }, ...(attackMoves ? { attackMoves: true } : {}) });
const run = (attackMoves) => {
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_mover: rec(attackMoves) } },
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_mover', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
return trace(h, 12);
};
const moving = run(true), standing = run(false);
assert.ok(moving.atks.length >= 3 && standing.atks.length >= 3);
for (const t of moving.atks.slice(1, 3)) assert.ok(walked(moving.log, t - 0.5, t + 1) > 0.6, `walks through the attack at ${t.toFixed(2)}`);
// (the first attack lands as the target comes into range, its cooldown long over: no wind-up to stand through)
for (const t of standing.atks.slice(1, 3)) assert.equal(walked(standing.log, t - 0.5 + 2 * TICK, t + 1 - TICK), 0, 'the same enemy without it stands');
});
test('no attack clip known: ATTACK_PAUSE after the strike and no wind-up stand; a stun cuts a stand short', () => {
assert.deepEqual(attackStand({ def: {}, s: { interval: 2 }, profile: {} }), { wind: 0, rest: ATTACK_PAUSE });
assert.deepEqual(attackStand({ def: { attackMoves: true, attackAnim: { dur: 2, hit: 1 } }, s: { interval: 2 }, profile: {} }), { wind: 0, rest: 0 });
const slow = { ...enemyRec({ key: 'enemy_slow', hp: 1e9, atk: 1, range: 2.5, bat: 5, speed: 1 }), attackAnim: { clip: 'Attack', dur: 3, hit: 0.5 } };
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0 } }) }, enemies: { enemy_slow: slow } },
units: [{ chessId: 't_a', row: 10, col: 6 }], enemies: [{ key: 'enemy_slow', pos: [9, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
});
h.step();
const e = h.enemy('enemy_slow');
assert.ok(e.lastAttackAt >= 0, 'attacked at once');
assert.ok(Math.abs(e.atkStandUntil - (h.b.time - TICK + 2.5)) < 1e-6, 'stands the 2.5 s after the strike');
h.run(0.5);
const x0 = e.x;
h.b.applyStatus(e, 'stun', { duration: 0.5 });
h.run(0.6);
assert.equal(e.atkStandUntil, -Infinity, 'the stun ended the stand');
h.run(0.3);
assert.notEqual(e.x, x0, 'walks once the stun is over');
});