feedback5: merge O23 — 自选 kits for 白铁 (devices, 铁钳号), 娜斯提 (three 支援装置, the built 高台) and 死芒 (复燃 servants, special form); engine (additive): an ally our attacks select (queries / ai, after enemies in target order, never healed); golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 14:17:00 +08:00
commit 9f68afe9f9
12 files changed
+2909 -133

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
// 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);
});