From ff1b259c61aed44f252015b65988bab16a220d7f Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:14:00 +0800 Subject: [PATCH] feedback1b WH: tests pin the own-kill exclusions, every lion off a reserved tile, the summoned count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/sim/content/tokens.js | 2 +- test/content/feedback1b_kits.test.js | 75 +++++++++++++++++++++++++++- test/content/kits_t6.test.js | 20 ++++++-- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77fa8bb0..693d49d0 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -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]); diff --git a/test/content/feedback1b_kits.test.js b/test/content/feedback1b_kits.test.js index 9a7fc2ea..f8a8ec18 100644 --- a/test/content/feedback1b_kits.test.js +++ b/test/content/feedback1b_kits.test.js @@ -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}`), []); + } +}); diff --git a/test/content/kits_t6.test.js b/test/content/kits_t6.test.js index f2fbe812..b5ca4e51 100644 --- a/test/content/kits_t6.test.js +++ b/test/content/kits_t6.test.js @@ -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); });