Files
ark-stronghold-protocol/test/sim/engine-requests.test.js
T

516 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.
// Engine change requests of the content review (docs/SIM.md): permanent rangeExtend in the initial range, forceAttack
// noAmmo, the massFlat mod, the attract (诱导) and resist (抵抗) statuses, extra range keys, content trigger ranges,
// skill onEnd order, redeploy { tile, keepSp }, path-first tactical points, statusApplied `entered`, layerGain `tile`.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants, flatRoutes } from '../helpers/battleHarness.js';
import { COLS } from '../../server/sim/constants.js';
import { RESIST_STATUSES } from '../../server/sim/buffs.js';
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} ${a} ≈ ${b}`);
const K = (r, c) => r * COLS + c;
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 AROUND = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
const sniper = (skill = {}, o = {}) => chessRec({ id: 't_sn', profession: 'SNIPER', subProfessionId: 'closerange', rangeGrid: RANGE3, stats: { atk: 100, bat: 1, ...(o.stats || {}) }, skill: { spCost: 5, initSp: 0, duration: 2, ...skill }, ...o });
const guard = (o = {}) => chessRec({ id: 't_gd', profession: 'WARRIOR', rangeGrid: [[0, 0], [0, 1]], stats: { atk: 100, maxHp: 1e6, blockCnt: 3, cost: 10, respawnTime: 5, ...(o.stats || {}) }, skill: { spCost: 10, initSp: 0, duration: 5, ...(o.skill || {}) }, ...o });
// ---------------------------------------------------------------------------------------------------------------
// 1) baseRangeKeys = own grid + permanent rangeExtend
test('baseRangeKeys: the initial range includes permanent rangeExtend (persist, never-expiring) — DEFAULT trigger sees the module tile', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spCost: 1, initSp: 1 }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['skillStart'],
kits: { t_sn: () => ({ skill: { kind: 'duration', duration: 2 }, install(b, u) { b.addBuff(u, { key: 'module', mods: { rangeExtend: 1 }, persist: true, allowDead: true }); } }) },
});
const u = h.unit('t_sn');
h.step();
assert.ok(u.baseRangeKeys.includes(K(10, 8)), 'module tile (col +4) in the initial range');
assert.ok(u.rangeKeySet.has(K(10, 8)));
h.spawn('enemy_dummy', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'DEFAULT fires for an enemy on the module tile only');
// relocation rebuilds it around the new tile
h.b.relocate(u, 11, 3);
assert.ok(u.baseRangeKeys.includes(K(11, 7)) && !u.baseRangeKeys.includes(K(11, 8)));
checkInvariants(h.b);
});
test('baseRangeKeys: temporary / skill rangeExtend never counts; a permanent one added after deployment is picked up', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spCost: 100 }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false,
kits: { t_sn: () => ({ skill: { kind: 'duration', duration: 2, targeting: { rangeExtend: 2 } } }) },
});
const u = h.unit('t_sn');
h.step();
h.b.addBuff(u, { key: 'temp', mods: { rangeExtend: 1 }, duration: 5 });
h.step();
assert.ok(u.rangeKeySet.has(K(10, 8)), 'temporary extend: current range');
assert.ok(!u.baseRangeKeys.includes(K(10, 8)), 'not the initial range');
u.skill.activate('test', { free: true });
assert.ok(u.rangeKeySet.has(K(10, 10)), 'skill extend: current range');
assert.ok(!u.baseRangeKeys.includes(K(10, 8)), 'skill range never the initial range');
u.skill.end('test');
h.b.removeBuff(u, 'temp');
h.b.addBuff(u, { key: 'late', mods: { rangeExtend: 1 }, persist: true, allowDead: true });
h.step();
assert.ok(u.baseRangeKeys.includes(K(10, 8)), 'permanent extend added later: rebuilt on the next tick');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 2) forceAttack noAmmo / onAttack ctx.noAmmo
test('forceAttack(u, targets, { noAmmo }): an extra attack that spends no bullet and emits no ammoUsed', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spCost: 100 }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['ammoUsed', 'attack'], captureNoisy: true,
kits: { t_sn: () => ({ skill: { kind: 'ammo', ammo: 5, onAttack(ctx) { if (ctx.unit.mem.freeShot) ctx.noAmmo = true; } } }) },
});
const u = h.unit('t_sn');
h.step();
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b._buildEnemyIndex();
u.skill.activate('test', { free: true });
const n0 = u.stats.attacks;
assert.equal(h.b.forceAttack(u, [e], { noAmmo: true }), true);
assert.equal(u.stats.attacks, n0 + 1, 'it is an attack');
assert.equal(u.skill.ammoLeft, 5, 'no bullet spent');
assert.equal(h.hooksOf('ammoUsed').length, 0, 'no ammoUsed');
h.b.forceAttack(u, [e]);
assert.equal(u.skill.ammoLeft, 4, 'a plain forced attack spends one');
assert.equal(h.hooksOf('ammoUsed').length, 1);
u.mem.freeShot = true; // SkillSpec onAttack: ctx.noAmmo = true
h.b.forceAttack(u, [e]);
assert.equal(u.skill.ammoLeft, 4, 'onAttack ctx.noAmmo');
assert.equal(h.hooksOf('ammoUsed').length, 1);
u.mem.freeShot = false;
// the last bullets: a noAmmo attack never ends the skill
u.skill.ammoLeft = 1;
h.b.forceAttack(u, [e], { noAmmo: true });
assert.ok(u.skill.active && u.skill.ammoLeft === 1);
h.b.forceAttack(u, [e]);
assert.ok(!u.skill.active, 'the last real bullet ends it');
// no target ⇒ false
const far = makeBattle({ defs: { chess: { t_sn: sniper() } }, units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false });
far.step();
assert.equal(far.b.forceAttack(far.unit('t_sn'), null, { noAmmo: true }), false);
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 3) massFlat
test('massFlat: current massLevel (weight) for displacement and 浮空 halving; base untouched; floored at 0', () => {
const h = makeBattle({ defs: { enemies: { enemy_heavy: enemyRec({ key: 'enemy_heavy', hp: 1e7, speed: 0, mass: 4 }) } }, units: [], content: 'none', autoFinish: false });
h.step();
const e = h.spawn('enemy_heavy', { pos: [10, 5] });
assert.equal(e.s.massLevel, 4);
// official 力度 − 重量 (PRTS 游戏数据基础 §重量公式): a 中力 push (1) on weight 4 = 受力等级 −3 ⇒ nothing
const d1 = h.b.push(e, 1, { from: { x: 4, y: 10 } });
assert.equal(d1, 0, 'weight 4 vs 中力: not moved');
h.b.addBuff(e, { key: 'weightless', mods: { massFlat: -1 }, duration: 10 });
assert.equal(e.s.massLevel, 3);
assert.equal(e.weight, 3);
assert.equal(e.base.massLevel, 4, 'base untouched');
e.x = 5; e.y = 10;
const d2 = h.b.push(e, 1, { from: { x: 4, y: 10 } });
approx(d2, 0.12, 1e-6, '失重 weight 3 vs 中力: 受力等级 −2 ⇒ 0.12 tiles');
h.b.applyStatus(e, 'levitate', { duration: 4 });
approx(e.findBuff('levitate').timeLeft, 4, 1e-9, 'weight 3: full 浮空');
h.b.removeStatus(e, 'levitate');
h.b.removeBuff(e, 'weightless');
h.b.applyStatus(e, 'levitate', { duration: 4 });
approx(e.findBuff('levitate').timeLeft, 2, 1e-9, 'weight 4: halved');
h.b.addBuff(e, { key: 'feather', mods: { massFlat: -10 } });
assert.equal(e.s.massLevel, 0, 'never below 0');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 4) attract (诱导)
test('attract: the enemy is released, walks (own speed) to the point, waits there, then resumes its route', () => {
const h = makeBattle({
defs: { chess: { t_gd: guard() }, enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e7, speed: 1 }) } },
units: [{ chessId: 't_gd', row: 9, col: 6 }], content: 'generic', autoFinish: false, hooks: ['statusApplied'],
enemies: [{ key: 'enemy_walker', route: 0 }],
});
const g = h.unit('t_gd');
const e = () => h.enemy('enemy_walker');
assert.ok(h.runUntil(() => e() && e().blockedBy === g, 20), 'blocked first');
assert.equal(h.b.applyStatus(e(), 'attract', { duration: 8, source: g, point: [11, 6] }), true);
assert.equal(e().blockedBy, null, 'released');
assert.ok(e().s.flags.unblockable && e().s.flags.attract);
assert.equal(h.hooksOf('statusApplied').at(-1).entered, true);
// grid path: back to the centre of the tile it stands on, then on to the point (it was blocked 0.71 tile in front of
// the guard — the block radius — i.e. on (9,7))
let walked = 0, [px, py] = [e().x, e().y];
for (let i = 0; i < 30; i++) { h.step(); walked += Math.hypot(e().x - px, e().y - py); [px, py] = [e().x, e().y]; }
approx(walked, 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
h.run(5);
approx(e().x, 6, 1e-6); approx(e().y, 11, 1e-6, 'arrived');
h.run(1);
approx(e().y, 11, 1e-6, 'waits there');
assert.ok(h.runUntil(() => !e().findBuff('attract'), 2), 'lasts its duration');
const x1 = e().x;
h.run(1.5);
assert.ok(e().x < x1 - 0.3, 'resumes its route (toward the goal on the left)');
checkInvariants(h.b);
});
test('attract: default point = the source tile; a new application moves the point; stun/bind stop it; flyers fly straight; resisted', () => {
const h = makeBattle({
defs: {
chess: { t_gd: guard() },
enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e7, speed: 2 }), enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e7, speed: 2, motion: 'FLY' }) },
},
units: [{ chessId: 't_gd', row: 12, col: 3 }], content: 'generic', autoFinish: false,
});
h.step();
const g = h.unit('t_gd');
const w = h.spawn('enemy_walker', { pos: [9, 8], routeIndex: 0 });
h.b.applyStatus(w, 'attract', { duration: 10, source: g });
const A = w.findBuff('attract').data.attract;
assert.deepEqual([A.r, A.c], [12, 3], 'default: the source tile');
h.b.applyStatus(w, 'stun', { duration: 1 });
const p0 = [w.x, w.y];
h.run(0.9);
assert.deepEqual([w.x, w.y], p0, 'stunned: no movement');
h.run(0.5);
assert.ok(w.x < p0[0] || w.y > p0[1], 'walks once the stun ended');
h.b.applyStatus(w, 'attract', { duration: 10, source: g, point: [9, 9] });
assert.deepEqual([w.findBuff('attract').data.attract.r, w.findBuff('attract').data.attract.c], [9, 9], 're-application moves the point');
h.run(4);
approx(w.x, 9, 1e-6); approx(w.y, 9, 1e-6);
// flyer: straight line
const f = h.spawn('enemy_fly', { pos: [9, 9], routeIndex: 2 });
h.b.applyStatus(f, 'attract', { duration: 5, point: [11, 7] });
h.run(0.5);
const dx = 9 - f.x, dy = f.y - 9;
approx(dx, dy, 1e-6, 'diagonal straight flight');
// 抵抗 halves 诱导
h.b.applyStatus(w, 'resist', { duration: 30 });
h.b.removeStatus(w, 'attract');
h.b.applyStatus(w, 'attract', { duration: 4, point: [10, 9] });
approx(w.findBuff('attract').timeLeft, 2, 1e-9);
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 5) resist (抵抗)
test('resist: halves control statuses (not stat debuffs), never compounds, keeps a longer running status; statusApplied reports it', () => {
const h = makeBattle({ defs: { chess: { t_gd: guard() } }, units: [{ chessId: 't_gd', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['statusApplied'] });
h.step();
const u = h.unit('t_gd');
assert.ok(RESIST_STATUSES.has('stun') && RESIST_STATUSES.has('attract') && !RESIST_STATUSES.has('weaken'));
assert.equal(h.b.resistOf(u), 0);
h.b.applyStatus(u, 'resist', { duration: 60 });
approx(h.b.resistOf(u), 0.5, 1e-12, 'default value: half');
h.b.applyStatus(u, 'stun', { duration: 4 });
approx(u.findBuff('stun').timeLeft, 2, 1e-9);
assert.equal(h.hooksOf('statusApplied').at(-1).duration, 2, 'statusApplied reports the resisted duration');
h.b.applyStatus(u, 'weaken', { duration: 4 });
approx(u.findBuff('weaken').timeLeft, 4, 1e-9, 'stat debuffs are not resisted');
// several sources: never compound (same-name: strongest applies)
h.b.applyStatus(u, 'resist', { duration: 60, value: 0.5 });
h.b.addBuff(u, { key: 'module:resist', status: 'resist', data: { value: 0.3 }, persist: true, allowDead: true });
approx(h.b.resistOf(u), 0.5, 1e-12);
assert.equal(u.buffs.filter((b) => b.key === 'resist').length, 1, 'one resist status');
h.b.removeStatus(u, 'stun');
h.b.applyStatus(u, 'stun', { duration: 10 });
approx(u.findBuff('stun').timeLeft, 5, 1e-9, '×0.5 once');
h.run(1);
h.b.applyStatus(u, 'stun', { duration: 8 }); // → 4 s < 4 s left… equal/shorter: the running one is kept
approx(u.findBuff('stun').timeLeft, 4, 1e-6, 'a shorter resisted re-application never cuts the running one');
// the permanent (persist) resist buff survives a knock-out; the status does not
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
approx(h.b.resistOf(u), 0.3, 1e-12);
checkInvariants(h.b);
});
test('resist: a resisting enemy loses one 麻痹 stack every 5 s; the value scales the cut', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], content: 'none', autoFinish: false });
h.step();
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
h.b.applyStatus(e, 'resist', { value: 0.25 });
h.b.applyStatus(e, 'palsy', { value: 3 });
assert.equal(e.findBuff('palsy').stacks, 3);
h.run(5.05);
assert.equal(e.findBuff('palsy').stacks, 2);
h.run(5);
assert.equal(e.findBuff('palsy').stacks, 1);
h.run(5);
assert.equal(e.findBuff('palsy'), null);
h.b.applyStatus(e, 'cold', { duration: 4 });
approx(e.findBuff('cold').timeLeft, 3, 1e-9, 'value 0.25 ⇒ ×0.75');
h.b.applyStatus(e, 'resist', { value: 0.5, duration: 1 }); // stronger: takes over, the weaker one resumes after
approx(h.b.resistOf(e), 0.5, 1e-12);
h.run(1.1);
approx(h.b.resistOf(e), 0.25, 1e-12, 'weaker resumes');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 6) extra range keys
test('setExtraRange: extra tiles join the range across every rebuild (skill, relocate), never the initial range', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spCost: 100 }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['attack'], captureNoisy: true,
kits: { t_sn: () => ({ skill: { kind: 'duration', duration: 1, targeting: { rangeGrid: [[0, 0], [0, 1]] } } }) },
});
h.step();
const u = h.unit('t_sn');
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
h.run(1);
assert.equal(h.hooksOf('attack').length, 0, 'out of range');
assert.equal(h.b.setExtraRange(u, [K(10, 9), K(10, 9), -3, 1.5, 'x']), true);
assert.deepEqual(u.extraRangeKeys, [K(10, 9)], 'junk filtered');
assert.ok(u.rangeKeySet.has(K(10, 9)) && !u.baseRangeKeys.includes(K(10, 9)));
h.run(1);
assert.ok(h.hooksOf('attack').some((c) => c.targets.includes(e)), 'attacks the extra target');
u.skill.activate('test', { free: true });
assert.ok(u.rangeKeySet.has(K(10, 9)) && u.rangeKeySet.has(K(10, 5)) && !u.rangeKeySet.has(K(10, 6)), 'skill range ∪ extra');
h.run(1.1);
assert.ok(!u.skill.active && u.rangeKeySet.has(K(10, 9)) && u.rangeKeySet.has(K(10, 7)), 'normal range ∪ extra after the skill');
h.b.relocate(u, 11, 4);
assert.ok(u.rangeKeySet.has(K(10, 9)), 'absolute keys survive a relocation');
h.b.setExtraRange(u, null);
assert.equal(u.extraRangeKeys, null);
assert.ok(!u.rangeKeySet.has(K(10, 9)));
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 7) content trigger ranges
test('addTriggerRange: an enemy in a content trigger range fires DEFAULT (attackers and non-attackers), not heal skills; unregister', () => {
const far = [K(10, 9)];
const h = makeBattle({
defs: {
chess: {
t_sn: sniper({ spCost: 1, initSp: 1 }),
t_bd: chessRec({ id: 't_bd', profession: 'SUPPORT', subProfessionId: 'bard', rangeGrid: [[0, 0]], skill: { spCost: 1, initSp: 1, duration: 2 } }),
t_md: chessRec({ id: 't_md', profession: 'MEDIC', subProfessionId: 'physician', rangeGrid: [[0, 0]], skill: { spCost: 1, initSp: 1, duration: 2 } }),
t_gd: guard(),
},
enemies: { enemy_dummy: dummy() },
},
units: [{ chessId: 't_sn', row: 10, col: 3 }, { chessId: 't_bd', row: 12, col: 3 }, { chessId: 't_md', row: 11, col: 2 }, { chessId: 't_gd', row: 11, col: 6 }],
content: 'generic', autoFinish: false,
});
h.step();
const sn = h.unit('t_sn'), bd = h.unit('t_bd'), md = h.unit('t_md'), gd = h.unit('t_gd');
const offSn = sn.skill.addTriggerRange(() => [far]);
bd.skill.addTriggerRange(() => [gd]); // a unit entry: its current range counts
md.skill.addTriggerRange(() => [far]);
h.run(0.5);
assert.equal(sn.skill.activations + bd.skill.activations + md.skill.activations, 0, 'nothing in any range yet');
h.spawn('enemy_dummy', { pos: [11, 7] }); // in the guard's range only
h.run(0.2);
assert.equal(bd.skill.activations, 1, 'bard: enemy in the ally unit\'s range');
assert.equal(sn.skill.activations, 0);
offSn();
h.spawn('enemy_dummy', { pos: [10, 9] });
h.run(0.2);
assert.equal(sn.skill.activations, 0, 'unregistered');
sn.skill.addTriggerRange(() => [far]);
h.run(0.2);
assert.equal(sn.skill.activations, 1, 'attacker: fires although nothing is in its own range');
assert.equal(sn.stats.attacks, 0);
assert.equal(md.skill.activations, 0, 'heal skills ignore enemy trigger ranges');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 8) onEnd order
test('skill end: onEnd runs with the skill mods and range still applied, then they are removed; re-activation in onEnd keeps them', () => {
const seen = [];
const h = makeBattle({
defs: { chess: { t_sn: sniper({ spCost: 100 }) } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['skillEnd'],
kits: {
t_sn: () => ({
skill: {
kind: 'duration', duration: 1, mods: { atkPct: 1 }, targeting: { rangeGrid: AROUND },
onEnd({ unit, skill }) {
seen.push({ atk: unit.s.atk, around: unit.rangeKeySet.has(K(unit.tileR, unit.tileC - 1)), active: skill.active });
if (unit.mem.again) { unit.mem.again = false; skill.activate('again', { free: true }); }
},
},
}),
},
});
h.step();
const u = h.unit('t_sn');
u.skill.activate('test', { free: true });
approx(u.s.atk, 200);
h.run(1.1);
assert.deepEqual(seen[0], { atk: 200, around: true, active: false }, 'skill ATK and range during onEnd');
approx(u.s.atk, 100, 1e-9, 'removed afterwards');
assert.ok(!u.rangeKeySet.has(K(10, 3)), 'normal range restored');
assert.equal(h.hooksOf('skillEnd').length, 1);
u.mem.again = true;
u.skill.activate('test', { free: true });
h.run(1.05);
assert.equal(seen.length, 2);
assert.ok(u.skill.active, 're-activated from onEnd');
approx(u.s.atk, 200, 1e-9, 'the new activation keeps its mods');
assert.ok(u.rangeKeySet.has(K(10, 3)));
h.run(1.1);
approx(u.s.atk, 100, 1e-9);
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 9) redeploy { free, tile, keepSp }
test('redeploy: { tile } lands on another tile (home unchanged; knocked out there, it comes back there); { keepSp } restores SP before deploy fires', () => {
let atDeploy = null;
const h = makeBattle({
defs: { chess: { t_gd: guard({ skill: { spCost: 10, initSp: 2 } }), t_x: guard({ id: 't_x' }) } },
units: [{ chessId: 't_gd', row: 10, col: 4 }, { chessId: 't_x', row: 11, col: 6 }], content: 'generic', autoFinish: false, hooks: ['deploy'],
setup: (b) => b.on('deploy', (c) => { if (c.unit.defId === 't_gd' && !c.initial) atDeploy = c.unit.skill.sp; }),
});
h.step();
const u = h.unit('t_gd');
h.run(3);
const sp = u.skill.sp;
assert.ok(sp > 4.9 && sp < 5.1);
h.b.retreat(u);
assert.equal(h.b.redeploy(u, { tile: [11, 6] }), false, 'occupied landing tile: refused, no fallback');
assert.ok(!u.alive);
assert.equal(h.b.redeploy(u, { tile: [3, 3] }), false, 'outside the rect');
assert.equal(h.b.redeploy(u, { tile: [10.5, 3] }), false, 'not a tile');
assert.equal(h.b.redeploy(u, { tile: [12, 8], keepSp: true }), true);
assert.deepEqual([u.tileR, u.tileC, u.homeR, u.homeC], [12, 8, 10, 4], 'landed; home unchanged');
assert.equal(h.b.unitAt(12, 8), u);
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 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');
const p = h.b.getPlayer('p1');
p.dp = 50;
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
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], [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;
assert.equal(h.b.redeploy(u, { free: false, tile: [9, 5] }), true);
approx(p.dp, dp - u.base.cost, 1e-9, 'paid');
checkInvariants(h.b);
});
test('redeploy keepSp: charges survive too', () => {
const h = makeBattle({
defs: { chess: { t_gd: guard({ skill: { spCost: 2, initSp: 0, maxChargeTime: 3, durationType: 'NONE', duration: 0 } }) } },
units: [{ chessId: 't_gd', row: 10, col: 4 }], content: 'generic', autoFinish: false,
kits: { t_gd: () => ({ skill: { kind: 'charges', trigger: 'NEVER' } }) },
});
h.step();
const u = h.unit('t_gd');
h.run(5);
assert.equal(u.skill.charges, 2);
const sp = u.skill.sp;
h.b.retreat(u);
h.b.redeploy(u, { keepSp: true });
assert.equal(u.skill.charges, 2);
approx(u.skill.sp, sp, 1e-9);
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 10) tactical point
test('findTacticalPoint: a free walkable tile of the initial range on an enemy ground path first, then the nearest', () => {
const h = makeBattle({
defs: { chess: { t_gd: guard({ rangeGrid: AROUND.concat([[0, 2], [1, 2], [-1, 2]]) }), t_x: guard({ id: 't_x' }) } },
units: [{ chessId: 't_gd', row: 10, col: 4 }, { chessId: 't_x', row: 12, col: 9 }], content: 'generic', autoFinish: false,
// the row-9 lane only (the upper gate's official smoothed route is a diagonal through (10,4)/(10,5), see grid.js)
routes: flatRoutes().filter((r) => r.start[0] === 9),
});
h.step();
const u = h.unit('t_gd');
const path = h.b.groundPathTiles();
assert.ok(path.has(K(9, 4)) && path.has(K(9, 10)) && !path.has(K(10, 4)), 'flat stage route 0 runs along row 9');
const tok = (r, c) => assert.ok(h.b.spawnToken(u, 'tok', r, c, { def: { name: 'x', stats: { maxHp: 10 } } }));
// (9,3) (9,4) (9,5) are on the path at the same distance: the lowest tile key wins
assert.deepEqual(h.b.findTacticalPoint(u), [9, 3], 'on the path (row 9) before the tiles of its own row');
tok(9, 3);
assert.deepEqual(h.b.findTacticalPoint(u), [9, 4], 'taken tiles are skipped');
tok(9, 4); tok(9, 5);
assert.deepEqual(h.b.findTacticalPoint(u), [9, 6], 'a farther path tile beats a nearer off-path one');
tok(9, 6);
assert.deepEqual(h.b.findTacticalPoint(u), [10, 3], 'no path tile left: the nearest (same row first), lowest key');
// the home tile of a piece waiting to (re)deploy is reserved
const x = h.unit('t_x');
h.b.relocate(x, 10, 3);
h.b.retreat(x);
x.homeR = 10; x.homeC = 3;
assert.deepEqual(h.b.findTacticalPoint(u), [10, 5]);
// the path cache follows obstacles
const v = h.b.grid.version;
h.b.setObstacle(9, 7, true);
assert.notEqual(h.b.grid.version, v);
assert.ok(!h.b.groundPathTiles().has(K(9, 7)), 're-pathed around the obstacle');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 11) statusApplied `entered`, 12) layerGain `tile`
test('statusApplied ctx.entered: true only when the status newly started (refreshes, weaker valued applications: false)', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], content: 'none', autoFinish: false, hooks: ['statusApplied'] });
h.step();
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
const last = () => h.hooksOf('statusApplied').at(-1).entered;
h.b.applyStatus(e, 'freeze', { duration: 1 });
assert.equal(last(), true);
h.b.applyStatus(e, 'freeze', { duration: 2 });
assert.equal(last(), false, 'refresh of a running freeze');
h.run(2.1);
h.b.applyStatus(e, 'freeze', { duration: 1 });
assert.equal(last(), true, 'after it ended');
h.b.applyStatus(e, 'slow', { duration: 5, value: 0.5 });
assert.equal(last(), true);
h.b.applyStatus(e, 'slow', { duration: 9, value: 0.2 });
assert.equal(last(), false, 'weaker valued application');
h.b.addBuff(e, { key: 'custom:sleep', flags: { sleep: true }, status: 'sleep' });
h.b.applyStatus(e, 'sleep', { duration: 1 });
assert.equal(last(), false, 'a custom buff carrying the status counts');
});
test('layerGain ctx.tile: the source\'s tile — or where it was knocked out this instant; null afterwards; explicit opts.tile', () => {
const h = makeBattle({
defs: { chess: { t_gd: guard() } }, units: [{ chessId: 't_gd', row: 10, col: 4 }], content: 'generic', autoFinish: false, hooks: ['layerGain'],
setup: (b) => b.on('death', (c) => { if (c.unit.defId === 't_gd') b.addLayers('p1', 'test', 1, 'garrison', { source: c.unit }); }),
});
h.step();
const u = h.unit('t_gd');
h.b.relocate(u, 11, 5);
h.b.addLayers('p1', 'test', 1, 'garrison', { source: u });
assert.deepEqual(h.hooksOf('layerGain').at(-1).tile, [11, 5]);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.deepEqual(h.hooksOf('layerGain').at(-1).tile, [11, 5], 'knocked out this instant');
h.step();
h.b.addLayers('p1', 'test', 1, 'garrison', { source: u });
assert.equal(h.hooksOf('layerGain').at(-1).tile, null, 'long gone');
h.b.addLayers('p1', 'test', 1, 'x', { tile: [9, 9] });
assert.deepEqual(h.hooksOf('layerGain').at(-1).tile, [9, 9]);
h.b.addLayers('p1', 'test', 1, 'x');
assert.equal(h.hooksOf('layerGain').at(-1).tile, null);
});