feedback2: merge WK — a ranged enemy stands for each attack's clip, then walks (GitHub #58); every exit leaves a 倒地干员 redeployed on that tile, per PRTS 卫戍协议/帮助 (GitHub #60)
This commit is contained in:
commit
8ce9934efb
19 files changed
+610
-72
No files matched your search
@@ -0,0 +1,102 @@
|
||||
// test/render/feedback2-enemy-attack-clip.test.js — GitHub #58, client side: an enemy plays its attack clip once per
|
||||
// attack at the clip's own speed (faster only when the attacks come quicker than the clip) and then its resting clip —
|
||||
// Move while the sim walks it again (server/sim/ai.js attackStand stands it for exactly that clip). It used to stretch
|
||||
// the clip over the whole attack interval and loop it for 1.4 intervals, so a ranged enemy seemed to slide while it
|
||||
// attacked. Operators keep the clip looping over their attack rhythm. Headless fake PIXI (test/render/fakepixi.js).
|
||||
|
||||
import { test, before, after } 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 { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { ANIM } from '../../shared/constants.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
const JSHOOT = 'enemy_1019_jshoot'; // 隐形弩手: Attack 1.0 s, strike at 0.533 s; attacks every 2.7 s
|
||||
|
||||
let fake, SpineActor, UnitView;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ SpineActor } = await import('../../public/js/render/spine.js'));
|
||||
({ UnitView } = await import('../../public/js/render/units.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const entryOf = (id) => assets.enemies[id].spine;
|
||||
const dataOf = (entry) => ({ animations: Object.keys(entry.animations).map((name) => ({ name })) });
|
||||
function actor(id, perAttack) {
|
||||
const entry = entryOf(id);
|
||||
const a = new SpineActor(dataOf(entry), entry);
|
||||
a.clipPerAttack = perAttack;
|
||||
a.setBase('move');
|
||||
return a;
|
||||
}
|
||||
const track = (a) => a.spine.state.tracks[0];
|
||||
const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); };
|
||||
|
||||
test('an enemy: wound up before the strike, the Attack clip plays once at its own speed, then Move again — no stretching over the interval', () => {
|
||||
const a = actor(JSHOOT, true);
|
||||
assert.equal(a.current, 'Move');
|
||||
assert.ok(a.windUp(2.7, 0.2), 'winds up 0.2 s before the strike');
|
||||
assert.equal(a.current, 'Attack');
|
||||
assert.equal(track(a).loop, false, 'one clip per attack');
|
||||
run(a, 0.2);
|
||||
a.attack(2.7);
|
||||
assert.equal(track(a).timeScale, 1, 'its own speed (the 2.7 s interval used to slow it to 0.37)');
|
||||
run(a, 0.45);
|
||||
assert.equal(a.current, 'Attack', 'the clip plays to its end (0.467 s after the strike)');
|
||||
run(a, 0.05);
|
||||
assert.equal(a.current, 'Move', 'then walks on its Move clip (it used to hold the attack for 1.4 intervals: 3.8 s)');
|
||||
// the next attack starts the clip anew
|
||||
run(a, 1.5);
|
||||
assert.ok(a.windUp(2.7, 0.3));
|
||||
assert.equal(a.current, 'Attack');
|
||||
run(a, 0.3);
|
||||
a.attack(2.7);
|
||||
run(a, 0.5);
|
||||
assert.equal(a.current, 'Move');
|
||||
});
|
||||
|
||||
test('an enemy attacking quicker than its clip plays it faster, one clip per attack; an attack seen late starts on the strike frame', () => {
|
||||
const a = actor(JSHOOT, true);
|
||||
a.attack(0.5); // no wind-up seen: from the strike frame, at 1.0 / 0.5 = 2×
|
||||
assert.equal(a.current, 'Attack');
|
||||
assert.equal(track(a).loop, false);
|
||||
assert.equal(track(a).timeScale, 2);
|
||||
assert.ok(Math.abs(track(a).trackTime - 0.533) < 1e-9, 'on the strike frame');
|
||||
run(a, 0.25);
|
||||
assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s');
|
||||
});
|
||||
|
||||
test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => {
|
||||
const a = actor(JSHOOT, false);
|
||||
a.attack(2.7);
|
||||
assert.equal(track(a).loop, true);
|
||||
assert.ok(Math.abs(track(a).timeScale - 1 / 2.7) < 1e-9);
|
||||
run(a, 2);
|
||||
assert.equal(a.current, 'Attack', 'still in attack mode 2 s later (1.4 × the interval)');
|
||||
});
|
||||
|
||||
test('UnitView: enemy models play a clip per attack, operator models do not', async () => {
|
||||
const entry = entryOf(JSHOOT);
|
||||
const store = { picture: () => null, image: async () => null, spineEntry: () => entry, spine: { acquire: async () => dataOf(entry), release() {} } };
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
const make = async (side) => {
|
||||
const v = new UnitView(fakeViewCtx(fake.P, { assets: store, cam }), { id: side === 'enemy' ? 7 : 8, side, kind: side === 'enemy' ? 'enemy' : 'op', defId: JSHOOT, spine: JSHOOT, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1 });
|
||||
await tick(); await tick();
|
||||
assert.ok(v.actor, 'model built');
|
||||
return v;
|
||||
};
|
||||
const e = await make('enemy');
|
||||
assert.equal(e.actor.clipPerAttack, true);
|
||||
e.sync({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: ANIM.MOVE, vx: 0 }, 1);
|
||||
e.onAttack(null, 1);
|
||||
assert.equal(e.actor.current, 'Attack');
|
||||
assert.equal(e.actor.spine.state.tracks[0].loop, false);
|
||||
const o = await make('ally');
|
||||
assert.equal(o.actor.clipPerAttack, false);
|
||||
});
|
||||
@@ -50,23 +50,25 @@ test('a knocked-out operator is `down` until it redeploys: its timer, then waiti
|
||||
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
|
||||
});
|
||||
|
||||
test('summons, devices, enemies and withdrawn / removed operators are never `down`', () => {
|
||||
test('summons, devices, enemies, operators removed for good and the 突袭 retreat are never `down`; an operator forced out is (GitHub #60)', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }],
|
||||
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c'), t_d: op('t_d') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
|
||||
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }, { chessId: 't_d', row: 9, col: 6 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 120,
|
||||
});
|
||||
h.step();
|
||||
const tok = h.b.spawnToken('p1', 'token_test', 12, 5, { def: TOKEN });
|
||||
h.b.kill(tok);
|
||||
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
|
||||
h.b.retreat(h.unit('t_b'), { reason: 'expired', permanent: true });
|
||||
h.b.retreat(h.unit('t_d'), { reason: 'raid' }); // 突袭: redeployed at once on its landing tile (here: nowhere)
|
||||
h.b.kill(h.enemy('enemy_dummy'));
|
||||
assert.equal(h.snapshot().down, undefined);
|
||||
// PRTS 卫戍协议/帮助 "干员退场后…原地留下一个“倒地干员”…": a forced exit (史尔特尔's 余烬 …) lies down like a knock-out
|
||||
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
|
||||
h.b.kill(h.unit('t_c'));
|
||||
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_c').id], 'only the knocked-out operator');
|
||||
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_a').id, h.unit('t_c').id], 'the forced-out and the knocked-out operator');
|
||||
const ids = new Set(h.b.fieldMeta().units.map((u) => u.id));
|
||||
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
|
||||
assert.ok(ids.has(h.unit('t_c').id) && ids.has(h.unit('t_a').id) && !ids.has(h.unit('t_b').id) && !ids.has(h.unit('t_d').id) && !ids.has(tok.id));
|
||||
});
|
||||
|
||||
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
|
||||
|
||||
@@ -392,7 +392,8 @@ test('redeploy: { tile } lands on another tile (home unchanged; knocked out ther
|
||||
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
|
||||
approx(u.hp, u.s.maxHp);
|
||||
// plain redeploy: initSp; knocked out there later → the auto-redeploy comes back on that tile (PRTS 卫戍协议/帮助
|
||||
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat → home
|
||||
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat too (every 退场:
|
||||
// GitHub #60)
|
||||
h.b.retreat(u);
|
||||
h.b.redeploy(u, { tile: [12, 8] });
|
||||
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
|
||||
@@ -402,8 +403,9 @@ test('redeploy: { tile } lands on another tile (home unchanged; knocked out ther
|
||||
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
|
||||
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'auto-redeploy on the tile it lay on');
|
||||
h.b.retreat(u);
|
||||
assert.deepEqual(h.b.snapshot().down?.map((d) => d.slice(4)), [[12, 8]], 'a retreated operator lies where it stood');
|
||||
assert.equal(h.b.redeploy(u), true);
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'a retreated unit comes back on its home tile');
|
||||
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'and comes back there, not on its home tile (GitHub #60)');
|
||||
// paid redeploy on a tile
|
||||
h.b.retreat(u);
|
||||
const dp = p.dp;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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');
|
||||
});
|
||||
|
||||
// review: a stand holds only the walking — a route's WAIT keeps running and DISAPPEAR / APPEAR still happen
|
||||
// (data/waves.json has 122 WAIT and 99 DISAPPEAR checkpoints); it used to hold every leg, so a wait ran long by each
|
||||
// clip and never ended for an enemy attacking quicker than its clip
|
||||
function routeField(key, bat, dur, checkpoints, ally) {
|
||||
const rec = { ...enemyRec({ key, hp: 1e9, atk: 1, range: 3, bat, speed: 1 }), attackAnim: { clip: 'Attack', dur, hit: dur / 2 } };
|
||||
return makeBattle({
|
||||
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { maxHp: 1e9, atk: 0, def: 1e6 } }) }, enemies: { [key]: rec } },
|
||||
units: ally ? [{ chessId: 't_a', row: 10, col: 7 }] : [], routes: [{ motion: 'WALK', start: [9, 8], end: [9, 2], checkpoints }],
|
||||
enemies: [{ key, time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['attack'],
|
||||
});
|
||||
}
|
||||
|
||||
test('a ranged enemy with a target in range on a WAIT checkpoint leaves when the wait (and its current clip) is over, as without a target', () => {
|
||||
const leaves = (bat, dur, ally) => {
|
||||
const h = routeField('enemy_waiter', bat, dur, [{ type: 'WAIT', time: 3 }], ally);
|
||||
let waited = null, moved = null, x0 = null;
|
||||
for (let i = 0; i < Math.round(8 / TICK); i++) {
|
||||
h.step();
|
||||
const e = h.enemies()[0];
|
||||
x0 ??= e.x;
|
||||
if (waited == null && e.route.legIdx >= 1) waited = h.b.time;
|
||||
if (moved == null && Math.abs(e.x - x0) > 1e-6) moved = h.b.time;
|
||||
}
|
||||
return { waited, moved, attacks: h.hooksOf('attack').length };
|
||||
};
|
||||
const free = leaves(2.7, 1, false), sniper = leaves(2.7, 1, true), quick = leaves(1, 2, true);
|
||||
assert.equal(free.attacks, 0);
|
||||
assert.ok(sniper.attacks >= 2 && quick.attacks >= 5);
|
||||
assert.ok(Math.abs(sniper.waited - free.waited) < 1e-9 && Math.abs(quick.waited - free.waited) < 1e-9, `the wait ends at ${free.waited.toFixed(2)} s for all three`);
|
||||
// it walks on once the attack clip running at the end of the wait is over (the attack at 2.7 s: until 3.2 s)
|
||||
assert.ok(sniper.moved > free.moved && sniper.moved <= 3.2 + 2 * TICK, `leaves at ${sniper.moved?.toFixed(2)} s (no target: ${free.moved.toFixed(2)} s)`);
|
||||
assert.equal(quick.moved, null, 'an enemy attacking quicker than its clip stands while the target stays in range');
|
||||
});
|
||||
|
||||
test('DISAPPEAR / APPEAR legs proceed while an enemy stands for its attacks; leaving the field ends the stand', () => {
|
||||
const h = routeField('enemy_ghost', 1, 2, [{ type: 'WAIT', time: 1 }, { type: 'DISAPPEAR' }, { type: 'WAIT', time: 1 }, { type: 'APPEAR', pos: [9, 4] }], true);
|
||||
let hiddenAt = null, appeared = null;
|
||||
for (let i = 0; i < Math.round(4 / TICK); i++) {
|
||||
h.step();
|
||||
const e = h.enemies()[0];
|
||||
if (hiddenAt == null && e.hidden) { hiddenAt = h.b.time; assert.equal(e.atkStandUntil, -Infinity, 'no stand left once hidden'); }
|
||||
if (appeared == null && hiddenAt != null && !e.hidden) appeared = { t: h.b.time, x: e.x, y: e.y };
|
||||
}
|
||||
assert.ok(h.hooksOf('attack').length >= 1, 'it attacked before leaving');
|
||||
assert.ok(hiddenAt != null && Math.abs(hiddenAt - 1) < 2 * TICK, `disappears after its 1 s wait (${hiddenAt})`);
|
||||
assert.ok(appeared && Math.abs(appeared.t - 2) < 2 * TICK && Math.abs(appeared.x - 4) < 0.05 && appeared.y === 9, `appears on (9,4) 1 s later (${JSON.stringify(appeared)})`);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// test/sim/feedback2-forced-exit-down.test.js — GitHub #60 「42突袭死后会消失」 (42 = 史尔特尔): every operator that leaves
|
||||
// the field lies down on its tile and redeploys there — PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,
|
||||
// 并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" (the owner's decision of
|
||||
// 2026-10-04). A forced exit — 史尔特尔's 余烬 (8 s after the lethal blow), 耀骑士临光 S2's withdrawal, the 骑士戒律 + 竞技旗
|
||||
// combo, 伊内丝 S3's first deployment, a 商人 that cannot pay — used to leave no body and no ring and to come back on the
|
||||
// board tile (Battle.isDown took only 'killed' / FORCED_EXIT); it still is no kill. The 突袭 retreat ('raid') redeploys at
|
||||
// once and leaves nothing; a real knock-out is unchanged.
|
||||
|
||||
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 } from '../helpers/battleHarness.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const SURTR = 'chess_char_5_07_a', NEARL2 = 'chess_char_6_17_a', INSIDE = 'chess_char_1_01_a';
|
||||
const DUMMY = { enemy_far: enemyRec({ key: 'enemy_far', hp: 1e9, speed: 0, atk: 0 }) };
|
||||
const approx = (a, b, msg, eps = 0.05) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
|
||||
const RAID = { raidShip: { count: 2, active: true, tier: 1, layers: 10 } };
|
||||
|
||||
function field(units, bonds = {}) {
|
||||
return makeBattle({ defs: { enemies: DUMMY }, units, bonds, enemies: [{ key: 'enemy_far', pos: [12, 8] }], autoFinish: false, timeLimit: 300, hooks: ['death', 'kill', 'deploy'] });
|
||||
}
|
||||
const downOf = (h, u) => (h.b.snapshot().down || []).find((d) => d[0] === u.id) ?? null;
|
||||
|
||||
/** Lethal blow on 史尔特尔: 余烬 holds her at 1 HP, then forces her out 8 s later. */
|
||||
function ember(h, u) {
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(u.alive && u.hp >= 1, '余烬 holds her at 1 HP');
|
||||
assert.ok(h.runUntil(() => !u.alive, 10), 'forced out');
|
||||
assert.equal(u.removeReason, 'retreat');
|
||||
}
|
||||
|
||||
test('史尔特尔 after 余烬 lies down on her tile (b.snap down, field meta) and redeploys there after 70 s — no kill', () => {
|
||||
const h = field([{ chessId: SURTR, row: 10, col: 3, dir: 'RIGHT' }]);
|
||||
const u = h.unit(SURTR);
|
||||
h.run(1);
|
||||
ember(h, u);
|
||||
assert.ok(h.b.isDown(u));
|
||||
const d = downOf(h, u);
|
||||
assert.ok(d, 'the 倒地干员 and its redeploy ring');
|
||||
assert.deepEqual(d.slice(4), [10, 3]);
|
||||
approx(d[2], 70, 'her redeploy time');
|
||||
assert.ok(h.b.fieldMeta().units.some((x) => x.id === u.id), 'a client joining late draws her down');
|
||||
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 0, 'no knock-out hooks');
|
||||
assert.deepEqual(h.hooksOf('death').filter((c) => c.unit === u).map((c) => c.reason), ['retreat']);
|
||||
assert.equal(h.b.result().perPlayer.p1.deaths, 0, 'no knock-down counted');
|
||||
h.b.getPlayer(u.ownerId).dp = 99;
|
||||
const out = u.deathAt;
|
||||
assert.ok(h.runUntil(() => u.alive, 75));
|
||||
approx(h.b.time - out, 70, 'back after her redeploy time', 0.1);
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 3]);
|
||||
assert.equal(downOf(h, u), null);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('史尔特尔 with 突袭: forced out after her jump she lies on the landing tile and comes back there, not on her board tile', () => {
|
||||
const h = field([{ chessId: SURTR, row: 10, col: 3, dir: 'RIGHT' }], RAID);
|
||||
const u = h.unit(SURTR);
|
||||
assert.ok(h.runUntil(() => u.tileR !== u.homeR || u.tileC !== u.homeC, 30), 'jumped');
|
||||
const at = [u.tileR, u.tileC];
|
||||
ember(h, u);
|
||||
assert.deepEqual(downOf(h, u)?.slice(4), at, 'down where she stood');
|
||||
assert.ok(h.b.isReservedTile(at[0], at[1]), 'no ally lands on her body');
|
||||
h.b.getPlayer(u.ownerId).dp = 99;
|
||||
const n = h.hooksOf('deploy').filter((c) => c.unit === u).length;
|
||||
assert.ok(h.runUntil(() => u.alive, 75));
|
||||
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u).length, n + 1);
|
||||
assert.deepEqual([u.tileR, u.tileC], at, 'redeployed on that tile (it used to be her board tile)');
|
||||
assert.deepEqual([u.homeR, u.homeC], [10, 3], 'her home is unchanged');
|
||||
});
|
||||
|
||||
test('耀骑士临光 S2: withdrawn at the skill end, she lies down on her tile with the ×1.25 redeploy ring and comes back there', () => {
|
||||
const idx = chess[NEARL2].skills.findIndex((s) => (s.skillId ?? s.id) === 'skchr_nearl2_2');
|
||||
assert.ok(idx >= 0);
|
||||
const h = field([{ chessId: NEARL2, row: 10, col: 4, skillIndex: idx }]);
|
||||
const u = h.unit(NEARL2);
|
||||
h.step();
|
||||
assert.ok(u.alive && u.findBuff('nearl2:night'), 'S2 on deploy');
|
||||
assert.ok(h.runUntil(() => !u.alive, 40), 'withdraws when the skill ends');
|
||||
assert.equal(u.removeReason, 'retreat');
|
||||
const d = downOf(h, u);
|
||||
assert.deepEqual(d?.slice(4), [10, 4]);
|
||||
approx(d[2], u.base.respawnTime * 1.25, 'this redeploy ×1.25', 0.02);
|
||||
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 0);
|
||||
h.b.getPlayer(u.ownerId).dp = 99;
|
||||
assert.ok(h.runUntil(() => u.alive, d[2] + 2));
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 4]);
|
||||
});
|
||||
|
||||
test('a real knock-out is unchanged: down on its tile, the kill hooks and the knock-down count', () => {
|
||||
const h = field([{ chessId: INSIDE, row: 10, col: 3, dir: 'RIGHT' }]);
|
||||
const u = h.unit(INSIDE);
|
||||
h.run(1);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(!u.alive);
|
||||
assert.equal(u.removeReason, 'killed');
|
||||
assert.deepEqual(downOf(h, u)?.slice(4), [10, 3]);
|
||||
assert.equal(h.hooksOf('kill').filter((c) => c.victim === u).length, 1);
|
||||
assert.equal(h.b.result().perPlayer.p1.deaths, 1);
|
||||
});
|
||||
Reference in new issue
Block a user