// The browser battle runner (public/js/battle/runner.js) under Node with a fake socket, a manual clock and manual // animation frames: pacing (60 ticks per real second at 2×, ≤ 8 per frame), the b.snap / b.ev feed and the field meta, // b.progress / b.result when authoritative (valid protocol frames, the same result as the server's simulation), // fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing, the live // leak count of normal fields (state().leaks, user playtest #3 item 2). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createBattleRunner, ticksPerFrameCap } 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 { validateClientResult, runHeadless } from '../../server/match/fields.js'; import { PHASE } from '../../shared/constants.js'; import { DATA, makeMatch } from './harness.js'; const DS = new DataSource(DATA, null); function fakeNet() { const handlers = new Map(); const n = { sent: [], on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); }, emit(t, msg) { for (const fn of handlers.get(t) || []) fn({ t, ...msg }); }, send(t, fields) { const msg = { ...fields, t }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return true; }, request(t, fields) { const msg = { ...fields, t, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return Promise.resolve({ t: 'ok' }); }, }; return n; } function rig({ hidden = false } = {}) { let t = 1000; const frames = []; const intervals = []; const doc = { hidden, addEventListener() {} }; const net = fakeNet(); const store = createStore(initialState); const runner = createBattleRunner({ net, store, doc, now: () => t, raf: (fn) => { frames.push(fn); return frames.length; }, caf: () => {}, setInterval: (fn) => { intervals.push(fn); return intervals.length; }, clearInterval: () => {}, loadSim: async () => ({ spec: specMod, ds: DS }), logger: { error() {}, warn() {}, info() {}, debug() {} }, }); const feed = { snaps: [], evs: [], fields: [] }; runner.on('snap', (s) => feed.snaps.push(s)); runner.on('ev', (e) => feed.evs.push(e)); runner.on('field', (f) => feed.fields.push(f)); const r = { runner, net, store, doc, feed, get t() { return t; }, /** advance the clock by `ms` in animation frames of `step` ms (or pump intervals when hidden) */ advance(ms, step = 1000 / 60) { const end = t + ms; while (t < end) { t = Math.min(end, t + step); if (doc.hidden) { for (const fn of intervals) fn(); continue; } const q = frames.splice(0); for (const fn of q) fn(t); } }, async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); const q = frames.splice(0); for (const fn of q) fn(t); } }, }; return r; } /** A real b.start of round 2 (a board with operators) from a client-combat match. */ function realStart(seed = 7301) { const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed, captureFrames: false, clientCombat: true, clients: false }); h.autoHumans(); h.m.start(); h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 1); // round 1: the bot fights, the human (no client here) is taken over at the deadline; round 2 has a real board h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 2, { maxSteps: 3e6 }); const msg = h.lastTo('p_0', 'b.start'); h.m.dispose(); return msg; } test('authoritative battle: 2× pacing, ≤ max(8, 4·speed) ticks per frame, b.snap / b.ev feed, field meta in the store, progress ~1 Hz, the server-identical result', async () => { const start = realStart(); assert.ok(start && start.authoritative); const r = rig(); r.net.emit('b.start', start); await r.settle(); assert.equal(r.feed.fields.length, 1); const field = r.store.get().match.field; assert.equal(field.fieldId, start.fieldId); assert.equal(field.local, true); assert.equal(field.battleId, start.battleId); assert.ok(Array.isArray(field.units), 'unit infos (none deployed before the first tick)'); assert.deepEqual(r.store.get().match.battle && [r.store.get().match.battle.authoritative, r.store.get().match.battle.own], [true, true]); const e = r.runner._entries.get(start.battleId); r.advance(1000); assert.ok(Math.abs(e.battle.tickCount - 60) <= 2, `60 ticks per real second at 2× (${e.battle.tickCount})`); assert.ok(r.feed.snaps.length >= 55, 'a snapshot per frame'); const snap = r.feed.snaps[r.feed.snaps.length - 1]; assert.equal(snap.t, 'b.snap'); assert.equal(snap.fieldId, start.fieldId); assert.equal(typeof snap.gt, 'number'); assert.ok(r.feed.evs.every((x) => x.t === 'b.ev' && Array.isArray(x.ev) && typeof x.gt === 'number')); const spawnedOf = () => r.feed.evs.flatMap((x) => x.ev).filter((x) => x[0] === 'spawn').map((x) => x[1]); assert.ok(spawnedOf().some((u) => u.side === 'ally' && /^chess_/.test(u.defId)), 'the board arrives as spawn events at the first tick'); // a slow frame never steps more than the cap const before = e.battle.tickCount; r.advance(100, 100); assert.ok(e.battle.tickCount - before <= ticksPerFrameCap(2), 'cap per frame'); // run to the end for (let i = 0; i < 400 && !e.done; i++) r.advance(1000, 50); assert.ok(e.done, 'finished'); assert.ok(spawnedOf().some((u) => u.side === 'enemy'), 'the enemies arrive as spawn events'); await r.settle(); const prog = r.net.sent.filter((x) => x.t === 'b.progress'); const secs = e.battle.time / 2; assert.ok(prog.length >= secs * 0.8 && prog.length <= secs + 3, `~1 progress per real second (${prog.length} over ${secs.toFixed(1)} s)`); assert.equal(prog[prog.length - 1].done, true); const res = r.net.sent.filter((x) => x.t === 'b.result'); assert.equal(res.length, 1); const server = runHeadless(specMod.createBattleFromSpec(start.spec, DS, { recordEvents: false }), { players: start.spec.players.map((p) => p.playerId) }).result; const a = validateClientResult(start.spec, res[0].result, {}); const b = validateClientResult(start.spec, specMod.compactResult(server), {}); assert.ok(a.ok && b.ok); assert.equal(specMod.resultDigest(a.result).hash, specMod.resultDigest(b.result).hash, 'the browser result equals the server simulation'); r.runner.dispose(); }); test('fast-forward to `elapsed` before showing; display replicas never report; b.end takeover demotes; hidden tab keeps an authoritative battle going', async () => { const start = realStart(7302); // observing a running field 20 game s in const r = rig(); r.net.emit('b.start', { ...start, authoritative: false, watch: true, elapsed: 20 }); await r.settle(); const e = r.runner._entries.get(start.battleId); assert.ok(e.battle.time >= 19.8, `caught up to the field clock (${e.battle.time})`); assert.equal(r.feed.fields.length, 1, 'shown once, after the catch-up'); r.advance(3000); assert.equal(r.net.sent.length, 0, 'a display replica never reports'); // the same battleId again (e.g. back from watching): no rebuild r.net.emit('b.start', { ...start, authoritative: false, watch: true, elapsed: 30 }); await r.settle(); assert.equal(r.runner._entries.get(start.battleId), e); r.runner.dispose(); // authoritative, then the server takes the field over const r2 = rig(); r2.net.emit('b.start', start); await r2.settle(); r2.advance(1500); r2.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'takeover' }); const n = r2.net.sent.length; for (let i = 0; i < 200; i++) r2.advance(1000, 50); assert.equal(r2.net.sent.length, n, 'no more reports after a takeover'); r2.runner.dispose(); // hidden tab: the interval pump advances the authoritative battle without rendering const r3 = rig({ hidden: true }); r3.net.emit('b.start', start); await r3.settle(); const e3 = r3.runner._entries.get(start.battleId); const snaps = r3.feed.snaps.length; r3.advance(2000, 1000); assert.ok(e3.battle.tickCount >= 110, `pumped while hidden (${e3.battle.tickCount})`); assert.equal(r3.feed.snaps.length, snaps, 'nothing rendered while hidden'); r3.runner.dispose(); }); test('b.end forced ends the local battle and reports at once; a new prep clears every battle', async () => { const start = realStart(7303); const r = rig(); r.net.emit('b.start', start); await r.settle(); r.advance(2000); r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' }); await r.settle(); const res = r.net.sent.filter((x) => x.t === 'b.result'); assert.equal(res.length, 1); assert.equal(res[0].result.reason, 'forced'); r.store.patch('match', { public: { phase: 'SETTLE' } }); assert.equal(r.runner._entries.size, 1, 'the finished field stays on screen through SETTLE'); r.store.patch('match', { public: { phase: 'PREP' } }); assert.equal(r.runner._entries.size, 0); assert.equal(r.store.get().match.battle, null); r.runner.dispose(); }); test('boss field: the local pool follows b.pool (server hp − unacknowledged local damage); progress carries bossDmg / by / leaks at 4 Hz', async () => { 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 }); if (h.m.phase !== PHASE.FINAL_ASSAULT) { h.m.dispose(); return; } // (the seed did not reach the boss round) const start = h.lastTo('p_0', 'b.start'); h.m.dispose(); assert.equal(start.kind, 'boss'); const r = rig(); r.net.emit('b.start', start); await r.settle(); const e = r.runner._entries.get(start.battleId); const pool = e.battle.sharedBoss; // the pool's cumulative damage at the instant of each report (damage dealt after the last report of the window is // reported by the next one) const cumAtSend = []; const send = r.net.send; r.net.send = (t, fields) => { if (t === 'b.progress') cumAtSend.push(pool.cum); return send(t, fields); }; r.advance(3000); const first = r.net.sent.filter((x) => x.t === 'b.progress'); assert.ok(first.length >= 10 && first.length <= 14, `4 Hz (${first.length} in 3 s)`); // how soon the operators reach the leader depends on the board the autoplayed match built for (let i = 0; i < 30 && !(pool.cum > 0); i++) r.advance(1000); assert.ok(pool.cum > 0, 'local damage to the pool'); const prog = r.net.sent.filter((x) => x.t === 'b.progress'); const last = prog[prog.length - 1]; assert.equal(last.bossDmg, cumAtSend[cumAtSend.length - 1]); assert.ok(last.bossDmg <= pool.cum); assert.ok(last.by && typeof last.by === 'object'); assert.equal(typeof last.leaks, 'number'); // the per-player split of the leak LP (the server holds the result's leaked lists to it, Match._bossLeaksAgree): the // leaks of every player of the field, never more than the field's LP cost (leader effects make up the rest) assert.ok(last.leaksBy && typeof last.leaksBy === 'object', 'boss progress carries leaksBy'); assert.deepEqual(Object.keys(last.leaksBy).sort(), start.spec.players.map((p) => p.playerId).sort()); assert.ok(Object.values(last.leaksBy).every((n) => Number.isFinite(n) && n >= 0)); assert.ok(Object.values(last.leaksBy).reduce((a, b) => a + b, 0) <= last.leaks + 1e-9); r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } }); assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged'); r.runner.dispose(); }); /** A fake net whose b.result requests follow a script: 'ok' | 'lost' (DISCONNECTED) | 'offline' | 'timeout' | 'refused'. */ function scriptResults(r, script) { r.net.request = (t, fields) => { const msg = { ...fields, t, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${t}`); r.net.sent.push(msg); const mode = script.shift() || 'ok'; if (mode === 'ok') return Promise.resolve({ t: 'ok' }); const code = { lost: 'DISCONNECTED', offline: 'OFFLINE', timeout: 'TIMEOUT', refused: 'WRONG_PHASE' }[mode]; return Promise.reject(Object.assign(new Error(code), { code })); }; } test('a b.result lost with the socket is sent again when the session resumes and on an authoritative b.start of the finished battle; a refusal is final', async () => { const start = realStart(7305); const results = (r) => r.net.sent.filter((x) => x.t === 'b.result'); const warn = console.warn; console.warn = () => {}; try { // DISCONNECTED around the end of the battle const r = rig(); scriptResults(r, ['lost']); r.net.emit('b.start', start); await r.settle(); r.advance(1500); r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' }); await r.settle(); const e = r.runner._entries.get(start.battleId); assert.equal(results(r).length, 1); assert.equal(e.delivery, 'undelivered'); r.net.emit('status', { status: 'reconnecting' }); await r.settle(); assert.equal(results(r).length, 1, 'not while reconnecting'); r.net.emit('status', { status: 'online' }); await r.settle(); assert.equal(results(r).length, 2, 're-sent on resume'); assert.deepEqual(results(r)[1].result, results(r)[0].result, 'the same result'); assert.equal(results(r)[1].battleId, start.battleId); assert.equal(e.delivery, 'delivered'); r.net.emit('status', { status: 'online' }); await r.settle(); assert.equal(results(r).length, 2, 'a delivered result is not sent again on resume'); // the server resyncs the finished field as still waiting for its authority's result: sent again r.net.emit('b.start', { ...start, authoritative: true, elapsed: 3 }); await r.settle(); assert.equal(results(r).length, 3); assert.equal(r.runner._entries.get(start.battleId), e, 'no rebuild'); // a display resend does not r.net.emit('b.start', { ...start, authoritative: false, watch: false, elapsed: 3 }); await r.settle(); assert.equal(results(r).length, 3); r.runner.dispose(); // two timeouts (retried once) → undelivered; OFFLINE → undelivered; both go out on resume const r2 = rig(); scriptResults(r2, ['timeout', 'timeout']); r2.net.emit('b.start', start); await r2.settle(); r2.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' }); await r2.settle(); assert.equal(results(r2).length, 2, 'one retry on a timeout'); assert.equal(r2.runner._entries.get(start.battleId).delivery, 'undelivered'); r2.net.emit('status', { status: 'online' }); await r2.settle(); assert.equal(results(r2).length, 3); r2.runner.dispose(); // the server answered with a refusal (stale battle, match over): never sent again const r3 = rig(); scriptResults(r3, ['refused']); r3.net.emit('b.start', start); await r3.settle(); r3.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' }); await r3.settle(); r3.net.emit('status', { status: 'online' }); await r3.settle(); assert.equal(results(r3).length, 1); r3.runner.dispose(); } finally { console.warn = warn; } }); test('solo pause: m.public.paused freezes every local battle clock (no ticks, no reports); resume continues where it stopped', async () => { const start = realStart(7306); const r = rig(); r.store.patch('match', { public: { phase: 'COMBAT', paused: false } }); r.net.emit('b.start', start); await r.settle(); const e = r.runner._entries.get(start.battleId); r.advance(1000); const t1 = e.battle.tickCount; assert.ok(Math.abs(t1 - 60) <= 2, `running (${t1})`); r.store.patch('match', { public: { phase: 'COMBAT', paused: true } }); assert.equal(r.runner.state().paused, true); assert.equal(r.store.get().match.battle.paused, true, 'published for the HUD'); const reports = r.net.sent.length; r.advance(5000); assert.equal(e.battle.tickCount, t1, 'no tick while paused'); assert.equal(r.net.sent.length, reports, 'no report while paused'); // a resend while paused (reconnect) keeps the clock frozen r.net.emit('b.start', { ...start, elapsed: t1 / 30 }); await r.settle(); r.advance(1000); assert.equal(e.battle.tickCount, t1); r.store.patch('match', { public: { phase: 'COMBAT', paused: false } }); assert.equal(r.runner.state().paused, false); r.advance(1000); assert.ok(Math.abs(e.battle.tickCount - (t1 + 60)) <= 2, `resumed on the same clock, no catch-up burst (${e.battle.tickCount})`); r.runner.dispose(); }); /** realStart() with an empty board: every enemy of the wave walks into the blue gate. */ function leakingStart(seed = 7307) { const start = realStart(seed); const spec = JSON.parse(JSON.stringify(start.spec)); for (const p of spec.players) p.units = []; return { ...start, spec }; } test('live leaks (user playtest #3 item 2): state().leaks carries the own field\'s counted leaks as they happen — authoritative or display — published in the frame of each leak; the settle rule\'s count at the end', async () => { const start = leakingStart(); const r = rig(); const seen = []; r.store.subscribe((s, prev) => { const n = s.match.battle && s.match.battle.leaks ? s.match.battle.leaks[start.fieldId] : undefined; const was = prev.match.battle && prev.match.battle.leaks ? prev.match.battle.leaks[start.fieldId] : undefined; if (n !== was && n !== undefined) seen.push(n); }); r.net.emit('b.start', start); await r.settle(); const e = r.runner._entries.get(start.battleId); assert.deepEqual(r.store.get().match.battle.leaks, { [start.fieldId]: 0 }, 'published with the field, 0 before any leak'); // step frame by frame: whenever the battle recorded a counted leak, the store has it after that same frame for (let i = 0; i < 200 * 60 && !e.done; i++) { r.advance(1000 / 60); const now = specMod.battleProgress(e.battle).leaks; assert.equal(r.store.get().match.battle.leaks[start.fieldId], now, `frame ${i}: the store follows the battle`); } assert.ok(e.done, 'finished'); const res = e.battle.result(); const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0); assert.ok(counted >= 3, `the empty board leaks (${counted})`); assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'the settle rule\'s count (Match.settle: counted !== false)'); assert.equal(seen[seen.length - 1], counted, 'the last publish carries the final count'); assert.ok(seen.every((n, i) => i === 0 || n > seen[i - 1]), `one publish per new count, increasing (${seen})`); r.runner.dispose(); // a display replica of the own field (the server simulates it: reconnect after a takeover) counts the same const r2 = rig(); r2.net.emit('b.start', { ...start, authoritative: false, watch: false, elapsed: 30 }); await r2.settle(); const e2 = r2.runner._entries.get(start.battleId); assert.equal(r2.store.get().match.battle.leaks[start.fieldId], specMod.battleProgress(e2.battle).leaks, 'counted during the silent catch-up'); for (let i = 0; i < 200 && !e2.done; i++) r2.advance(1000, 50); assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted); assert.equal(r2.net.sent.length, 0, 'still never reports'); // the round moves on: the next prep drops the battles and their counts r2.store.patch('match', { public: { phase: 'SETTLE' } }); assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted, 'kept through SETTLE (the top bar resets on the settled m.private)'); r2.store.patch('match', { public: { phase: 'PREP' } }); assert.equal(r2.store.get().match.battle, null); r2.runner.dispose(); }); test('live leaks: a boss field is not counted (the merged team LP moves through b.pool); b.end forced publishes the final count', async () => { const start = leakingStart(7308); const r = rig(); r.net.emit('b.start', start); await r.settle(); r.advance(1500); r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'timeout' }); await r.settle(); const e = r.runner._entries.get(start.battleId); assert.ok(e.done); const res = e.battle.result(); const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0); assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'timeout leaks (the enemies left on the field) are counted at once'); r.runner.dispose(); const r2 = rig(); r2.net.emit('b.start', { ...start, kind: 'boss', fieldId: 'b1', battleId: `${start.battleId}x` }); await r2.settle(); assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields'); r2.runner.dispose(); }); test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle on screen — the sim\'s last computed stats, never unit.s (no recompute from the UI)', async () => { const start = realStart(7305); const r = rig(); r.net.emit('b.start', start); await r.settle(); const e = r.runner._entries.get(start.battleId); r.advance(2000); const ally = e.battle.allyUnits.find((u) => u.alive && u.deployed && u.kind === 'op'); assert.ok(ally, 'a deployed operator'); // a trap on the lazy getter: reading the live stats must not recompute them (it would move the sim's floats) const s = ally._s; Object.defineProperty(ally, 's', { configurable: true, get() { throw new Error('unit.s read by the UI'); } }); const got = r.runner.unitStats(ally.id); delete ally.s; assert.ok(got, 'the unit of the battle on screen'); assert.equal(got.id, ally.id); assert.equal(got.uid, ally.uid); assert.equal(got.defId, ally.defId); assert.equal(got.atk, Math.round(s.atk)); assert.equal(got.maxHp, Math.round(s.maxHp)); assert.equal(got.def, Math.round(s.def)); assert.equal(got.interval, Math.round(s.interval * 100) / 100); assert.equal(got.blockCnt, s.blockCnt); assert.equal(got.hp, Math.round(ally.hp)); // the unit's own numbers: its base with its 练度 (0.2.2: the match states 精英2 Lv.60 by default — ×1.1) assert.equal(ally.cultivate, 3); assert.equal(got.base.atk, Math.round(ally.base.atk * ally.cultMul.atk)); assert.equal(got.base.maxHp, Math.round(ally.base.maxHp * ally.cultMul.hp)); assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field'); assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing'); assert.equal(r.runner.unitStats(999999), null, 'unknown unit'); // an own board piece's card left open into the battle: its unit by uid and owner assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), ally.id); assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, start.fieldId), ally.id); assert.equal(r.runner.unitIdOf(ally.uid, 'someone_else'), null, 'another player\'s piece'); assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field'); assert.equal(r.runner.unitIdOf(null, ally.ownerId), null); assert.equal(r.runner.unitStats('x'), null); // a teammate's bond popup reads the owner's operators with their equipment (a 变形同构体 wearer is a member of the bond // it grants: test/ui/morph-bonds.test.js); an operator without items keeps the plain shape const saved = ally.items; ally.items = ['chess_item_6_09_e_a', 'chess_item_1_01_e_a']; const op = r.runner.ownerOps(ally.ownerId, start.fieldId).find((o) => o.defId === ally.defId); assert.deepEqual(op, { kind: 'op', ownerId: ally.ownerId, defId: ally.defId, items: ['chess_item_6_09_e_a', 'chess_item_1_01_e_a'] }); ally.items = []; assert.deepEqual(r.runner.ownerOps(ally.ownerId, start.fieldId).find((o) => o.defId === ally.defId), { kind: 'op', ownerId: ally.ownerId, defId: ally.defId }); ally.items = saved; assert.deepEqual(r.runner.ownerOps(ally.ownerId, 'n:someone_else'), [], 'another field'); // enemies too, once one is out for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50); const foe = e.battle.enemies.find((x) => x.alive); if (foe) { const fs = r.runner.unitStats(foe.id); assert.ok(fs && fs.maxHp > 0 && fs.base.maxHp > 0, 'an enemy\'s live stats'); assert.equal(fs.moveSpeed, Math.round((foe._s || foe.base).moveSpeed * 100) / 100); } r.runner.clear(); assert.equal(r.runner.unitStats(ally.id), null, 'nothing on screen after the battles were dropped'); assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId), null); r.runner.dispose(); });