- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
725 lines
44 KiB
JavaScript
725 lines
44 KiB
JavaScript
// Client-side combat (DESIGN §14): BattleSpec round trip and determinism, authority per field, b.progress / b.result,
|
||
// result validation (implausible results → server re-simulation), takeovers (disconnect, deadline, leave), the shared
|
||
// boss pool across two client-run fields (b.pool, team LP, overtime, b.end), 联防 built from the helpers' reported end
|
||
// state, observing rules (research 09 §3.1 / §6.3), SP_VERIFY, reconnect. SimClients (test/match/simClient.js) play
|
||
// the browsers in virtual time.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
import { validateC2S, isBattleResult } from '../../shared/protocol.js';
|
||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js';
|
||
import { DataSource } from '../../server/sim/simdata.js';
|
||
import { validateClientResult, specBounds, syntheticResult } from '../../server/match/fields.js';
|
||
import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js';
|
||
import { FakeBattle } from './fakeBattle.js';
|
||
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
|
||
|
||
/** A fresh DataSource over the raw data, like a browser that fetched /data/*.json (no research fallback). */
|
||
const browserDs = () => new DataSource(DATA, null);
|
||
|
||
function fields(h) { return h.m.fields; }
|
||
|
||
test('spec: JSON-safe round trip; a browser-built battle and the server battle give identical results (real sim, many rounds)', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 9101, captureFrames: false, clientCombat: true });
|
||
h.autoHumans();
|
||
const specs = [];
|
||
h.onSend.push((pid, msg) => { if (msg.t === 'b.start' && msg.authoritative) specs.push(msg.spec); });
|
||
h.m.start();
|
||
h.run(() => h.ended != null || (h.m.round >= 6 && h.m.phase === PHASE.PREP), { maxSteps: 5e6 });
|
||
assert.ok(specs.length >= 8, `authoritative specs seen (${specs.length})`);
|
||
const kinds = new Set(specs.map((s) => s.kind));
|
||
assert.ok(kinds.has('normal'), 'normal fields');
|
||
for (const spec of specs) {
|
||
assert.equal(JSON.stringify(jsonClone(spec)), JSON.stringify(spec), 'a spec survives JSON unchanged');
|
||
assert.equal(typeof spec.stageId, 'string');
|
||
assert.ok(!('stage' in spec) && !('sharedBoss' in spec) && !('data' in spec), 'no object references travel');
|
||
const a = createBattleFromSpec(spec, browserDs(), { recordEvents: false, quiet: true }).runToEnd(4000);
|
||
const b = createBattleFromSpec(JSON.parse(JSON.stringify(spec)), h.m.ds, { recordEvents: true, quiet: true }).runToEnd(4000);
|
||
assert.equal(resultDigest(a).hash, resultDigest(b).hash, `${spec.fieldId} R${spec.round}: same result on both sides`);
|
||
// what the client uploads passes the protocol validator and the server's semantic validation
|
||
const up = compactResult(a);
|
||
assert.ok(isBattleResult(up), 'compact result is a valid b.result payload');
|
||
assert.equal(validateC2S({ t: 'b.result', battleId: spec.battleId, result: up }), null);
|
||
const v = validateClientResult(spec, up, { gd: h.m.gd });
|
||
assert.ok(v.ok, `${spec.fieldId} R${spec.round}: accepted (${v.reason})`);
|
||
}
|
||
h.m.dispose();
|
||
});
|
||
|
||
test('client-side combat is outcome-identical to server-run combat (same seed: LP, funds, layers every round)', () => {
|
||
const trace = (clientCombat) => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 9102, captureFrames: false, clientCombat });
|
||
h.autoHumans();
|
||
const m = h.m;
|
||
const out = [];
|
||
let last = '';
|
||
m.start();
|
||
h.run(() => {
|
||
const k = `${m.phase}:${m.round}`;
|
||
if (k !== last) { last = k; if (m.phase === PHASE.SETTLE) out.push(`${k} ${m.order.map((p) => `${p.lp}/${p.funds}+${p.pendingFunds}/${JSON.stringify(p.layers)}`).join(' ')}`); }
|
||
return h.ended != null || m.round >= 8;
|
||
}, { maxSteps: 5e6 });
|
||
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
|
||
m.dispose();
|
||
return out;
|
||
};
|
||
const server = trace(false);
|
||
const client = trace(true);
|
||
assert.ok(server.length >= 6);
|
||
assert.deepEqual(client, server);
|
||
});
|
||
|
||
test('COMBAT: humans get their own spec (authoritative), bots are simulated by the server; no b.snap / b.ev; progress in m.public', () => {
|
||
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 starts = h.sent.filter(([, x]) => x.t === 'b.start');
|
||
assert.deepEqual(starts.map(([pid, x]) => [pid, x.fieldId, x.authoritative]).sort(), [['p_0', 'n:p_0', true], ['p_1', 'n:p_1', true]]);
|
||
const st = starts[0][1];
|
||
assert.equal(st.speed, m.gameSpeed);
|
||
assert.equal(st.elapsed, 0);
|
||
assert.equal(typeof st.startAt, 'number');
|
||
assert.equal(st.spec.kind, 'normal');
|
||
assert.equal(st.spec.battleId, st.battleId);
|
||
const bot = fields(h).find((f) => f.fieldId === 'n:ai_0');
|
||
assert.equal(bot.mode, 'server', 'the bot field runs on the server');
|
||
assert.ok(bot.result, 'its result is computed at once');
|
||
assert.equal(bot.live, true, '… and released at the battle\'s natural end');
|
||
const human = fields(h).find((f) => f.fieldId === 'n:p_0');
|
||
assert.equal(human.mode, 'client');
|
||
assert.equal(human.authority, 'p_0');
|
||
// progress reports drive the teammates' waiting UI
|
||
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();
|
||
// `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);
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: 'nope', gt: 1, killed: 0, total: 0 }), { ok: true });
|
||
h.sched.advance(5000);
|
||
assert.equal(h.sent.filter(([, x]) => x.t === 'b.snap' || x.t === 'b.ev').length, 0, 'no combat streaming');
|
||
// both humans report (FakeBattle stands in for the browser sim)
|
||
for (const pid of ['p_0', 'p_1']) {
|
||
const f = fields(h).find((x) => x.fieldId === `n:${pid}`);
|
||
const b = createBattleFromSpec(f.spec, m.ds, { BattleClass: FakeBattle });
|
||
b.runToEnd?.(); while (!b.finished) b.step();
|
||
assert.deepEqual(m.handle(pid, { t: 'b.result', battleId: f.battleId, result: compactResult(b.result()) }), { ok: true });
|
||
assert.equal(f.resultSource, 'client');
|
||
}
|
||
h.run(() => m.phase !== PHASE.COMBAT);
|
||
assert.ok([PHASE.SETTLE, PHASE.UNITE].includes(m.phase), m.phase);
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('validation: implausible client results are rejected and replaced by the server\'s simulation', () => {
|
||
const cases = [
|
||
['coins beyond the bounties', (r) => { for (const p of Object.values(r.perPlayer)) p.coins = 999; return r; }],
|
||
['a leak of an enemy that never spawned', (r) => { const p = Object.values(r.perPlayer)[0]; p.leaked.push({ enemyKey: 'enemy_1502_crowns', mods: null, lpr: 1, sourcePlayerId: null, tag: null, counted: true }); p.perfect = false; return r; }],
|
||
['perfect despite counted leaks', (r, spec) => { const p = Object.values(r.perPlayer)[0]; p.leaked = [{ enemyKey: spec.spawns[0].enemyKey, counted: true }]; p.perfect = true; return r; }],
|
||
['layer gains above the bound', (r) => { for (const p of Object.values(r.perPlayer)) p.layerGains = { yanShip: 500 }; return r; }],
|
||
['a player who is not in the battle', (r) => { r.perPlayer.ghost = { ...Object.values(r.perPlayer)[0] }; return r; }],
|
||
];
|
||
for (const [name, tamper] of cases) {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9104, captureFrames: false, clientCombat: true, perPlayer: { p_0: { tamper } } });
|
||
h.autoHumans();
|
||
const m = h.m;
|
||
m.start();
|
||
h.run(() => m.phase === PHASE.SETTLE || h.ended != null);
|
||
const f = m.lastResults.get('p_0');
|
||
assert.ok(f, name);
|
||
assert.ok(m.verifyStats.rejected >= 1, `${name}: rejected`);
|
||
assert.ok(m.verifyStats.takeovers >= 1, `${name}: server re-simulation`);
|
||
assert.ok(Object.values(f.layerGains || {}).every((n) => n <= 60 + 4 * m.round), `${name}: no forged layers`);
|
||
assert.ok(f.coins <= 50, `${name}: no forged coins`);
|
||
const takeover = h.clients.get('p_0').ends.find((x) => x.reason === 'takeover');
|
||
assert.ok(takeover, `${name}: the client was told the server took over`);
|
||
m.dispose();
|
||
}
|
||
// unit checks of the validator
|
||
const spec = buildBattleSpec({ battleId: 'x', fieldId: 'n:p', kind: 'normal', round: 3, stageId: 's', timeLimit: 120, players: [{ playerId: 'p', units: [{ uid: 5, kind: 'chess', chessId: 'chess_char_1_a', row: 9, col: 3 }] }], spawns: [{ enemyKey: 'enemy_1007_slime', count: 3, time: 0, bounty: { coins: 2, ownerPlayerId: 'p' } }], flags: { layerGainsEnabled: true } });
|
||
const ok = { reason: 'timeout', time: 120, perPlayer: { p: { killed: 2, total: 3, leaked: [{ enemyKey: 'enemy_1007_slime', counted: true }], perfect: false, layerGains: { yanShip: 3 }, coins: 4, unitsEnd: [{ uid: 5, hpPct: 0.5, sp: 2, alive: true }] } } };
|
||
assert.equal(validateClientResult(spec, ok, {}).ok, true);
|
||
const bad = (edit) => { const r = JSON.parse(JSON.stringify(ok)); edit(r); return validateClientResult(spec, r, {}); };
|
||
assert.equal(bad((r) => { r.perPlayer.p.coins = 7; }).reason, 'coins');
|
||
assert.equal(bad((r) => { r.perPlayer.p.leaked.push(...Array(3).fill({ enemyKey: 'enemy_1007_slime', counted: true })); }).reason, 'leak multiset');
|
||
assert.equal(bad((r) => { r.perPlayer.p.perfect = true; }).reason, 'perfect');
|
||
assert.equal(bad((r) => { r.perPlayer.p.layerGains.yanShip = 73; }).reason, 'layer bound');
|
||
assert.equal(bad((r) => { r.time = 500; }).reason, 'time');
|
||
assert.equal(bad((r) => { r.perPlayer.q = r.perPlayer.p; }).reason, 'players');
|
||
assert.equal(bad((r) => { r.perPlayer.p.unitsEnd[0].hpPct = 2; }).reason, 'unit state');
|
||
const noLayers = buildBattleSpec({ ...spec, kind: 'unite', flags: { layerGainsEnabled: false } });
|
||
assert.equal(validateClientResult(noLayers, ok, {}).reason, 'layers disabled');
|
||
// a structurally broken payload never reaches the match (protocol validator)
|
||
assert.notEqual(validateC2S({ t: 'b.result', battleId: 'x', result: { ...ok, perPlayer: {} } }), null);
|
||
assert.notEqual(validateC2S({ t: 'b.result', battleId: 'x', result: { ...ok, reason: 'won' } }), null);
|
||
assert.notEqual(validateC2S({ t: 'b.result', battleId: 'x', result: { ...ok, perPlayer: { p: { ...ok.perPlayer.p, leaked: Array(401).fill({ enemyKey: 'enemy_a' }) } } } }), null);
|
||
assert.equal(specBounds(spec).bountyCoins, 6);
|
||
});
|
||
|
||
test('takeover: an authority that disconnects mid-combat loses its field; the server re-simulates it; reconnect gets a display replica', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9105, captureFrames: false, clientCombat: true, perPlayer: { p_1: { mute: true } } });
|
||
h.autoHumans();
|
||
const m = h.m;
|
||
m.start();
|
||
h.run(() => m.phase === PHASE.COMBAT);
|
||
const f = fields(h).find((x) => x.fieldId === 'n:p_1');
|
||
assert.equal(f.mode, 'client');
|
||
h.sched.advance(3000);
|
||
m.onDisconnect('p_1');
|
||
assert.equal(f.mode, 'server', 'server takeover on disconnect');
|
||
assert.ok(f.result && f.resultSource === 'server');
|
||
assert.equal(m.verifyStats.takeovers, 1);
|
||
m.onReconnect('p_1');
|
||
const again = h.lastTo('p_1', 'b.start');
|
||
assert.equal(again.battleId, f.battleId);
|
||
assert.equal(again.authoritative, false, 'the returning player only watches (the server holds the result)');
|
||
assert.ok(again.elapsed > 0, `fast-forward target (${again.elapsed})`);
|
||
h.run(() => m.phase === PHASE.SETTLE || h.ended != null);
|
||
assert.ok(m.lastResults.has('p_1'));
|
||
// the server's result equals what an honest client would have reported (deterministic spec)
|
||
const honest = createBattleFromSpec(f.spec, browserDs(), { quiet: true }).runToEnd(4000);
|
||
assert.equal(resultDigest(validateClientResult(f.spec, compactResult(honest), { gd: m.gd }).result).hash, resultDigest(validateClientResult(f.spec, compactResult(f.result), { gd: m.gd }).result).hash);
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('takeover: a connected client that never reports is taken over at time limit + 15 s grace; departed players are settled at once', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9106, captureFrames: false, clientCombat: true, instant: false, perPlayer: { p_2: { stall: true } } });
|
||
h.autoHumans();
|
||
const m = h.m;
|
||
m.start();
|
||
h.run(() => m.phase === PHASE.COMBAT);
|
||
const f = fields(h).find((x) => x.fieldId === 'n:p_2');
|
||
const t0 = m.sched.now();
|
||
const limitMs = (f.spec.timeLimit / m.gameSpeed) * 1000;
|
||
h.run(() => f.mode === 'server' || m.phase !== PHASE.COMBAT, { maxTime: limitMs + 20000 });
|
||
assert.equal(f.mode, 'server');
|
||
const at = m.sched.now() - t0;
|
||
assert.ok(at >= limitMs + 15000 - 50 && at <= limitMs + 15000 + 400, `taken over at ${at} ms (limit ${limitMs} + 15000)`);
|
||
h.sched.advance(1);
|
||
assert.ok(h.clients.get('p_2').ends.some((x) => x.reason === 'takeover'), 'the stalled client is told');
|
||
h.run(() => m.phase !== PHASE.COMBAT);
|
||
// a player who leaves mid-combat: its field is closed at once (it is eliminated anyway)
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 2, { maxSteps: 3e6 });
|
||
if (m.phase === PHASE.COMBAT) {
|
||
const g = fields(h).find((x) => x.fieldId === 'n:p_1');
|
||
m.onLeave('p_1');
|
||
assert.equal(g.done, true);
|
||
assert.equal(g.live, false);
|
||
}
|
||
m.dispose();
|
||
});
|
||
|
||
test('observing: no watching while the own battle runs; after it ends a teammate\'s spec + clock; eliminated players watch anything', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9107, fake: true, clientCombat: true, instant: false, pace: 'paced',
|
||
script: (b) => ({ duration: b.players.includes('p_0') ? 2 : 30 }) }).start();
|
||
const m = h.m;
|
||
h.toPrep(1);
|
||
for (const pid of ['p_0', 'p_1', 'p_2']) m.handle(pid, { t: 'g.ready', ready: true });
|
||
h.run(() => m.phase === PHASE.COMBAT);
|
||
// p_1 still fights: it may not look elsewhere
|
||
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
|
||
h.run(() => fields(h).find((f) => f.fieldId === 'n:p_0').done);
|
||
const target = fields(h).find((f) => f.fieldId === 'n:p_2');
|
||
assert.equal(target.live, true);
|
||
h.sched.advance(1000);
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
|
||
const st = h.lastTo('p_0', 'b.start');
|
||
assert.equal(st.fieldId, 'n:p_2');
|
||
assert.equal(st.watch, true);
|
||
assert.equal(st.authoritative, false);
|
||
assert.ok(st.elapsed > 0, 'the replica fast-forwards to the field clock');
|
||
assert.deepEqual(st.spec, target.spec);
|
||
assert.equal(m.watchers.get('p_0'), 'n:p_2');
|
||
// back to the own (finished) field
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||
assert.equal(h.lastTo('p_0', 'b.start').done, true);
|
||
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b9' }).error, 'BAD_TARGET');
|
||
// an eliminated player may watch anything
|
||
h.ps('p_0').alive = false;
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||
h.ps('p_0').alive = true;
|
||
m.dispose();
|
||
});
|
||
|
||
test('Final Assault: two client-run pair fields share the server pool (b.progress bossDmg), b.pool ≤ 4 Hz, victory, per-player damage', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 9108, fake: true, clientCombat: true, instant: false,
|
||
script: (b) => (b.kind === 'boss' ? { bossDps: 20000 } : { duration: 2 }) }).start();
|
||
const m = h.m;
|
||
const poolAt = [];
|
||
h.onBroadcast.push((msg) => { if (msg.t === 'b.pool') poolAt.push(h.sched.now()); });
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
const [b1, b2] = fields(h);
|
||
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
|
||
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
|
||
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
|
||
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
|
||
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
|
||
const max = m.bossPool.maxHp;
|
||
h.sched.advance(3000);
|
||
const hp = m.bossPool.hp;
|
||
assert.ok(hp < max, 'client damage reaches the server pool');
|
||
assert.ok(b1.bossAcked > 0 && b2.bossAcked > 0, 'both fields contributed');
|
||
assert.ok(Math.abs((max - hp) - (b1.bossAcked + b2.bossAcked)) < 1e-6, 'pool = max − Σ reported field damage');
|
||
const pools = h.bc.filter((x) => x.t === 'b.pool');
|
||
assert.ok(pools.length > 3);
|
||
for (let i = 1; i < poolAt.length; i++) assert.ok(poolAt[i] - poolAt[i - 1] >= 250, `b.pool ≤ 4 Hz (${poolAt[i] - poolAt[i - 1]} ms apart)`);
|
||
assert.deepEqual(Object.keys(pools[pools.length - 1].acked).sort(), ['b1', 'b2']);
|
||
const end = h.runToEnd();
|
||
assert.equal(end.victory, true);
|
||
const dmg = [...m.players.values()].map((p) => p.stats.bossDamage);
|
||
assert.ok(dmg.every((d) => d > 0), `per-player boss damage attributed (${dmg})`);
|
||
assert.ok(Math.abs(dmg.reduce((a, b) => a + b, 0) - max) < 1, 'Σ player damage = pool');
|
||
m.dispose();
|
||
});
|
||
|
||
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; all-human overtime after 120 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
|
||
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 2, lpr: 3 }], lpLossEvents: [{ at: 3, amount: 2 }] } : { duration: 2 }) }).start();
|
||
const m = h.m;
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
const lp0 = m.teamLp;
|
||
h.sched.advance(4000);
|
||
assert.equal(m.teamLp, lp0 - 5, 'leak lpr 3 + leader effect 2');
|
||
const lp1 = m.teamLp;
|
||
// All-human teams start overtime at the visible level countdown, without the AI team's extra 30 seconds.
|
||
const t0 = m._bossStartAt;
|
||
const pub = () => h.bc.filter((x) => x.t === 'm.public').pop();
|
||
m.flush(true);
|
||
assert.equal(pub().deadline, t0 + 120000, 'the HUD counts the boss level\'s 120 s down');
|
||
assert.equal(pub().overtimeAt, t0 + 120000, 'and knows when the drain starts');
|
||
h.sched.advance(t0 + 120000 - m.sched.now() + 500);
|
||
assert.equal(m.teamLp, lp1, 'the first deduction is at the next whole real second');
|
||
h.sched.advance(5000);
|
||
assert.ok(m.teamLp <= lp1 - 4 && m.teamLp >= lp1 - 6, `overtime drain ≈ 5 LP over 5 real s (${lp1} → ${m.teamLp})`);
|
||
const end = h.runToEnd();
|
||
assert.equal(end.victory, false);
|
||
assert.equal(m.teamLp, 0);
|
||
assert.ok(h.clients.get('p_1').ends.some((x) => x.reason === 'forced') || h.clients.get('p_0').ends.some((x) => x.reason === 'forced'), 'b.end reached the field');
|
||
m.dispose();
|
||
});
|
||
|
||
test('Final Assault: the authority leaves → its partner\'s replica takes over; nobody left → the server paces the field (credits only new damage)', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
|
||
script: (b) => (b.kind === 'boss' ? { bossDps: 2000 } : { duration: 2 }) }).start();
|
||
const m = h.m;
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
const f = fields(h)[0];
|
||
h.sched.advance(2000);
|
||
const acked = f.bossAcked;
|
||
assert.ok(acked > 0);
|
||
m.onDisconnect('p_0');
|
||
assert.equal(f.authority, 'p_1', 'the partner takes over');
|
||
assert.equal(h.lastTo('p_1', 'b.start').authoritative, true);
|
||
h.sched.advance(2000);
|
||
assert.ok(f.bossAcked > acked, 'the new authority keeps reporting cumulative damage');
|
||
const before = m.bossPool.hp;
|
||
m.onDisconnect('p_1');
|
||
assert.equal(f.mode, 'server');
|
||
const afterHp = m.bossPool.hp;
|
||
assert.ok(Math.abs(afterHp - before) < 1e-6, 'the re-simulation does not double count reported damage');
|
||
h.sched.advance(3000);
|
||
assert.ok(m.bossPool.hp < afterHp, 'the server run keeps damaging the pool');
|
||
const end = h.runToEnd();
|
||
assert.equal(end.victory, true);
|
||
m.dispose();
|
||
});
|
||
|
||
test('联防 under client-side combat: helpers\' reported end state (HP%, SP) carries into the spec; everyone gets the spec; authority = lowest-seat helper', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
|
||
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}) }).start();
|
||
const m = h.m;
|
||
h.toPrep(1);
|
||
const ps1 = h.ps('p_1');
|
||
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
|
||
const unit = give(m, ps1, id, 'board', [...ps1.deployMap()].find(([, v]) => v === 'melee')[0].split(',').map(Number));
|
||
h.drive(() => m.phase === PHASE.UNITE);
|
||
const f = fields(h)[0];
|
||
assert.equal(f.fieldId, 'u');
|
||
assert.deepEqual(f.players, ['p_1', 'p_2']);
|
||
assert.equal(f.authority, 'p_1');
|
||
const carried = f.spec.players[0].units.find((u) => u.uid === unit.uid);
|
||
const reported = m.lastResults.get('p_1').unitsEnd.find((u) => u.uid === unit.uid);
|
||
assert.deepEqual(carried.carryState, { hpPct: reported.hpPct, sp: reported.sp });
|
||
const got = h.sent.filter(([, x]) => x.t === 'b.start' && x.fieldId === 'u').map(([pid, x]) => `${pid}:${x.authoritative ? 'A' : x.watch ? 'w' : 'r'}`).sort();
|
||
assert.deepEqual(got, ['p_0:w', 'p_1:A', 'p_2:r']);
|
||
h.drive(() => m.phase === PHASE.SETTLE);
|
||
assert.ok(f.result && f.resultSource === 'client');
|
||
m.dispose();
|
||
});
|
||
|
||
test('SP_VERIFY=all: an accepted but wrong client result is replaced by the server simulation', () => {
|
||
// an empty board leaks everything; the client claims perfect rounds (valid shape, within every bound)
|
||
const tamper = (r) => { for (const p of Object.values(r.perPlayer)) { p.leaked = []; p.perfect = true; p.killed = p.total; } return r; };
|
||
const run = (verify) => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 9112, captureFrames: false, clientCombat: true, verify, perPlayer: { p_0: { tamper } } });
|
||
const m = h.m;
|
||
m.start();
|
||
h.drive(() => h.ended != null || ((m.phase === PHASE.UNITE || m.phase === PHASE.SETTLE) && m.round === 1), { band: 'band_bldsk' });
|
||
const s = { ...m.verifyStats, leaks: m.lastResults.get('p_0').leaked.length };
|
||
m.dispose();
|
||
return s;
|
||
};
|
||
const off = run('off');
|
||
const all = run('all');
|
||
assert.equal(off.checked, 0);
|
||
assert.equal(all.checked, 1, 'the result was re-simulated before it was accepted');
|
||
assert.equal(all.mismatches, 1, 'the forged perfect round is caught');
|
||
assert.equal(off.leaks, 0, 'without verification the forged result stands');
|
||
assert.ok(all.leaks > 0, `the server's leaks count (${all.leaks})`);
|
||
});
|
||
|
||
test('LocalBossPool (client) and CreditPool (server takeover) keep the shared pool consistent', () => {
|
||
const lp = new LocalBossPool(1000, 800);
|
||
assert.equal(lp.hp, 800);
|
||
assert.equal(lp.damage('a', 300), 300);
|
||
assert.equal(lp.hp, 500);
|
||
lp.sync(450, 300); // server counted our 300 and another field's 50
|
||
assert.equal(lp.hp, 450);
|
||
assert.equal(lp.damage('a', 1000), 450);
|
||
assert.equal(lp.hp, 0);
|
||
assert.equal(lp.cum, 750);
|
||
const pool = new SharedBossPool(1000);
|
||
pool.damage('a', 300);
|
||
const cp = new CreditPool(pool, { acked: 300 });
|
||
assert.equal(cp.damage('a', 200), 0, 'replayed damage is not counted twice');
|
||
assert.equal(pool.hp, 700);
|
||
assert.equal(cp.damage('a', 150), 50);
|
||
assert.equal(pool.hp, 650);
|
||
assert.equal(cp.hp, 650);
|
||
});
|
||
|
||
test('full co-op match with client-side combat and the real sim reaches RESULT with zero errors (humans on AI 托管)', () => {
|
||
for (const [humans, bots, seed, pace] of [[2, 1, 9113, 'instant'], [4, 0, 9114, 'paced'], [1, 2, 9115, 'instant']]) {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans, bots, seed, captureFrames: false, checkFrames: true, clientCombat: true, pace });
|
||
h.autoHumans();
|
||
const m = h.m;
|
||
let last = '';
|
||
m.start();
|
||
h.run(() => {
|
||
const k = `${m.phase}:${m.round}`;
|
||
if (k !== last) { last = k; checkInvariants(m); if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 300; }
|
||
return h.ended != null;
|
||
}, { maxSteps: 6e6 });
|
||
assert.ok(h.ended, `${humans}+${bots}: ended (${m.phase} R${m.round})`);
|
||
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
|
||
assert.deepEqual(h.logs.error.filter((l) => !l.startsWith('[sim]')), []);
|
||
assert.deepEqual(h.badFrames, []);
|
||
assert.equal(m.verifyStats.rejected, 0, 'honest clients are never rejected');
|
||
assert.equal(m.verifyStats.takeovers, 0);
|
||
assert.equal(h.sent.filter(([, x]) => x.t === 'b.snap' || x.t === 'b.ev').length, 0);
|
||
for (const c of h.clients.values()) assert.ok(c.log.some((x) => x.t === 'b.result'), 'every human reported results');
|
||
m.dispose();
|
||
}
|
||
});
|
||
|
||
test('fuzz: random / hostile b.progress and b.result frames never throw, never corrupt, never end a field early', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9116, captureFrames: false, clientCombat: true, clients: false });
|
||
h.autoHumans();
|
||
const m = h.m;
|
||
m.start();
|
||
h.run(() => m.phase === PHASE.COMBAT);
|
||
let seed = 12345;
|
||
const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 2 ** 32; };
|
||
const pick = (a) => a[Math.floor(rnd() * a.length)];
|
||
const f0 = fields(h).find((f) => f.fieldId === 'n:p_0');
|
||
const junk = () => pick([undefined, null, -1, 1e20, NaN, 'x', {}, [], 3, 0.5, true]);
|
||
let accepted = 0;
|
||
for (let i = 0; i < 3000; i++) {
|
||
const battleId = pick([f0.battleId, 'nope', junk()]);
|
||
const msg = rnd() < 0.5
|
||
? { t: 'b.progress', battleId, gt: pick([1, 5, junk()]), killed: pick([0, 3, junk()]), total: pick([10, junk()]), leaks: pick([0, 2, junk()]), bossDmg: pick([undefined, 5, junk()]), by: pick([undefined, { p_0: 3 }, junk()]) }
|
||
: { t: 'b.result', battleId, result: pick([junk(), { reason: 'cleared', time: 3, perPlayer: { p_0: { killed: 1, total: pick([1, 0, junk()]), leaked: pick([[], [{ enemyKey: 'enemy_x' }], junk()]), perfect: pick([true, false, junk()]), layerGains: pick([{}, { yanShip: 999 }, junk()]), unitsEnd: [] } } }]) };
|
||
for (const k of Object.keys(msg)) if (msg[k] === undefined) delete msg[k];
|
||
if (validateC2S(msg) != null) continue; // the platform drops what the protocol validator refuses
|
||
accepted++;
|
||
assert.doesNotThrow(() => m.handle(pick(['p_0', 'p_1']), msg));
|
||
}
|
||
assert.ok(accepted > 100, `fuzz frames reached the match (${accepted})`);
|
||
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
|
||
assert.ok(f0.progress.killed <= f0.progress.total);
|
||
// a structurally valid but implausible result for p_0's field was replaced by the server's run, never trusted
|
||
if (f0.done) assert.equal(f0.resultSource, 'server');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
// ---- boss-field report plausibility (a modified authority client) -------------------------------------------------
|
||
|
||
function faWithForger(seed) {
|
||
// p_0 is the authority of b1 but "modified": its SimClient is muted and the test sends its frames by hand
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed, fake: true, clientCombat: true, instant: false,
|
||
perPlayer: { p_0: { mute: true } }, script: (b) => (b.kind === 'boss' ? { bossDps: 0 } : { duration: 2 }) }).start();
|
||
h.autoHumans();
|
||
h.drive(() => h.m.phase === PHASE.FINAL_ASSAULT);
|
||
const f = fields(h)[0];
|
||
assert.equal(f.authority, 'p_0');
|
||
return { h, m: h.m, f };
|
||
}
|
||
|
||
test('Final Assault: a forged b.progress (bossDmg 1e13, leaks 1e6) is credited only up to the field clock\'s budget — one frame never empties the pool or the team LP', () => {
|
||
const { h, m, f } = faWithForger(9120);
|
||
const max = m.bossPool.maxHp;
|
||
const lp0 = m.teamLp;
|
||
h.sched.advance(500); // 1 game s on the field clock
|
||
const gt = m._fieldElapsed(f);
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 1, killed: 0, total: 0, bossDmg: 1e13, by: { p_0: 1e13 }, leaks: 1e6 }), { ok: true });
|
||
assert.ok(max - m.bossPool.hp <= (max * gt) / 5 + 1, `≤ the whole pool per 5 game s (${max - m.bossPool.hp} of ${max})`);
|
||
assert.ok(m.bossPool.hp > 0.7 * max);
|
||
assert.ok(lp0 - m.teamLp <= 10 + gt + 1e-9, `≤ 10 + 1 LP per game s (${lp0} → ${m.teamLp})`);
|
||
assert.ok(m.teamLp > 0);
|
||
assert.equal(m._finalEnding, null, 'the fight goes on');
|
||
assert.ok(!h.bc.some((x) => x.t === 'b.end'));
|
||
// the held-back part is credited as the clock advances, at the bounded rate (nothing reported is lost)
|
||
h.sched.advance(1000);
|
||
const gt2 = m._fieldElapsed(f);
|
||
assert.ok(max - m.bossPool.hp <= (max * gt2) / 5 + 1 && max - m.bossPool.hp > (max * gt) / 5, 'more credited, still bounded');
|
||
assert.ok(lp0 - m.teamLp <= 10 + gt2 + 1e-9);
|
||
assert.equal(f.bossAcked, max - m.bossPool.hp, 'b.pool acked = what was credited');
|
||
m.dispose();
|
||
});
|
||
|
||
test('Final Assault: an early boss b.result (forced at t≈0) does not end the pair\'s fight — the partner takes the field over and the sender is never its authority again', () => {
|
||
const { h, m, f } = faWithForger(9121);
|
||
h.sched.advance(500);
|
||
const res = { reason: 'forced', time: 1, killed: 0, total: 0, errors: 0,
|
||
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
|
||
assert.equal(isBattleResult(res), true);
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||
assert.equal(f.done, false, 'the field keeps running');
|
||
assert.equal(m.phase, PHASE.FINAL_ASSAULT);
|
||
assert.equal(f.authority, 'p_1', 'the partner\'s replica is promoted');
|
||
assert.equal(h.lastTo('p_1', 'b.start').authoritative, true);
|
||
assert.equal(h.lastTo('p_0', 'b.end').reason, 'takeover');
|
||
assert.ok(m.verifyStats.rejected >= 1);
|
||
// the demoted client never gets the field back: the partner leaving hands it to the server
|
||
m.onDisconnect('p_1');
|
||
assert.equal(f.mode, 'server');
|
||
assert.equal(f.authority, null);
|
||
m.dispose();
|
||
});
|
||
|
||
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
|
||
const { h, m, f } = faWithForger(9122);
|
||
const ps = h.ps('p_0');
|
||
const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||
m.addBounty(ps, { ...training, rounds: 2, multiRound: false });
|
||
const pending = ps.pendingFunds;
|
||
const max = m.bossPool.maxHp;
|
||
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
|
||
const gt = m._fieldElapsed(f);
|
||
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks: 0 }), { ok: true });
|
||
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
|
||
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
|
||
assert.equal(isBattleResult(res), true);
|
||
const rejected0 = m.verifyStats.rejected;
|
||
const takeovers0 = m.verifyStats.takeovers; // the muted client's earlier normal fields went to the server
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||
assert.ok(m.bossPool.hp > 0.7 * max, 'still only the budget is credited');
|
||
assert.equal(f.done, false, 'the field waits for the budget');
|
||
assert.equal(f.authority, 'p_0', 'no takeover');
|
||
assert.equal(f.mode, 'client');
|
||
assert.equal(m.verifyStats.rejected, rejected0, 'not counted as rejected');
|
||
assert.ok(!h.sent.some(([pid, x]) => pid === 'p_0' && x.t === 'b.end' && x.battleId === f.battleId), 'the sender is not told to stop');
|
||
assert.equal(m._finalEnding, null);
|
||
// a re-sent copy of the result changes nothing; the silence watchdog never hands the waiting field over
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
|
||
assert.equal(m._finalEnding, 'cleared', 'the budget credited the report: the pool is empty → victory');
|
||
assert.equal(m.bossPool ? m.bossPool.hp : 0, 0);
|
||
assert.ok(m._fieldElapsed(f) <= 5 * (max + 5) / max + 1, 'within the budget\'s 5 game s');
|
||
assert.equal(f.done, true);
|
||
assert.equal(f.resultSource, 'client', 'the held client result completed the field');
|
||
assert.equal(m.verifyStats.takeovers, takeovers0);
|
||
assert.equal(m.verifyStats.rejected, rejected0);
|
||
assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount');
|
||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||
assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice');
|
||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
|
||
m.dispose();
|
||
});
|
||
|
||
test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => {
|
||
for (const [ending, synthetic, perfect, leaked, eligible] of [
|
||
['cleared', false, true, [], true],
|
||
['cleared', true, true, [], false],
|
||
['cleared', false, false, [], false],
|
||
['cleared', false, true, [{ counted: true }], false],
|
||
['cleared', false, true, [{ counted: false }], true],
|
||
['forced', false, true, [], false],
|
||
]) {
|
||
const { m, f } = faWithForger(9122);
|
||
const ps = m.players.get(f.players[0]);
|
||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||
const res = syntheticResult(f.players);
|
||
if (!synthetic) delete res.synthetic;
|
||
res.reason = 'forced';
|
||
Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 });
|
||
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
|
||
m._finalEnding = ending;
|
||
m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat
|
||
m._finishFinal(false, () => res);
|
||
assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0));
|
||
assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0));
|
||
assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]);
|
||
m.dispose();
|
||
}
|
||
});
|
||
|
||
// ---- a perfect-payout bounty and what the authority reported (a modified client could tell the two halves apart) -------
|
||
|
||
/**
|
||
* A Final Assault pair field played by hand (the SimClient of p_0, the authority, is muted): both players hold the same
|
||
* perfect-payout card; the authority sends one b.progress — `leaks` (the field's LP cost) and `leaksBy` (its per-player
|
||
* split, when given) — then a 'cleared' b.result whose leaked lists hold `leaked[pid]` counted leaks (lpr 1); the fight
|
||
* is driven to its end. Returns the funds each player was paid (kill coins: none) and what the server charged.
|
||
*/
|
||
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
|
||
const { h, m, f } = faWithForger(seed);
|
||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||
const ps = f.players.map((pid) => h.ps(pid));
|
||
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||
const before = ps.map((p) => p.pendingFunds);
|
||
const lp0 = m.teamLp;
|
||
const max = m.bossPool.maxHp;
|
||
h.sched.advance(500);
|
||
const gt = m._fieldElapsed(f);
|
||
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
|
||
const progress = { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks, ...(leaksBy ? { leaksBy } : {}) };
|
||
assert.equal(validateC2S(progress), null);
|
||
assert.deepEqual(m.handle('p_0', progress), { ok: true });
|
||
const entry = (pid) => ({ enemyKey: card.enemyKey, mods: null, lpr: 1, sourcePlayerId: pid, tag: null, counted: true, spawned: true });
|
||
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
|
||
perPlayer: Object.fromEntries(f.players.map((pid) => {
|
||
const list = Array.from({ length: leaked[pid] || 0 }, () => entry(pid));
|
||
return [pid, { killed: 0, total: 0, leaked: list, perfect: list.length === 0, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }];
|
||
})) };
|
||
assert.equal(isBattleResult(res), true);
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
|
||
const out = { ending: m._finalEnding, source: f.resultSource, coin: card.coin, charged: lp0 - m.teamLp, paid: ps.map((p, i) => p.pendingFunds - before[i]), left: ps.map((p) => p.bounties.map((b) => b.roundsLeft)) };
|
||
m.dispose();
|
||
return out;
|
||
}
|
||
|
||
test('Final Assault, a modified authority: LP charged by b.progress while both leaked lists are empty pays no perfect-payout card', () => {
|
||
const r = bossPaid(9124, { leaks: 1, leaked: {} });
|
||
assert.equal(r.ending, 'cleared');
|
||
assert.equal(r.source, 'client');
|
||
assert.equal(r.charged, 1, 'the server charged the team leak');
|
||
assert.deepEqual(r.paid, [0, 0], 'a result that shows no leak after a charged one is not believed');
|
||
assert.deepEqual(r.left, [[1], [1]], 'the boss battle still used one of the cards\' battles');
|
||
});
|
||
|
||
test('Final Assault, a modified authority: one seat\'s reported leak cannot be moved to the other — nobody is paid', () => {
|
||
// progress: the leak is p_0's; the result pins it on p_1 so that p_0 reads perfect (and p_1 cannot be paid anyway)
|
||
const r = bossPaid(9125, { leaks: 1, leaksBy: { p_0: 1, p_1: 0 }, leaked: { p_1: 1 } });
|
||
assert.equal(r.ending, 'cleared');
|
||
assert.equal(r.charged, 1);
|
||
assert.deepEqual(r.paid, [0, 0]);
|
||
// …and with no split at all the result decides nothing either: the same leak pinned on p_1
|
||
assert.deepEqual(bossPaid(9126, { leaks: 1, leaked: { p_1: 1 } }).paid, [0, 0]);
|
||
});
|
||
|
||
test('Final Assault, honest authority: the seat whose half let nothing through is paid, the leaker is not; a leader\'s own LP effect costs nobody the card', () => {
|
||
const leak = bossPaid(9127, { leaks: 1, leaksBy: { p_0: 0, p_1: 1 }, leaked: { p_1: 1 } });
|
||
assert.equal(leak.charged, 1);
|
||
assert.deepEqual(leak.paid, [leak.coin, 0]);
|
||
// 2 LP of leader "扣除目标生命" effects (no leaked enemy): the report says so, the result agrees — both halves are perfect
|
||
const fx = bossPaid(9128, { leaks: 2, leaksBy: { p_0: 0, p_1: 0 }, leaked: {} });
|
||
assert.equal(fx.charged, 2);
|
||
assert.deepEqual(fx.paid, [fx.coin, fx.coin]);
|
||
// nothing charged, nothing leaked: as before
|
||
assert.deepEqual(bossPaid(9129, { leaks: 0, leaked: {} }).paid.map((n, i, a) => n === a[0]), [true, true]);
|
||
assert.ok(bossPaid(9130, { leaks: 0, leaked: {} }).paid.every((n) => n > 0));
|
||
});
|
||
|
||
test('Final Assault, honest clients (client-side combat): a leak on one half costs that seat its perfect-payout card, not its partner; the leader\'s own LP effect costs nobody', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9132, fake: true, clientCombat: true, instant: false,
|
||
script: (b) => (b.kind === 'boss' ? { bossDps: 20000, leaks: { p_1: 1 }, leakEvents: [{ at: 1, lpr: 1 }], lpLossEvents: [{ at: 1.5, amount: 2 }] } : { duration: 2 }) }).start();
|
||
const m = h.m;
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
|
||
for (const p of [p0, p1]) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||
const [a, b] = [p0.pendingFunds, p1.pendingFunds];
|
||
const end = h.runToEnd();
|
||
assert.equal(end.victory, true);
|
||
const f = fields(h)[0];
|
||
assert.equal(f.resultSource, 'client');
|
||
assert.equal(f.lpReported, 3, 'leak lpr 1 + leader effect 2');
|
||
assert.deepEqual(f.leaksBy, { p_1: 1 }, 'the authority split it: p_1\'s one leak (the other 2 LP are the leader\'s effect), nothing on p_0');
|
||
assert.equal(p0.pendingFunds - a, card.coin, 'the seat that let nothing through is paid');
|
||
assert.equal(p1.pendingFunds - b, 0, 'the seat that leaked is not');
|
||
m.dispose();
|
||
});
|
||
|
||
test('b.progress carries `leaksBy`, the per-player split of a boss field\'s leak LP (shared/protocol.js)', () => {
|
||
const base = { t: 'b.progress', battleId: 'a.1.1.b1', gt: 3, killed: 0, total: 0, leaks: 2, bossDmg: 10, by: { p_0: 10 } };
|
||
assert.equal(validateC2S({ ...base, leaksBy: { p_0: 1, p_1: 0.5 } }), null);
|
||
assert.equal(validateC2S({ ...base, leaksBy: {} }), null);
|
||
assert.equal(validateC2S(base), null, 'optional: a client that never sends it still validates');
|
||
for (const bad of [{ p_0: -1 }, { p_0: 'x' }, { p_0: 1e7 }, [1, 2], 'p_0', null, { 'a b': 1 }]) assert.notEqual(validateC2S({ ...base, leaksBy: bad }), null, JSON.stringify(bad));
|
||
});
|
||
|
||
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
|
||
const { h, m, f } = faWithForger(9123);
|
||
const max = m.bossPool.maxHp;
|
||
h.sched.advance(500);
|
||
const res = { reason: 'cleared', time: 1, killed: 0, total: 0, errors: 0,
|
||
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: pid === 'p_0' ? max * 0.3 : 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
|
||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||
assert.equal(f.heldResult, null);
|
||
assert.equal(f.authority, 'p_1', 'the partner\'s replica is promoted');
|
||
assert.equal(h.lastTo('p_0', 'b.end').reason, 'takeover');
|
||
assert.ok(m.verifyStats.rejected >= 1);
|
||
m.dispose();
|
||
});
|
||
|
||
test('Final Assault run on the server (nobody connected at its start): a human who reconnects gets b.pool with the field\'s own damage acknowledged', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
|
||
script: (b) => (b.kind === 'boss' ? { bossDps: 3000 } : { duration: 2 }) }).start();
|
||
const m = h.m;
|
||
h.autoHumans();
|
||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
|
||
m.onDisconnect('p_0');
|
||
m.onDisconnect('p_1');
|
||
h.run(() => m.phase === PHASE.FINAL_ASSAULT);
|
||
h.sched.advance(2000);
|
||
const f = fields(h)[0];
|
||
assert.equal(f.mode, 'server');
|
||
const n0 = h.bc.filter((x) => x.t === 'b.pool').length;
|
||
m.onReconnect('p_0');
|
||
assert.equal(h.lastTo('p_0', 'b.start').authoritative, false, 'a display replica');
|
||
h.sched.advance(3000);
|
||
const pools = h.bc.slice().filter((x) => x.t === 'b.pool').slice(n0);
|
||
assert.ok(pools.length >= 8, `b.pool keeps the replica in sync (${pools.length} frames in 3 s)`);
|
||
const last = pools[pools.length - 1];
|
||
assert.ok(Math.abs(last.hp - m.bossPool.hp) < 1e-6 || last.hp > m.bossPool.hp, 'the server pool');
|
||
assert.ok(last.acked[f.fieldId] > 0, 'the server run\'s own damage is acknowledged (the replica does not count it twice)');
|
||
const dealt = m.bossPool.maxHp - last.hp;
|
||
assert.ok(Math.abs(Object.values(last.acked).reduce((a, b) => a + b, 0) - dealt) <= 3000 * 2, 'Σ acked ≈ the pool damage');
|
||
m.dispose();
|
||
});
|