Files
ark-stronghold-protocol/test/match/runner.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

460 lines
23 KiB
JavaScript
Raw 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) 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');
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));
assert.equal(got.base.atk, Math.round(ally.base.atk));
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
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);
// 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();
});