From 59b3c49b2bc6c9c10d774f6f0af19b4a24e66091 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 22:02:25 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20WAA:=20a=20bounty=20enemy=20that=20?= =?UTF-8?q?dies=20with=20no=20operator=20behind=20it=20=E2=80=94=20?= =?UTF-8?q?=E6=97=A0=E6=9D=A5=E6=BA=90=20damage,=20its=20own=20HP=20loss,?= =?UTF-8?q?=20another=20enemy=20=E2=80=94=20pays=20in=20=E8=81=94=E9=98=B2?= =?UTF-8?q?=20too:=20the=20helper=20whose=20half=20it=20fell=20on=20(commu?= =?UTF-8?q?nity=20report=20of=202026-10-06=20=E3=80=8C=E8=A2=AB=E6=BA=90?= =?UTF-8?q?=E7=9F=B3=E5=9C=B0=E6=9D=BF=E7=83=AB=E6=AD=BB=E7=9A=84=E6=82=AC?= =?UTF-8?q?=E8=B5=8F=E6=B2=A1=E7=BB=99=E8=B5=8F=E9=87=91=E3=80=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/META.md | 2 +- docs/PLAYING.md | 2 +- docs/SIM.md | 5 ++ server/match/unite.js | 9 ++- server/sim/battle/deploy.js | 5 +- server/sim/battle/economy.js | 18 +++++ test/sim/feedback5-bounty-payee.test.js | 99 +++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 test/sim/feedback5-bounty-payee.test.js diff --git a/docs/META.md b/docs/META.md index 6a13b581..19cc3a80 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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. diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 501fd0fc..97fd9694 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -92,7 +92,7 @@ - 联防在官方的**联防专用地图**上进行(与官方相同,不是本回合的战场):一整片道路,没有水池、箱子、装置等地形;干员按休整期的位置站好。一人协防时敌人从中间的入口进入左半场;两人协防时敌人从最右侧进入,先经过右半场,再穿过中间进入左半场。 - 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。 -- 联防中击杀悬赏敌人,资金归击杀者。 +- 联防中击杀悬赏敌人,资金归击杀者;没有干员击倒它时(无来源伤害、它自己扣血、其他敌人的伤害等),资金归它倒下处所在半场的队友。悬赏敌人在自己的战场上被地形(如活性源石)烫死,资金照样归你。 - 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。 - 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。 - **有敌人走进蓝门就会响一声**:不等它真的扣到目标生命值——一回合里超过 10 点上限的那部分、数据里标明不计数目的敌人(BOSS 部件之类),以及联防里漏过去、随后被队友清掉的那几只,目标生命值都不会再扣,但这一声照样会响。播的是**原版关卡里怪进蓝门那一声**(官方音效表里的 `ON_ENEMY_REACHED_EXIT`,任何一关都是它)。你正在看哪个战场就听哪个战场的,所以看队友的战场、以及漏怪后观看联防时同样听得到。这一声 1.44 秒,我们按官方的「同时只允许一个」做成 **1.5 秒内共用一声**:一波打穿防线只响一遍(不会叠成一片),过一会儿再漏会再响。 diff --git a/docs/SIM.md b/docs/SIM.md index 1ecd482f..032643c7 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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`) diff --git a/server/match/unite.js b/server/match/unite.js index f4bde76a..66c7d96d 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -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. diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js index 13df9fa1..d5c23965 100644 --- a/server/sim/battle/deploy.js +++ b/server/sim/battle/deploy.js @@ -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 diff --git a/server/sim/battle/economy.js b/server/sim/battle/economy.js index e3e1861b..f2b96078 100644 --- a/server/sim/battle/economy.js +++ b/server/sim/battle/economy.js @@ -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; diff --git a/test/sim/feedback5-bounty-payee.test.js b/test/sim/feedback5-bounty-payee.test.js new file mode 100644 index 00000000..037f18e1 --- /dev/null +++ b/test/sim/feedback5-bounty-payee.test.js @@ -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); +});