Files
ark-stronghold-protocol/test/sim/hud-capsule-resolved.test.js
xcdoge b4bc89ad4e 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
2026-10-08 19:30:36 +08:00

229 lines
14 KiB
JavaScript

// 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);
});