Files
ark-stronghold-protocol/test/sim/feedback3-bounty-children.test.js

145 lines
5.8 KiB
JavaScript

// 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);
});