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:
commit
53ccf5d8fe
3 files changed
+381
-57
No files matched your search
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user