Player report: with 变形同构体 and the matching bond item the wearer gets the bond's effect, but the bond's member count leaves the converted operator out. The count that activates the bond already included the wearer on every server path (bondsMeta.pieceBonds: prep count, views, battle input; support.unitBonds: the battle). The client did not: the bond popup's member list (成员 x/y) and the wearer's card read only the chess records' bonds, so the popup said 在场 3 but 成员 2/10 without the wearer, and the card had no chip for the bond. - gameLogic.grantedBonds / pieceBondIds: the server's 变形同构体 rule on the client; bondMembers lists wearers (tagged 同构, one row per operator, on the board or owned), so 成员 x/y agrees with 在场. - detailPanel: the card's chips include the granted bond (dashed, 同构). - Teammates: UnitInfo carries an ally operator's item ids (sim unitInfo, Match.prepFieldMeta); ownerBoard / runner.ownerOps keep them. - Tests: every pairing on real matches (prep, views, battle input, the real battle), bench vs board, half pairs, distinct members; the client rule against the server's; popup / card rendering.
470 lines
24 KiB
JavaScript
470 lines
24 KiB
JavaScript
// 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);
|
||
// 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();
|
||
});
|