卫戍协议:盟约 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).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+507
View File
@@ -0,0 +1,507 @@
// 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);
const d1 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
approx(d1, 0.3, 0.051, 'mass 4 vs force 1: 25 %');
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.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
approx(d2, 0.5, 0.051, 'mass 3: 50 %');
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);
const [x0, y0] = [e().x, e().y];
h.run(1);
// grid path: back to its tile centre (9,6), then down the column — axis-aligned legs
approx(Math.abs(e().x - x0) + Math.abs(e().y - y0), 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
h.run(4.2);
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, later redeploys at home); { 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 later → the auto-redeploy goes home
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], [10, 4], 'auto-redeploy on the home tile');
// 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);
});