Files
ark-stronghold-protocol/test/sim/feedback5-o23-engine.test.js
sganggs 5ef47b0cf1 feedback5 O23: 自选 kits for 白铁, 娜斯提 (with their 支援装置) and 死芒 (with her 悲叹的仆役); ally targets for 白铁's 铁钳号·原型机
- 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.
2026-10-06 05:28:54 +08:00

73 lines
4.7 KiB
JavaScript
Raw Permalink 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.
// 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);
});