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
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).
This commit is contained in:
1 parent
e63214571d
commit
66a228c64c
12 files changed
+171
-11
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-3
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user