feedback7 UIBACK: a finished 联防 / boss-pair field publishes the capsule's own resolved, not the sum of the players' clamps — an enemy that enters one helper's half and leaks on the other's is billed to one player's total and to the other's leakedInTotal, so each player's min(total, killedInTotal + leakedInTotal) read 0 while the field read 1/1 and the teammates' m.public progress dropped to 0/1 once the field finished (found reviewing the capsule port of PR #308): validateClientResult keeps the result's field-level resolved (Battle.resolved, clamped to the field's total; junk counts as not reported), _fieldProgress publishes it for client-run and server-run fields alike, the players' own numbers are only the fallback of a result with none, and none at all stays null; SIM.md §1.3 and network.md (顶栏胶囊, result validation) say so; test/match/hud-capsule-resolved.test.js +5 (a 3-human match whose 联防 field runs the real sim with unarmed helpers: capsule 4/4 and m.public 4/4 not 0/4; the boss pair's crossing route; a server-run 联防 field and its timeline; validateClientResult's field-level rule; _fieldProgress's precedence) — all failing before; golden: unchanged (no sim change; compare: roster 49, bonds 46, fields 22, matches 18, standins 10, diy 138 match)

This commit is contained in:
sganggs committed 2026-10-08 20:40:42 +08:00
1 parent f632e0b009
commit 1a88069968
5 files changed
+186 -16

No files matched your search

+2 -2
View File
@@ -88,10 +88,10 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
- `resolved` = `min(total, killedInTotal + leakedInTotal)` (`Battle.resolved`): that same set's enemies that are 已解决 — knocked down (`killedInTotal`) **or** leaked (`leakedInTotal`). Official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
- Everything spawned while the battle runs is in **neither** part: a split child (磨砻's 木制瑞印), a summon (枯朽萃聚使徒's 枯朽之种, a boss's 余音 / 妖怪 / 不祥幻影 / 王权号令 equipment), a 变身 copy, a part. They still count for LP, 完美作战 and `killed` exactly as master did (`counted`): a leaked split child costs its `lpr` and breaks 完美作战, it just moves no capsule number.
- `killed` (`Battle.killed`, per player too) keeps the official `counted` reading — a runtime split / summon knocked down counts — so it may **exceed** `total` (4 磨砻 killed plus one of their 8 children reads `killed` 5 with `total` 4 while the capsule reads 4/4). Nothing clamps it: the match's stats and the bot's scoring read those knock-outs.
- Carried by `b.snap`, `BattleResult` (battle level and per player, `resolved` clamped to the player's `total`), `b.progress` and `m.public.fields[].progress`. A bot / server-run field has no authority reporting `b.progress`, so its capsule reads the battle's own counters through the `HeadlessJob` timeline sample (`[gt, killed, total, resolved(, left)]`), never the report-driven `progress.leaks` (which stays 0 there); `progress.resolved` starts at `null` and is only adopted from a real reported integer — a reported `0` is a value, "not reported" is not.
- Carried by `b.snap`, `BattleResult` (battle level and per player, the per-player `resolved` clamped to the player's `total`), `b.progress` and `m.public.fields[].progress`. **A finished field publishes the field's own number, never the sum of the players':** in a two-half field (the 联防 lane, the boss pair's crossing routes) an enemy that spawns on one half and leaks on the other is billed to one player's `total` and to the other's `leakedInTotal`, so each player's own `min(total, …)` reads 0 for an enemy the field resolved (capsule 1/1, players 0 and 0). `validateClientResult` therefore keeps the result's field-level `resolved` (the client's `Battle.resolved`, clamped to the field's `total`) and `_fieldProgress` publishes it — for a server-run field too, whose `Battle.result()` carries the same number; the per-player numbers are only the fallback of a result that has none, and with none at all `progress.resolved` is `null`. A bot / server-run field has no authority reporting `b.progress`, so its capsule reads the battle's own counters through the `HeadlessJob` timeline sample (`[gt, killed, total, resolved(, left)]`), never the report-driven `progress.leaks` (which stays 0 there); `progress.resolved` starts at `null` and is only adopted from a real reported integer — a reported `0` is a value, "not reported" is not.
- The HUD reads it as `resolved ?? killed` (`ui/hud.js` two capsules, `ui/combatHud.js` teammates' rows, `ui/gameLogic/format.js snapHud`, `battle/observe.js teammateProgress`), so a server that does not send `resolved` displays master's numbers unchanged.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ maxTotal` and `total ≤ maxTotal` (`4·spawns + 100`, the content-spawn allowance — `killed ≤ total` no longer holds, see the capsule above) and `resolved ≤ total`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ maxTotal` and `total ≤ maxTotal` (`4·spawns + 100`, the content-spawn allowance — `killed ≤ total` no longer holds, see the capsule above) and the field-level `resolved ≤ total`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Boss rounds.** The shared pool (`SharedBossPool`, size = `bloodPoint[difficulty]` × the players alive when the fight starts, solo × 1 — the owner's decision of 2026-10-06 adopting PR #209, which replaces the fixed pool of 「保持固定血量」, §25.13.4) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`; BOSS_HIT tickers from each player's damage to that round's pool, §21.10) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.