feedback5 WAA: a bounty enemy that dies with no operator behind it — 无来源 damage, its own HP loss, another enemy — pays in 联防 too: the helper whose half it fell on (community report of 2026-10-06 「被源石地板烫死的悬赏没给赏金」)
- 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.
This commit is contained in:
1 parent
6372f2892b
commit
59b3c49b2b
7 files changed
+130
-10
No files matched your search
+1
-1
@@ -621,7 +621,7 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
||||
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
|
||||
45 s, R3's at 62 s of 55 s); × 2 every limit ≈ last spawn + one flyer crossing. docs/BALANCE.md §2.1.
|
||||
* **SETTLE**: LP −min(counted leaks, 10) (after 联防: survivors attributed to their source, same cap); IN_BATTLE layer
|
||||
gains applied, each bond up to `BOND_LAYER_CAP` (999, `layerGainRoom`, as `PlayerState.addLayers`); kill-bounty coins (paid by the Battle to the killer — a 联防 helper included) and perfect-bounty coins
|
||||
gains applied, each bond up to `BOND_LAYER_CAP` (999, `layerGainRoom`, as `PlayerState.addLayers`); kill-bounty coins (paid by the Battle to the killer — a 联防 helper included; a death no operator caused pays the card's owner, in 联防 the helper whose half it fell on: `Battle._bountyPayee`) and perfect-bounty coins
|
||||
(own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool).
|
||||
* **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board
|
||||
rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side.
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@
|
||||
- 联防在官方的**联防专用地图**上进行(与官方相同,不是本回合的战场):一整片道路,没有水池、箱子、装置等地形;干员按休整期的位置站好。一人协防时敌人从中间的入口进入左半场;两人协防时敌人从最右侧进入,先经过右半场,再穿过中间进入左半场。
|
||||
|
||||
- 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。
|
||||
- 联防中击杀悬赏敌人,资金归击杀者。
|
||||
- 联防中击杀悬赏敌人,资金归击杀者;没有干员击倒它时(无来源伤害、它自己扣血、其他敌人的伤害等),资金归它倒下处所在半场的队友。悬赏敌人在自己的战场上被地形(如活性源石)烫死,资金照样归你。
|
||||
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
|
||||
- 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。
|
||||
- **有敌人走进蓝门就会响一声**:不等它真的扣到目标生命值——一回合里超过 10 点上限的那部分、数据里标明不计数目的敌人(BOSS 部件之类),以及联防里漏过去、随后被队友清掉的那几只,目标生命值都不会再扣,但这一声照样会响。播的是**原版关卡里怪进蓝门那一声**(官方音效表里的 `ON_ENEMY_REACHED_EXIT`,任何一关都是它)。你正在看哪个战场就听哪个战场的,所以看队友的战场、以及漏怪后观看联防时同样听得到。这一声 1.44 秒,我们按官方的「同时只允许一个」做成 **1.5 秒内共用一声**:一波打穿防线只响一遍(不会叠成一片),过一会儿再漏会再响。
|
||||
|
||||
@@ -180,6 +180,11 @@ and research routes (`{m, s, e, cp:[['MOVE',r,c]…]}`). `spawnsFromTemplate(wav
|
||||
template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful`
|
||||
and `tag:'part'` spawns don't count in `total`).
|
||||
|
||||
A `bounty` pays `coins` once, when the enemy really dies (not a knock-out it survives; a leak pays nothing), to
|
||||
`Battle._bountyPayee`: the player of the operator or summon that dealt the blow, if that player is in the battle; any
|
||||
other death (无来源 damage such as 活性源石, its own HP loss, another enemy, an ownerless unit) pays `ownerPlayerId` when
|
||||
that player is in the battle, else — 联防, where it is the leaker — the helper whose half the enemy fell on (0.2.0).
|
||||
|
||||
Leader parts (`tag:'part'`) pass damage to their leader with `loseHp(leader, share, { source, from, sourceless: true })`
|
||||
(无来源, credited to the attacker's `bossDamage`): `PART_TRANSFER` 1 for 斩胄之剑 / 破胄之锤 (`BLADE_TRANSFER`, the same
|
||||
constant) and 碎铳之簧 (PRTS "受到伤害时令假想敌:胄/铳受到等量的无来源生命流失"; DESIGN §20.10, §20.13). Content may replace a part mid-battle: every 剑/锤 sortie (content/bosses.js `kitBlade`)
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
// them beside the operators; one off the field at the end enters fresh [ASSUMED]). Enemies = the union of every leaker's
|
||||
// counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single`
|
||||
// for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` /
|
||||
// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains ("该阶段
|
||||
// 不能叠加层数"); the helpers' bonds carry the layers their own combat reached (PlayerState.battleInput `reached`: the
|
||||
// round's pending gains, capped like settle() — the strip's count; "以其阵地当前的状态" [ASSUMED] includes them; until 0.1.3
|
||||
// the round-start layers), and settle() still adds those gains once. Time limit = the round's combat limit.
|
||||
// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper) — a death no operator caused pays
|
||||
// the helper whose half it fell on (Battle._bountyPayee). No IN_BATTLE layer gains ("该阶段不能叠加层数"); the helpers'
|
||||
// bonds carry the layers their own combat reached (PlayerState.battleInput `reached`: the round's pending gains, capped
|
||||
// like settle() — the strip's count; "以其阵地当前的状态" [ASSUMED] includes them; until 0.1.3 the round-start layers),
|
||||
// and settle() still adds those gains once. Time limit = the round's combat limit.
|
||||
// LP: an enemy still alive at the end (leaked in the unite battle, or never spawned before the limit) costs its
|
||||
// SOURCE player 1 LP; each player's round loss = min(lpCap, survivors attributed to them + leaks that could not
|
||||
// re-enter) — the same 10 cap as a normal round.
|
||||
|
||||
@@ -139,10 +139,7 @@ export class BattleDeploy {
|
||||
if (pp) pp.killed++;
|
||||
}
|
||||
if (killer) killer.stats.kills++;
|
||||
if (unit.bounty && unit.bounty.coins > 0) {
|
||||
const payee = killer && killer.side === 'ally' && killer.ownerId != null ? killer.ownerId : (unit.bounty.ownerPlayerId ?? unit.ownerId);
|
||||
this.addCoins(payee, unit.bounty.coins);
|
||||
}
|
||||
if (unit.bounty && unit.bounty.coins > 0) this.addCoins(this._bountyPayee(unit, killer), unit.bounty.coins);
|
||||
}
|
||||
}
|
||||
// the reason ('killed' | 'retreat' | 'expired' | …) lets the client keep the knock-down sound for real knock-outs
|
||||
|
||||
@@ -48,6 +48,24 @@ export class BattleEconomy {
|
||||
return u.side === 'ally' ? [u.tileR, u.tileC] : [Math.round(u.y), Math.round(u.x)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The player a kill bounty (`unit.bounty`: a 悬赏 card's enemy, a 鸭爵 swap) pays when its enemy dies: the bounty pays
|
||||
* whenever the body dies (the owner's decision, relayed 2026-10-06). An operator or summon of a player of this battle
|
||||
* that dealt the blow pays its player ("将其击倒者获得N资金" — a 联防 helper included); any other death — 无来源 damage
|
||||
* (terrain, an uncredited tick), its own drain, another enemy, an ownerless unit — pays the card's owner when that
|
||||
* player fights here (own field, Final Assault / Hidden Core), else (联防, where the owner is the leaker) the player
|
||||
* whose half of the field it fell on: PRTS 卫戍协议:盟约 决策 "该敌人于对应玩家所属区域倒下时,使相应玩家获得额外资金"
|
||||
* [ASSUMED for 联防: no source names the payee of a death nobody caused there]. Until 0.2.0 that fallback was the
|
||||
* leaker, who has no entry in a 联防 battle, and addCoins dropped the coins (community report of 2026-10-06
|
||||
* 「被源石地板烫死的悬赏没给赏金」: until 0.1.3 the 联防 field was the round's map, 战场#04's 活性源石 included).
|
||||
*/
|
||||
_bountyPayee(unit, killer = null) {
|
||||
if (killer && killer.side === 'ally' && killer.ownerId != null && this._pp(killer.ownerId)) return killer.ownerId;
|
||||
const owner = unit.bounty?.ownerPlayerId ?? unit.ownerId;
|
||||
if (owner != null && this._pp(owner)) return owner;
|
||||
return this._ownerForTile([Math.round(unit.y), Math.round(unit.x)]);
|
||||
}
|
||||
|
||||
addCoins(playerId, n) {
|
||||
const pp = this._pp(playerId);
|
||||
if (!pp || !(n > 0) || !Number.isFinite(n)) return 0;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in new issue
Block a user