diff --git a/server/match/unite.js b/server/match/unite.js index afe9bcd7..4ca18121 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -52,11 +52,16 @@ export function planUnite(m, results) { for (const l of r.leaked || []) { if (!l || l.counted === false) continue; if (!m.gd.enemy(l.enemyKey)) { notReentered.set(ps.playerId, (notReentered.get(ps.playerId) || 0) + 1); continue; } - // kill bounties keep paying in 联防: a 悬赏 card (mods.bountyId) or a bounty set on the SpawnSpec by content - // (copied into mods.bountyCoins by the match) + // kill bounties keep paying in 联防, and only on the card's own enemy. A split or summoned child + // never carries bountyId / bountyCoins (spawnChildren). A leak that still has the card's id but is + // some other enemy — a copy that did not go through spawnChildren — does not collect the card either + // (GitHub #67, #89-2; owner 2026-10-04: the main body only). A bounty set on the SpawnSpec by content + // is copied into mods.bountyCoins by the match and has no card to match. const bountyId = l.mods && l.mods.bountyId; const b = bountyId ? ps.bounties.find((x) => x.id === bountyId) : null; - let bounty = b && b.card && b.card.payout !== 'perfect' && Number(b.card.coin) > 0 ? { coins: Math.trunc(b.card.coin), ownerPlayerId: ps.playerId } : null; + const card = b && b.card; + let bounty = card && card.payout !== 'perfect' && Number(card.coin) > 0 && l.enemyKey === card.enemyKey + ? { coins: Math.trunc(card.coin), ownerPlayerId: ps.playerId } : null; const extra = !bountyId && l.mods ? Math.trunc(Number(l.mods.bountyCoins) || 0) : 0; if (!bounty && extra > 0) bounty = { coins: extra, ownerPlayerId: ps.playerId }; leaked.push({ enemyKey: l.enemyKey, mods: l.mods ? { ...l.mods } : null, lpr: l.lpr ?? 1, sourcePlayerId: ps.playerId, tag: l.tag ?? null, bounty }); diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index ecafa4a1..0816c14c 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -554,16 +554,31 @@ export function stayRoute(e, secs = 99999) { return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: p, end: p, steps: [{ t: 'wait', s: secs }] }; } +/** + * Mods copied onto a split or summoned child. The parent's stat multipliers travel; a kill bounty does not + * (owner 2026-10-04: the main body alone carries it — GitHub #67, #89-2). `bountyId` is the 悬赏 card and + * `bountyCoins` is the coin copy the match writes for 联防. The parent object is left as it is. + */ +function modsWithoutBounty(mods) { + if (!mods || typeof mods !== 'object') return mods ?? null; + if (mods.bountyId == null && mods.bountyCoins == null) return mods; + const copy = { ...mods }; + delete copy.bountyId; + delete copy.bountyCoins; + return copy; +} + /** Spawn `n` enemies at the parent's position that continue its route. Returns the spawned units. */ export function spawnChildren(b, parent, key, n, opts = {}) { const out = []; const route = opts.route ?? remainingRoute(parent); const cnt = Math.max(0, Math.min(20, Math.floor(n))); + const mods = modsWithoutBounty(opts.mods ?? parent.mods ?? null); for (let i = 0; i < cnt; i++) { const off = cnt > 1 ? (i - (cnt - 1) / 2) * 0.2 : 0; const pos = opts.pos ?? [parent.y, parent.x + off]; const c = b.spawnEnemy(key, { - pos, route, mods: opts.mods ?? parent.mods ?? null, tag: opts.tag ?? null, countInTotal: opts.countInTotal, + pos, route, mods, tag: opts.tag ?? null, countInTotal: opts.countInTotal, ownerPlayerId: parent.ownerId ?? null, sourcePlayerId: parent.sourcePlayerId ?? null, }); if (c) out.push(c); diff --git a/test/sim/feedback3-bounty-children.test.js b/test/sim/feedback3-bounty-children.test.js new file mode 100644 index 00000000..8cae86e0 --- /dev/null +++ b/test/sim/feedback3-bounty-children.test.js @@ -0,0 +1,144 @@ +// GitHub #67 / #89-2: a split or summoned child must not carry the kill bounty. The main body does. +// Owner 2026-10-04: the bounty stays on the body only. +// +// spawnChildren used to copy parent.mods, bountyId and bountyCoins included, so a leaked child paid the +// card again in 联防 (planUnite reads those fields). planUnite also paid any leak whose bountyId matched a +// card, even when the enemy was not the card's enemy. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { spawnChildren } from '../../server/sim/content/enemies.js'; +import { planUnite } from '../../server/match/unite.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; + +function parentBattle() { + return makeBattle({ + content: 'none', autoFinish: false, timeLimit: 30, + defs: { + enemies: { + enemy_parent: enemyRec({ key: 'enemy_parent', hp: 100, speed: 0 }), + enemy_child: enemyRec({ key: 'enemy_child', hp: 100, speed: 0 }), + }, + }, + enemies: [{ + key: 'enemy_parent', pos: [10, 6], + mods: { hpMul: 2, atkMul: 1.5, bountyId: 'b1', bountyCoins: 8 }, + bounty: { coins: 8, ownerPlayerId: 'p1' }, + }], + }); +} + +const coinsOf = (h) => h.eventsOf('bounty').reduce((s, e) => s + e[2], 0); + +test('spawnChildren keeps the stat mods and drops bountyId and bountyCoins', () => { + const h = parentBattle(); + h.step(); + const parent = h.enemy('enemy_parent'); + const kids = spawnChildren(h.b, parent, 'enemy_child', 2); + assert.equal(kids.length, 2); + assert.equal(parent.mods.bountyId, 'b1', 'the body keeps its card id'); + assert.equal(parent.mods.bountyCoins, 8); + for (const c of kids) { + assert.equal(c.mods.hpMul, 2); + assert.equal(c.mods.atkMul, 1.5); + assert.equal(c.mods.bountyId, undefined); + assert.equal(c.mods.bountyCoins, undefined); + assert.equal(c.bounty, null, 'kill payout reads unit.bounty, which a child does not get'); + assert.notEqual(c.mods, parent.mods); + } + const explicit = spawnChildren(h.b, parent, 'enemy_child', 1, { mods: { bountyId: 'b9', bountyCoins: 3, defMul: 4 } }); + assert.equal(explicit[0].mods.defMul, 4); + assert.equal(explicit[0].mods.bountyId, undefined); + assert.equal(explicit[0].mods.bountyCoins, undefined); + assert.equal(parent.mods.bountyId, 'b1', 'an explicit child mods object is not the parent\'s'); + // opts.mods null falls through to the parent, then the bounty fields are stripped + const fallen = spawnChildren(h.b, parent, 'enemy_child', 1, { mods: null }); + assert.equal(fallen[0].mods.hpMul, 2); + assert.equal(fallen[0].mods.bountyId, undefined); +}); + +test('killing the body pays its bounty once; killing the children pays nothing', () => { + const h = parentBattle(); + h.step(); + const parent = h.enemy('enemy_parent'); + const kids = spawnChildren(h.b, parent, 'enemy_child', 2); + h.b.kill(parent, null); + for (const c of kids) h.b.kill(c, null); + assert.equal(coinsOf(h), 8); +}); + +test('磨砻 on death spawns children that do not inherit the bounty; the body still pays', REAL, () => { + const h = makeBattle({ + autoFinish: false, timeLimit: 30, + enemies: [{ + key: 'enemy_1195_sfyin', pos: [10, 6], + mods: { hpMul: 1, bountyId: 'card-yin', bountyCoins: 5 }, + bounty: { coins: 5, ownerPlayerId: 'p1' }, + }], + }); + h.step(); + const parent = h.enemy('enemy_1195_sfyin'); + assert.ok(parent, '磨砻 spawned'); + h.b.kill(parent, null); + const kids = h.b.enemies.filter((e) => e !== parent && e.defId !== 'enemy_1195_sfyin'); + assert.ok(kids.length >= 1, `death children spawned (${kids.length})`); + for (const c of kids) { + assert.ok(!c.mods || (c.mods.bountyId == null && c.mods.bountyCoins == null), `${c.defId} still carries a bounty field`); + assert.equal(c.bounty, null); + } + assert.equal(coinsOf(h), 5); +}); + +function seat(id, seatNo, o = {}) { + return { + playerId: id, seat: seatNo, deployCount: o.deployCount ?? 1, bonds: {}, layers: {}, + board: new Map(), bounties: o.bounties ?? [], + }; +} + +function uniteOf(leaks, bounties) { + const leaker = seat('L', 1, { bounties }); + const helper = seat('H', 0, { deployCount: 3 }); + const m = { + isSolo: false, + alivePlayers: () => [leaker, helper], + gd: { + unite: { maxHelpers: 2 }, + enemy: (k) => (k === 'enemy_parent' || k === 'enemy_child' ? { key: k } : null), + }, + }; + const results = new Map([ + ['L', { perfect: false, leaked: leaks }], + ['H', { perfect: true, leaked: [], unitsEnd: [] }], + ]); + return planUnite(m, results); +} + +const CARD = { id: 'b1', card: { payout: 'kill', coin: 8, enemyKey: 'enemy_parent' } }; + +test('planUnite pays the card only when the leaked enemy is the card\'s enemy', () => { + const plan = uniteOf([ + { enemyKey: 'enemy_parent', mods: { bountyId: 'b1', hpMul: 2 }, counted: true, lpr: 1 }, + { enemyKey: 'enemy_child', mods: { bountyId: 'b1', bountyCoins: 8, hpMul: 2 }, counted: true, lpr: 1 }, + { enemyKey: 'enemy_child', mods: { bountyCoins: 4 }, counted: true, lpr: 1 }, + { enemyKey: 'enemy_parent', mods: { bountyId: 'missing' }, counted: true, lpr: 1 }, + ], [CARD]); + assert.ok(plan); + assert.deepEqual(plan.leaked.map((l) => [l.enemyKey, l.bounty ? l.bounty.coins : null]), [ + ['enemy_parent', 8], + ['enemy_child', null], + ['enemy_child', 4], + ['enemy_parent', null], + ]); +}); + +test('planUnite does not pay a perfect-clear card or a card with no coin', () => { + const leaks = [{ enemyKey: 'enemy_parent', mods: { bountyId: 'b1' }, counted: true, lpr: 1 }]; + const perfect = uniteOf(leaks, [{ id: 'b1', card: { payout: 'perfect', coin: 8, enemyKey: 'enemy_parent' } }]); + assert.equal(perfect.leaked[0].bounty, null); + const zero = uniteOf(leaks, [{ id: 'b1', card: { payout: 'kill', coin: 0, enemyKey: 'enemy_parent' } }]); + assert.equal(zero.leaked[0].bounty, null); +});