- kits/ops/op-ironmn.js — 白铁 (char_4072_ironmn): S1 / S2 / S3, 战地工程师 (device stock 3, CRA-X +1, refilled at each deployment; a device that left comes back on its tile after its redeploy time, paying its cost, one stock, while he stands; his devices leave with him), 节约经费 (prob 0.7 / CRA-X stage 3 0.9), CRA-X (stock, costs, stage 3 SP +0.2/s), CRA-Y (redeploy times, stage 3 ASPD +6 on device-buffed operators); the device kits 白铁™多功能平台 (S1 ATK +12 % ×2.5 / 3 in S1; S2 1 SP / 3.5 s, 2.5 / 2 s in S2, 0.4 % / s 流失 ×2) and 铁钳号·原型机 (an ally target: damage cancelled, 1 SP per hit, a 工匠's hit heals 1 %; its skill −2.5 % then 230 / 260 % + 110 / 120 % within 1.1 of 白铁's ATK; 团结的力量 ×0.8 physical behind it). Exports the shared 工匠 device helpers. - kits/ops/op-nasti.js — 娜斯提 (char_4212_nasti): S1 / S2 / S3, 前方施工 (stock; CRA-X stage 3 first device −3, combined ranges), 注意安全 (10 % 庇护, 15 % + 1 SP / 6 s for her and our ranged operators once a ranged enemy appeared), CRA-X; the device kits “质检专员”, “监工专员” (3 / 8 HP, its S2 pulses of SP and her barrier) and “应急承重小组” (小工程师 + the 高台 it builds and levels). - kits/ops/op-necras.js — 死芒 (char_450_necras): S1 / S2 / S3, 复燃 (summon on the kill tile or a random tile of her range, upgrade at 3), 回光黯淡, the trait (the enemies her summons block are her targets; SOC-X ×1.15), SOC-X stage 3; the 悲叹的仆役 kit and its special form (S3). - Engine (additive): Battle.setAllyTarget / allyTargetsInKeys / isAllyTarget (battle/queries.js); ai.js acquireTargets puts a registered ally target of the range after every enemy, performAttack flies ranged attacks to it. Nothing changes while none is registered: every golden family but diy unchanged. docs/SIM.md §6 documents it. - kits/index.js: the three files appended to OPERATOR_KIT_FILES. - Tests: test/content/op_ironmn.test.js, op_nasti.test.js, op_necras.test.js, test/sim/feedback5-o23-engine.test.js. - Golden: only diy moved — diy-106 … diy-109 re-chunked (the new operators' configurations come before the prototypes), diy-110 … diy-114 new.
73 lines
4.7 KiB
JavaScript
73 lines
4.7 KiB
JavaScript
// test/sim/feedback5-o23-engine.test.js — the additive engine rule the 0.2.0 自选 kit of 白铁 needs: ally targets
|
||
// (Battle.setAllyTarget / allyTargetsInKeys / isAllyTarget, ai.js acquireTargets / performAttack). 白铁's 铁钳号·原型机 is a
|
||
// summon of the enemy camp (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方") that "可被我方干员攻击但不受伤害": our attacks select a
|
||
// registered ally standing on the attacker's range after every enemy (嘲讽等级 −2), ranged attacks fly to it; heals never
|
||
// select it; with none registered the attack loop is exactly the old one. Cancelling the damage it takes is the kit's job
|
||
// (test/content/op_ironmn.test.js); here a plain guard is registered and takes the hits.
|
||
// Run: node --test test/sim/feedback5-o23-engine.test.js
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
import { acquireTargets } from '../../server/sim/ai.js';
|
||
|
||
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
|
||
const CHESS = {
|
||
test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 1e7, atk: 300, def: 0, res: 0 } }),
|
||
test_sniper_a: chessRec({ id: 'test_sniper_a', profession: 'SNIPER', stats: { maxHp: 2000, atk: 200, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] }),
|
||
test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProf: 'physician', stats: { maxHp: 2000, atk: 100, def: 0, res: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2]] }),
|
||
};
|
||
|
||
function field() {
|
||
const h = makeBattle({
|
||
defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, hooks: ['damaged'], captureNoisy: true,
|
||
units: [
|
||
{ uid: 1, chessId: 'test_guard_a', row: 10, col: 4 }, // melee 1-1: (10, 4), (10, 5)
|
||
{ uid: 2, chessId: 'test_guard_a', row: 10, col: 5 }, // the ally target
|
||
{ uid: 3, chessId: 'test_sniper_a', row: 10, col: 2 }, // ranged: (10, 2) … (10, 5)
|
||
{ uid: 4, chessId: 'test_medic_a', row: 10, col: 3 },
|
||
],
|
||
});
|
||
h.step();
|
||
return { h, guard: h.unit(1), target: h.unit(2), sniper: h.unit(3), medic: h.unit(4) };
|
||
}
|
||
|
||
test('ally targets: none registered ⇒ nobody selects an ally; registered ⇒ the attackers whose range holds it select it, after every enemy', () => {
|
||
const { h, guard, target, sniper, medic } = field();
|
||
assert.equal(h.b.isAllyTarget(target), false);
|
||
assert.deepEqual(acquireTargets(h.b, guard, guard.profile), [], 'not registered: no target');
|
||
assert.ok(h.b.setAllyTarget(target, true));
|
||
assert.ok(h.b.isAllyTarget(target));
|
||
assert.deepEqual(h.b.allyTargetsInKeys(guard.rangeKeys, guard).map((u) => u.id), [target.id]);
|
||
assert.deepEqual(h.b.allyTargetsInKeys(target.rangeKeys, target), [], 'never itself');
|
||
assert.deepEqual(acquireTargets(h.b, guard, guard.profile).map((u) => u.id), [target.id], 'no enemy: the ally target');
|
||
assert.deepEqual(acquireTargets(h.b, medic, medic.profile), [], 'a healer never selects it');
|
||
// an enemy in range comes first; all-in-range / two targets take both
|
||
const e = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||
h.step();
|
||
assert.deepEqual(acquireTargets(h.b, guard, guard.profile).map((u) => u.id), [e.id], 'one target: the enemy');
|
||
assert.deepEqual(acquireTargets(h.b, guard, { ...guard.profile, maxTargets: 2 }).map((u) => u.id), [e.id, target.id], 'two: the enemy, then it');
|
||
assert.deepEqual(acquireTargets(h.b, guard, { ...guard.profile, allInRange: true }).map((u) => u.id), [e.id, target.id], 'all in range: both');
|
||
h.b.setAllyTarget(target, false);
|
||
assert.deepEqual(acquireTargets(h.b, guard, { ...guard.profile, maxTargets: 2 }).map((u) => u.id), [e.id], 'dropped: enemies only');
|
||
void sniper;
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('ally targets: attacks on it are real attacks (melee instant, ranged by projectile); off the field it is skipped', () => {
|
||
const { h, guard, target, sniper } = field();
|
||
h.b.setAllyTarget(target, true);
|
||
h.run(3);
|
||
const by = (src) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === target && c.dmg?.isAttack);
|
||
assert.ok(by(guard).length >= 1, 'the guard hits it');
|
||
assert.ok(by(sniper).length >= 1, 'the sniper hits it');
|
||
let flew = false;
|
||
for (let i = 0; i < 90 && !flew; i++) { h.step(); flew = h.b.projectiles.list.some((p) => p.source === sniper && p.target === target); }
|
||
assert.ok(flew, 'by projectile');
|
||
h.b.retreat(target, { reason: 'retreat' });
|
||
h.step();
|
||
assert.deepEqual(h.b.allyTargetsInKeys(guard.rangeKeys, guard), [], 'off the field: skipped while registered');
|
||
assert.ok(h.b.isAllyTarget(target));
|
||
checkInvariants(h.b);
|
||
});
|