feedback7: merge fb7-ui-backlog — #229 device tooltips, #238 settings button in lobby / room, #138 准备 folds the shop, #120 in-match spectator roster (cap stays 2), PR #308 capsule counts (+ the shared-field progress fix), PR #333 empty new shop slot on 调度中心 upgrade

- Conflicts: the stats entry (merged fb7-stats) and the settings button both sit in the lobby / room top bars — both kept;
  public/js/ui/settings.js (#321 diagnostics, voiceLang, SettingsButton); docs/PLAYING.md table rows resolved line by
  line against the merge base (#368, #354, #333 edits kept); docs/SIM.md / design/network.md snapshot field lists
  (resolved + ammo / wolves / neg / stand / standCut); design/client.md Screens line (#368's AI switch kept).
- Grok review: MERGE AFTER FIXES — the shared-field capsule finding fixed on the branch (1a880699).
- Golden: roster 23, bonds 2, fields 11, matches 18, diy 11 — #308 totals / snapshots and #333's shop RNG (union OK);
  broad node --test 5473 (5472 pass, 0 fail, 1 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
sganggs committed 2026-10-08 20:56:20 +08:00
commit dc609ea13f
71 files changed
+3016 -806

No files matched your search

+6 -2
View File
@@ -36,11 +36,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; }
+37 -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,45 @@ 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, 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)) own += Number(pp.resolved); else ownKnown = false;
}
// 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) {
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 };
}
/**