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

+8 -1
View File
@@ -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);
+36 -25
View File
@@ -1,8 +1,9 @@
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
// GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
// upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
// it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
// tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
// (item 19 of the community report of 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark);
// before that the shop kept its old slot count until the next roll. Either way the upgrade leaves the cards shown where they are.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, DATA } from './harness.js';
@@ -11,7 +12,7 @@ import { GameData } from '../../server/match/gamedata.js';
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
test('a level-up opens the new level\'s operator slot EMPTY; the cards shown stay in place; a refresh fills it', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
@@ -20,35 +21,37 @@ test('a level-up opens the new level\'s operator slot at once with a new card; t
const s1 = ps.shop.slots.slice();
assert.equal(s1.length, 4);
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), the new slot empty
assert.equal(ps.shop.level, 2);
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
assert.equal(ps.shop.slots.length, 5);
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
const added = ps.shop.slots[3];
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
assert.equal(added.frozen, false);
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
// the player sees it right away (m.private shop)
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
// 2 → 3 keeps 4 operator slots: nothing rerolled
// a manual refresh fills every slot at the new level (the empty one included)
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
assert.equal(ps.shop.slots.length, 5);
for (const s of ps.shop.slots) assert.ok(s && !s.sold, 'every slot carries a fresh card after the refresh');
for (const s of ps.shop.slots) if (s.kind === 'chess') assert.ok(tierOf(s.id) >= 1 && tierOf(s.id) <= 2, `drawn at level 2 (tier ${tierOf(s.id)})`);
// 2 → 3 keeps 4 operator slots: nothing rerolled, nothing added
const s2 = ps.shop.slots.slice();
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 3);
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
// 3 → 4: the fifth operator slot
// 3 → 4: the fifth operator slot, empty again
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 4);
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
assert.equal(ps.shop.slots[5], s2[4]);
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
assert.equal(ps.shop.slots[4], null, 'the new slot is open and empty');
h.invariants();
h.m.dispose();
});
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
test('a bought card stays sold; the empty new slot has nothing to freeze; the round start fills the shop to the new level', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
@@ -58,35 +61,43 @@ test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED]
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
const added = ps.shop.slots[3];
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
// the next round start keeps every frozen card (in place) and rolls only the sold slot
assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
// the round start fills every slot of the new level: frozen cards kept in place, the sold and the empty slot rolled
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
assert.equal(ps.shop.slots.length, 5);
for (const s of ps.shop.slots) assert.ok(s, 'the round start leaves no empty slot');
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id).slice(0, 2), keep.slice(0, 2), 'the frozen operator cards kept');
assert.equal(ps.shop.slots[4].id, keep[2], 'the frozen item kept after the operator slots');
assert.notEqual(ps.shop.slots[0].id, undefined, 'the sold slot rerolled');
h.invariants();
h.m.dispose();
});
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
test('every mode\'s slot table: a level-up opens exactly the new positions, empty (标准: 3,4,4,4,4,5)', () => {
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
const gd = h.m.gd;
ps.funds = 500;
let empty = 0;
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
const before = ps.shop.slots.slice();
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
const { chess, item } = gd.shopSlots(lv);
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
const grew = chess - gd.shopSlots(lv - 1).chess;
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
// the empty positions accumulate: nothing fills them between the upgrades (no refresh / round start here)
empty += grew;
assert.equal(ps.shop.slots.filter((s) => !s).length, empty, `${mode} ${difficulty} L${lv}: the new positions are open and empty`);
}
// one refresh fills everything at the final level
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
for (const s of ps.shop.slots) assert.ok(s, `${mode} ${difficulty}: the refresh leaves no empty slot`);
h.invariants();
h.m.dispose();
}
+281
View File
@@ -0,0 +1,281 @@
// 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 ②);
// * a finished two-half field (联防 with two helpers, the boss pair) publishes the FIELD's own `resolved` — the capsule's
// number — never the sum of the players' own clamps: an enemy that spawns on one half and leaks on the other is
// billed to one player's `total` and to the other's `leakedInTotal`, so both players' min(total, …) read 0 for it
// while the field resolved it (found reviewing the capsule port, 2026-10-08).
// 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, DATA } from './harness.js';
import { timelineSample, timelineAt, validateClientResult, runHeadless } from '../../server/match/fields.js';
import { buildBattleSpec, createBattleFromSpec, compactResult, battleProgress } from '../../server/sim/spec.js';
import { GameData } from '../../server/match/gamedata.js';
import { MatchViews } from '../../server/match/match/views.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 carries the reported 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');
});
// ---- an enemy that crosses the midline of a two-half field -------------------------------------------------------------
//
// The capsule counts per FIELD (Battle.resolved = min(total, killedInTotal + leakedInTotal)), but the sim also keeps every
// player's own counters: `total` and a knock-out belong to the half the enemy spawned on, a leak to the half whose tile it
// reached (deploy.js _recordLeak). A lane that runs through both halves — the 联防 lane (col 18 → col 2), the boss pair's
// crossing routes — bills one enemy's `total` to one player and its leak to the other, so each player's own
// min(total, killedInTotal + leakedInTotal) clamps it away: 0 and 0 for an enemy the field resolved. A finished field must
// publish the field's own number (what the capsule showed), never the sum of the players' clamps.
const gd = new GameData(DATA, 'mode_multi_hard');
/** m.public.fields[].progress of a finished client-combat field (the done branch of Match._fieldProgress). */
const doneView = (result, progress = { killed: 0, total: 0, leaks: 0, resolved: null }) => MatchViews.prototype._fieldProgress.call({}, { cc: true, done: true, result, progress });
const clone = (v) => JSON.parse(JSON.stringify(v));
/** A 联防 spec of two helpers who fielded nothing, and one escaped enemy that walks the whole lane (col 18 → col 2). */
function uniteCrossSpec() {
return buildBattleSpec({
battleId: 'mid-u', fieldId: 'u', kind: 'unite', seed: 11, modeId: 'mode_multi_normal', round: 1, stageId: 'act1autochess_m02',
rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, timeLimit: 90,
players: [
{ playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} }, // the half the lane starts on (col 18)
{ playerId: 'p_2', seat: 2, side: 'L', colOffset: 0, units: [], bonds: {} }, // the half it leaves on (col 2)
],
routes: [{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] }],
spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 1, sourcePlayerId: 'p_0', mods: { hpMul: 0.8, atkMul: 0.8, speedMul: 1, slot: 'N' } }],
flags: { layerGainsEnabled: false, dpInit: 10, dpPerSec: 1, dpMax: 99 },
});
}
const newBattle = (spec) => createBattleFromSpec(spec, null, { recordEvents: false, quiet: true });
test('联防 whose escaped enemies enter on one helper\'s half and leak on the other\'s: the finished field publishes the capsule\'s own 4/4, not 0/4', () => {
let ran = null;
// every client runs the 联防 spec with the REAL sim (the fake battles only stand in for the normal round); the helpers
// fielded nothing, so the leaker's four escaped enemies walk the whole lane and leak on the far half
const realUnite = (result, spec) => {
if (spec.kind !== 'unite') return result;
const b = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true });
const res = b.runToEnd(4000);
ran = { res, capsule: battleProgress(b) };
return compactResult(res);
};
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}),
perPlayer: Object.fromEntries(['p_0', 'p_1', 'p_2'].map((pid) => [pid, { tamper: realUnite }])) }).start();
const m = h.m;
h.toPrep(1);
const unite = () => fields(h).find((x) => x.fieldId === 'u');
assert.ok(h.drive(() => (unite() && unite().done) || h.ended != null) && unite().done, 'the 联防 field finished');
const f = unite();
assert.equal(f.resultSource, 'client', 'the authority\'s own result was accepted');
assert.ok(ran, 'the authority ran the 联防 spec with the real sim');
// the premise: the enemies crossed the midline — `total` on the half they entered, the leaks on the half they left
const per = Object.values(ran.res.perPlayer);
assert.deepEqual(per.map((p) => p.total).sort(), [0, 4], 'every total is billed to the half the enemies spawned on');
assert.deepEqual(per.map((p) => p.leaked.length).sort(), [0, 4], 'every leak to the half they reached');
assert.deepEqual(per.map((p) => p.resolved), [0, 0], 'so each player\'s own min(total, …) reads 0');
assert.equal(ran.capsule.resolved, 4, 'while the field\'s capsule read 4/4');
assert.equal(ran.res.resolved, 4);
// what the server keeps and publishes is the field's number
assert.equal(f.result.resolved, 4, 'the validated result carries the field-level numerator');
m.flush(true);
assert.deepEqual(progressOf(m, 'u'), { killed: 0, resolved: 4, total: 4, done: true }, 'm.public: the capsule\'s own 4/4');
m.dispose();
});
test('the boss pair: an enemy spawned on one half that leaks on the other half\'s goal reads 1/1 in the published progress too', () => {
const PAIR = [
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} },
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {} },
];
// route 3 of the flat boss stage: the centre gate (col 10, the left half's) → the right goal (col 18, the right half's)
const h = makeBattle({ kind: 'boss', players: PAIR, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 3 }], timeLimit: Infinity, content: 'none', autoFinish: true });
h.runToEnd(120);
const res = h.result();
assert.equal(res.reason, 'cleared');
assert.equal(res.perPlayer.L.total, 1, 'billed to the half it spawned on');
assert.equal(res.perPlayer.R.leaked.length, 1, 'leaked on the other half');
assert.deepEqual([res.perPlayer.L.resolved, res.perPlayer.R.resolved], [0, 0], 'each player\'s own clamp reads 0');
assert.equal(res.resolved, 1, 'the field resolved it');
const spec = buildBattleSpec({ battleId: 'mid-b', fieldId: 'b:pair', kind: 'boss', seed: 3, round: 8, players: PAIR,
spawns: [{ time: 0, enemyKey: 'enemy_1007_slime', routeIndex: 3, count: 1 }], flags: { layerGainsEnabled: false } });
const v = validateClientResult(spec, compactResult(res), { gd });
assert.ok(v.ok, v.reason);
assert.equal(v.result.resolved, 1);
assert.deepEqual(doneView(v.result), { killed: 0, resolved: 1, total: 1, done: true });
});
test('a server-run 联防 field (a bot, a takeover) reads the same: Battle.result()\'s field-level number, and so does its timeline', () => {
const spec = uniteCrossSpec();
const run = runHeadless(newBattle(spec), { players: spec.players.map((p) => p.playerId) });
const res = run.result;
assert.equal(res.reason, 'cleared');
assert.deepEqual(Object.values(res.perPlayer).map((p) => [p.total, p.leaked.length, p.resolved]).sort(), [[0, 1, 0], [1, 0, 0]], 'the lane crossed the midline');
assert.equal(res.resolved, 1);
assert.deepEqual(doneView(res), { killed: 0, resolved: 1, total: 1, done: true }, 'the done field of a server run');
assert.equal(run.timeline[run.timeline.length - 1][3], 1, 'the live timeline sample of that field read 1 too');
// SP_VERIFY compares a client result with the server's run: both go through the same validation
assert.equal(validateClientResult(spec, compactResult(res), { gd }).result.resolved, 1);
});
test('validateClientResult: the field-level resolved is kept as reported (clamped to the field\'s total); the players\' own numbers are only the fallback', () => {
const spec = uniteCrossSpec();
const raw = compactResult(newBattle(spec).runToEnd(200));
assert.equal(raw.resolved, 1);
const checked = (edit) => {
const r = clone(raw);
edit(r);
const v = validateClientResult(spec, r, { gd });
assert.ok(v.ok, v.reason);
return v.result;
};
assert.equal(checked(() => {}).resolved, 1, 'as the client reported it (the per-player numbers are both 0 here)');
assert.equal(checked((r) => { r.resolved = 0; }).resolved, 0, 'a reported 0 is a value');
assert.equal(checked((r) => { r.resolved = 99; }).resolved, 1, 'never above the field\'s total');
// an older client (or a hand-made result) that reports no field-level number: the players' own, when every one has one
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 1; }).resolved, 1, 'the fallback is the sum of the players\' own numbers');
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 5; r.perPlayer.p_2.resolved = 5; }).resolved, 1, 'the fallback too is clamped to the total');
// a number that is not a non-negative integer counts as not reported
for (const junk of [-1, 0.5, '1', null, NaN]) assert.equal(checked((r) => { r.resolved = junk; r.perPlayer.p_1.resolved = 1; }).resolved, 1, `junk ${String(junk)} → the fallback`);
// nothing reported anywhere, or one player without a number: absent — the teammate UI falls back to `killed`, never to a fabricated 0
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; }), false);
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_2.resolved; }), false);
assert.equal(doneView(checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; })).resolved, null, 'm.public: null, not 0');
});
test('a finished field\'s progress: the result\'s field-level numerator wins, the players\' own are the fallback, a synthetic result keeps the field\'s live report', () => {
const pp = (o) => ({ killed: 0, total: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, ...o });
// the field-level number wins over the players' own (a crossing field sums to less than it resolved)
assert.deepEqual(doneView({ perPlayer: { a: pp({ total: 3, resolved: 0 }), b: pp({ total: 0, resolved: 0 }) }, resolved: 2 }), { killed: 0, resolved: 2, total: 3, done: true });
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 9 }).resolved, 3, 'clamped to the published total');
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 0 }).resolved, 0, 'a reported 0 is a value');
// no field-level number: the sum of the players' own when every one reported, else unknown (never a fabricated 0)
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({ resolved: 2 }) } }).resolved, 3);
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({}) } }).resolved, null);
assert.equal(doneView({ perPlayer: { a: pp({}) } }).resolved, null);
// a synthetic result (the field could not run: zeroes everywhere) shows the last live report instead
const synthetic = { synthetic: true, resolved: 0, perPlayer: { a: pp({ total: 0, resolved: 0 }) } };
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: 3 }), { killed: 2, resolved: 3, total: 5, done: true });
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: null }), { killed: 2, resolved: null, total: 5, done: true });
});
+63
View File
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
assert.deepEqual(errors, []);
});
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
// time") but the lobby tests only removed one in the lobby: this is the running match.
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const watcher = async (name) => {
const srvNow = await server();
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
clients.push(c);
const w = await c.hello(name);
c.id = w.playerId;
c.token = w.token;
return c;
};
const a = await player('A');
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const st = await a.waitFor('room.state');
await ok(a, { t: 'room.addBot' });
const s1 = await watcher('Spec1');
const s2 = await watcher('Spec2');
await ok(s1, { t: 'room.spectate', code: st.code });
await ok(s2, { t: 'room.spectate', code: st.code });
await ok(a, { t: 'room.start' });
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
await ok(a, { t: 'g.infoReady' });
await pickCoopBands([a]);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
// who may remove: the host only, and only a spectator of this room
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
const mark = s1.log.length;
s2.log.length = 0;
await ok(a, { t: 'g.ready', ready: true });
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
// the freed seat can be taken again (no ban list: the key is all it takes)
const s3 = await watcher('Spec3');
await ok(s3, { t: 'room.spectate', code: st.code });
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
assert.deepEqual(errors, []);
await ok(a, { t: 'g.leave' });
});
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
+6 -1
View File
@@ -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 },
+7 -1
View File
@@ -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)');
+2
View File
@@ -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;