From 66a228c64ca67aa755c9c6dad0e185e77e02cc5c Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 22:01:26 +0800 Subject: [PATCH] feedback7 dober: a boss field's perfect-payout card is paid only where the client's result agrees with its own b.progress (b.progress.leaksBy splits the field's LP per player); golden: unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen (review of this branch): the Final Assault / Hidden Core path trusts a client's b.result — validateClientResult skips the leak multiset for boss fields and nothing re-simulates — while the team LP a field costs arrives through b.progress.leaks. Commit 05eb2b80 paid the perfect-payout cards from the result's leaked list, so a modified authority could have the server charge a leak and then report two empty lists (LP 58 -> 57 charged, both seats +2: FORGE_BOTH_EMPTY) or pin the leak on the partner's list (the forger +2, the partner 0: FORGE_PAY). Now: b.progress carries `leaksBy` (shared/protocol.js, optional; spec.js battleProgress for a boss / hidden field): per player, the LP of the leak entries the result will show (compactResult's list, lpr clamped as on the wire); `leaks` minus its sum is the leader's own "扣除目标生命" effects (boss "Doom" / 斥退 / 萨米 Doom), which are no leak and must not cost a perfect seat its card. The browser runner and the test SimClient send it; the server keeps the highest value per player (f.leaksBy). Match._bossLeaksAgree (called by _finishFinal) pays a seat of a client-reported field only when (a) the LP the authority reported was split at all (leaksBy seen whenever lpReported > 0 — a report without the split explains nothing) and (b) the seat's leaked list shows at least the leak LP reported for it. A server-run field (no client, a takeover) is the truth itself. Not stopped, and said so in the docs: an authority that lies consistently (a split that blames the partner, a leak labelled as the leader's effect) — the boss path has no second source; what is caught is a result that contradicts its own progress. Tests (test first; the three forge tests failed on the parent with [2, 2] / [2, 0] paid, the runner test on the missing field): clientCombat.test.js — charged leak + two empty lists -> 0 / 0; the leak moved to the partner -> 0 / 0 (with and without a split); honest split -> the clean seat paid, the leaker not, a leader's effect LP costs nobody; two honest SimClients end to end; the protocol field; runner.test.js — the browser runner's boss progress carries leaksBy; docs-consistency pins the b.progress signature. Grok's two repro scripts now print +0 / +0. Golden: unchanged (matches 18 / full 283 scenarios match; every golden boss field is server-run). --- docs/META.md | 7 ++- docs/design/network.md | 2 +- public/js/battle/runner.js | 3 + server/match/match/bossRounds.js | 32 +++++++++- server/match/match/clientCombat.js | 6 +- server/match/match/reports.js | 1 + server/sim/spec.js | 17 +++++- shared/protocol.js | 7 ++- test/docs-consistency.test.js | 3 +- test/match/clientCombat.test.js | 97 ++++++++++++++++++++++++++++++ test/match/runner.test.js | 6 ++ test/match/simClient.js | 1 + 12 files changed, 171 insertions(+), 11 deletions(-) diff --git a/docs/META.md b/docs/META.md index bafc83ab..dbdc5d76 100644 --- a/docs/META.md +++ b/docs/META.md @@ -653,7 +653,12 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o 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. + the card's enemies do come in the boss battle] — and, for a field a client reported (the boss path never re-simulates + it), the result agrees with what its authority reported while it fought: LP it asked the team to pay must have been + split in `b.progress.leaksBy` (per player, the enemy leaks; the rest is the leader's own "扣除目标生命" effects), and a + player's leaked list must show at least its reported leak LP (`Match._bossLeaksAgree`), so a result cannot hide a charged + leak or hand it to the other seat; a server-run field is the truth itself. 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/docs/design/network.md b/docs/design/network.md index 0dcb3bf5..1a2ad4a2 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -77,7 +77,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { **Protocol (additions; `shared/protocol.js` is normative).** - `battleId` = `...` (`Match._ccField`; `` = the match seed in base 36 plus `-`, the room's match number the lobby passes as `opts.matchNo`; `` counts every battle of the match; the informational `.` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle. - S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up). -- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast). +- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, leaksBy?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`), `leaksBy` = its split per player (the LP the enemies that reached that player's goal cost: every leak entry the result will show × its `lpr`; `leaks` minus its sum is the leader's effects; `spec.js battleProgress`) — the server keeps the highest value per player and, before a perfect-payout bounty pays in a client-reported field, requires the result's leaked list of that player to show at least that much and the LP asked for to have been split at all (`Match._bossLeaksAgree`, the boss path does not re-simulate). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast). - C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`). - S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary. - S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below. diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index e8d25fbe..75c07c7d 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -431,6 +431,9 @@ export function createBattleRunner(deps) { if (bossLike(e)) { const pool = e.battle.sharedBoss; msg.leaks = Math.min(1e6, e.meter.lp); + // the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's + // leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree) + if (p.leaksBy) msg.leaksBy = p.leaksBy; msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0; if (pool && pool.byPlayer) { const by = {}; diff --git a/server/match/match/bossRounds.js b/server/match/match/bossRounds.js index 5397d954..2fc1bc9b 100644 --- a/server/match/match/bossRounds.js +++ b/server/match/match/bossRounds.js @@ -231,6 +231,35 @@ export class MatchBoss { if (credit > 0) this._teamLpLoss(credit); } + /** + * The authority's per-player split of the LP its field's enemy leaks cost (b.progress `leaksBy`: cumulative, per player, + * the leader's own LP effects not in it) — the highest value seen per player. The field's perfect-payout bounties are + * paid only where the final result agrees with it (`_bossLeaksAgree`). + */ + _noteLeaksBy(f, by) { + if (!by || typeof by !== 'object') return; + if (!f.leaksBy) f.leaksBy = {}; + for (const pid of f.players) { + const v = Number(by[pid]); + if (Number.isFinite(v) && v > (f.leaksBy[pid] || 0)) f.leaksBy[pid] = v; + } + } + + /** + * Whether a boss field's b.result may be believed about one player's leaks before a perfect-payout bounty pays: the + * result comes from a client (a server run is the truth itself) and does not contradict what that client reported while + * it fought — LP it asked the team to pay must have been split into the players' own leaks and the leader's effects + * (b.progress `leaksBy`; reports without the split explain nothing), and the player's leaked list must show at least the + * leak LP reported for it (a leak is never moved to the other seat or dropped). A modified authority can still lie in + * both; what the server cannot see it cannot check (the boss path never re-simulates). + */ + _bossLeaksAgree(f, res, pid) { + if (!f.cc || f.resultSource !== 'client') return true; + if (f.lpReported > 1e-9 && !f.leaksBy) return false; + const shown = ((res.perPlayer[pid] && res.perPlayer[pid].leaked) || []).reduce((n, l) => n + (Number.isFinite(l.lpr) && l.lpr >= 0 ? l.lpr : 1), 0); + return shown + 1e-6 >= ((f.leaksBy && f.leaksBy[pid]) || 0); + } + /** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */ _bossServerRun(f) { const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy }); @@ -496,7 +525,8 @@ export class MatchBoss { // 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); + const perfect = victory && !res.synthetic && pp.perfect === true && !(pp.leaked || []).some((l) => l && l.counted !== false) + && this._bossLeaksAgree(f, res, pid); this._settleBossBounties(ps, pp, perfect); } if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true }); diff --git a/server/match/match/clientCombat.js b/server/match/match/clientCombat.js index 3b78229d..91bf6b23 100644 --- a/server/match/match/clientCombat.js +++ b/server/match/match/clientCombat.js @@ -43,8 +43,10 @@ export class MatchClientCombat { bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null, // boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's // CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared' - // b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult) - bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null, + // b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult); `leaksBy`: the authority's + // per-player split of the LP its enemy leaks cost (b.progress, the highest value seen; null until one carried it), + // which a result must not contradict before a perfect-payout bounty pays (_bossLeaksAgree) + bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null, leaksBy: null, }; } diff --git a/server/match/match/reports.js b/server/match/match/reports.js index 1ee18482..ca58d019 100644 --- a/server/match/match/reports.js +++ b/server/match/match/reports.js @@ -29,6 +29,7 @@ export class MatchReports { if (f.kind === 'boss' || f.kind === 'hidden') { this._creditBoss(f, msg.bossDmg, msg.by); this._creditLp(f, msg.leaks, true); + this._noteLeaksBy(f, msg.leaksBy); this._checkFinalEnd(); this._broadcastPool(false); // 4 Hz per field: b.pool carries the exact pool / team LP; m.public (boss HP, LP, progress) follows at ~1 Hz diff --git a/server/sim/spec.js b/server/sim/spec.js index 225099a1..80c28d3e 100644 --- a/server/sim/spec.js +++ b/server/sim/spec.js @@ -263,7 +263,9 @@ export function uniteLeft(battle) { /** * Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks - * (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies + * (normal / unite), the boss pool damage of this field, `leaksBy` of a boss / hidden field — per player, the LP the + * enemies that reached its goal cost (every leak entry the result will show, × its `lpr`; the field's LP meter minus + * their sum is the leader's own "扣除目标生命" effects) — and, unite fields, `left` (uniteLeft: each leaker's enemies * still standing). */ export function battleProgress(battle) { @@ -285,6 +287,11 @@ export function battleProgress(battle) { const left = uniteLeft(battle); if (left) out.left = left; } + if (battle && (battle.kind === 'boss' || battle.kind === 'hidden')) { + const leaksBy = {}; + for (const pid of Object.keys(pp).slice(0, 4)) leaksBy[pid] = Math.min(1e6, leakEntries(pp[pid]).reduce((n, l) => n + leakLpr(l), 0)); + out.leaksBy = leaksBy; + } return out; } @@ -322,6 +329,10 @@ const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []); const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v); const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null); const keyOr = (v) => (isKey(v) ? v : null); +/** A player's leak entries as the b.result carries them (compactResult: a valid enemy key, at most 400). */ +const leakEntries = (p) => cap(p && p.leaked, 400).filter((l) => l && isKey(l.enemyKey)); +/** What one leak entry costs the team on the wire: its `lpr` (0 … 1000), 1 when absent. */ +const leakLpr = (l) => Math.max(0, Math.min(1000, fnum(l && l.lpr, 1))); function compactMods(m) { if (!m || typeof m !== 'object') return null; @@ -350,8 +361,8 @@ export function compactResult(res) { perPlayer[pid] = { killed: Math.min(total, Math.max(0, Math.trunc(fnum(p.killed)))), total, - leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => { - const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false }; + leaked: leakEntries(p).map((l) => { + const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: leakLpr(l), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false }; if (l.boss) o.boss = true; if (l.spawned) o.spawned = true; return o; diff --git a/shared/protocol.js b/shared/protocol.js index b7c8dfc6..401caf83 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -382,13 +382,16 @@ export const C2S = { // client-side combat (DESIGN §14): the authoritative client of a field reports its battle; a 联防 field adds // `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js - // uniteLeft; user playtest #6 item 7 — the leakers' live counter) + // uniteLeft; user playtest #6 item 7 — the leakers' live counter); a boss / hidden field adds `leaksBy` = { [playerId]: + // the cumulative LP the enemies that reached that player's goal cost } — `leaks` minus its sum is the leader's own + // "扣除目标生命" effects; the server holds the b.result's leaked lists to it (Match._bossLeaksAgree) 'b.progress': { battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5), leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG), by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool, left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)), - $optional: ['leaks', 'bossDmg', 'by', 'done', 'left'], + leaksBy: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, 1e6)), + $optional: ['leaks', 'bossDmg', 'by', 'done', 'left', 'leaksBy'], }, 'b.result': { battleId: isId, result: isBattleResult }, }; diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index e604e53d..576682c6 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -490,7 +490,8 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo assert.match(sec(17), /\*\*revised by §20\.6\*\*/); assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone'); assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/); - assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/); + assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, leaksBy\?, done\? \}`/); + assert.match(doc('shared/protocol.js'), /leaksBy: \(v\) => isMap\(v, RESULT_LIMITS\.players, isId, \(x\) => isNum\(x, 0, 1e6\)\)/); assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/); assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/); // #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2) diff --git a/test/match/clientCombat.test.js b/test/match/clientCombat.test.js index dab1443b..2f919e2f 100644 --- a/test/match/clientCombat.test.js +++ b/test/match/clientCombat.test.js @@ -579,6 +579,103 @@ test('boss bounty eligibility: forced real results on a team victory qualify; sy } }); +// ---- a perfect-payout bounty and what the authority reported (a modified client could tell the two halves apart) ------- + +/** + * A Final Assault pair field played by hand (the SimClient of p_0, the authority, is muted): both players hold the same + * perfect-payout card; the authority sends one b.progress — `leaks` (the field's LP cost) and `leaksBy` (its per-player + * split, when given) — then a 'cleared' b.result whose leaked lists hold `leaked[pid]` counted leaks (lpr 1); the fight + * is driven to its end. Returns the funds each player was paid (kill coins: none) and what the server charged. + */ +function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) { + const { h, m, f } = faWithForger(seed); + const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey)); + const ps = f.players.map((pid) => h.ps(pid)); + for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false }); + const before = ps.map((p) => p.pendingFunds); + const lp0 = m.teamLp; + const max = m.bossPool.maxHp; + h.sched.advance(500); + const gt = m._fieldElapsed(f); + const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 }; + const progress = { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks, ...(leaksBy ? { leaksBy } : {}) }; + assert.equal(validateC2S(progress), null); + assert.deepEqual(m.handle('p_0', progress), { ok: true }); + const entry = (pid) => ({ enemyKey: card.enemyKey, mods: null, lpr: 1, sourcePlayerId: pid, tag: null, counted: true, spawned: true }); + const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0, + perPlayer: Object.fromEntries(f.players.map((pid) => { + const list = Array.from({ length: leaked[pid] || 0 }, () => entry(pid)); + return [pid, { killed: 0, total: 0, leaked: list, perfect: list.length === 0, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }]; + })) }; + assert.equal(isBattleResult(res), true); + assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true }); + h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 }); + const out = { ending: m._finalEnding, source: f.resultSource, coin: card.coin, charged: lp0 - m.teamLp, paid: ps.map((p, i) => p.pendingFunds - before[i]), left: ps.map((p) => p.bounties.map((b) => b.roundsLeft)) }; + m.dispose(); + return out; +} + +test('Final Assault, a modified authority: LP charged by b.progress while both leaked lists are empty pays no perfect-payout card', () => { + const r = bossPaid(9124, { leaks: 1, leaked: {} }); + assert.equal(r.ending, 'cleared'); + assert.equal(r.source, 'client'); + assert.equal(r.charged, 1, 'the server charged the team leak'); + assert.deepEqual(r.paid, [0, 0], 'a result that shows no leak after a charged one is not believed'); + assert.deepEqual(r.left, [[1], [1]], 'the boss battle still used one of the cards\' battles'); +}); + +test('Final Assault, a modified authority: one seat\'s reported leak cannot be moved to the other — nobody is paid', () => { + // progress: the leak is p_0's; the result pins it on p_1 so that p_0 reads perfect (and p_1 cannot be paid anyway) + const r = bossPaid(9125, { leaks: 1, leaksBy: { p_0: 1, p_1: 0 }, leaked: { p_1: 1 } }); + assert.equal(r.ending, 'cleared'); + assert.equal(r.charged, 1); + assert.deepEqual(r.paid, [0, 0]); + // …and with no split at all the result decides nothing either: the same leak pinned on p_1 + assert.deepEqual(bossPaid(9126, { leaks: 1, leaked: { p_1: 1 } }).paid, [0, 0]); +}); + +test('Final Assault, honest authority: the seat whose half let nothing through is paid, the leaker is not; a leader\'s own LP effect costs nobody the card', () => { + const leak = bossPaid(9127, { leaks: 1, leaksBy: { p_0: 0, p_1: 1 }, leaked: { p_1: 1 } }); + assert.equal(leak.charged, 1); + assert.deepEqual(leak.paid, [leak.coin, 0]); + // 2 LP of leader "扣除目标生命" effects (no leaked enemy): the report says so, the result agrees — both halves are perfect + const fx = bossPaid(9128, { leaks: 2, leaksBy: { p_0: 0, p_1: 0 }, leaked: {} }); + assert.equal(fx.charged, 2); + assert.deepEqual(fx.paid, [fx.coin, fx.coin]); + // nothing charged, nothing leaked: as before + assert.deepEqual(bossPaid(9129, { leaks: 0, leaked: {} }).paid.map((n, i, a) => n === a[0]), [true, true]); + assert.ok(bossPaid(9130, { leaks: 0, leaked: {} }).paid.every((n) => n > 0)); +}); + +test('Final Assault, honest clients (client-side combat): a leak on one half costs that seat its perfect-payout card, not its partner; the leader\'s own LP effect costs nobody', () => { + const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9132, fake: true, clientCombat: true, instant: false, + script: (b) => (b.kind === 'boss' ? { bossDps: 20000, leaks: { p_1: 1 }, leakEvents: [{ at: 1, lpr: 1 }], lpLossEvents: [{ at: 1.5, amount: 2 }] } : { duration: 2 }) }).start(); + const m = h.m; + h.autoHumans(); + h.drive(() => m.phase === PHASE.FINAL_ASSAULT); + const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey)); + const [p0, p1] = [h.ps('p_0'), h.ps('p_1')]; + for (const p of [p0, p1]) m.addBounty(p, { ...card, rounds: 2, multiRound: false }); + const [a, b] = [p0.pendingFunds, p1.pendingFunds]; + const end = h.runToEnd(); + assert.equal(end.victory, true); + const f = fields(h)[0]; + assert.equal(f.resultSource, 'client'); + assert.equal(f.lpReported, 3, 'leak lpr 1 + leader effect 2'); + assert.deepEqual(f.leaksBy, { p_1: 1 }, 'the authority split it: p_1\'s one leak (the other 2 LP are the leader\'s effect), nothing on p_0'); + assert.equal(p0.pendingFunds - a, card.coin, 'the seat that let nothing through is paid'); + assert.equal(p1.pendingFunds - b, 0, 'the seat that leaked is not'); + m.dispose(); +}); + +test('b.progress carries `leaksBy`, the per-player split of a boss field\'s leak LP (shared/protocol.js)', () => { + const base = { t: 'b.progress', battleId: 'a.1.1.b1', gt: 3, killed: 0, total: 0, leaks: 2, bossDmg: 10, by: { p_0: 10 } }; + assert.equal(validateC2S({ ...base, leaksBy: { p_0: 1, p_1: 0.5 } }), null); + assert.equal(validateC2S({ ...base, leaksBy: {} }), null); + assert.equal(validateC2S(base), null, 'optional: a client that never sends it still validates'); + for (const bad of [{ p_0: -1 }, { p_0: 'x' }, { p_0: 1e7 }, [1, 2], 'p_0', null, { 'a b': 1 }]) assert.notEqual(validateC2S({ ...base, leaksBy: bad }), null, JSON.stringify(bad)); +}); + 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/runner.test.js b/test/match/runner.test.js index 7ebf1318..0648ede4 100644 --- a/test/match/runner.test.js +++ b/test/match/runner.test.js @@ -217,6 +217,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo assert.ok(last.bossDmg <= pool.cum); assert.ok(last.by && typeof last.by === 'object'); assert.equal(typeof last.leaks, 'number'); + // the per-player split of the leak LP (the server holds the result's leaked lists to it, Match._bossLeaksAgree): the + // leaks of every player of the field, never more than the field's LP cost (leader effects make up the rest) + assert.ok(last.leaksBy && typeof last.leaksBy === 'object', 'boss progress carries leaksBy'); + assert.deepEqual(Object.keys(last.leaksBy).sort(), start.spec.players.map((p) => p.playerId).sort()); + assert.ok(Object.values(last.leaksBy).every((n) => Number.isFinite(n) && n >= 0)); + assert.ok(Object.values(last.leaksBy).reduce((a, b) => a + b, 0) <= last.leaks + 1e-9); r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } }); assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged'); r.runner.dispose(); diff --git a/test/match/simClient.js b/test/match/simClient.js index 1765e4a4..f1a3acd8 100644 --- a/test/match/simClient.js +++ b/test/match/simClient.js @@ -133,6 +133,7 @@ export class SimClient { if (bossLike) { const pool = e.battle.sharedBoss; msg.leaks = e.meter.lp; + if (p.leaksBy) msg.leaksBy = p.leaksBy; // like public/js/battle/runner.js msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0; if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer }; } else {