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:
commit
dc609ea13f
71 files changed
+3016
-806
No files matched your search
@@ -229,7 +229,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
|
||||
const next = gd.upgradeBase(ps.shop.level) ?? 0;
|
||||
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
|
||||
// the new level's extra slots open at once (item 19 of 2026-10-06); the cards shown before stay in place
|
||||
// the new level's extra slots open at once, empty (GitHub #332 / PR #333); the cards shown before stay in place
|
||||
const { chess, item } = gd.shopSlots(ps.shop.level);
|
||||
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
|
||||
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
|
||||
|
||||
+52
-18
@@ -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,12 @@ 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): 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 } = {}) {
|
||||
@@ -781,7 +794,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 +836,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 +886,26 @@ 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; }
|
||||
let ownResolved = 0, ownKnown = pids.length > 0;
|
||||
for (const pid of pids) {
|
||||
result.killed += perPlayer[pid].killed;
|
||||
result.total += perPlayer[pid].total;
|
||||
if (Number.isInteger(perPlayer[pid].resolved)) ownResolved += perPlayer[pid].resolved; else ownKnown = false;
|
||||
}
|
||||
// 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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,11 +93,14 @@ export class PlayerEconomy {
|
||||
}
|
||||
|
||||
/**
|
||||
* The 调度中心's upgrade opens the new level's extra slots at once, each with a new card drawn at the new level; the
|
||||
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the
|
||||
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06
|
||||
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a
|
||||
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do.
|
||||
* The 调度中心's upgrade opens the new level's extra slots at once — EMPTY: no card is drawn into them (a `null` slot).
|
||||
* The next roll (a manual refresh or the round start) fills them with the other unfrozen slots; the cards already shown
|
||||
* stay where they are (chess slots keep their index, the item slot stays after them). Official: the footage of a 1→2
|
||||
* upgrade (bilibili BV1AXwuzdEys 1:39, GitHub #332 / PR #333 by @2321Robin) shows the new slot appear and stay empty, and
|
||||
* the texts only name the slots — the tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」 —,
|
||||
* never a card that comes with them. 0.2.0 drew a card into each new slot instead (item 19 of the community report of
|
||||
* 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark; its follow-on, that the new card
|
||||
* follows the freeze toggle, was [ASSUMED]); until then the extra slots waited for the next roll as well.
|
||||
*/
|
||||
_openLevelSlots() {
|
||||
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
|
||||
@@ -106,9 +109,8 @@ export class PlayerEconomy {
|
||||
if (nChess <= layout.chess && nItem <= layout.item) return;
|
||||
const chess = old.slice(0, layout.chess);
|
||||
const items = old.slice(layout.chess);
|
||||
const fresh = (s) => { if (s) s.frozen = this.shop.frozen; return s; };
|
||||
while (chess.length < nChess) chess.push(fresh(this._rollChessSlot()));
|
||||
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
|
||||
while (chess.length < nChess) chess.push(null);
|
||||
while (items.length < nItem) items.push(null);
|
||||
this.shop.slots = [...chess, ...items];
|
||||
this.shop.layout = { chess: chess.length, item: items.length };
|
||||
}
|
||||
|
||||
@@ -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 : ''}>`;
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user