feedback7: merge fb7-owner-sim — community reports via the owner: 白铁's 铁钳号 opens only the skills that act on it (圣约送葬人 spends and stacks), 伊内丝 S3 after a 突袭 jump, 引星棘刺 alchemy units as HP regen (不屈者 / 禁疗), 不可对空 effects spare flying allies (炎佑), 望's 跟子 at battle start, 香槟炸弹 unhealable / no HP loss (owner decision) and not set off by unrevealed 隐匿 or sleeping enemies

- Grok review: MERGE AFTER FIXES — the HIGH finding (skills opened on the device but spending nothing) fixed on the branch
  (69d791f9: skills.js allyTargetsOk, per-kit allyTargets flags).
- Golden union OK; broad node --test 5500 (5499 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 21:14:27 +08:00
commit f5595a7a31
43 files changed
+1129 -356

No files matched your search

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