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

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

No files matched your search

+6 -1
View File
@@ -298,7 +298,12 @@ test('real data: R5 template on act1 m04 (crates) with a real lineup runs to the
assert.ok(h.b.allyUnits.some((u) => u.kind === 'device'), 'm04 crates present');
const r = h.result();
const p = r.perPlayer.p1;
assert.equal(p.killed + p.leaked.filter((l) => l.counted).length, p.total, 'every counted enemy is killed or leaked');
// the capsule identity (PR #157): this round's own scheduled enemies are all 已解决 — knocked down or leaked. It
// replaces `killed + counted leaks = total`, which mixed the counted reading (runtime splits / summons included) with
// the scheduled denominator: `killed` may now exceed `total`, so only `≥` holds for it, while the capsule pair is exact
assert.ok(p.killed + p.leaked.filter((l) => l.counted).length >= p.total, 'every counted enemy is killed or leaked');
assert.equal(p.killedInTotal + p.leakedInTotal, p.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
assert.equal(p.resolved, p.total);
});
test('generic kit never throws and yields finite numbers for every real chess (normal & elite)', () => {
+14 -2
View File
@@ -106,9 +106,21 @@ test('fuzz: 200 random battles on real data run to completion with invariants',
const r = b.result();
stats[r.reason]++;
for (const [pid, pp] of Object.entries(r.perPlayer)) {
assert.ok(pp.killed <= pp.total || kind === 'unite', `${pid} killed ≤ total`);
// the capsule's own pair: `total` counts only the enemies this field scheduled (`inTotal`), and its numerator
// never exceeds it. `killed` deliberately keeps the `counted` reading — a runtime split child / summon knocked
// down counts there — so `killed > total` is legal since PR #157's capsule semantics (a 4-磨砻 round killed 4
// plus a child reads 5/4 on nothing but `killed/total`; the capsule reads resolved/total = 4/4)
assert.ok(pp.killedInTotal <= pp.total, `${pid} killedInTotal ≤ total`);
assert.ok(pp.resolved <= pp.total, `${pid} resolved ≤ total`);
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
if (r.reason === 'cleared' && kind === 'normal') assert.equal(pp.killed + pp.leaked.filter((l) => l.counted).length, pp.total, 'every counted enemy killed or leaked');
// the capsule identity: every one of the field's own enemies is 已解决 — knocked down (killedInTotal) or leaked
// (leakedInTotal). It replaces the old `killed + counted leaks = total`, which mixed the counted reading with the
// scheduled denominator and no longer holds (killed counts runtime children, total does not)
if (r.reason === 'cleared' && kind === 'normal') {
assert.ok(pp.killed + pp.leaked.filter((l) => l.counted).length >= pp.total, 'every counted enemy killed or leaked');
assert.equal(pp.killedInTotal + pp.leakedInTotal, pp.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
assert.equal(pp.resolved, pp.total, 'a cleared normal field resolves its own list');
}
}
JSON.stringify(r);
}
+228
View File
@@ -0,0 +1,228 @@
// test/sim/hud-capsule-resolved.test.js — the HUD capsule's own counters (PR #157, maintainer-reviewed direction; the
// bounty / kill semantics stay master's):
// resolved = min(total, killedInTotal + leakedInTotal) — the field's OWN scheduled enemies (`inTotal`) that are
// 已解决: knocked down or leaked. The official example: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
// A runtime spawn — a split child, a summon (a boss's included), a part — is in neither the numerator nor the
// denominator, while `counted` (the LP / 完美作战 / `killed` reading) keeps counting it exactly as master did.
// Run: node --test test/sim/hud-capsule-resolved.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildBossWave } from '../../server/match/waves.js';
import { createRng, deriveSeed } from '../../server/sim/rng.js';
const QUIET = { warn() {}, error() {}, info() {}, log() {}, debug() {} };
/** The real mode config: the per-round LP cap settlement charges min(cap, counted leaks) with (match/settle.js). */
const gd = new GameData(getData({ log: QUIET }), 'mode_multi_normal');
const MOLANG = 'enemy_1195_sfyin'; // 磨砻 — 被击倒后生成2个<木制瑞印> (data talents DeadSpawn)
const MOLANG_CHILD = 'enemy_1196_msfyin'; // 木制瑞印
const APOSTLE = 'enemy_1321_wdarft'; // 枯朽萃聚使徒 — 召唤数个<枯朽之种> while it lives
const SEED = 'enemy_1269_nhfly'; // 枯朽之种
const done = (h) => {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
};
const capsule = (b) => ({ total: b.total, killedInTotal: b.killedInTotal, leakedInTotal: b.leakedInTotal, resolved: b.resolved });
test('官方例子: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3 (分裂子体不进胶囊)', () => {
const h = makeBattle({
seed: 11, autoFinish: false, timeLimit: 300,
enemies: [
{ key: 'enemy_1195_sfyin', pos: [9, 6], count: 1 },
{ key: 'enemy_1005_yokai', pos: [9, 8], count: 2 },
],
});
h.step();
const molang = h.b.enemies.find((e) => e.defId === MOLANG);
const yokai = h.b.enemies.filter((e) => e.defId === 'enemy_1005_yokai');
assert.equal(h.b.total, 3, '分母 = 本关自己排定的 3 个敌人');
assert.ok(molang.inTotal && yokai.every((e) => e.inTotal), '排定的敌人 inTotal');
assert.equal(h.b.resolved, 0, '0/3');
const snap = h.snapshot();
assert.equal(snap.resolved, 0, 'b.snap carries the capsule numerator');
assert.equal(snap.total, 3);
h.b.leak(yokai[0]); // 漏一个
assert.equal(h.b.resolved, 1, '1/3');
h.b.kill(yokai[1], null); // 打死一个
assert.equal(h.b.resolved, 2, '2/3');
h.b.kill(molang, null); // 打死会分裂的
assert.equal(h.b.killedInTotal + h.b.leakedInTotal, 3, '三个排定的敌人都已解决 (2 击倒 + 1 漏掉)');
assert.equal(h.b.killedInTotal, 2);
assert.equal(h.b.leakedInTotal, 1);
assert.equal(h.b.resolved, 3, '3/3');
assert.equal(h.b.total, 3, '分裂出来的子体没有抬高分母');
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
assert.equal(kids.length, 2, '磨砻 split into 2 木制瑞印');
for (const k of kids) assert.ok(!k.inTotal && k.counted, 'a split child counts for LP, not for the capsule');
assert.equal(h.b.resolved, 3, 'the children move no capsule number');
done(h);
});
test('磨砻 4 个全被击倒 → 胶囊 4/4;它们 8 个子体全漏 → leakedInTotal 仍 0、counted 口径的 LP 照扣 min(10, 8) = 8', () => {
const h = makeBattle({ seed: 12, autoFinish: false, timeLimit: 300, enemies: [{ key: MOLANG, pos: [9, 6], count: 4 }] });
h.step();
const molang = h.b.enemies.filter((e) => e.defId === MOLANG && e.alive);
assert.equal(molang.length, 4);
assert.equal(h.b.total, 4, '分母 = 排定的 4 个磨砻');
for (const e of molang) h.b.kill(e, null);
assert.equal(h.b.killedInTotal, 4);
assert.equal(h.b.resolved, 4, '胶囊 4/4 — 击倒的是本关自己的敌人');
assert.equal(h.b.total, 4, '子体不进分母');
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
assert.equal(kids.length, 8, '四个磨砻各分裂出 2 个木制瑞印');
assert.equal(h.b.leakedInTotal, 0);
for (const k of kids) h.b.leak(k);
// the capsule: the leaked children are no enemies of the stage's own list — the numerator stays at 4/4
assert.deepEqual(capsule(h.b), { total: 4, killedInTotal: 4, leakedInTotal: 0, resolved: 4 });
// the LP reading is master's, unchanged: every leaked child is a counted leak, and settlement charges min(cap, 8)
// (server/match/settle.js `Math.min(cap, counted)`); leakedCount is the count the LP meter reads
assert.equal(h.b.leakedCount, 8);
const r = h.result();
const countedLeaks = r.perPlayer.p1.leaked.filter((l) => l.counted !== false).length;
assert.equal(countedLeaks, 8, '8 counted leaks — the children each cost LP as before');
assert.equal(Math.min(gd.lpCapPerRound, countedLeaks), 8, 'the settled charge is min(10, 8) = 8, exactly as master');
assert.equal(r.perPlayer.p1.perfect, false, 'a counted leak still breaks 完美作战');
assert.equal(r.perPlayer.p1.resolved, 4, 'BattleResult carries the capsule numerator');
assert.equal(r.resolved, 4);
done(h);
});
test('分裂子体被击倒: 胶囊数字不动,`killed` 仍按 counted 口径 +1 (master 的击杀语义不变)', () => {
const h = makeBattle({ seed: 13, autoFinish: false, timeLimit: 300, enemies: [{ key: MOLANG, pos: [9, 6] }] });
h.step();
const molang = h.b.enemies.find((e) => e.defId === MOLANG);
h.b.kill(molang, null);
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
assert.equal(kids.length, 2);
const before = capsule(h.b);
const killedBefore = h.b.killed;
const coinsBefore = h.result().perPlayer.p1.coins;
h.b.kill(kids[0], null);
assert.deepEqual(capsule(h.b), before, 'a knocked-down split child moves no capsule number');
assert.equal(h.b.killed, killedBefore + 1, '`killed` counts every counted knock-out (master semantics, unchanged)');
assert.equal(h.result().perPlayer.p1.coins, coinsBefore, 'no bounty for a split child');
h.b.leak(kids[1]);
assert.deepEqual(capsule(h.b), before, 'a leaked split child moves no capsule number either');
assert.equal(h.b.leakedCount, 1, '… but it is a counted leak (LP / 完美作战)');
assert.equal(h.b.resolved, 1, 'the capsule stands at 1/1 — the 磨砻 itself');
done(h);
});
test('枯朽萃聚使徒: 召唤出的枯朽之种出生 / 被击倒 / 漏掉都不动胶囊,本体照旧结算 (悬赏口径不变)', () => {
const card = { id: 'enemyeffect_10_8', card: { payout: 'kill', coin: 2, enemyKey: APOSTLE } };
const h = makeBattle({
seed: 14, autoFinish: false, timeLimit: 300,
enemies: [{ key: APOSTLE, route: 2, mods: { hpMul: 1, bountyId: card.id }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
});
assert.ok(h.runUntil(() => h.b.enemies.filter((e) => e.alive && e.defId === SEED).length >= 2, 30), 'BornBugs cast');
const seeds = h.b.enemies.filter((e) => e.alive && e.defId === SEED);
assert.equal(h.b.total, 1, '分母只有排定的本体 — 召唤物不进分母');
for (const s of seeds) {
assert.ok(!s.inTotal, 'a summoned 枯朽之种 is not in the capsule');
assert.ok(s.counted, '… but it counts for LP / 完美作战 (master)');
assert.equal(s.bounty, null, '悬赏只在召唤物本体上 (master rule, untouched)');
}
const before = capsule(h.b);
const killedBefore = h.b.killed;
h.b.kill(seeds[0], null);
assert.deepEqual(capsule(h.b), before, 'the summoned seed\'s knock-out moves no capsule number');
assert.equal(h.b.killed, killedBefore + 1, '`killed` still counts it (master)');
assert.deepEqual(h.eventsOf('bounty'), [], 'a summoned seed pays no bounty');
h.b.leak(seeds[1]);
assert.deepEqual(capsule(h.b), before, 'the summoned seed\'s leak moves no capsule number');
assert.equal(h.b.leakedCount, 1, 'counted leak: the LP charge is unchanged from master');
// the body itself is the field's own scheduled enemy: its knock-out resolves the capsule and pays once
const body = h.b.enemies.find((e) => e.alive && e.defId === APOSTLE);
assert.ok(body && body.inTotal);
h.b.kill(body, null);
assert.deepEqual(h.eventsOf('bounty'), [['bounty', 'p1', 2]], 'the body pays once (master rule)');
assert.equal(h.b.resolved, h.b.total, 'the field\'s own list is resolved');
done(h);
});
// =====================================================================================================================
// A real Boss round: a boss summon is a runtime spawn too. The 假想敌:管 (boss_3, act1autochess_h07_03) summons a
// 余音 (enemy_9023_acdums) every 40 game s (20 below half HP); its wave ALSO schedules 2 余音 of its own — those are
// the round's own enemies and do enter the capsule, the summoned ones do not.
test('Boss 召唤物 (假想敌:管的余音) 出生 / 被击倒 / 漏掉都不动胶囊;Boss 死后 resolved === total', () => {
const bossId = 'boss_3';
const ECHO = 'enemy_9023_acdums'; // 余音 — 管 summons it every 40 game s (20 s below half HP)
const seed = deriveSeed(20261005, `boss:${bossId}:p`);
const wave = buildBossWave(gd, createRng(deriveSeed(seed, 'waves')), setupMatchWaves(gd, createRng(deriveSeed(seed, 'setup'))), gd.bossRound, { bossId, solo: false });
const h = makeBattle({
kind: 'boss', seed: 21, autoFinish: false,
routes: wave.routes,
enemies: wave.spawns.map((s) => ({ key: s.enemyKey, time: s.time, route: s.routeIndex, count: s.count, interval: s.interval, mods: s.mods, tag: s.tag, countInTotal: s.countInTotal })),
});
// the round's own list: 2 疏术士 + 2 疏den + 2 余音 (the wave's own 余音, scheduled 40 s apart) — the 领袖 (tag 'boss')
// and everything 管 summons are outside it. 会召唤的 Boss 关胶囊 therefore fills up now (the old `total++` of a runtime
// spawn left it short forever).
const scheduled = wave.spawns.filter((s) => s.tag !== 'boss' && s.countInTotal !== false).reduce((n, s) => n + s.count, 0);
assert.equal(scheduled, 6);
assert.equal(h.b.total, scheduled, `分母 = 本关排定且该计入的 ${scheduled} 个敌人`);
h.run(3); // the 领袖 comes in at t = 1 s, the wave's first 余音 at t = 0
const leader = h.b.enemies.find((e) => e.defId === 'enemy_9021_acduml');
assert.ok(leader, 'the 领袖 is on the field');
assert.ok(!leader.inTotal && !leader.counted, 'the 领袖 (tag boss) is in no capsule part');
assert.equal(h.b.units.filter((u) => u.side === 'enemy' && u.inTotal && u.defId === ECHO).length, 1, 'the wave scheduled its own 余音');
assert.equal(h.b.total, scheduled, 'the 领袖 does not add to the denominator (tag boss ⇒ not counted)');
// the boss summons one of its own (pipeCore, bosses.js: `spawnEnemy` — never the spawn queue)
const runtime = () => h.b.units.find((u) => u.side === 'enemy' && u.alive && !u.inTotal && u.defId === ECHO);
assert.ok(h.runUntil(() => !!runtime(), 90), 'the boss summoned a 余音');
const first = runtime();
assert.ok(first.counted, 'the summon still counts for LP (master)');
assert.equal(h.b.total, scheduled, 'the summoned 余音 never raised the denominator (the old defect: an unfillable capsule)');
const before = capsule(h.b);
const killedBefore = h.b.killed;
h.b.kill(first, null);
assert.deepEqual(capsule(h.b), before, 'a knocked-down boss summon moves no capsule number');
assert.equal(h.b.killed, killedBefore + 1, '`killed` counts it (master semantics unchanged)');
assert.ok(h.runUntil(() => !!runtime(), 120), 'a second summon');
const beforeLeak = capsule(h.b);
const leakedBefore = h.b.leakedCount;
h.b.leak(runtime());
assert.deepEqual(capsule(h.b), beforeLeak, 'a leaked boss summon moves no capsule number');
assert.equal(h.b.leakedCount, leakedBefore + 1, 'a counted leak — the LP charge follows the real data (`counted`), as master');
// the leader itself: tag 'boss' ⇒ counted false ⇒ in no capsule number, and killing it is no `killed` either (master)
const killedBeforeLeader = h.b.killed;
h.b.kill(leader, null);
assert.equal(h.b.killed, killedBeforeLeader, 'the leader is not a counted knock-out (master reading)');
// every one of the round's own enemies resolved ⇒ the capsule is full
for (const e of h.b.enemies.filter((e) => e.alive && e.inTotal)) h.b.kill(e, null);
for (const e of h.b.enemies.filter((e) => e.alive && e.inTotal)) h.b.leak(e);
assert.equal(h.b.killedInTotal + h.b.leakedInTotal, h.b.total);
assert.equal(h.b.resolved, h.b.total, 'Boss 死后 resolved === total — the summons never pushed the denominator up');
assert.equal(h.b.total, scheduled, 'nor did they move it at the end');
done(h);
});
test('kept honest: a helper enemy record the tests build is a scheduled enemy (inTotal) while spawnEnemy is not', () => {
const h = makeBattle({
seed: 31, autoFinish: false, timeLimit: 60,
defs: { enemies: { enemy_helper: enemyRec({ key: 'enemy_helper', hp: 10, speed: 0 }) } },
enemies: [{ key: 'enemy_helper' }],
});
h.step();
assert.equal(h.b.total, 1);
assert.equal(h.b.enemies[0].inTotal, true);
const runtime = h.spawn('enemy_helper', { pos: [9, 4] });
assert.equal(h.b.total, 1, 'a runtime spawnEnemy is not in the denominator');
assert.ok(!runtime.inTotal && runtime.counted, '… and not in the capsule, while counted stays master\'s');
const explicit = h.b.spawnEnemy('enemy_helper', { pos: [9, 3], inTotal: true }); // the content back door
assert.equal(h.b.total, 2, 'an explicit `inTotal: true` is honoured');
assert.ok(explicit.inTotal);
assert.equal(h.b.enemies.filter((e) => e.alive).length, 3);
done(h);
});