feedback1b WH: tests pin the own-kill exclusions, every lion off a reserved tile, the summoned count

The own-kill set closes after each of his attacks: a new test kills an enemy his
S3 / S2 attack hit by 无动于衷's reflection and by a mark another Kazimierz
operator set off, and checks neither counts (fails without the clear). The
reserved-tile test checks every lion and the exact free tiles; the B3 test pins
the summoned lion count again.
This commit is contained in:
sganggs committed 2026-10-03 03:14:00 +08:00
1 parent 20b65a5ff0
commit ff1b259c61
3 files changed
+90 -7

No files matched your search

+1 -1
View File
@@ -1404,7 +1404,7 @@ const SKILL_SUMMONS = Object.freeze({
*/
const SKILL_SUMMON_PER_CAST = Object.freeze({ [TOKEN_IDS.rosmonGear]: 2, [TOKEN_IDS.goldenOath]: 8 });
/**
* Skill summons the per-owner deploy limit does not apply to: the data `deployLimit` (character_table maxDeployCount 1)
* Skill summons the per-owner deploy limit does not apply to: the data `deployLimit` (character_table phase maxDeployCount 1)
* of 黄金盟誓 is a hand count, while 维娜 S3 summons one on every free deployable tile of her talent-1 area at once.
*/
const SKILL_SUMMON_UNCAPPED = new Set([TOKEN_IDS.goldenOath]);
+73 -2
View File
@@ -18,7 +18,7 @@ import { makeMatch, DATA } from '../match/harness.js';
import { checkLoadout } from '../../shared/protocol.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { COLS } from '../../server/sim/constants.js';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
const VINA = 'chess_char_6_07_a';
const MLYNAR = 'chess_char_5_19_a';
@@ -85,7 +85,7 @@ test('B3 维娜·维多利亚 S3 (default skill, real match): a 黄金盟誓 on
const t0 = u.def.talents[0].bb;
for (let i = 0; i < 14; i++) b.step();
const around = b.allyUnits.filter((a) => a !== u && a.alive && Math.max(Math.abs(a.tileR - u.tileR), Math.abs(a.tileC - u.tileC)) <= 1);
assert.ok(around.filter((a) => a.defId === LION).length >= 1);
assert.equal(around.filter((a) => a.defId === LION).length, expected.length, 'every lion still around her');
approx(u.findBuff('siege2:kings').mods.atkPct, t0.atk * around.length, 1e-9, '+5 % per ally around, lions included');
while (u.skill.active && !b.finished) { b.step(); most = Math.max(most, b.allyUnits.filter((t) => t.defId === LION && t.alive).length); }
assert.ok(most <= expected.length);
@@ -271,3 +271,74 @@ test('B4 玛恩纳: a kill by the damage his attack carries (卡西米尔 bond t
}
}
});
test('B4 玛恩纳: a kill outside his attack does not count, even of an enemy his attack hit — 无动于衷 reflection, a mark another operator set off (PRTS 备注 exclusions)', () => {
const tough = enemyRec({ key: 'enemy_tough', hp: 1e9, def: 0, speed: 0, atk: 0 });
// a Kazimierz teammate without attacks of its own here: the reflection's target and the mark's trigger
const kaz = chessRec({ id: 'test_kaz_a', bonds: ['kazimierzShip'], skill: null, stats: { atk: 1 }, rangeGrid: [[0, 0]] });
const setup = (skillIndex) => {
const h = makeBattle({
defs: { enemies: { enemy_tough: tough }, chess: { test_kaz_a: kaz } },
units: [{ chessId: MLYNAR, row: 10, col: 4, dir: 'RIGHT', skillIndex }, { chessId: 'test_kaz_a', row: 9, col: 4, dir: 'RIGHT' }],
autoFinish: false, timeLimit: 200,
});
const u = h.unit(MLYNAR), mate = h.unit('test_kaz_a');
let attacked = 0;
h.b.on('attack', (c) => { if (c.attacker === u) attacked++; }, { priority: -1000 });
const kills = [];
h.b.on('kill', (c) => kills.push(c));
// his attack has hit `foe` and is over (the hit set is closed): the moment between two of his attacks
const afterHisAttackOn = (foe) => {
let hitFoe = false;
const off = h.b.on('damaged', (c) => { if (c.source === u && c.target === foe && c.dmg?.isAttack) hitFoe = true; });
for (let i = 0; i < 300 && !(hitFoe && attacked); i++) { if (!hitFoe) attacked = 0; h.step(); }
h.b.off(off);
assert.ok(hitFoe && attacked, 'his attack hit the enemy');
};
const nextAttack = () => { attacked = 0; assert.ok(h.runUntil(() => attacked > 0, 5), 'his next attack'); };
return { h, u, mate, kills, afterHisAttackOn, nextAttack };
};
const killOutside = (h, u, mate, foe, how) => {
foe.hp = 1;
if (how === 'reflect') h.b.dealDamage(foe, mate, { amount: 1, type: 'phys', isAttack: true }); // 无动于衷 reflects onto it
else h.b.dealDamage(mate, foe, { amount: 0.01, type: 'true', isAttack: true }); // the mate's attack sets off his mark
};
// S3: the bonus stays where it is
for (const how of ['reflect', 'mark']) {
const { h, u, mate, kills, afterHisAttackOn, nextAttack } = setup(2);
h.run(10.5);
const A = h.spawn('enemy_tough', { pos: [10, 5] });
h.spawn('enemy_tough', { pos: [10, 7] }); // keeps him attacking
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2));
afterHisAttackOn(A);
const before = u.s.atk / u.base.atk - 1;
killOutside(h, u, mate, A, how);
assert.equal(A.alive, false, `S3 ${how}: the enemy is knocked out`);
const k = kills.find((c) => c.victim === A);
assert.equal(k?.killer, u, `S3 ${how}: by 玛恩纳`);
nextAttack();
h.step();
assert.ok(u.skill.active);
assert.equal(u.mem.mlyKills, 0, `S3 ${how}: not a kill of his own attack`);
approx(u.s.atk / u.base.atk - 1, before, 1e-9, `S3 ${how}: trait bonus unchanged`);
assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []);
}
// S2: a reflection kill of an enemy he hit does not keep the ramp
{
const { h, u, mate, kills, afterHisAttackOn } = setup(1);
h.run(20.5);
approx(u.trait.ramp, 1, 1e-9, 'ramp after 20 s');
const A = h.spawn('enemy_tough', { pos: [10, 5] });
h.spawn('enemy_tough', { pos: [10, 6] });
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 2));
afterHisAttackOn(A);
killOutside(h, u, mate, A, 'reflect');
assert.equal(A.alive, false);
assert.equal(kills.find((c) => c.victim === A)?.killer, u);
assert.ok(h.runUntil(() => !u.skill.active, 40));
assert.ok(u.trait.ramp < 0.05, `S2: the ramp resets at the skill end (${u.trait.ramp})`);
assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []);
}
});
+16 -4
View File
@@ -885,10 +885,22 @@ test('summon tiles skip reserved home tiles: 黄金盟誓 never takes the tile o
h.step();
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
assert.equal(g.alive, false);
assert.ok(h.runUntil(() => u.skill.active, 5));
const lion = h.b.allyUnits.find((x) => x.defId === 'token_10040_siege2_vlion' && x.alive);
assert.ok(lion, '黄金盟誓 summoned');
assert.ok(!(lion.tileR === 10 && lion.tileC === 5), 'not on the dead guard\'s home tile (closest to the enemy)');
// the free deployable melee tiles of her 3×3 area just before the cast — the dead guard's home tile among them
const HOME = 10 * 100 + 5;
let free = [];
for (let i = 0; i < 100 && !u.skill.active; i++) {
free = [];
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
const r = u.tileR + dr, c = u.tileC + dc;
if ((dr || dc) && h.b.grid.inRect(r, c) && h.b.grid.canStand(r, c, { ranged: false }) && !h.b.unitAt(r, c)) free.push(r * 100 + c);
}
h.step();
}
assert.ok(u.skill.active, 'S3 cast');
assert.ok(free.includes(HOME) && free.length >= 2, `the scenario: the guard's home tile is one of the free tiles (${free})`);
const lions = h.b.allyUnits.filter((x) => x.defId === 'token_10040_siege2_vlion' && x.alive);
assert.ok(lions.every((l) => !(l.tileR === 10 && l.tileC === 5)), 'no lion on the dead guard\'s home tile');
assert.deepEqual(lions.map((l) => l.tileR * 100 + l.tileC).sort((a, b) => a - b), free.filter((k) => k !== HOME).sort((a, b) => a - b), 'a lion on every other free tile');
assert.ok(h.runUntil(() => g.alive, g.base.respawnTime + 2), 'the guard redeploys on its tile');
checkInvariants(h.b);
});