Files
ark-stronghold-protocol/test/sim/feedback7-allytarget-cast.test.js
sganggs 37e45e362e feedback7 R22-OSIM: 白铁's 铁钳号·原型机 counts like an enemy for a non-heal skill's automatic start; golden: unchanged
Community report relayed by the owner: 圣约送葬人 hitting 白铁's device spent no bullet and gained no layer.
Our attacks already selected the device (Battle.setAllyTarget, damage cancelled), but every skill start
condition counted enemies only, so her MANUAL ammo skill (DEFAULT) never opened. The owner's rule of
2026-10-08: a registered ally target in range counts like an enemy for a non-heal skill's auto start.

- skills.js `_allyTargetIn`: DEFAULT (about to attack, the initial range; SEARCH / ACTIVE_RANGE through
  it), CUSTOM_RANGE, SKILL_RANGE, GDGLOW_SKILL_2 (anywhere) and the content trigger ranges. Heal skills
  never count it (禁疗). It stays out of enemiesInKeys / canTargetEnemy.
- 雪猎's empty-magazine S2 cast counts it too; allyTargetsInKeys takes a Set of tile keys.
- 白铁™多功能平台 stays no target. Kits whose own selectors pick enemies (陈 S3, 艾丽妮 S2 guards,
  蕾缪安 S3 locks) are unchanged.

Tests: test/sim/feedback7-allytarget-cast.test.js (4; 3 fail on the parent).
2026-10-08 19:36:35 +08:00

118 lines
6.8 KiB
JavaScript

// test/sim/feedback7-allytarget-cast.test.js — a registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, a
// summon of the enemy camp our attacks select — PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方") counts like an enemy for a
// non-heal skill's automatic start: the owner's rule of 2026-10-08 (community report: 圣约送葬人 hitting the device spent no
// bullet and gained no layer — her MANUAL ammo skill never opened, since every start condition counted enemies only).
// It stays out of enemiesInKeys / canTargetEnemy; a heal skill never counts it (禁疗); 白铁™多功能平台 is not one.
// Run: node --test test/sim/feedback7-allytarget-cast.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { COLS } from '../../server/sim/constants.js';
const CRAB = 'token_10027_ironmn_pile3';
const iron = (skillIndex) => ({ uid: 2, diy: { slot: 6, charId: 'char_4072_ironmn', skillIndex }, elite: true, row: 12, col: 9, dir: 'RIGHT' });
const IRON = iron(2); // S3: his pieces are 铁钳号·原型机 (S1: 白铁™多功能平台 极致火力)
const piece = (tokenId, col) => ({ uid: 10, kind: 'token', tokenId, ownerUid: 2, row: 10, col, dir: 'RIGHT' });
function field(units, extra = {}) {
const h = makeBattle({
timeLimit: 40, autoFinish: false, seed: 3, flags: { dpPerSec: 0, dpMax: 999 },
hooks: ['ammoUsed', 'skillStart', 'layerGain', 'attack'], captureNoisy: true, units, ...extra,
});
h.step();
return h;
}
test('圣约送葬人 S2 (MANUAL, DEFAULT, 12 bullets) opens with 铁钳号 alone in range, spends its bullets on it, and the 每消耗7发 garrisons gain layers', () => {
const h = field([{ uid: 1, chessId: 'chess_char_5_01_a', row: 10, col: 4, dir: 'RIGHT' }, IRON, piece(CRAB, 5)], {
bonds: { lateranoShip: { count: 3, active: true, tier: 1, layers: 0 }, visiShip: { count: 1, active: true, tier: 1, layers: 0 } },
});
const u = h.unit(1);
const crab = h.b.allyUnits.find((a) => a.defId === CRAB);
assert.equal(h.b.isAllyTarget(crab), true);
assert.equal(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 0, 'not an enemy of the selectors');
assert.equal(u.skill.id, 'skchr_excu2_2');
assert.equal(u.skill.rule, 'DEFAULT');
assert.equal(u.skill.kind, 'ammo');
u.skill.gainSp(999, 'test');
h.run(14);
assert.equal(u.skill.activations, 1, 'the skill opened on the device');
const atk = h.hooksOf('attack').filter((c) => c.attacker === u);
assert.ok(atk.length >= 10 && atk.every((c) => c.targets.every((t) => t === crab)), 'every attack hit the device');
const spent = h.hooksOf('ammoUsed').filter((c) => c.unit === u).length;
assert.ok(spent >= 7, `bullets spent on it (${spent})`);
const gains = h.hooksOf('layerGain').filter((c) => c.reason === 'garrison');
assert.ok(gains.length >= 2 && gains.every((c) => c.n > 0), '拉特兰 / 远见 garrison layers after 7 bullets');
assert.equal(crab.hp, crab.s.maxHp, 'the hits are still cancelled');
checkInvariants(h.b);
});
test('the other normal-attack ammo skills spend their bullets on 铁钳号 too (隐现 S2, 烛煌 S3, 新约能天使 S2)', () => {
for (const id of ['chess_char_1_01_a', 'chess_char_5_03_a', 'chess_char_6_13_a']) {
const h = field([{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, piece(CRAB, 5)]);
const u = h.unit(1);
assert.equal(u.skill.kind, 'ammo', id);
u.skill.gainSp(999, 'test');
h.run(10);
assert.equal(u.skill.activations, 1, `${id} opened`);
assert.ok(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length > 0, `${id} spent bullets`);
checkInvariants(h.b);
}
});
test('白铁™多功能平台 is no ally target: never attacked, the skill stays closed', () => {
const h = field([{ uid: 1, chessId: 'chess_char_5_01_a', row: 10, col: 4, dir: 'RIGHT' }, iron(0), piece('token_10027_ironmn_pile1', 5)]);
const u = h.unit(1);
const pile = h.b.allyUnits.find((a) => a.defId === 'token_10027_ironmn_pile1');
assert.ok(pile && pile.deployed);
assert.equal(h.b.isAllyTarget(pile), false);
u.skill.gainSp(999, 'test');
h.run(8);
assert.equal(u.skill.activations, 0);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0);
assert.equal(h.hooksOf('ammoUsed').length, 0);
checkInvariants(h.b);
});
test('every start rule counts it (SEARCH, SKILL_RANGE, CUSTOM_RANGE, ACTIVE_RANGE, GDGLOW anywhere, a content trigger range) — never a heal skill', () => {
const id = 'test_cast_a';
const open = (rule, { grid = null, rangeGrid = [[0, 0], [0, 1]], col = 5, heal = false, area = null, enemy = false } = {}) => {
const h = makeBattle({
defs: {
chess: { [id]: chessRec({ id, profession: heal ? 'MEDIC' : 'SNIPER', rangeGrid, skill: { skillType: 'MANUAL', spCost: 1, initSp: 1 } }) },
enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) },
},
kits: {
[id]: () => ({
skill: { kind: 'duration', duration: 5, spCost: 1, initSp: 1, heal, trigger: grid ? { rule, grid } : rule },
install(battle, unit) { if (area) unit.skill.addTriggerRange(() => [area]); },
}),
},
units: [{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, piece(CRAB, col)],
enemies: enemy ? [{ key: 'enemy_dummy', time: 0, pos: [11, 4] }] : [],
timeLimit: 20, autoFinish: false, seed: 2, flags: { dpPerSec: 0, dpMax: 999 },
});
h.step();
const u = h.unit(1);
const crab = h.b.allyUnits.find((a) => a.defId === CRAB);
crab.hp = crab.s.maxHp * 0.2; // an injured unit: still no heal patient (禁疗)
u.skill.gainSp(999, 'test');
h.run(3);
checkInvariants(h.b);
return u.skill.activations;
};
assert.equal(open('DEFAULT', { heal: true }), 0, 'a heal skill: the 禁疗 device is no patient');
assert.equal(open('SEARCH', { heal: true }), 0);
assert.equal(open('DEFAULT'), 1, 'DEFAULT: about to attack it');
assert.equal(open('SEARCH'), 1);
assert.equal(open('SKILL_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]] }), 1, 'on the skill range only');
assert.equal(open('CUSTOM_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]] }), 1);
assert.equal(open('ACTIVE_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]] }), 1);
assert.equal(open('SKILL_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]], col: 8 }), 0, 'outside the grid: no');
assert.equal(open('GDGLOW_SKILL_2', { rangeGrid: [[0, 0]], col: 9 }), 1, '全场: outside its own range too');
assert.equal(open('DEFAULT', { rangeGrid: [[0, 0]], col: 8, area: [10 * COLS + 8] }), 1, 'a content trigger range (addTriggerRange)');
assert.equal(open('DEFAULT', { rangeGrid: [[0, 0]], col: 8 }), 0, 'neither in range nor in a trigger range: no');
});