229 lines
14 KiB
JavaScript
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);
|
|
});
|