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

+15
View File
@@ -148,6 +148,11 @@ export class Battle {
this.killed = 0;
this.total = 0;
this.leakedCount = 0;
// The HUD capsule's own counters (DESIGN §14, PR #157 semantics): only the enemies the field itself scheduled
// (`inTotal`, spawns.js `_queueSpawn` — `total` is their denominator) count here. `killed` / `leakedCount` above keep
// the official LP / 完美作战 reading: every `counted` enemy, runtime splits and summons included.
this.killedInTotal = 0;
this.leakedInTotal = 0;
this.errors = [];
this.errorCount = 0;
this._errKeys = new Set();
@@ -172,6 +177,16 @@ export class Battle {
if (typeof opts.setup === 'function') this._safe(() => opts.setup(this), 'opts.setup');
}
/**
* The HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies (`inTotal`) that are 已解决 — knocked
* down (`killedInTotal`) or leaked (`leakedInTotal`) — never above the denominator `total`. The official reading:
* 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3; a leaked split child still costs LP (`leakedCount`) and breaks
* 完美作战, but it is no enemy of the stage's own list and moves no capsule number.
*/
get resolved() {
return Math.min(this.total, this.killedInTotal + this.leakedInTotal);
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Battle<${this.kind} seed=${this.seed} t=${this.time.toFixed(2)} units=${this.units.length}${this.finished ? ' ' + this.reason : ''}>`;
}
+14 -1
View File
@@ -136,11 +136,17 @@ export class BattleDeploy {
this._unblock(unit);
this._enemiesDirty = true;
if (reason === 'killed') {
const pp = this._pp(unit.ownerId);
if (unit.counted) {
this.killed++;
const pp = this._pp(unit.ownerId);
if (pp) pp.killed++;
}
// the HUD capsule's numerator (DESIGN §14): only the enemies the stage itself scheduled (`inTotal`) — a split
// child / summon / part knocked down here is no 已解决 of the stage's own list and moves the capsule not at all
if (unit.inTotal) {
this.killedInTotal++;
if (pp) pp.killedInTotal++;
}
if (killer) killer.stats.kills++;
if (unit.bounty && unit.bounty.coins > 0) this.addCoins(this._bountyPayee(unit, killer), unit.bounty.coins);
}
@@ -253,5 +259,12 @@ export class BattleDeploy {
if (e.counted || e.isBoss) pp.perfect = false;
}
if (e.counted) this.leakedCount++;
// the HUD capsule's leak counter (DESIGN §14): only the stage's own enemies (`inTotal`) — a leaked split child or
// summon still costs LP through `counted`/`leakedCount` and still breaks 完美作战, but it is no 漏掉 of the stage's
// own list, so the capsule's numerator does not move for it
if (e.inTotal) {
this.leakedInTotal++;
if (pp) pp.leakedInTotal++;
}
}
}
+3
View File
@@ -66,6 +66,9 @@ export class BattleEvents {
dp: this.players.length ? Math.floor(this.players[0].dp) : 0,
killed: this.killed,
total: this.total,
// the HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies that are 已解决 (down or leaked).
// `killed` above counts every counted knock-out (runtime splits / summons too) and may exceed `total`.
resolved: this.resolved,
};
if (this.players.length > 1) {
snap.dps = {};
+5 -2
View File
@@ -121,7 +121,7 @@ export class BattleLifecycle {
this.finished = true;
this.reason = reason === 'timeout' ? 'timeout' : 'forced';
this._endReq = null;
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, errors: this.errorCount }; }
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, resolved: this.resolved, errors: this.errorCount }; }
this.projectiles.clear();
}
@@ -209,6 +209,9 @@ export class BattleLifecycle {
for (const ps of this.players) {
const pp = this._perPlayer[ps.playerId];
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
// the HUD capsule's numerator of this field (DESIGN §14): this player's own scheduled enemies resolved — knocked
// down or leaked (`counted` keeps the LP / 完美作战 reading, runtime splits and summons included)
pp.resolved = Math.min(pp.total, pp.killedInTotal + pp.leakedInTotal);
// the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's
// SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该
// 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge).
@@ -226,7 +229,7 @@ export class BattleLifecycle {
}));
perPlayer[ps.playerId] = pp;
}
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, errors: this.errorCount };
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, resolved: this.resolved, errors: this.errorCount };
if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned;
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
return res;
+5 -2
View File
@@ -39,8 +39,11 @@ export class BattlePlayers {
};
this.players.push(ps);
this._perPlayer[ps.playerId] = {
killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0,
damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [],
// `killed` / `total` / `leaked` / `perfect` keep the official `counted` reading (DESIGN §5.1); `killedInTotal` /
// `leakedInTotal` are the HUD capsule's counters of this player's own field (only enemies the round scheduled:
// spawns.js `_queueSpawn` / `inTotal`, deploy.js), `resolved` is derived from them at result time.
killed: 0, total: 0, leaked: [], perfect: true, killedInTotal: 0, leakedInTotal: 0,
layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [],
};
const late = [];
for (const u of p.units ?? []) {
+14 -4
View File
@@ -1,4 +1,5 @@
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (counted in `total` up front), routes and the
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (the stage's own enemies: `inTotal`, counted in
// `total` up front — the HUD capsule's numerator/denominator, deploy.js killedInTotal / leakedInTotal), routes and the
// owner of a tile, spawnEnemy, and the enemies on the field — hidden ones, the shared boss pool sync, the field clamp
// and the list compaction.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
@@ -31,7 +32,11 @@ export class BattleSpawns {
ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal,
};
p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part');
if (precount && p.counted) {
// inTotal: the enemies THIS STAGE scheduled that count — the HUD capsule's own set (DESIGN §14 "顶栏胶囊"). A
// runtime spawn (a split child, a summon, a part, a form change) is never in it, so the capsule's denominator stays
// the stage's own enemies while `counted` (LP, 破坏完美作战 and the kill counters) keeps counting them all.
p.inTotal = !!p.counted;
if (precount && p.inTotal) {
this.total++;
const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start);
const pp = this._pp(owner);
@@ -73,7 +78,7 @@ export class BattleSpawns {
/**
* Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag,
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal }
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal, inTotal }
*/
spawnEnemy(enemyKey, opts = {}) {
if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) {
@@ -144,7 +149,12 @@ export class BattleSpawns {
if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true });
}
e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part');
if (e.counted && !opts._precounted) {
// inTotal (the HUD capsule's own counter, DESIGN §14): only an enemy the stage scheduled (`_queueSpawn`, which
// pre-counts it) — or, for future content, an explicit `opts.inTotal: true` — and that counts. Everything spawned
// while the battle runs (a split child, a summon — bosses included —, a part, a 变身 copy) stays out of the capsule's
// numerator and denominator, while `counted` (LP, 破坏完美作战, `killed`) keeps counting it as before.
e.inTotal = opts.inTotal === true && e.counted;
if (e.inTotal && !opts._precounted) {
this.total++;
const pp = this._pp(e.ownerId);
if (pp) pp.total++;
+1 -1
View File
@@ -1,6 +1,6 @@
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2).
//
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved }
// (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left)
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo
+22 -4
View File
@@ -262,7 +262,8 @@ export function uniteLeft(battle) {
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, the capsule's
* `resolved` (this field's own scheduled enemies knocked out or leaked — Battle.resolved), counted leaks
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
* still standing).
*/
@@ -273,10 +274,14 @@ export function battleProgress(battle) {
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
const pool = battle && battle.sharedBoss;
const gt = Number(battle && battle.time) || 0;
const total = Math.max(0, Math.trunc(Number(battle && battle.total) || 0));
const out = {
gt: Math.round(gt * 1000) / 1000,
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
total,
// `null` (never 0) when the battle cannot report it: a display replica / a stand-in must not look like "0 resolved"
// (a `0` from here would defeat every `resolved ?? killed` fallback of the HUD)
resolved: numberOrNull(battle && battle.resolved, total),
leaks,
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
done: !!(battle && battle.finished),
@@ -288,6 +293,14 @@ export function battleProgress(battle) {
return out;
}
/** A finite, non-negative integer (never above `cap`), else null — a value the HUD may fall back from. */
function numberOrNull(v, cap = Infinity) {
if (v == null) return null; // Number(null) === 0: a battle without the counter (a stand-in) must not read as "0"
const n = Number(v);
if (!Number.isFinite(n)) return null;
return Math.max(0, Math.min(cap, Math.trunc(n)));
}
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
const modsKey = (m) => (m && typeof m === 'object' ? JSON.stringify(Object.keys(m).filter((k) => m[k] !== undefined).sort().map((k) => [k, m[k]])) : '');
@@ -348,7 +361,9 @@ export function compactResult(res) {
for (const [k, v] of Object.entries(p.layerGains || {}).slice(0, 40)) if (isKey(k) && fnum(v) > 0) layerGains[k] = Math.min(1e4, fnum(v));
const total = Math.max(0, Math.trunc(fnum(p.total)));
perPlayer[pid] = {
killed: Math.min(total, Math.max(0, Math.trunc(fnum(p.killed)))),
// `killed` keeps the sim's reading (every counted knock-out, runtime splits / summons included) and is deliberately
// NOT clamped to `total`: the denominator counts only the round's own scheduled enemies (Battle.resolved)
killed: 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 };
@@ -373,6 +388,9 @@ export function compactResult(res) {
taken: Math.max(0, Math.round(fnum(u.taken))), attacks: Math.max(0, Math.trunc(fnum(u.attacks))),
})),
};
// the HUD capsule's numerator (or absent for a result that has none: the teammate UI falls back to `killed`)
const resolved = Number.isFinite(p.resolved) ? Math.max(0, Math.min(total, Math.trunc(p.resolved))) : null;
if (resolved != null) perPlayer[pid].resolved = resolved;
}
const out = {
reason: ['cleared', 'timeout', 'forced'].includes(r.reason) ? r.reason : 'forced',
@@ -382,7 +400,7 @@ export function compactResult(res) {
perPlayer,
errors: Math.max(0, Math.min(1e9, Math.trunc(fnum(r.errors)))),
};
if (out.killed > out.total) out.killed = out.total;
if (Number.isFinite(r.resolved)) out.resolved = Math.max(0, Math.min(out.total, Math.trunc(r.resolved)));
if (Array.isArray(r.unspawned) && r.unspawned.length) {
out.unspawned = cap(r.unspawned, 400).filter((u) => u && isKey(u.enemyKey)).map((u) => ({
enemyKey: u.enemyKey, sourcePlayerId: keyOr(u.sourcePlayerId), tag: typeof u.tag === 'string' && u.tag.length <= 16 ? u.tag : null,
+3
View File
@@ -99,6 +99,9 @@ export class Unit {
this.anim = 0;
this.persist = { redeployMul: 1, freeRedeploys: 0 };
this.isBoss = false;
// the HUD capsule's own flag (battle/spawns.js: set on the enemies the field itself scheduled, DESIGN §14); declared
// here so every unit keeps the same object shape (the hot loops' property reads)
this.inTotal = false;
this.bossPool = null;
}