- Reproduced (scratchpad scripts): on its own field a bounty enemy the 活性源石 alone burns to death pays its owner — on this tree and on v0.1.4 (all 96 walking kill-bounty enemies, 战场#04, no operator: 0 unpaid). The loss is in 联防: a leaked bounty enemy re-enters with `bounty.ownerPlayerId` = the leaker (unite.js planUnite), who has no per-player entry in that battle. A death without an operator killer fell back to that owner, and Battle.addCoins dropped the coins. Until 0.1.3 the 联防 field was the round's map, so 战场#04's 活性源石 burned leaked bounty enemies for nothing — the report; since then the 联防 maps have no terrain, but a 无来源 tick, the enemy's own drain or another enemy's hit still paid nobody. - Official: PRTS 卫戍协议:盟约 决策 "该敌人于对应玩家所属区域倒下时,使相应玩家获得额外资金"; the bounty pays whenever the body dies (the owner's decision, relayed 2026-10-06; 0.1.1 "paid once, at the final death"). No PRTS exception for 环境伤害. - Now: Battle._bountyPayee (battle/economy.js): an operator or summon of a player in the battle that dealt the blow pays its player (as before, 联防 helpers included); any other death pays the card's owner when that player fights there (own field, Final Assault / Hidden Core — unchanged), else the player whose half of the field the enemy fell on (Battle._ownerForTile) [ASSUMED for 联防: no source names the payee of a death nobody caused there]. - Docs: SIM.md §1.2, META.md SETTLE, PLAYING.md (联防 rules), unite.js header. - Tests: test/sim/feedback5-bounty-payee.test.js — the 联防 case (one helper; two helpers by half; killed by nobody, 无来源 terrain damage, another enemy, its own HP loss) fails on the parent; pinned: the own-field 活性源石 death, a helper's operator on the other half, a pair boss field. Golden unchanged.
100 lines
5.9 KiB
JavaScript
100 lines
5.9 KiB
JavaScript
// test/sim/feedback5-bounty-payee.test.js — community report of 2026-10-06 「被源石地板烫死的悬赏没给赏金」. The bounty pays
|
|
// whenever the body dies (the owner's decision, relayed 2026-10-06; 0.1.1 "a bounty coin is paid once, at the final
|
|
// death"; 0.1.3 §23.25 "Killing the body pays once"); PRTS 卫戍协议:盟约 决策: "该敌人于对应玩家所属区域倒下时,使相应玩家获得
|
|
// 额外资金". Battle._bountyPayee: an operator of a player in the battle that dealt the blow pays its player; any other
|
|
// death pays the card's owner when that player fights there — on its own field a 活性源石 death always paid (pinned) —
|
|
// and in 联防, where the owner is the leaker (unite.js planUnite) and fights elsewhere, the helper whose half it fell on.
|
|
// Until 0.2.0 that last case fell back to the leaker, who has no per-player entry in the 联防 battle: the coins were
|
|
// dropped (until 0.1.3 the 联防 field was the round's map — 战场#04's 活性源石 burned leaked bounty enemies for nothing).
|
|
// Run: node --test test/sim/feedback5-bounty-payee.test.js
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
|
|
|
const coins = (h) => {
|
|
const out = {};
|
|
for (const e of h.eventsOf('bounty')) out[e[1]] = (out[e[1]] || 0) + e[2];
|
|
return out;
|
|
};
|
|
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
|
const helper = (playerId, seat, side, colOffset, units = []) => ({ playerId, seat, side, colOffset, units, bonds: {}, layers: {} });
|
|
// a 联防 field: helpers A (left half) and B (right half), a bounty enemy the leaker L sent in (planUnite's shape)
|
|
function unite(players, pos) {
|
|
return makeBattle({
|
|
kind: 'unite', content: 'none', autoFinish: false, timeLimit: 60, players,
|
|
defs: { enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0 }), enemy_x: enemyRec({ key: 'enemy_x', hp: 100, speed: 0 }) } },
|
|
enemies: [{ key: 'enemy_b', pos, sourcePlayerId: 'L', mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'L' }, tag: 'bounty' }],
|
|
});
|
|
}
|
|
const target = (h) => h.b.enemies.find((e) => e.defId === 'enemy_b');
|
|
|
|
test('own field (战场#04): a bounty enemy the 活性源石 alone burns to death pays its owner — as before', () => {
|
|
const h = makeBattle({
|
|
stageId: 'act1autochess_m04', autoFinish: false, timeLimit: 60,
|
|
defs: { enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0.5 }) } },
|
|
enemies: [{ key: 'enemy_b', route: { motion: 'WALK', start: [10, 7], end: [9, 2], checkpoints: [] }, mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
|
|
});
|
|
assert.ok(h.runUntil(() => !h.b.enemies.some((e) => e.alive) && h.b.time > 1, 30));
|
|
assert.deepEqual(h.eventsOf('die').map((d) => d[2]), ['killed'], 'it died, no leak');
|
|
assert.deepEqual(coins(h), { p1: 2 });
|
|
assert.equal(h.result().perPlayer.p1.coins, 2);
|
|
done(h);
|
|
});
|
|
|
|
test('联防: a leaked bounty enemy that dies with no operator behind it pays the helper of the half it fell on', () => {
|
|
const ways = [
|
|
['killed by nobody', (h, e) => h.b.kill(e, null)],
|
|
['无来源 terrain-tagged damage', (h, e) => h.b.dealDamage(null, e, { amount: 500, type: 'true', canDodge: false, tags: ['terrain'] })],
|
|
['another enemy\'s hit', (h, e) => h.b.dealDamage(h.spawn('enemy_x', { pos: [10, 3] }), e, { amount: 500, type: 'true' })],
|
|
['its own HP loss', (h, e) => h.b.loseHp(e, 500, { source: e })],
|
|
];
|
|
for (const [label, how] of ways) {
|
|
// one helper: the whole field is hers
|
|
const h1 = unite([helper('A', 0, 'L', 0)], [10, 6]);
|
|
h1.step();
|
|
how(h1, target(h1));
|
|
assert.ok(!target(h1).alive, label);
|
|
assert.deepEqual(coins(h1), { A: 2 }, `one helper, ${label}`);
|
|
assert.equal(h1.result().perPlayer.A.coins, 2);
|
|
done(h1);
|
|
// two helpers: by the half it fell on (the right-hand helper's half from column 11)
|
|
for (const [col, who] of [[5, 'A'], [15, 'B']]) {
|
|
const h2 = unite([helper('A', 0, 'L', 0), helper('B', 1, 'R', 8)], [10, col]);
|
|
h2.step();
|
|
how(h2, target(h2));
|
|
assert.deepEqual(coins(h2), { [who]: 2 }, `two helpers, column ${col}, ${label}`);
|
|
done(h2);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('联防: a helper\'s operator that knocks it out still takes the coins, whatever half it fell on', () => {
|
|
const GUN = chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
|
|
const h = makeBattle({
|
|
kind: 'unite', content: 'none', autoFinish: false, timeLimit: 60,
|
|
players: [helper('A', 0, 'L', 0, [{ uid: 1, kind: 'chess', chessId: 't_gun', row: 10, col: 4 }]), helper('B', 1, 'R', 8)],
|
|
defs: { chess: { t_gun: GUN }, enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0 }) } },
|
|
enemies: [{ key: 'enemy_b', pos: [10, 15], sourcePlayerId: 'L', mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'L' }, tag: 'bounty' }],
|
|
});
|
|
h.step();
|
|
const gun = h.b.allies().find((u) => u.ownerId === 'A');
|
|
assert.ok(gun, 'A\'s operator');
|
|
h.b.dealDamage(gun, target(h), { amount: 500, type: 'phys' });
|
|
assert.deepEqual(coins(h), { A: 2 }, 'the killer\'s player, on B\'s half');
|
|
done(h);
|
|
});
|
|
|
|
test('Final Assault pair field: the card\'s owner fights there, so a death nobody caused pays the owner on either half', () => {
|
|
const h = makeBattle({
|
|
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60,
|
|
players: [helper('A', 0, 'L', 0), helper('B', 1, 'R', 8)],
|
|
defs: { enemies: { enemy_b: enemyRec({ key: 'enemy_b', hp: 100, speed: 0 }) } },
|
|
enemies: [{ key: 'enemy_b', pos: [3, 15], ownerPlayerId: 'A', mods: { bountyId: 'card1' }, bounty: { coins: 2, ownerPlayerId: 'A' }, tag: 'bounty' }],
|
|
});
|
|
h.step();
|
|
h.b.kill(target(h), null);
|
|
assert.deepEqual(coins(h), { A: 2 });
|
|
done(h);
|
|
});
|