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:
1 parent
d7c665dbf6
commit
b4bc89ad4e
39 files changed
+836
-252
No files matched your search
@@ -95,7 +95,14 @@ test('COMBAT: humans get their own spec (authoritative), bots are simulated by t
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
const pub = m.publicView();
|
||||
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, total: 9, done: false });
|
||||
// `resolved` is null while the authority has not reported one — never 0, so the client's `resolved ?? killed` fallback
|
||||
// holds (a report without `resolved` must read as "unknown", not as "nothing resolved")
|
||||
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: null, total: 9, done: false });
|
||||
// a report that carries `resolved` is adopted, a reported 0 included (PR #157 capsule numerator)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
const pub2 = m.publicView();
|
||||
assert.deepEqual(pub2.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: 0, total: 9, done: false });
|
||||
// a stale / foreign report is ignored (never an error toast)
|
||||
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: human.battleId, gt: 9, killed: 9, total: 9 }), { ok: true });
|
||||
assert.equal(human.progress.killed, 3);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// test/match/hud-capsule-resolved.test.js — the HUD capsule's server half (PR #157): m.public.fields[].progress carries
|
||||
// the field's own `resolved` (knocked out + leaked among the enemies the round scheduled), and
|
||||
// * a bot / server-run field has no authority reporting b.progress — its capsule must read the battle's own counters
|
||||
// (`timelineSample` → Battle.resolved), not the report-driven `progress.leaks`, which stays 0 forever (maintainer
|
||||
// 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 ②).
|
||||
// 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 { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { teammateProgress } from '../../public/js/battle/observe.js';
|
||||
import { uniteLeftMark } from '../../public/js/battle/runner.js';
|
||||
|
||||
const fields = (h) => h.m.fields;
|
||||
const progressOf = (m, fieldId) => (m.publicView().fields.find((f) => f.fieldId === fieldId) || {}).progress;
|
||||
|
||||
test('① 机器人战场 (mode server, 无上报): 胶囊读该战斗自己的计数器,不是停在 0 的 progress.leaks', () => {
|
||||
// no FakeBattle: the bot's field runs the real sim on the server (HeadlessJob + timeline), and nobody reports b.progress
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, instant: false, clientCombat: true, clients: false }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const f = fields(h).find((x) => x.fieldId === 'n:ai_0');
|
||||
assert.equal(f.mode, 'server', 'the bot field runs on the server');
|
||||
assert.equal(f.authority, null, 'no authority ever reports it');
|
||||
assert.ok(f.timeline && f.timeline.length > 1, 'it has a progress timeline');
|
||||
assert.equal(f.progress.leaks, 0, 'nothing was reported: progress.leaks stays 0');
|
||||
assert.equal(f.progress.resolved, null, 'and progress.resolved stays null (never a fabricated 0)');
|
||||
|
||||
let filled = 0;
|
||||
for (let i = 0; i < 20 && !f.done; i++) {
|
||||
h.sched.advance(2000);
|
||||
const pr = progressOf(m, 'n:ai_0');
|
||||
// the capsule reads the battle's own counters, sampled on the field clock
|
||||
assert.equal(pr.resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'resolved = the timeline sample');
|
||||
assert.ok(pr.resolved != null && pr.resolved <= pr.total, 'a real number, never above the denominator');
|
||||
if (pr.resolved > 0) { filled = pr.resolved; break; }
|
||||
}
|
||||
assert.ok(filled > 0, 'the bot field\'s capsule fills up (a report-driven number would have stayed at 0)');
|
||||
// the sample is composed of the battle's own two counters — the field's own scheduled enemies that were resolved
|
||||
assert.equal(timelineSample(f.battle)[3], Math.min(f.battle.total, f.battle.killedInTotal + f.battle.leakedInTotal));
|
||||
assert.equal(progressOf(m, 'n:ai_0').resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'clamped by the field clock');
|
||||
});
|
||||
|
||||
test('① 一场只漏不杀的机器人战场: resolved 由 leakedInTotal 顶起来 (不是 0)', () => {
|
||||
// three walkers, nobody to stop them: the field's own enemies leak — leakedInTotal is what the capsule must show
|
||||
const h = makeBattle({ seed: 5, enemies: [{ key: 'enemy_1005_yokai', count: 3 }], content: 'full' });
|
||||
h.runToEnd(120);
|
||||
assert.equal(h.b.total, 3);
|
||||
assert.equal(h.b.leakedInTotal, 3, 'the field leaked all three of its own enemies');
|
||||
assert.equal(h.b.leakedCount, 3, 'counted leaks — the LP charge, unchanged from master');
|
||||
const sample = timelineSample(h.b);
|
||||
assert.equal(sample[3], 3, 'the timeline sample carries the capsule numerator');
|
||||
assert.equal(battleProgress(h.b).resolved, 3, 'b.progress too (what a teammate HUD reads)');
|
||||
assert.equal(battleProgress(h.b).total, 3);
|
||||
assert.equal(battleProgress(h.b).killed, 0);
|
||||
});
|
||||
|
||||
test('② 上报没有 resolved ⇒ progress.resolved = null,客户端回落到 killed;上报 resolved: 0 ⇒ 采用 0', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9103, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 6 }) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||
assert.equal(human.mode, 'client');
|
||||
assert.equal(human.progress.resolved, null, 'a fresh field: resolved unknown, not 0');
|
||||
|
||||
// a report without `resolved` (an older client / a field that cannot count it) leaves it unknown
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: null, total: 9, done: false });
|
||||
// … and the teammate HUD falls back to `killed` (master's display: resolved ?? killed)
|
||||
const mine = (pub) => teammateProgress(pub, 'p_1').find((x) => x.playerId === 'p_0');
|
||||
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 3, total: 9, done: false });
|
||||
|
||||
// a reported 0 is a real value: adopted, and the HUD shows 0 (not the fallback)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: 0, total: 9, done: false });
|
||||
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)', () => {
|
||||
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);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||
// a result carrying the capsule numbers: 5 counted knock-outs (a split child included), 3 of the round's own resolved
|
||||
const result = {
|
||||
reason: 'cleared', time: 6, killed: 5, total: 3, resolved: 3,
|
||||
perPlayer: { p_0: { killed: 5, total: 3, resolved: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] } },
|
||||
errors: 0,
|
||||
};
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: human.battleId, result }), { ok: true });
|
||||
m.flush(true);
|
||||
const pr = progressOf(m, 'n:p_0');
|
||||
assert.equal(pr.done, true);
|
||||
assert.equal(pr.killed, 5, '`killed` keeps the counted reading (may exceed the denominator)');
|
||||
assert.equal(pr.total, 3, 'the denominator is the round\'s own list');
|
||||
assert.equal(pr.resolved, 3, 'the capsule numerator of the finished field');
|
||||
});
|
||||
|
||||
test('a 联防 battle\'s live-counter mark still moves for a spawn — `total` no longer does (a split child / a summon is outside the capsule)', () => {
|
||||
// the runner re-reads the leakers' enemies still standing (uniteLeft) only when this mark moves; before the capsule
|
||||
// counted the round's own enemies only, every counted spawn raised `total` and so moved it
|
||||
const h = makeBattle({ seed: 41, autoFinish: false, timeLimit: 60, enemies: [{ key: 'enemy_1195_sfyin', pos: [9, 6] }] });
|
||||
h.step();
|
||||
const m0 = uniteLeftMark(h.b);
|
||||
assert.equal(h.b.total, 1);
|
||||
assert.equal(uniteLeftMark(h.b), m0, 'nothing happened: the same mark');
|
||||
const child = h.spawn('enemy_1196_msfyin', { pos: [9, 4] }); // a runtime spawn: counted, outside the denominator
|
||||
assert.ok(child && child.counted && !child.inTotal);
|
||||
assert.equal(h.b.total, 1, 'the denominator did not move');
|
||||
assert.notEqual(uniteLeftMark(h.b), m0, 'the mark did: the spawn is a standing enemy the counter must read');
|
||||
const m1 = uniteLeftMark(h.b);
|
||||
h.b.kill(child, null);
|
||||
assert.notEqual(uniteLeftMark(h.b), m1, 'a knock-out moves it');
|
||||
});
|
||||
@@ -22,7 +22,12 @@ test('observeTarget: prep, own battle running / over, boss pairs, eliminated pla
|
||||
assert.deepEqual(observeTarget(players[1], combat(true), 'a', { ownDone: true }), { fieldId: 'n:b' }, 'the local battle already ended');
|
||||
assert.deepEqual(observeTarget(players[1], combat(false), 'a'), { fieldId: 'n:b' });
|
||||
assert.deepEqual(observeTarget(players[0], combat(false), 'a', { observing: true }), { back: true });
|
||||
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, total: 8, done: false }]);
|
||||
// the teammate's capsule reads `resolved` (the field's own enemies knocked out or leaked); a report without one falls
|
||||
// back to `killed` (public/js/battle/observe.js teammateProgress)
|
||||
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, resolved: 3, total: 8, done: false }]);
|
||||
const reported = pubOf('COMBAT', [{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true, progress: { killed: 5, resolved: 3, total: 8, done: false } }], players);
|
||||
assert.deepEqual(teammateProgress(reported, 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 5, resolved: 3, total: 8, done: false }],
|
||||
'a reported `resolved` wins over `killed` (5 counted knock-outs — splits included —, 3 of the round\'s own enemies resolved)');
|
||||
const fa = pubOf('FINAL_ASSAULT', [
|
||||
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
|
||||
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
|
||||
|
||||
@@ -430,7 +430,13 @@ test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter
|
||||
seen.push(p.uniteLeft);
|
||||
h.sched.advance(500);
|
||||
}
|
||||
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
|
||||
// the split children are runtime spawns: they enter neither part of the HUD capsule (PR #157: the denominator counts
|
||||
// only what the 联防 scheduled — `b.total` — and the numerator only that set's own knock-outs / leaks), while the
|
||||
// 联防 live counter (m.public uniteLeft) still bills them to the leaker
|
||||
const kids = b.units.filter((u) => u.side === 'enemy' && !u.inTotal).length;
|
||||
assert.ok(kids > 0, `the 磨砻 split into ${kids} runtime children`);
|
||||
assert.equal(b.total, SENT, `the children stay out of the capsule's denominator (total = the ${SENT} the 联防 scheduled)`);
|
||||
assert.equal(b.resolved, Math.min(SENT, b.killedInTotal + b.leakedInTotal), 'the capsule numerator counts only the 联防\'s own enemies');
|
||||
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
|
||||
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
|
||||
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
|
||||
|
||||
@@ -129,6 +129,8 @@ export class SimClient {
|
||||
_progress(e) {
|
||||
const p = battleProgress(e.battle);
|
||||
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
|
||||
// the HUD capsule's numerator, like public/js/battle/runner.js (kept only when the battle reports one)
|
||||
if (Number.isFinite(p.resolved)) msg.resolved = Math.min(p.resolved, p.total);
|
||||
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
|
||||
if (bossLike) {
|
||||
const pool = e.battle.sharedBoss;
|
||||
|
||||
Reference in new issue
Block a user