feedback6: merge R21G — battle control messages that arrive while the sim loads or catches up (takeover, forced end, a duplicate start, pool, pause) are held and applied in order (PR #266 by @siruimei07, ported); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 17:57:18 +08:00
commit 53ccf5d8fe
3 files changed
+381 -57

No files matched your search

+273
View File
@@ -0,0 +1,273 @@
// The browser battle runner (public/js/battle/runner.js) while a b.start is still being prepared — the simulation module
// loading (a deferred loader here) or the silent catch-up to the field clock (it waits for an animation frame after every
// PREPARE_SLICE of 600 ticks): a b.end (takeover / forced), a second b.start of the same battle, a b.start of another
// field, b.pool, the solo pause and the next prep's clear that arrive meanwhile are applied to that battle, in order,
// once it exists (PR #266 by @siruimei07). Manual clock and animation frames, a real round-2 b.start.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { validateC2S } from '../../shared/protocol.js';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch } from './harness.js';
const DS = new DataSource(DATA, null);
const QUIET = { error() {}, warn() {}, info() {}, debug() {} };
const tick = () => new Promise((res) => setImmediate(res));
/** A real authoritative b.start of round 2 (a board with operators) from a client-combat match. */
const START = (() => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7301, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 2, { maxSteps: 3e6 });
const msg = h.lastTo('p_0', 'b.start');
h.m.dispose();
assert.ok(msg && msg.authoritative && msg.kind === 'normal');
return msg;
})();
let bossStart = null;
/** A real b.start of a Final Assault field (a shared boss pool). */
function realBossStart() {
if (bossStart) return bossStart;
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 7304, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.ended != null || h.m.phase === PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
bossStart = h.lastTo('p_0', 'b.start');
h.m.dispose();
assert.equal(bossStart && bossStart.kind, 'boss');
return bossStart;
}
/** A display replica of another field (a teammate's battle). */
const watched = () => ({ ...START, battleId: `${START.battleId}w`, fieldId: 'n:p_9', spec: { ...START.spec, fieldId: 'n:p_9' }, authoritative: false, watch: true, elapsed: 0 });
function rig() {
let t = 1000;
let frames = [];
const handlers = new Map();
const sent = [];
const built = [];
const shown = [];
let release;
const loaded = new Promise((res) => { release = res; });
const sim = { ds: DS, spec: { ...specMod, createBattleFromSpec: (...a) => { const b = specMod.createBattleFromSpec(...a); built.push(b); return b; } } };
const net = {
on(type, fn) { if (!handlers.has(type)) handlers.set(type, new Set()); handlers.get(type).add(fn); return () => handlers.get(type).delete(fn); },
emit(type, msg) { for (const fn of handlers.get(type) || []) fn({ t: type, ...msg }); },
send(type, fields) { const msg = { ...fields, t: type }; assert.equal(validateC2S(msg), null, `invalid ${type}`); sent.push(msg); return true; },
request(type, fields) { const msg = { ...fields, t: type, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${type}`); sent.push(msg); return Promise.resolve({ t: 'ok' }); },
};
const store = createStore(initialState);
const runner = createBattleRunner({
net, store, doc: { hidden: false, addEventListener() {} }, now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; }, caf: () => {},
setInterval: () => 1, clearInterval: () => {},
loadSim: () => loaded, logger: QUIET,
});
runner.on('field', (f) => shown.push(f.battleId));
store.patch('match', { public: { phase: PHASE.COMBAT } });
return {
runner, net, store, sent, built, shown,
load() { release(sim); },
elapse(ms) { t += ms; },
/** `n` animation frames `ms` apart, the promise continuations settled before and after each */
async frames(n = 1, ms = 0) {
for (let i = 0; i < n; i++) {
await tick();
t += ms;
const q = frames;
frames = [];
for (const fn of q) fn(t);
}
await tick();
},
end(reason, battleId = START.battleId) { net.emit('b.end', { battleId, fieldId: START.fieldId, reason }); },
results: (battleId = START.battleId) => sent.filter((m) => m.t === 'b.result' && m.battleId === battleId),
};
}
/**
* The b.start, stopped while the sim still loads ('load', elapsed 0: the COMBAT start) or while the silent catch-up to
* 30 game s (900 ticks at 2×) waits for its next frame after the first 600-tick slice ('catch-up', a reconnect).
*/
async function startAt(r, stage, msg = START) {
r.net.emit('b.start', { ...msg, elapsed: stage === 'catch-up' ? 30 : 0 });
if (stage === 'catch-up') r.load();
await tick();
if (stage === 'catch-up') {
assert.equal(r.built.length, 1);
assert.equal(r.built[0].tickCount, 600, 'one silent catch-up slice, waiting for the next frame');
assert.ok(!r.built[0].finished);
} else {
assert.equal(r.built.length, 0, 'the simulation still loads');
}
assert.equal(r.runner._entries.size, 0, 'not registered yet');
assert.equal(r.runner.state().loading, true);
}
/** Let the preparation finish (the sim arrives when it was still loading). */
async function prepared(r, stage) {
if (stage === 'load') r.load();
await r.frames(5);
}
async function runOut(r, e) {
for (let i = 0; i < 400 && !e.battle.finished; i++) await r.frames(1, 1000);
assert.ok(e.battle.finished, 'the battle ran to its end');
}
for (const stage of ['load', 'catch-up']) {
test(`b.end takeover during the ${stage}: the battle is still shown but never reports from then on`, async () => {
const r = rig();
await startAt(r, stage);
const before = r.sent.length;
r.end('takeover');
await prepared(r, stage);
const e = r.runner._entries.get(START.battleId);
assert.ok(e, 'registered and shown (a takeover keeps displaying)');
assert.deepEqual(r.shown, [START.battleId]);
assert.equal(e.authoritative, false);
assert.equal(r.runner.state().authoritative, false);
await runOut(r, e);
assert.equal(r.sent.length, before, 'no b.progress / b.result after the takeover');
r.runner.dispose();
});
test(`b.end forced during the ${stage}: the battle ends with that reason as soon as it exists and reports once (the first end wins)`, async () => {
const r = rig();
await startAt(r, stage);
r.end('forced');
if (stage === 'catch-up') {
assert.ok(r.built[0].finished, 'ended at once, no further catch-up');
assert.equal(r.built[0].tickCount, 600);
assert.equal(r.results().length, 1, 'reported at once');
}
r.end('timeout');
await prepared(r, stage);
const e = r.runner._entries.get(START.battleId);
assert.ok(e && e.done && e.battle.finished);
assert.equal(e.battle.tickCount, stage === 'catch-up' ? 600 : 0, 'never stepped past the end');
assert.equal(e.battle.result().reason, 'forced');
assert.equal(r.results().length, 1);
assert.equal(r.results()[0].result.reason, 'forced');
assert.deepEqual(r.shown, [START.battleId]);
assert.equal(r.runner.state().done, true);
await r.frames(5, 1000);
r.end('forced');
assert.equal(r.results().length, 1, 'once');
r.runner.dispose();
});
test(`b.end takeover then forced during the ${stage}: applied in that order — ended, no result`, async () => {
const r = rig();
await startAt(r, stage);
const before = r.sent.length;
r.end('takeover');
r.end('forced');
await prepared(r, stage);
const e = r.runner._entries.get(START.battleId);
assert.ok(e && e.battle.finished);
assert.equal(e.battle.result().reason, 'forced');
assert.equal(e.authoritative, false);
assert.equal(r.sent.length, before, 'the former authority sends nothing');
r.runner.dispose();
});
test(`a second b.start of the battle during the ${stage}: the same preparation, one Battle, the flags of the last message`, async () => {
const r = rig();
await startAt(r, stage);
r.end('takeover');
r.net.emit('b.start', { ...START, elapsed: 30 }); // authoritative again (a resync / a handover back)
await prepared(r, stage);
assert.equal(r.built.length, 1, 'one Battle per battleId');
const e = r.runner._entries.get(START.battleId);
assert.equal(e.authoritative, true, 'the later b.start grants the authority again');
assert.deepEqual(r.shown, [START.battleId], 'shown once');
await r.frames(3, 1000);
assert.ok(r.sent.some((m) => m.t === 'b.progress'), 'reporting');
r.end('takeover');
const n = r.sent.length;
await r.frames(5, 1000);
assert.equal(r.sent.length, n, 'the last takeover wins');
r.runner.dispose();
});
test(`the next prep during the ${stage} drops the battle: nothing is built, stepped, registered or reported afterwards`, async () => {
const r = rig();
await startAt(r, stage);
r.store.patch('match', { public: { phase: PHASE.PREP } });
const n = r.sent.length;
const ticks = r.built.map((b) => b.tickCount);
if (stage === 'load') r.load();
await r.frames(5, 1000);
r.end('forced');
await r.frames(3, 1000);
assert.equal(r.runner._entries.size, 0);
assert.equal(r.runner.state(), null);
assert.equal(r.store.get().match.battle, null);
assert.deepEqual(r.built.map((b) => b.tickCount), ticks, 'no build, no step');
assert.equal(r.sent.length, n);
assert.deepEqual(r.shown, []);
r.runner.dispose();
});
test(`another field's b.start during the ${stage}: the latest view is shown, the own authoritative battle still runs and reports`, async () => {
const r = rig();
await startAt(r, stage);
r.net.emit('b.start', watched());
await prepared(r, stage);
assert.equal(r.runner.state().battleId, watched().battleId, 'the latest view');
const e = r.runner._entries.get(START.battleId);
assert.ok(e && e.authoritative, 'the authority is kept');
await runOut(r, e);
await r.frames(2);
assert.equal(r.results().length, 1);
assert.equal(r.results(watched().battleId).length, 0, 'the display replica never reports');
assert.deepEqual(r.shown, [watched().battleId], 'the authority never takes the view back');
r.runner.dispose();
});
}
test('a display replica superseded during the catch-up is dropped (no Battle kept for it)', async () => {
const r = rig();
await startAt(r, 'catch-up', { ...START, authoritative: false, watch: true });
r.net.emit('b.start', watched());
await r.frames(5);
assert.equal(r.runner._entries.has(START.battleId), false);
assert.equal(r.runner.state().battleId, watched().battleId);
assert.equal(r.sent.length, 0);
r.runner.dispose();
});
test('solo pause during the catch-up: the paused time is not caught up', async () => {
const r = rig();
await startAt(r, 'catch-up');
r.store.patch('match', { public: { phase: PHASE.COMBAT, paused: true } });
r.elapse(5000);
r.store.patch('match', { public: { phase: PHASE.COMBAT, paused: false } });
await r.frames(3);
const e = r.runner._entries.get(START.battleId);
assert.equal(e.battle.tickCount, 900, 'the field clock (30 game s), not 5 paused real seconds further');
r.runner.dispose();
});
test('b.pool during the catch-up of a boss field reaches its Battle at once', async () => {
const start = realBossStart();
const r = rig();
r.net.emit('b.start', { ...start, elapsed: 30 });
r.load();
await tick();
assert.equal(r.runner._entries.size, 0, 'still catching up');
const pool = r.built[0].sharedBoss;
const hp = pool.maxHp * 0.75;
r.net.emit('b.pool', { hp, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } });
assert.ok(Math.abs(pool.hp - hp) < 1e-6, 'the server hp when everything is acknowledged');
await r.frames(3);
assert.equal(r.runner._entries.get(start.battleId).battle, r.built[0]);
r.runner.dispose();
});