Files

274 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});