feedback7 UIBACK: the top-bar capsule counts like the official — resolved = killed + leaked among the enemies the round scheduled (opening 0/3, a leak 1/3, a kill 2/3, the splitter 3/3); a split child, a summon and a boss summon are in neither number but still cost LP and break 完美作战 (PR #308 by @xcdoge, the capsule half of PR #157 — authored by them: the sim / protocol / HUD hunks applied as reviewed): inTotal / killedInTotal / leakedInTotal / resolved, b.snap / BattleResult / b.progress (optional field) / m.public progress carry resolved, killed may exceed total (validation bounds it by the content-spawn allowance); dropped the PR's stray design-doc line — it had replaced the S→C b.end bullet of docs/design/network.md, which is back; the 联防 live-counter change mark follows the enemy list now that a spawn no longer moves total (uniteLeftMark + test); golden: only total / players.*.total / snaps move, outcomes bit-identical — roster 23 (roster-002 003 007 008 009 010 011 012 013 015 017 018 020 023 024 025 026 027 037 045 047 048 049), bonds 2 (bond-maniShip-low, bond-soloShip-high), fields 11 (boss-boss_1 3 5 pair+solo, boss-boss_6-solo, hidden-boss_8 10 pair+solo), diy 11 (diy-036 040 056 062 068 073 083 091 095 114 115), matches 12 (solo-FUNNY-1 NORMAL-1 HARD-1 ABYSS-1 ABYSS-2, coop2-NORMAL-3 coop2-HARD-4 coop3-ABYSS-5 coop4-ABYSS-7 coop2-NORMAL-8-serverrun solo-HARD-9-boosted coop2-NORMAL-70-diy: the total column of 36 round rows), standins unchanged

This commit is contained in:
xcdoge authored and sganggs committed 2026-10-08 19:30:36 +08:00
1 parent d7c665dbf6
commit b4bc89ad4e
39 files changed
+836 -252

No files matched your search

+40 -18
View File
@@ -17,9 +17,11 @@
// Client-side combat (DESIGN §14) helpers:
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
// under a real scheduler) or at once (verification, virtual time) — and keep a
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
// enemies still standing, for their live counter; user playtest #6 item 7)
// progress timeline [[gt, killed, total, resolved]] for the teammates' waiting UI
// (联防: a 5th element — left = spec.js uniteLeft, the leakers'
// enemies still standing, for their live counter; user playtest #6 item 7).
// `resolved` = the HUD capsule's numerator (Battle.resolved), sampled on the
// field clock: a server-run / bot field has no authority reporting b.progress
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
// (boss fields that share the pool while other fields run on clients); a
// takeover's fast-forward is spread over the pacing intervals
@@ -56,7 +58,7 @@ export function snapFrame(fieldId, snap) {
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
export function emptyPerPlayer() {
return { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
return { killed: 0, total: 0, resolved: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
}
/** A finished stand-in for a battle that failed to construct. */
@@ -72,13 +74,13 @@ export class DeadBattle {
this.errorCount = 1;
const perPlayer = {};
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
this.errors = [];
}
step() {}
forceEnd() {}
result() { return this._result; }
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0 }; }
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0, resolved: 0 }; }
drainEvents() { return []; }
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
on() { return null; }
@@ -238,7 +240,7 @@ export class FieldRunner {
}
const perPlayer = {};
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
}
}
@@ -259,11 +261,15 @@ export const CATCHUP_TICKS_PER_INTERVAL = 240;
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/**
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
* (spec.js uniteLeft; omitted when unknown).
* A timeline sample of a battle: [gt, killed, total, resolved], plus — 联防 — the leakers' enemies still standing at
* index 4 (spec.js uniteLeft; omitted when unknown). `resolved` is the HUD capsule's numerator of that game second
* (Battle.resolved: the field's own scheduled enemies knocked out or leaked) — the server-run / bot fields have no
* authority that reports b.progress, so the teammates' capsule reads it from here, never from `progress.leaks`.
*/
export function timelineSample(b) {
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
const total = Number(b && b.total) || 0;
const resolved = Number.isFinite(b && b.resolved) ? Math.max(0, Math.min(total, b.resolved)) : null;
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, total, resolved];
if (b && b.kind === 'unite') {
let left = null;
try { left = uniteLeft(b); } catch { left = null; }
@@ -276,7 +282,7 @@ export function timelineSample(b) {
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
* runs (timelineSample: [gt, killed, total, resolved(, left)] every TIMELINE_EVERY game seconds, then the final state);
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
*/
@@ -345,9 +351,9 @@ export function runHeadless(battle, opts = {}) {
return job.output();
}
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
/** Timeline sample at game time `gt`: [gt, killed, total, resolved(, left)] of the last sample ≤ gt. */
export function timelineAt(timeline, gt) {
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0, null];
let lo = 0, hi = timeline.length - 1;
if (gt >= timeline[hi][0]) return timeline[hi];
while (lo < hi) {
@@ -734,7 +740,9 @@ const sameMods = (a, b) => {
/**
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
* nobody else; killed ≤ total ≤ bound; each leaked enemy key exists in the spawns (multiset bound; keys content may
* nobody else; killed ≤ bound and total ≤ bound (maxTotal: `killed` counts every counted knock-out — runtime splits
* and summons included — and may exceed `total`, which counts only the round's own scheduled enemies; PR #157 capsule
* semantics); each leaked enemy key exists in the spawns (multiset bound; keys content may
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
@@ -742,7 +750,8 @@ const sameMods = (a, b) => {
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
* 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.
* 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.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
export function validateClientResult(spec, raw, { gd = null } = {}) {
@@ -781,7 +790,10 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
const p = raw.perPlayer[pid];
if (!p || typeof p !== 'object') return bad('player');
const own = B.players.get(pid);
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > p.total || p.total > B.maxTotal) return bad('counts');
// `killed` counts every counted knock-out — a runtime split child / summon too — while `total` counts only the
// enemies the round scheduled, so `killed ≤ total` no longer holds (PR #157 capsule semantics): bound it by
// maxTotal (the spec's content-spawn allowance) instead, exactly like `total`
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > B.maxTotal || p.total > B.maxTotal) return bad('counts');
const leaked = [];
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
@@ -820,7 +832,9 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
leaked.push(e);
}
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
if (!bossLike && countedLeaks > p.total + B.spawnCount) return bad('leaks > total');
// `total` is the round's own scheduled enemies; the leaks may include content-spawned children (a split / summon),
// which the spec bounds by maxTotal (the same allowance `killed` is checked against above)
if (!bossLike && countedLeaks > p.total + B.maxTotal) return bad('leaks > total');
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
@@ -868,10 +882,18 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
unitsEnd, unitStats,
};
// the HUD capsule's numerator: kept only when the client reported one (an absent value must stay absent — the
// teammate UI then falls back to `killed`, never to a fabricated 0); clamped to the denominator
if (Number.isInteger(p.resolved) && p.resolved >= 0) perPlayer[pid].resolved = Math.min(p.total, p.resolved);
}
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)) };
for (const pid of pids) { result.killed += perPlayer[pid].killed; result.total += perPlayer[pid].total; }
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 (pids.some((pid) => !Number.isInteger(perPlayer[pid].resolved))) delete result.resolved;
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
const unspawned = [];
for (const u of raw.unspawned) {
+6 -2
View File
@@ -35,11 +35,15 @@ export class MatchClientCombat {
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
let total = 0;
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part') total += Math.max(1, Math.floor(Number(x.count) || 1));
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part' && x.countInTotal !== false) total += Math.max(1, Math.floor(Number(x.count) || 1));
return {
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
// progress: the field's own numbers as far as they are known before its first b.progress / result (`total` = the
// spec's scheduled enemies — the capsule's denominator). `resolved` starts at **null**: it is only adopted from a
// real report, so `resolved ?? killed` fallbacks (an unreported field, a synthetic result) keep working — a 0 here
// would read as "nothing resolved yet" and could never be told apart from a reported 0.
progress: { gt: 0, killed: 0, total, leaks: 0, resolved: null, done: false }, lastProgressAt: this.sched.now(),
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'
+5
View File
@@ -25,6 +25,11 @@ export class MatchReports {
// the latest total (spawns never reached before the limit leave it at the end)
p.total = Math.min(maxTotal, Math.max(0, msg.total | 0));
p.killed = Math.min(p.total, Math.max(p.killed, msg.killed | 0));
// the HUD capsule's numerator as the authority reported it (shared/protocol.js b.progress `resolved`). Only a real
// integer is adopted — an absent field leaves the null placeholder alone, so "not reported" and a reported 0 differ
// (a `Number(null) === 0` here would defeat every `resolved ?? killed` fallback); a lower value is kept (the final
// report drops never-spawned enemies from `total`, and a takeover may report a different number)
if (Number.isInteger(msg.resolved)) p.resolved = Math.max(0, Math.min(1e5, msg.resolved));
f.lastProgressAt = this.sched.now();
if (f.kind === 'boss' || f.kind === 'hidden') {
this._creditBoss(f, msg.bossDmg, msg.by);
+2 -1
View File
@@ -126,8 +126,9 @@ export class MatchUnite {
let live = null;
if (f && f.cc) {
if (f.mode === 'server' && f.timeline) {
// the sample is [gt, killed, total, resolved(, left)]: a 联防 sample's `left` is its 5th element
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
live = sample && sample[4] && typeof sample[4] === 'object' ? sample[4] : null;
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
} else if (f && f.battle) {
try { live = uniteLeft(f.battle); } catch { live = null; }
+32 -9
View File
@@ -13,6 +13,14 @@ import { timelineAt } from '../fields.js';
import { bossFieldPlacement } from '../finalAssault.js';
import { battleProgress } from '../../sim/spec.js';
/** A reported capsule numerator clamped to its denominator, else null (unknown — never a fabricated 0). */
const finiteOrNull = (v, cap = Infinity) => {
if (v == null) return null; // Number(null) === 0: an unreported value must not read as "0 resolved"
const n = Number(v);
if (!Number.isFinite(n)) return null;
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
};
export class MatchViews {
statusOf(ps) {
if (ps.left) return 'left';
@@ -127,25 +135,40 @@ export class MatchViews {
return v;
}
/** m.public.fields[].progress: { killed, total, done } (teammates' waiting UI). */
/**
* m.public.fields[].progress: { killed, resolved, total, done } (teammates' waiting UI). `resolved` is the HUD
* capsule's numerator — the field's own scheduled enemies knocked out or leaked (Battle.resolved / the reported
* b.progress `resolved`); it is `null` (never 0) while unknown, so the client's `resolved ?? killed` fallback holds.
* `total` is the capsule's denominator: only the enemies the round scheduled (runtime splits / summons — boss summons
* included — are in neither part).
*/
_fieldProgress(f) {
if (!f) return null;
if (!f.cc) {
const b = f.battle;
if (!b) return null;
return { killed: Number(b.killed) || 0, total: Number(b.total) || 0, done: !f.live };
const total = Number(b.total) || 0;
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;
for (const pp of Object.values(f.result.perPlayer || {})) { killed += Number(pp && pp.killed) || 0; total += Number(pp && pp.total) || 0; }
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; }
return { killed, total, done: true };
let killed = 0, total = 0, resolved = 0, known = 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 (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 };
}
if (f.mode === 'server' && f.timeline) {
const [, killed, total] = timelineAt(f.timeline, this._fieldElapsed(f));
return { killed, total, done: false };
// a server-run / bot field: no authority ever sends a b.progress, so the capsule reads the battle's own counters —
// the timeline sample carries `resolved` (Battle.resolved: knocked out + leaked among the field's own enemies),
// never the report-driven `progress.leaks`, which would leave such a field at 0 forever
const [, killed, total, resolved] = timelineAt(f.timeline, this._fieldElapsed(f));
return { killed, resolved: finiteOrNull(resolved, total), total, done: false };
}
return { killed: f.progress.killed, total: f.progress.total, done: false };
const total = f.progress.total;
return { killed: f.progress.killed, resolved: finiteOrNull(f.progress.resolved, total), total, done: false };
}
/**