Files
ark-stronghold-protocol/test/match/runner.test.js
T
sganggs 96d9441ff1 feedback7 R22-LOADOUT: the server takes each player's 潜能 / 练度 per operator and every owned operator fights at them (default 潜能 6, 精英2 Lv.60); golden: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 134/138 moved (+10 % 练度), standins unchanged
The owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」. Official: the account's potential (调度手册);
the 自持有 bonus to owned operators of the same name, initial and elite alike (activity_table cultivateEffectList).

- shared/protocol.js: room.loadout { entries, ops? } — `ops` = { [charId]: { potential?: 1–6, cultivate?: 0–3 } }
  (isLoadoutOps, ≤ 256), checkLoadoutOps (strict like checkLoadout: an operator of the 干员调配 roster or the 自选 owned
  pool — cultivationCharIds; defaults dropped, entries stored complete); absent = none set.
- server/lobby.js / net.js: checked with the entries (either refused ⇒ nothing stored), session.ops / seat.ops /
  seats[].ops, match.setLoadout(playerId, loadout, ops) in INFO_CHECK only.
- PlayerState: ops re-checked and frozen (bots none); cultivationFor (shared/potential.js cultivationOf: null for a
  补位 stand-in or a prototype 自选 pick); loadoutFor carries potential / cultivate; battleInput states `potential` /
  `cultivate` for every owned operator (the chess as itself, an owned 自选 pick) — an old seat without ops and every bot
  at 6 / 3; the 自选 summons' hand count follows the owner's potential (望's 棋子 6 below 潜能3); m.private.ops; the
  scouting UnitInfo carries potential (below 6) / cultivate.
- tools/golden.mjs: the direct-spec scenarios field the default player (owned operators at 潜能 6 / 练度 3; stand-ins
  and prototype picks neither), as PlayerState does.
- Golden: every battle with an owned operator moves — ATK / DEF / max HP ×1.1 from the first second (roster-001:
  角峰 ends at 3527 HP instead of 3115; the first differing snapshot window is 0–10 s in every moved battle); the
  matches part at round 1–8 (battle results, then the bots' boards). Unchanged: the 10 stand-in battles (a stand-in has
  no 练度) and diy-135…138 (the four prototype 自选 battles). No engine error.
- Docs: design/match.md §16 (潜能 / 练度), design/network.md (room.loadout ops).
- Tests: test/match/potential.test.js (new, 4): the ops wire / data check, battle input of a human (settings, defaults,
  stand-in none, elite shares the charId), an old seat and a bot at the defaults, spec → battle, scouting units; 自选
  owned pick vs prototype and 望's hand count; Match.setLoadout in INFO_CHECK only, a refusal changes nothing.
  test/lobby-loadout.test.js (+2): strict validation, storage beside the loadout, an older client's message, the real
  match's briefing (m.private.ops) and lock. test/match/runner.test.js / diy-shop.test.js expect the new fields.
2026-10-08 20:52:30 +08:00

472 lines
24 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));
// 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();
});