Files
ark-stronghold-protocol/test/sim/feedback2-whitw2-yu.test.js
T

371 lines
22 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.
// GitHub issue #32 (after v0.1.1), items 7 and 1 — DESIGN §22.9 / §22.10.
// - Item 7 「易拉三技能浮游单元无法造成伤害,只能造成三技能自带dot伤害」: 荒芜拉普兰德 S3 终幕·浩劫. PRTS 荒芜拉普兰德 S3 备注
// "技能流程": ① for attack@times (1.3) s the drones spread evenly from her, one along her facing (0.1 → 2.0 tiles/s at
// 1.9/s²); ② each then picks the target nearest to itself (ties: nearest to her) and chases it (2.0 → 4.0 at 1.0/s²);
// ③ on the target it attacks like a normal drone ("此状态下的攻击行为同正常浮游单元"), a target lost → it reappears in
// the 1.5-side square around it and picks again; nothing selectable → it circles (radius 0.9, 1.0 tiles/s, counter-
// clockwise). The drones' damage is neither a normal attack nor skill damage ("该技能释放的浮游单元造成的伤害不属于普通
// 攻击/技能直接伤害"); every drone is out, so she makes no attack of her own. Before 0.1.2 the skill only dealt its
// once-per-second aura.
// - Item 1 「余二技能有伤害和特效,但不能拉怪」: 余 S2 厚礼上宾 teleports the ground-reachable enemies of x-1 onto his tile —
// leaders too unless 自缚 (PRTS 卢西恩 / 假想敌:铳 "传送抗性=无"; it skipped every leader), and its 'pull' fx plays only
// when someone was teleported (it played with nobody pulled).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { TICK } from '../../server/sim/constants.js';
import { rotateOffset, toLocal } from '../../server/sim/dir.js';
const BOTH = ['chess_char_6_18_a', 'chess_char_6_18_b'];
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
/** 荒芜拉普兰德 on (10,4) facing RIGHT with S3 (her default) ready; `enemies` parked. */
function wolf(id, enemies, o = {}) {
return makeBattle({
seed: 3, timeLimit: 400, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack', 'statusApplied', 'death', 'skillStart'],
defs: { enemies: Object.fromEntries(enemies.map((e) => [e.key, e.rec ?? dummy(e.key)])) },
units: [{ chessId: id, row: 10, col: 4, dir: 'RIGHT', carryState: o.carry ?? { sp: 999 } }],
enemies: enemies.map((e) => ({ key: e.key, pos: e.pos })), ...o.battle,
});
}
const droneHits = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('droneAttack') && f(c));
/** Each drone's attacks, seen from its attack clock (reset to her interval when it attacks): [[{ t, ramp, target }]]. */
function watchDrones(h, u) {
const log = [];
let last = [];
h.b.on('tick', () => {
const D = u.mem.drones || [];
D.forEach((d, i) => {
log[i] ??= [];
if (d.cd > (last[i] ?? 0) + 1e-9) log[i].push({ t: h.b.time, ramp: d.ramp, target: d.t });
});
last = D.map((d) => d.cd);
});
return log;
}
// =====================================================================================================================
// item 7 — 荒芜拉普兰德 S3 终幕·浩劫
test('荒芜拉普兰德 S3 ① ②: the drones spread attack@times s (evenly, one along her facing, 0.1 → 2.0 tiles/s), then chase at 2.0 → 4.0 tiles/s', () => {
for (const id of BOTH) {
// 联防 field (21 columns): room for a chase long enough to reach 4.0
const h = wolf(id, [{ key: 'far', pos: [10, 17] }], { battle: { kind: 'unite' } });
const u = h.unit(id), bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 5), 'S3 fires with an enemy anywhere on the field (GDGLOW_SKILL_2)');
const t0 = h.b.time;
const D = u.mem.drones;
assert.equal(D.length, 1 + bb['attack@cnt'], '浮游单元+attack@cnt (one drone of her own)');
for (let i = 0; i < D.length; i++) {
const a = (2 * Math.PI * i) / D.length; // RIGHT = +col; row 0 is the bottom row, so the angles run counter-clockwise
approx(D[i].hx, Math.cos(a), `drone ${i} heading x`, 1e-12);
approx(D[i].hy, Math.sin(a), `drone ${i} heading y`, 1e-12);
}
const ticks = Math.round(bb['attack@times'] / TICK);
assert.equal(ticks, 39, 'attack@times = 1.3 s (PRTS ① "1.3s内")');
h.step(ticks - 1);
const t = (ticks - 1) * TICK;
const spread = t <= 1 ? 0.1 * t + 0.95 * t * t : 1.05 + 2 * (t - 1); // v = min(2.0, 0.1 + 1.9 t)
for (const d of D) {
assert.equal(d.phase, 'spread');
approx(Math.hypot(d.x - u.x, d.y - u.y), spread, 'spread distance', 1e-9);
}
h.step(1);
const far = h.enemies()[0];
for (const d of D) assert.ok(d.phase === 'chase' && d.t === far, 'then every drone chases the one enemy');
const d0 = D[0];
approx(d0.v, 2 + TICK, 'chase: 初速度 2.0, 加速度 1.0', 1e-9);
h.step(Math.round(2 / TICK));
approx(d0.v, 4, '最大速度 4.0', 1e-12);
// drone 0 flies straight on: 1.65 tiles of spread, then 11.35 more to the enemy (2 s to reach 4.0 covering 6 tiles,
// 5.35 at 4.0) → it lands its first hit ≈ 1.3 + 2 + 1.3375 s after the cast
assert.ok(h.runUntil(() => droneHits(h, u).length > 0, 5));
const first = droneHits(h, u)[0];
assert.ok(Math.abs(first.t - t0 - 4.6375) <= 2 * TICK, `first drone hit at ${(first.t - t0).toFixed(3)} s`);
done(h);
}
});
test('荒芜拉普兰德 S3 ③: a drone on its target attacks every attack interval of hers for ATK × its own funnel ramp; she makes no attack; neither attack nor skill damage; 缴械 does not stop it', () => {
for (const id of BOTH) {
const h = wolf(id, [{ key: 'e', pos: [10, 6] }]);
const u = h.unit(id), bb = u.def.skill.bb;
const log = watchDrones(h, u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const t0 = h.b.time;
h.run(6);
h.b.applyStatus(u, 'disarm', { duration: 3, source: null });
assert.ok(u.s.flags.disarm, 'disarmed');
const td = h.b.time;
h.run(8);
assert.ok(u.skill.active);
const f = u.profile.funnel, e = h.enemies()[0];
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= t0).length, 0, 'no normal attack of hers during S3');
const hits = droneHits(h, u);
assert.ok(hits.length >= 3 * 8, `${hits.length} drone hits in 14 s`);
for (const c of hits) {
assert.equal(c.type, 'arts');
assert.equal(c.target, e);
assert.ok(!c.dmg.isAttack && !c.dmg.isSkill, 'PRTS: 不属于普通攻击/技能直接伤害');
}
const scale = (c) => c.amount / (u.s.atk * u.s.dmgDealtMul);
assert.equal(hits.filter((c) => Math.abs(scale(c) - f.init) < 1e-6).length, u.mem.drones.length, 'each drone starts its own ramp at init');
assert.ok(hits.every((c) => scale(c) <= f.max + 1e-9) && hits.some((c) => Math.abs(scale(c) - f.max) < 1e-6), 'up to the trait cap');
assert.ok(hits.some((c) => c.t > td && c.t < td + 3), '缴械 does not stop the drones');
// per drone: init, +delta per hit (capped), one hit per attack interval of hers (rounded up to whole ticks)
const gap = Math.ceil(u.s.interval / TICK - 1e-9) * TICK;
assert.equal(log.length, 1 + bb['attack@cnt']);
for (const L of log) {
assert.ok(L.length >= 8);
L.forEach((a, k) => {
approx(a.ramp, Math.min(f.max, f.init + k * f.delta), `hit ${k} ramp`, 1e-9);
if (k) approx(a.t - L[k - 1].t, gap, `hit ${k} gap`, 1e-6);
});
}
// the aura stays: attack@magic_atk_scale × ATK arts once per second on the enemies around the drones
const aura = h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('drone'));
assert.ok(aura.length >= 10 && aura.every((c) => Math.abs(c.amount - u.s.atk * u.s.dmgDealtMul * bb['attack@magic_atk_scale']) < 1e-6));
assert.ok(h.hooksOf('statusApplied').filter((c) => c.status === 'fear' && c.target === e).length >= 1, '恐惧 on the catch');
done(h);
}
});
test('荒芜拉普兰德 S3: every drone picks the enemy nearest to itself; a dead target sends it to the 1.5 square around it, the next lock restarts its ramp; nothing left → it circles (0.9, 1 tile/s, counter-clockwise)', () => {
for (const id of BOTH) {
const h = wolf(id, [{ key: 'weak', pos: [10, 6], rec: dummy('weak', { hp: 900 }) }, { key: 'e', pos: [12, 9] }]);
const u = h.unit(id);
const log = watchDrones(h, u);
let deadAt = null, deadPos = null;
h.b.on('death', (c) => { if (c.unit.defId === 'enemy_weak' || c.unit.defId === 'weak') { deadAt = h.b.time; deadPos = { x: c.unit.x, y: c.unit.y }; } });
assert.ok(h.runUntil(() => u.skill.active, 5));
const D = u.mem.drones;
const weak = h.enemies().find((x) => /weak/.test(x.defId)), far = h.enemies().find((x) => !/weak/.test(x.defId));
h.step(Math.round(u.def.skill.bb['attack@times'] / TICK));
assert.ok(D.every((d) => d.t === weak), 'PRTS ②: the nearest to each drone — all three take the weak one (no spreading over free targets)');
assert.ok(h.runUntil(() => deadAt != null, 20), 'the weak one falls');
h.step(1);
const f = u.profile.funnel;
for (const d of D) {
assert.equal(d.t, far, 'then the remaining enemy');
// reappeared in the square around the fallen target, then one chase step (< 4 tiles/s × 1 tick)
assert.ok(Math.max(Math.abs(d.x - deadPos.x), Math.abs(d.y - deadPos.y)) <= 0.75 + 4 * TICK + 1e-9, `drone at (${d.y.toFixed(2)}, ${d.x.toFixed(2)})`);
}
assert.ok(h.runUntil(() => log.every((L) => L.some((a) => a.target === far)), 20));
for (const L of log) approx(L.find((a) => a.target === far).ramp, f.init, 'a new target: back to init', 1e-9);
// nothing selectable: circle on its left
h.b.dealDamage(null, far, { amount: 1e9, type: 'true' });
h.step(2);
for (const d of D) {
assert.ok(d.orbit && d.phase === 'seek');
const a0 = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx);
approx(Math.hypot(d.x - d.orbit.cx, d.y - d.orbit.cy), 0.9, 'radius', 1e-9);
h.step(3);
const a1 = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx);
const turn = ((a1 - a0) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
approx(turn, (3 * TICK * 1) / 0.9, 'counter-clockwise at 1.0 tiles/s', 1e-9);
}
done(h);
}
});
test('荒芜拉普兰德 S3: 头狼 stage 3 reached while the skill runs releases one more drone (PRTS ① "补充浮游单元"), which spreads along her facing and attacks too', () => {
const id = 'chess_char_6_18_a';
const h = wolf(id, [{ key: 'e', pos: [10, 6] }], { carry: {} });
const u = h.unit(id), bb = u.def.skill.bb;
// hold her SP until 头狼 stage 2 (2 × interval s on the field), then cast
let armed = false;
h.b.on('tick', () => {
if (armed || !u.skill || u.skill.active) return;
if ((u.mem.wolfStage || 0) >= 2) { armed = true; u.skill.gainSp(u.skill.spCost, 'init', true); } else u.skill.sp = 0;
});
const iv = u.def.talents[0].bb.interval;
assert.ok(h.runUntil(() => u.skill.active, 2 * iv + 3));
assert.equal(u.mem.drones.length, 1 + bb['attack@cnt']);
assert.ok(h.runUntil(() => u.mem.wolfStage === 3, iv + 2));
h.step(1);
assert.ok(u.skill.active, 'stage 3 inside the skill');
const D = u.mem.drones;
assert.equal(D.length, 2 + bb['attack@cnt'], '数量+1');
const nd = D.at(-1);
assert.equal(nd.phase, 'spread');
approx(nd.hx, 1, 'along her facing', 1e-12);
assert.ok(Math.hypot(nd.x - u.x, nd.y - u.y) < 0.1, 'from her');
const tn = h.b.time;
assert.ok(h.runUntil(() => nd.rampId != null, 10), 'the new drone reaches the enemy and attacks');
assert.ok(droneHits(h, u, (c) => c.t >= tn).length >= 1);
done(h);
});
test('荒芜拉普兰德 S3: knocked out in the middle of a tick (here by a kill hook), she deals nothing more in that tick', () => {
// the weak one dies to a drone hit with the strong one on the same spot; its death hook knocks her out at once
const h = wolf('chess_char_6_18_a', [{ key: 'w', pos: [10, 7], rec: dummy('w', { hp: 300 }) }, { key: 'e', pos: [10, 7] }]);
const u = h.unit('chess_char_6_18_a');
let ko = false;
const late = [];
h.b.on('damaged', (c) => { if (c.source === u && !u.alive) late.push((c.dmg?.tags || []).join('+')); });
h.b.on('death', (c) => { if (c.unit.side === 'enemy' && !ko) { ko = true; h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); } });
assert.ok(h.runUntil(() => ko, 30), 'a drone hit kills the weak one');
assert.equal(u.alive, false);
assert.equal(u.mem.drones, null, 'the skill ended with her');
assert.deepEqual(late, [], 'no drone / area hit from the knocked-out wolf');
done(h);
});
test('荒芜拉普兰德 S3 facing UP / LEFT / DOWN plays like facing RIGHT (enemies at turned offsets; a target falls, so the drones reappear)', () => {
const OPEN = { id: 'open', name: 'open', rows: Array.from({ length: 19 }, () => '#' + 'r'.repeat(19) + '#'), devices: [] };
const R0 = 9, C0 = 9;
const OFFS = [[0, 2, 'weak'], [1, 4, 'e'], [-2, 3, 'e'], [3, -1, 'e']]; // facing-RIGHT offsets, no distance ties
const run = (dir) => {
const h = makeBattle({
stage: OPEN, rect: { r0: 0, r1: 18, c0: 0, c1: 20 }, seed: 5, autoFinish: false, timeLimit: 60, hooks: [],
routes: [{ motion: 'FLY', start: [18, 20], end: [18, 19], checkpoints: [] }],
defs: { enemies: { weak: dummy('weak', { hp: 900 }), e: dummy('e') } },
units: [{ chessId: 'chess_char_6_18_a', row: R0, col: C0, dir, abs: true, carryState: { sp: 999 } }],
enemies: OFFS.map(([dr, dc, key]) => {
const [ar, ac] = rotateOffset(dr, dc, dir), pos = [R0 + ar, C0 + ac];
return { key, pos, route: { motion: 'WALK', start: pos, checkpoints: [{ type: 'WAIT', time: 1e4 }], end: pos } };
}),
});
const u = h.unit('chess_char_6_18_a');
h.step(1);
const foes = h.b.enemies.slice(); // in OFFS order (a fallen one leaves battle.enemies)
assert.equal(foes.length, OFFS.length);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(12);
done(h);
return { hp: foes.map((e) => (e.alive ? e.hp : 0)), drones: u.mem.drones.map((d) => toLocal(d.y - R0, d.x - C0, dir)) };
};
const base = run('RIGHT');
assert.equal(base.hp[0], 0, 'the weak one fell: its drones reappeared around it and picked again');
for (const dir of ['UP', 'LEFT', 'DOWN']) {
const o = run(dir);
base.hp.forEach((hp, i) => approx(o.hp[i], hp, `${dir}: enemy ${OFFS[i]} HP`, 1e-9));
base.drones.forEach(([lr, lc], i) => {
approx(o.drones[i][0], lr, `${dir}: drone ${i} local row`, 1e-6);
approx(o.drones[i][1], lc, `${dir}: drone ${i} local col`, 1e-6);
});
}
});
// =====================================================================================================================
// item 1 — 余 S2 厚礼上宾
/** 余 on (10,5) with S2 (`sp` carried in); parked enemies (`[key, row, col, tag?, motion?]`). */
function yuS2(chessId, foes, sp = 999, defs = undefined) {
const hold = (r, c, motion = 'WALK') => ({ motion, start: [r, c], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] });
const h = makeBattle({
seed: 5, timeLimit: 120, autoFinish: false, defs,
units: [{ chessId, row: 10, col: 5, dir: 'RIGHT', skillIndex: 1, carryState: { sp } }],
enemies: foes.map(([key, r, c, tag, motion]) => ({ key, route: hold(r, c, motion), time: 0, tag })),
});
h.step(1);
return h;
}
const castOf = (h, u) => h.hooksOf('skillStart').find((c) => c.unit === u);
test('余 S2 (deliberate deviation, DESIGN §22.10): it fires with a ground enemy two tiles away and nobody hitting him — SKILL_RANGE on x-1 — and the enemy lands on his tile', () => {
for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) {
const h = yuS2(id, [['e', 10, 7]], 999, { enemies: { e: enemyRec({ key: 'e', hp: 1e6, speed: 0, atk: 0 }) } });
const yu = h.unit(id);
assert.equal(yu.skill.rule, 'SKILL_RANGE', 'the data rule (rawRule TAKE_DAMAGE)');
const e = h.b.enemies[0];
assert.ok(yu.skill.active, 'cast in the first tick');
assert.equal(castOf(h, yu).reason, 'SKILL_RANGE');
assert.equal(yu.stats.taken, 0, 'nothing hit him');
assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], 'pulled onto his tile');
h.step(1);
assert.equal(e.blockedBy, yu, 'and blocked there');
done(h);
}
});
test('余 S2: a ranged enemy hitting him from outside x-1 no longer casts it (it was the official TAKE_DAMAGE); it fires once someone stands on x-1', () => {
// 深池暗影术师 (range 2.5) on (9,7): outside x-1 (|Δrow| + |Δcol| = 3) but in reach of him
const h = yuS2('chess_char_6_03_a', [['enemy_1168_dumage', 9, 7]]);
const yu = h.unit('chess_char_6_03_a');
assert.ok(h.runUntil(() => yu.stats.taken > 0, 10), 'it hits him');
h.run(3);
assert.equal(yu.skill.activations, 0, 'no cast from the hit');
h.spawn('enemy_1422_lrsldr', { pos: [10, 6] });
h.step(2);
assert.equal(yu.skill.activations, 1, 'an enemy steps onto x-1: cast');
done(h);
});
test('余 S2: a leader inside x-1 is teleported onto his tile like any ground enemy (PRTS 卢西恩 / 假想敌:铳 传送抗性 无); a 自缚 leader stays', () => {
for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) {
// (S2 empty at first: it casts the tick its SP is filled, the three leaders already on x-1)
const h = yuS2(id, [['enemy_2016_csphtm', 10, 7, 'boss'], ['enemy_9017_achunt', 11, 6, 'boss'], ['enemy_9033_acdeer', 9, 4, 'boss']], 0);
const yu = h.unit(id);
assert.ok(!yu.skill.active && !yu.skill.ready);
const [lucien, gun, sami] = ['enemy_2016_csphtm', 'enemy_9017_achunt', 'enemy_9033_acdeer'].map((k) => h.b.enemies.find((e) => e.defId === k));
assert.ok(lucien.isBoss && gun.isBoss && sami.isBoss, 'three leaders');
assert.ok(sami.s.flags.selfBound && !lucien.s.flags.selfBound && !gun.s.flags.selfBound, '“萨米的意志” is 自缚 (content/bosses.js SELF_BOUND)');
const samiAt = [sami.x, sami.y];
const fx0 = h.eventsOf('fx').length;
yu.skill.gainSp(yu.skill.spCost, 'init', true);
h.step(1);
assert.ok(yu.skill.active && yu.skill.id === 'skchr_yu_2' && castOf(h, yu).reason === 'SKILL_RANGE', 'S2 cast (SKILL_RANGE)');
for (const e of [lucien, gun]) assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], `${e.name} teleported onto 余`);
assert.deepEqual([sami.x, sami.y], samiAt, '自缚: not a reachable target (PRTS 余 S2 备注)');
const fx = h.eventsOf('fx').slice(fx0);
assert.deepEqual(fx.filter((e) => e[1] === 'teleport').map((e) => e[4].id).sort(), [lucien.id, gun.id].sort());
assert.deepEqual(fx.filter((e) => e[1] === 'pull').map((e) => e[4].n), [2], 'one pull fx: two teleported');
h.step(1);
assert.equal(lucien.blockedBy, yu, '卢西恩 (blockable) is blocked on his tile; 铳 is 不可阻挡');
assert.equal(gun.blockedBy, null);
done(h);
}
});
test('余 S2 with nobody to pull (a flyer on x-1 sets it off): the burst hits it, no teleport and no pull fx', () => {
const h = yuS2('chess_char_6_03_a', [['fly', 10, 6, null, 'FLY']], 999, { enemies: { fly: enemyRec({ key: 'fly', hp: 1e6, speed: 0, motion: 'FLY' }) } });
const yu = h.unit('chess_char_6_03_a');
const fly = h.b.enemies[0];
assert.ok(fly.isFlying && yu.skill.active, 'cast: SKILL_RANGE counts any enemy on x-1');
assert.ok(fly.hp < fly.s.maxHp, 'the burst hits air units [ASSUMED]');
const fx = h.eventsOf('fx').filter((e) => e[1] === 'teleport' || e[1] === 'pull');
assert.deepEqual(fx, [], 'a flyer is no 地面可达目标: no teleport, no pull fx');
assert.ok(!(Math.round(fly.y) === yu.tileR && Math.round(fly.x) === yu.tileC), 'it stays');
done(h);
});
test('余 S2 on the real leader rounds: 假想敌:铳 teleported onto 余 walks on to its next patrol checkpoint; 卢西恩 is blocked there, then resumes its loop', () => {
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
const pool = (hp) => ({ hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } });
const X1 = [[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]];
for (const [tplId, key, board] of [['act1autochess_h07_02', 'enemy_9017_achunt', [11, 5]], ['act2autochess_h07_05', 'enemy_2016_csphtm', [10, 7]]]) {
const tpl = W[tplId];
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m01', waveTemplate: tpl, sharedBoss: pool(3e6), seed: 9, autoFinish: false,
units: [{ chessId: 'chess_char_6_03_a', row: board[0], col: board[1], dir: 'RIGHT', skillIndex: 1 }], setup(b) { b.enemyOverrides = tpl.overrides; } });
h.step(1);
const yu = h.unit('chess_char_6_03_a');
const L = () => h.b.enemies.find((e) => e.defId === key && e.alive);
const inX1At = (y, x) => X1.some(([dr, dc]) => Math.round(y) === yu.tileR + dr && Math.round(x) === yu.tileC + dc);
// well inside x-1 (0.1 tiles of margin: the next tick's step cannot take it out before the cast)
const deep = (e) => [[0, 0], [0.1, 0], [-0.1, 0], [0, 0.1], [0, -0.1]].every(([a, b]) => inX1At(e.y + a, e.x + b));
h.b.on('tick', () => { if (!yu.skill.active && !L()?.mem.yuTest) yu.skill.sp = 0; }); // keep S2 for the leader
assert.ok(h.runUntil(() => L() && deep(L()), 90), `${key} walks into x-1`);
const e = L();
assert.ok(e.isBoss && !e.s.flags.selfBound && e.route.legs.length > 1, 'a patrolling leader');
e.mem.yuTest = true;
const leg0 = e.route.legIdx;
yu.skill.gainSp(yu.skill.spCost, 'init', true);
h.step(1);
assert.ok(yu.skill.active, 'S2 (SKILL_RANGE)');
assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], 'teleported onto 余');
assert.equal(e.route.pts, null, 'its path is re-planned from there');
if (key === 'enemy_9017_achunt') {
assert.ok(h.runUntil(() => e.route.legIdx !== leg0, 25), '不可阻挡: walks on to its next patrol checkpoint');
} else {
h.step(1);
assert.equal(e.blockedBy, yu, 'blocked by 余 (block +2)');
assert.ok(h.runUntil(() => e.route.legIdx !== leg0 && !(Math.round(e.y) === yu.tileR && Math.round(e.x) === yu.tileC), 30), 'blinks past and resumes its loop');
}
assert.ok(e.alive && !h.b.enemies.some((x) => x === e && x.leaked), 'never leaks');
done(h);
}
});