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

+5 -2
View File
@@ -431,8 +431,11 @@ recorded for the player whose half contains the goal it reached, with `sourcePla
capsule's numerator (official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3). A leaked split child or summon
moves none of them, while `leakedCount` / `leaked[]` still charge its LP and break 完美作战. The per-player numbers are
attributed like their `counted` twins — a knock-out to the enemy's owner (as `total`), a leak to the player whose half it
reached (`_recordLeak`) — so in a 联防 field a per-player sum can pass that player's `total` and `resolved` clamps it
(the battle-level numbers stay exact).
reached (`_recordLeak`) — so in a two-half field (the 联防 lane, col 18 → col 2; the boss pair's crossing routes) an enemy
that spawns on one half and leaks on the other is in one player's `total` and in the other's `leakedInTotal`, and each
player's own `resolved` clamps it away (0 and 0 for an enemy the field resolved). Only the battle-level numbers are exact:
the match never sums the per-player `resolved` — `validateClientResult` keeps the result's own and `m.public` publishes
it (design/network.md 顶栏胶囊).
- `killed` therefore may **exceed** `total` (it counts runtime children, they do not): 4 磨砻 killed plus one of their 8
木制瑞印 reads `killed` 5 with `total` 4 and a capsule of 4/4. `spec.js compactResult` and the match's validation bound
`killed` by the spec's content-spawn allowance (`4·spawns + 100`) instead of by `total`.
+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.
+15 -3
View File
@@ -751,7 +751,11 @@ const sameMods = (a, b) => {
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* perfect consistent with the counted leaks; unit states only for the player's own units, within range. `resolved` (the
* HUD capsule's numerator) is kept as reported, clamped to the player's `total`; an absent one stays absent.
* HUD capsule's numerator): the result's own — the FIELD's, what the client's capsule showed — is kept as reported,
* clamped to the field's `total`, and is what m.public publishes (never the sum of the players': an enemy that crosses
* the midline of a two-half field is billed to one player's `total` and to the other's leak, so their own numbers
* clamp it to 0); the players' own (each clamped to their `total`) are the fallback of a result with none; absent when
* neither is complete.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
export function validateClientResult(spec, raw, { gd = null } = {}) {
@@ -888,12 +892,20 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
}
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
let ownResolved = 0, ownKnown = pids.length > 0;
for (const pid of pids) {
result.killed += perPlayer[pid].killed;
result.total += perPlayer[pid].total;
result.resolved = (result.resolved ?? 0) + (Number.isInteger(perPlayer[pid].resolved) ? perPlayer[pid].resolved : 0);
if (Number.isInteger(perPlayer[pid].resolved)) ownResolved += perPlayer[pid].resolved; else ownKnown = false;
}
if (pids.some((pid) => !Number.isInteger(perPlayer[pid].resolved))) delete result.resolved;
// The FIELD's capsule numerator (Battle.resolved, what the client's own capsule showed), kept as reported and clamped
// to the field's total — NOT the sum of the players' own numbers: an enemy that spawns on one half and leaks on the
// other (the 联防 lane, the boss pair's crossing routes) is billed to one player's `total` and to the other's
// `leakedInTotal`, so each player's own min(total, …) clamps it away (0 and 0 for an enemy the field resolved). The sum
// is only the fallback of a result with no field-level number; none of either stays absent (the teammate UI then
// falls back to `killed`, never to a fabricated 0).
if (Number.isInteger(raw.resolved) && raw.resolved >= 0) result.resolved = Math.min(result.total, raw.resolved);
else if (ownKnown) result.resolved = Math.min(result.total, ownResolved);
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
const unspawned = [];
for (const u of raw.unspawned) {
+9 -4
View File
@@ -151,14 +151,19 @@ export class MatchViews {
return { killed: Number(b.killed) || 0, resolved: finiteOrNull(b.resolved, total), total, done: !f.live };
}
if (f.done && f.result) {
let killed = 0, total = 0, resolved = 0, known = true;
let killed = 0, total = 0, own = 0, ownKnown = true;
for (const pp of Object.values(f.result.perPlayer || {})) {
killed += Number(pp && pp.killed) || 0;
total += Number(pp && pp.total) || 0;
if (Number.isFinite(pp && pp.resolved)) resolved += Number(pp.resolved); else known = false;
if (Number.isFinite(pp && pp.resolved)) own += Number(pp.resolved); else ownKnown = false;
}
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; resolved = f.progress.resolved; known = Number.isFinite(resolved); }
return { killed, resolved: known ? Math.min(total, resolved) : null, total, done: true };
// the FIELD's numerator (the validated client result / Battle.result(): what the capsule showed) — not the sum of the
// players' own: an enemy that spawns on one half and leaks on the other (a 联防 lane, the boss pair's crossing routes)
// is billed to one player's `total` and to the other's leak, so their own min(total, …) clamp it to 0
let resolved = finiteOrNull(f.result.resolved, total);
if (resolved == null && ownKnown) resolved = Math.min(total, own);
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; resolved = finiteOrNull(f.progress.resolved, total); }
return { killed, resolved, total, done: true };
}
if (f.mode === 'server' && f.timeline) {
// a server-run / bot field: no authority ever sends a b.progress, so the capsule reads the battle's own counters —
+155 -5
View File
@@ -5,15 +5,21 @@
// review point ①);
// * `progress.resolved` starts at **null**, never 0: only a real reported number is adopted, so the client's
// `resolved ?? killed` fallback keeps working (a `Number(null) === 0` would defeat it) — and a reported 0 is a real
// value, told apart from "not reported" (review point ②).
// value, told apart from "not reported" (review point ②);
// * a finished two-half field (联防 with two helpers, the boss pair) publishes the FIELD's own `resolved` — the capsule's
// number — never the sum of the players' own clamps: 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 both players' min(total, …) read 0 for it
// while the field resolved it (found reviewing the capsule port, 2026-10-08).
// Run: node --test test/match/hud-capsule-resolved.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch } from './harness.js';
import { timelineSample, timelineAt } from '../../server/match/fields.js';
import { battleProgress } from '../../server/sim/spec.js';
import { makeMatch, DATA } from './harness.js';
import { timelineSample, timelineAt, validateClientResult, runHeadless } from '../../server/match/fields.js';
import { buildBattleSpec, createBattleFromSpec, compactResult, battleProgress } from '../../server/sim/spec.js';
import { GameData } from '../../server/match/gamedata.js';
import { MatchViews } from '../../server/match/match/views.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { teammateProgress } from '../../public/js/battle/observe.js';
import { uniteLeftMark } from '../../public/js/battle/runner.js';
@@ -90,7 +96,7 @@ test('② 上报没有 resolved ⇒ progress.resolved = null,客户端回落
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 0, total: 9, done: false });
});
test('a done field\'s m.public progress sums the reported per-player resolved (never a fabricated one)', () => {
test('a done field\'s m.public progress carries the reported resolved (never a fabricated one)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9104, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 2 }) }).start();
const m = h.m;
h.toPrep(1);
@@ -129,3 +135,147 @@ test('a 联防 battle\'s live-counter mark still moves for a spawn — `total` n
h.b.kill(child, null);
assert.notEqual(uniteLeftMark(h.b), m1, 'a knock-out moves it');
});
// ---- an enemy that crosses the midline of a two-half field -------------------------------------------------------------
//
// The capsule counts per FIELD (Battle.resolved = min(total, killedInTotal + leakedInTotal)), but the sim also keeps every
// player's own counters: `total` and a knock-out belong to the half the enemy spawned on, a leak to the half whose tile it
// reached (deploy.js _recordLeak). A lane that runs through both halves — the 联防 lane (col 18 → col 2), the boss pair's
// crossing routes — bills one enemy's `total` to one player and its leak to the other, so each player's own
// min(total, killedInTotal + leakedInTotal) clamps it away: 0 and 0 for an enemy the field resolved. A finished field must
// publish the field's own number (what the capsule showed), never the sum of the players' clamps.
const gd = new GameData(DATA, 'mode_multi_hard');
/** m.public.fields[].progress of a finished client-combat field (the done branch of Match._fieldProgress). */
const doneView = (result, progress = { killed: 0, total: 0, leaks: 0, resolved: null }) => MatchViews.prototype._fieldProgress.call({}, { cc: true, done: true, result, progress });
const clone = (v) => JSON.parse(JSON.stringify(v));
/** A 联防 spec of two helpers who fielded nothing, and one escaped enemy that walks the whole lane (col 18 → col 2). */
function uniteCrossSpec() {
return buildBattleSpec({
battleId: 'mid-u', fieldId: 'u', kind: 'unite', seed: 11, modeId: 'mode_multi_normal', round: 1, stageId: 'act1autochess_m02',
rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, timeLimit: 90,
players: [
{ playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} }, // the half the lane starts on (col 18)
{ playerId: 'p_2', seat: 2, side: 'L', colOffset: 0, units: [], bonds: {} }, // the half it leaves on (col 2)
],
routes: [{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] }],
spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 1, sourcePlayerId: 'p_0', mods: { hpMul: 0.8, atkMul: 0.8, speedMul: 1, slot: 'N' } }],
flags: { layerGainsEnabled: false, dpInit: 10, dpPerSec: 1, dpMax: 99 },
});
}
const newBattle = (spec) => createBattleFromSpec(spec, null, { recordEvents: false, quiet: true });
test('联防 whose escaped enemies enter on one helper\'s half and leak on the other\'s: the finished field publishes the capsule\'s own 4/4, not 0/4', () => {
let ran = null;
// every client runs the 联防 spec with the REAL sim (the fake battles only stand in for the normal round); the helpers
// fielded nothing, so the leaker's four escaped enemies walk the whole lane and leak on the far half
const realUnite = (result, spec) => {
if (spec.kind !== 'unite') return result;
const b = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true });
const res = b.runToEnd(4000);
ran = { res, capsule: battleProgress(b) };
return compactResult(res);
};
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}),
perPlayer: Object.fromEntries(['p_0', 'p_1', 'p_2'].map((pid) => [pid, { tamper: realUnite }])) }).start();
const m = h.m;
h.toPrep(1);
const unite = () => fields(h).find((x) => x.fieldId === 'u');
assert.ok(h.drive(() => (unite() && unite().done) || h.ended != null) && unite().done, 'the 联防 field finished');
const f = unite();
assert.equal(f.resultSource, 'client', 'the authority\'s own result was accepted');
assert.ok(ran, 'the authority ran the 联防 spec with the real sim');
// the premise: the enemies crossed the midline — `total` on the half they entered, the leaks on the half they left
const per = Object.values(ran.res.perPlayer);
assert.deepEqual(per.map((p) => p.total).sort(), [0, 4], 'every total is billed to the half the enemies spawned on');
assert.deepEqual(per.map((p) => p.leaked.length).sort(), [0, 4], 'every leak to the half they reached');
assert.deepEqual(per.map((p) => p.resolved), [0, 0], 'so each player\'s own min(total, …) reads 0');
assert.equal(ran.capsule.resolved, 4, 'while the field\'s capsule read 4/4');
assert.equal(ran.res.resolved, 4);
// what the server keeps and publishes is the field's number
assert.equal(f.result.resolved, 4, 'the validated result carries the field-level numerator');
m.flush(true);
assert.deepEqual(progressOf(m, 'u'), { killed: 0, resolved: 4, total: 4, done: true }, 'm.public: the capsule\'s own 4/4');
m.dispose();
});
test('the boss pair: an enemy spawned on one half that leaks on the other half\'s goal reads 1/1 in the published progress too', () => {
const PAIR = [
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} },
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {} },
];
// route 3 of the flat boss stage: the centre gate (col 10, the left half's) → the right goal (col 18, the right half's)
const h = makeBattle({ kind: 'boss', players: PAIR, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 3 }], timeLimit: Infinity, content: 'none', autoFinish: true });
h.runToEnd(120);
const res = h.result();
assert.equal(res.reason, 'cleared');
assert.equal(res.perPlayer.L.total, 1, 'billed to the half it spawned on');
assert.equal(res.perPlayer.R.leaked.length, 1, 'leaked on the other half');
assert.deepEqual([res.perPlayer.L.resolved, res.perPlayer.R.resolved], [0, 0], 'each player\'s own clamp reads 0');
assert.equal(res.resolved, 1, 'the field resolved it');
const spec = buildBattleSpec({ battleId: 'mid-b', fieldId: 'b:pair', kind: 'boss', seed: 3, round: 8, players: PAIR,
spawns: [{ time: 0, enemyKey: 'enemy_1007_slime', routeIndex: 3, count: 1 }], flags: { layerGainsEnabled: false } });
const v = validateClientResult(spec, compactResult(res), { gd });
assert.ok(v.ok, v.reason);
assert.equal(v.result.resolved, 1);
assert.deepEqual(doneView(v.result), { killed: 0, resolved: 1, total: 1, done: true });
});
test('a server-run 联防 field (a bot, a takeover) reads the same: Battle.result()\'s field-level number, and so does its timeline', () => {
const spec = uniteCrossSpec();
const run = runHeadless(newBattle(spec), { players: spec.players.map((p) => p.playerId) });
const res = run.result;
assert.equal(res.reason, 'cleared');
assert.deepEqual(Object.values(res.perPlayer).map((p) => [p.total, p.leaked.length, p.resolved]).sort(), [[0, 1, 0], [1, 0, 0]], 'the lane crossed the midline');
assert.equal(res.resolved, 1);
assert.deepEqual(doneView(res), { killed: 0, resolved: 1, total: 1, done: true }, 'the done field of a server run');
assert.equal(run.timeline[run.timeline.length - 1][3], 1, 'the live timeline sample of that field read 1 too');
// SP_VERIFY compares a client result with the server's run: both go through the same validation
assert.equal(validateClientResult(spec, compactResult(res), { gd }).result.resolved, 1);
});
test('validateClientResult: the field-level resolved is kept as reported (clamped to the field\'s total); the players\' own numbers are only the fallback', () => {
const spec = uniteCrossSpec();
const raw = compactResult(newBattle(spec).runToEnd(200));
assert.equal(raw.resolved, 1);
const checked = (edit) => {
const r = clone(raw);
edit(r);
const v = validateClientResult(spec, r, { gd });
assert.ok(v.ok, v.reason);
return v.result;
};
assert.equal(checked(() => {}).resolved, 1, 'as the client reported it (the per-player numbers are both 0 here)');
assert.equal(checked((r) => { r.resolved = 0; }).resolved, 0, 'a reported 0 is a value');
assert.equal(checked((r) => { r.resolved = 99; }).resolved, 1, 'never above the field\'s total');
// an older client (or a hand-made result) that reports no field-level number: the players' own, when every one has one
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 1; }).resolved, 1, 'the fallback is the sum of the players\' own numbers');
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 5; r.perPlayer.p_2.resolved = 5; }).resolved, 1, 'the fallback too is clamped to the total');
// a number that is not a non-negative integer counts as not reported
for (const junk of [-1, 0.5, '1', null, NaN]) assert.equal(checked((r) => { r.resolved = junk; r.perPlayer.p_1.resolved = 1; }).resolved, 1, `junk ${String(junk)} → the fallback`);
// nothing reported anywhere, or one player without a number: absent — the teammate UI falls back to `killed`, never to a fabricated 0
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; }), false);
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_2.resolved; }), false);
assert.equal(doneView(checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; })).resolved, null, 'm.public: null, not 0');
});
test('a finished field\'s progress: the result\'s field-level numerator wins, the players\' own are the fallback, a synthetic result keeps the field\'s live report', () => {
const pp = (o) => ({ killed: 0, total: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, ...o });
// the field-level number wins over the players' own (a crossing field sums to less than it resolved)
assert.deepEqual(doneView({ perPlayer: { a: pp({ total: 3, resolved: 0 }), b: pp({ total: 0, resolved: 0 }) }, resolved: 2 }), { killed: 0, resolved: 2, total: 3, done: true });
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 9 }).resolved, 3, 'clamped to the published total');
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 0 }).resolved, 0, 'a reported 0 is a value');
// no field-level number: the sum of the players' own when every one reported, else unknown (never a fabricated 0)
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({ resolved: 2 }) } }).resolved, 3);
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({}) } }).resolved, null);
assert.equal(doneView({ perPlayer: { a: pp({}) } }).resolved, null);
// a synthetic result (the field could not run: zeroes everywhere) shows the last live report instead
const synthetic = { synthetic: true, resolved: 0, perPlayer: { a: pp({ total: 0, resolved: 0 }) } };
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: 3 }), { killed: 2, resolved: 3, total: 5, done: true });
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: null }), { killed: 2, resolved: null, total: 5, done: true });
});