From 05eb2b807f4af6b60865149af5b40b04dc6fb58f Mon Sep 17 00:00:00 2001 From: Sukvii Date: Thu, 8 Oct 2026 20:12:14 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20dober:=20the=20Final=20Assault=20an?= =?UTF-8?q?d=20the=20Hidden=20Core=20pay=20a=20=E6=88=98=E6=9C=AF=E7=89=B9?= =?UTF-8?q?=E8=AE=AD=20card's=20perfect-battle=20coins,=20they=20paid=20ki?= =?UTF-8?q?ll=20coins=20only=20(PR=20#353=20by=20@Sukvii);=20golden:=20unc?= =?UTF-8?q?hanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen: Match._settleBossBounties banked only the kill coins of the boss field (pp.coins). A perfect-payout bounty (a 战术特训 card, from the 杜宾 Art 教鞭) has its enemies spawned in the Final Assault / Hidden Core like any bounty with battles left, and used a battle there, but its coins were never paid — SETTLE pays them after every normal battle. Official: PRTS 杜宾 "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" and the cards' "若各自行动阶段就达成完美作战,获得N资金" have no boss-battle exception. The condition is [ASSUMED]: the team won (the first end the server registered), the result is a real one (not the synthetic stand-in of a field that never reported) and the player's own field let no counted enemy through; paid once per player, spent in the Hidden Core's prep with the kill coins. Now: _finishFinal reads the victory before the per-field loop and hands _settleBossBounties a `perfect` flag. Tests: clientCombat.test.js (the held 'cleared' result pays once, a duplicate never twice; six eligibility cases: team win / synthetic / own leak / uncounted leak / forced team defeat), followups.test.js (R14 -> R15 income and the Hidden Core; a boss win with an own leak keeps the kill coins only). Both fail on the parent. Golden: unchanged (matches: 18 scenarios match). --- docs/BALANCE.md | 2 +- docs/META.md | 6 +++- server/match/match/bossRounds.js | 26 ++++++++++++----- test/match/clientCombat.test.js | 40 ++++++++++++++++++++++++- test/match/followups.test.js | 50 ++++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 11 deletions(-) diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 5edb42da..4fbac26d 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -393,7 +393,7 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms. * Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2) also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round - preview, and the boss battle uses up one of their battles (kill coins → pending funds for the Hidden Core prep). + preview, and the boss battle uses up one of their battles (kill coins, and a perfect-payout 战术特训 card's coins when the player's own field is perfect and the team wins → pending funds for the Hidden Core prep). * A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned). * docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key). * 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports diff --git a/docs/META.md b/docs/META.md index 212ab116..d4031210 100644 --- a/docs/META.md +++ b/docs/META.md @@ -619,7 +619,11 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o `enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault / Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of - the bounty's battles and its kill coins go to pending funds. + the bounty's battles and its kill coins go to pending funds — with, for every perfect-payout card (战术特训), its coins + as after a normal battle ("若各自行动阶段就达成完美作战,获得N资金"): once per player, when the team won (the first end the + server registered: the leader's pool empty), the result is a real one (not the stand-in of a field that never + reported) and the player's own field let no counted enemy through [ASSUMED: the text has no boss-battle exception, and + the card's enemies do come in the boss battle]. They are spent in the Hidden Core's prep like the kill coins. * **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2× battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`, default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of diff --git a/server/match/match/bossRounds.js b/server/match/match/bossRounds.js index 74d659a1..5397d954 100644 --- a/server/match/match/bossRounds.js +++ b/server/match/match/bossRounds.js @@ -372,11 +372,15 @@ export class MatchBoss { } /** - * Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every - * bounty used one of its battles, exactly like SETTLE does for normal rounds. + * Bounties after a boss field: kill-bounty coins and — when `perfect` — the coins of every perfect-payout bounty (a + * 战术特训 card: PRTS "若各自行动阶段就达成完美作战,获得N资金", the same sum SETTLE pays after a normal battle) go to + * pending funds (spent in the Hidden Core's prep), and every bounty used one of its battles, exactly like SETTLE does + * for normal rounds. + * @param {boolean} perfect the player's own field was perfect and the team won (`_finishFinal`) */ - _settleBossBounties(ps, pp) { - const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0)); + _settleBossBounties(ps, pp, perfect) { + let coins = Math.max(0, Math.trunc(Number(pp.coins) || 0)); + if (perfect) for (const b of ps.bounties) if (b.card.payout === 'perfect') coins += b.card.coin; if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; } if (!ps.bounties.length) return; for (const b of ps.bounties) b.roundsLeft--; @@ -471,6 +475,9 @@ export class MatchBoss { _finishFinal(hidden, resultOf) { if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return; this._stopClientCombat(); + // the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0 + // → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5) + const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0; for (const f of this.fields) { const res = resultOf(f); this._collectSimErrors(f, res); @@ -485,13 +492,16 @@ export class MatchBoss { ps.dirty(); // m.private.stats this._charDamageTickers(ps, pp); } - if (pp && ps) this._settleBossBounties(ps, pp); + if (pp && ps) { + // the own battle counts as perfect when the team won, the result is a real one (not the stand-in of a field that + // never reported) and the player's own field let no counted enemy through [ASSUMED: the boss battle is a battle + // of the player's own — "下场作战" — so a perfect-payout card pays as after a normal one] + const perfect = victory && !res.synthetic && pp.perfect === true && !(pp.leaked || []).some((l) => l && l.counted !== false); + this._settleBossBounties(ps, pp, perfect); + } if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true }); } } - // the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0 - // → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5) - const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0; this._syncTeamLp(); this.deadline = 0; this.overtimeAt = 0; diff --git a/test/match/clientCombat.test.js b/test/match/clientCombat.test.js index f314a7e4..dab1443b 100644 --- a/test/match/clientCombat.test.js +++ b/test/match/clientCombat.test.js @@ -9,7 +9,7 @@ import { PHASE } from '../../shared/constants.js'; import { validateC2S, isBattleResult } from '../../shared/protocol.js'; import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js'; import { DataSource } from '../../server/sim/simdata.js'; -import { validateClientResult, specBounds } from '../../server/match/fields.js'; +import { validateClientResult, specBounds, syntheticResult } from '../../server/match/fields.js'; import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js'; import { FakeBattle } from './fakeBattle.js'; import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js'; @@ -509,6 +509,10 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => { const { h, m, f } = faWithForger(9122); + const ps = h.ps('p_0'); + const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey)); + m.addBounty(ps, { ...training, rounds: 2, multiRound: false }); + const pending = ps.pendingFunds; const max = m.bossPool.maxHp; h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool const gt = m._fieldElapsed(f); @@ -537,10 +541,44 @@ test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' assert.equal(f.resultSource, 'client', 'the held client result completed the field'); assert.equal(m.verifyStats.takeovers, takeovers0); assert.equal(m.verifyStats.rejected, rejected0); + assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount'); + assert.equal(ps.bounties[0].roundsLeft, 1); + assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true }); + assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice'); + assert.equal(ps.bounties[0].roundsLeft, 1); for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`); m.dispose(); }); +test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => { + for (const [ending, synthetic, perfect, leaked, eligible] of [ + ['cleared', false, true, [], true], + ['cleared', true, true, [], false], + ['cleared', false, false, [], false], + ['cleared', false, true, [{ counted: true }], false], + ['cleared', false, true, [{ counted: false }], true], + ['forced', false, true, [], false], + ]) { + const { m, f } = faWithForger(9122); + const ps = m.players.get(f.players[0]); + const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey)); + m.addBounty(ps, { ...card, rounds: 2, multiRound: false }); + m.addBounty(ps, { ...card, rounds: 2, multiRound: false }); + const res = syntheticResult(f.players); + if (!synthetic) delete res.synthetic; + res.reason = 'forced'; + Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 }); + const pending = ps.pendingFunds, gained = ps.stats.fundsGained; + m._finalEnding = ending; + m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat + m._finishFinal(false, () => res); + assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0)); + assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0)); + assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]); + m.dispose(); + } +}); + test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => { const { h, m, f } = faWithForger(9123); const max = m.bossPool.maxHp; diff --git a/test/match/followups.test.js b/test/match/followups.test.js index 37054329..7be1adf7 100644 --- a/test/match/followups.test.js +++ b/test/match/followups.test.js @@ -56,6 +56,56 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show m.dispose(); }); +// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金"); +// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income. +test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => { + const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true, + script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start(); + const m = h.m; + h.toPrep(14); + const ps = h.ps('p_0'); + const [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey)); + m.addBounty(ps, card); + const pending = ps.pendingFunds, gained = ps.stats.fundsGained; + h.drive(() => m.phase === PHASE.FINAL_ASSAULT); + m.hiddenLayerSum = 1e6; // the Hidden Core opens + assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle'); + h.run(() => m.runner === null); + assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count'); + assert.equal(ps.stats.fundsGained, gained + 3 + card.coin); + const left = bountyBattles(card); + assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles'); + if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1); + h.toPrep(15); + assert.equal(ps.pendingFunds, 0); + assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds'); + m.addBounty(ps, second); + const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0); + h.drive(() => m.phase === PHASE.HIDDEN_CORE); + assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === second.enemyKey), 'and in the Hidden Core'); + h.runToEnd(); + assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule'); + assert.equal(ps.bounties.length, 0, 'every bounty used its battles'); + checkInvariants(m); + m.dispose(); +}); + +test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true, + script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start(); + h.toPrep(14); + const m = h.m, ps = h.ps('p_0'); + const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey)); + m.addBounty(ps, { ...card, rounds: 2, multiRound: false }); + const gained = ps.stats.fundsGained; + const result = h.drive(() => h.ended != null); + assert.ok(result && h.ended.victory); + assert.equal(ps.pendingFunds, 3); + assert.equal(ps.stats.fundsGained, gained + 3); + assert.equal(ps.bounties[0].roundsLeft, 1); + m.dispose(); +}); + test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => { const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start(); const m = h.m;