369 lines
21 KiB
JavaScript
369 lines
21 KiB
JavaScript
// The data behind the watched player's bond strip (DESIGN §20.15, user report after playtest #6): what the server
|
|
// sends of every player's bonds and what the browser's battle runner adds live.
|
|
// * m.public players[].bonds = every bond with members, layers or an active tier — the player's own m.private list
|
|
// without the per-bond extras the client reads from bonds.json (thresholds / countsHand); nothing else (but `harmony`
|
|
// on an entry whose count holds 调和's +1, DESIGN §21.26: test/match/feedback1-gaps.test.js) — and nothing at all for
|
|
// an eliminated player (nobody can watch them).
|
|
// * From the end of COMBAT until the settlement the views (m.private and m.public) carry the finished normal battle's
|
|
// in-battle gains (PlayerState.bondsView / bondsMeta.bondsWithGains, capped at 999): the strip of a player — and of a
|
|
// teammate watching him in the 联防 — keeps the layers his battle reached. The persistent state is untouched and the
|
|
// settlement adds the gains once.
|
|
// * The runner publishes state().bondLayers { [playerId]: { [bondId]: n } } — absolute live counts of every bond that
|
|
// grew in a battle it simulates (the own one, a teammate's display replica), ≤ 999, kept through the round; and
|
|
// ownerOps(ownerId) — that player's operators in the battle on screen (a teammate's popup: the members in play).
|
|
// UI selection logic: test/ui/watch-bonds.test.js; on screen: test/ui/watch-bonds.e2e.test.js.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
|
|
import { bondsWithGains, computeBonds } from '../../server/match/bondsMeta.js';
|
|
import { createBattleRunner } from '../../public/js/battle/runner.js';
|
|
import { createStore, initialState } from '../../public/js/store.js';
|
|
import * as specMod from '../../server/sim/spec.js';
|
|
import { DataSource } from '../../server/sim/simdata.js';
|
|
import { DATA, makeMatch, checkInvariants, legalTileFor } from './harness.js';
|
|
import { FakeBattle } from './fakeBattle.js';
|
|
import { ownerBoard } from '../../public/js/ui/watchBonds.js';
|
|
import { bondMembers, diyGetter, diyRecordFor, memberHeadCount } from '../../public/js/ui/gameLogic.js';
|
|
|
|
const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid);
|
|
const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null;
|
|
|
|
test('m.public players[].bonds: every bond with members, layers or a tier — the m.private list minus thresholds / countsHand', () => {
|
|
const h = makeMatch({ mode: 'coop', humans: 2, seed: 91, fake: true }).start();
|
|
h.toPrep(1);
|
|
const ps = h.ps('p_1');
|
|
ps.layers.sargonShip = 7; // layers, no member on the board: the own strip shows it (grey) — so does the teammate's
|
|
ps.recompute();
|
|
const pub = row(h, 'p_1').bonds;
|
|
const priv = ps.privateView().bonds;
|
|
assert.deepEqual(entry(pub, 'sargonShip'), { bondId: 'sargonShip', count: 0, active: false, tier: 0, layers: 7 });
|
|
assert.deepEqual(pub.map((b) => b.bondId), priv.map((b) => b.bondId), 'the same bonds, the same order');
|
|
for (const b of pub) assert.deepEqual(Object.keys(b).sort(), ['active', 'bondId', 'count', 'layers', 'tier'], `${b.bondId}: only what the strip needs`);
|
|
assert.ok(priv.every((b) => Array.isArray(b.thresholds) && typeof b.countsHand === 'boolean'), 'm.private keeps its extras');
|
|
h.m.dispose();
|
|
});
|
|
|
|
test('m.public players[].bonds of an eliminated player: [] (nobody can watch them), even with layers and pending gains', () => {
|
|
const h = makeMatch({ mode: 'coop', humans: 2, seed: 95, fake: true }).start();
|
|
h.toPrep(1);
|
|
const ps = h.ps('p_1');
|
|
ps.layers.sargonShip = 7;
|
|
ps.recompute();
|
|
assert.ok(entry(row(h, 'p_1').bonds, 'sargonShip'), 'alive: the layers-only bond is sent');
|
|
ps.eliminate(1);
|
|
ps.layers.yanShip = 40; // layers survive the elimination (computeBonds still lists them) …
|
|
ps.pendingLayerGains = { sargonShip: 3 };
|
|
ps.recompute();
|
|
assert.ok(ps.bondsView().sargonShip.layers > 0, '… the state still has them');
|
|
assert.deepEqual(row(h, 'p_1').bonds, [], 'm.public: nothing for an eliminated player');
|
|
assert.equal(row(h, 'p_1').alive, false);
|
|
const a = h.ps('p_0');
|
|
a.layers.yanShip = 5;
|
|
a.recompute();
|
|
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip')?.layers, 5, 'a living teammate keeps the list');
|
|
h.m.dispose();
|
|
});
|
|
|
|
test('bondsWithGains: adds a result\'s layerGains like settle() (floored, ≤ 999), never mutates, ignores unknown bonds', () => {
|
|
const ps = { board: new Map(), hand: [], layers: { yanShip: 990, sargonShip: 4 } };
|
|
const bonds = computeBonds(gdOf(), ps);
|
|
const snap = JSON.stringify(bonds);
|
|
assert.equal(bondsWithGains(bonds, null), bonds);
|
|
assert.equal(bondsWithGains(bonds, {}), bonds);
|
|
const v = bondsWithGains(bonds, { yanShip: 50, sargonShip: 2.7, noSuchBond: 9 });
|
|
assert.equal(v.yanShip.layers, BOND_LAYER_CAP, '990 + 50 shows 999');
|
|
assert.equal(v.sargonShip.layers, 6, 'floored like settle()');
|
|
assert.ok(!('noSuchBond' in v));
|
|
assert.equal(JSON.stringify(bonds), snap, 'the computed states are untouched');
|
|
assert.equal(bondsWithGains({ yanShip: { ...bonds.yanShip, layers: 999 } }, { yanShip: 5 }).yanShip.layers, 999, 'at the cap: nothing');
|
|
});
|
|
|
|
test('after COMBAT the views carry the battle\'s gains until SETTLE makes them persistent (once); the state is untouched meanwhile', () => {
|
|
const h = makeMatch({
|
|
mode: 'coop', humans: 2, seed: 92, fake: true,
|
|
script: (b) => (b.round === 1 ? { layerGains: { p_0: { yanShip: 50 }, p_1: { sargonShip: 12 } } } : {}),
|
|
}).start();
|
|
const m = h.m;
|
|
h.toPrep(1);
|
|
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
|
|
a.layers.yanShip = 980;
|
|
a.recompute();
|
|
// the COMBAT_END pause: every result is in, nothing settled yet
|
|
h.drive(() => m.phase === PHASE.COMBAT && m.lastResults.size === 2);
|
|
assert.equal(m.phase, PHASE.COMBAT);
|
|
assert.deepEqual([a.layers.yanShip, b.layers.sargonShip || 0], [980, 0], 'nothing persistent yet');
|
|
assert.equal(a.bonds.yanShip.layers, 980, 'the computed state is untouched (rules read it)');
|
|
assert.equal(entry(a.privateView().bonds, 'yanShip').layers, BOND_LAYER_CAP, 'm.private: 980 + 50 shows 999');
|
|
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip').layers, BOND_LAYER_CAP, 'm.public: the same');
|
|
assert.equal(entry(row(h, 'p_1').bonds, 'sargonShip').layers, 12, 'a teammate\'s gain shows in his public row');
|
|
checkInvariants(m);
|
|
h.drive(() => m.phase === PHASE.SETTLE || m.phase === PHASE.PREP);
|
|
assert.deepEqual([a.layers.yanShip, b.layers.sargonShip], [BOND_LAYER_CAP, 12], 'settled once');
|
|
assert.deepEqual([a.pendingLayerGains, b.pendingLayerGains], [null, null]);
|
|
assert.equal(entry(row(h, 'p_1').bonds, 'sargonShip').layers, 12, 'not added twice');
|
|
assert.equal(entry(b.privateView().bonds, 'sargonShip').layers, 12);
|
|
checkInvariants(m);
|
|
m.dispose();
|
|
});
|
|
|
|
test('the 联防 phase keeps showing the gains (a teammate watching the helper sees the layers his battle reached)', () => {
|
|
// p_0 perfect with a gain, p_1 leaks: 联防 with p_0 as the helper
|
|
const h = makeMatch({
|
|
mode: 'coop', humans: 2, seed: 93, fake: true,
|
|
script: (b) => (b.round === 1 && b.kind === 'normal' ? { layerGains: { p_0: { yanShip: 9 } }, leaks: { p_1: 2 } } : {}),
|
|
}).start();
|
|
const m = h.m;
|
|
h.toPrep(1);
|
|
h.drive(() => m.phase === PHASE.UNITE || m.phase === PHASE.SETTLE);
|
|
assert.equal(m.phase, PHASE.UNITE, 'p_1 leaked, p_0 was perfect: 联防');
|
|
assert.equal(h.ps('p_0').layers.yanShip || 0, 0, 'not settled during 联防');
|
|
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip').layers, 9, 'the helper\'s public row shows his battle\'s count');
|
|
assert.equal(entry(h.ps('p_0').privateView().bonds, 'yanShip').layers, 9, 'and his own strip');
|
|
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
|
|
assert.equal(h.ps('p_0').layers.yanShip, 9);
|
|
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip').layers, 9);
|
|
m.dispose();
|
|
});
|
|
|
|
test('the 联防 field fights with the layers the helper\'s own combat reached (PRTS 联防阶段 "以其阵地当前的状态"), capped like the settlement, which still adds them once', () => {
|
|
// p_0 perfect with gains, p_1 leaks: 联防 with p_0 as the helper. Until 0.1.3 the 联防 input carried the round-start
|
|
// layers while the strip showed the reached ones (community report #24: 不屈 at 200+ layers "sometimes not triggering")
|
|
const h = makeMatch({
|
|
mode: 'coop', humans: 2, seed: 95, fake: true,
|
|
script: (b) => (b.round === 1 && b.kind === 'normal' ? { layerGains: { p_0: { indomShip: 20, yanShip: 9 } }, leaks: { p_1: 2 } } : {}),
|
|
}).start();
|
|
const m = h.m;
|
|
h.toPrep(1);
|
|
const a = h.ps('p_0');
|
|
a.layers.indomShip = 230;
|
|
a.layers.yanShip = 995;
|
|
a.recompute();
|
|
const first = FakeBattle.instances.length;
|
|
const input = (kind, round) => FakeBattle.instances.slice(first).find((x) => x.kind === kind && x.round === round)?.opts.players.find((p) => p.playerId === 'p_0');
|
|
h.drive(() => m.phase === PHASE.UNITE || m.phase === PHASE.SETTLE);
|
|
assert.equal(m.phase, PHASE.UNITE, 'p_1 leaked, p_0 was perfect: 联防');
|
|
assert.deepEqual([input('normal', 1).bonds.indomShip.layers, input('normal', 1).bonds.yanShip.layers], [230, 995], 'the own combat: the round-start layers');
|
|
const u = input('unite', 1);
|
|
assert.equal(u.bonds.indomShip.layers, 250, '联防: 230 + the 20 reached in the own combat');
|
|
assert.equal(u.bonds.yanShip.layers, BOND_LAYER_CAP, '995 + 9 is capped at 999, as the settlement caps it');
|
|
assert.equal(u.bonds.indomShip.layers, entry(a.privateView().bonds, 'indomShip').layers, 'the count the strip shows');
|
|
assert.deepEqual([a.layers.indomShip, a.bonds.indomShip.layers], [230, 230], 'nothing persistent yet');
|
|
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
|
|
assert.deepEqual([a.layers.indomShip, a.layers.yanShip], [250, BOND_LAYER_CAP], 'settled once (not 270)');
|
|
assert.equal(input('normal', 2).bonds.indomShip.layers, 250, 'the next combat starts from 250');
|
|
checkInvariants(m);
|
|
m.dispose();
|
|
});
|
|
|
|
// ---- the browser runner's live layers ---------------------------------------------------------------------------------
|
|
|
|
const DS = new DataSource(DATA, null);
|
|
|
|
function gdOf() {
|
|
const h = makeMatch({ mode: 'solo', seed: 94, fake: true });
|
|
const gd = h.m.gd;
|
|
h.m.dispose();
|
|
return gd;
|
|
}
|
|
|
|
function rig() {
|
|
let t = 1000;
|
|
const frames = [];
|
|
const handlers = new Map();
|
|
const net = {
|
|
on(type, fn) { if (!handlers.has(type)) handlers.set(type, new Set()); handlers.get(type).add(fn); return () => handlers.get(type).delete(fn); },
|
|
emit(type, msg) { for (const fn of handlers.get(type) || []) fn({ t: type, ...msg }); },
|
|
send() { return true; },
|
|
request() { return Promise.resolve({ t: 'ok' }); },
|
|
};
|
|
const store = createStore(initialState);
|
|
const runner = createBattleRunner({
|
|
net, store, doc: { hidden: false, addEventListener() {} }, now: () => t,
|
|
raf: (fn) => { frames.push(fn); return frames.length; }, caf() {}, setInterval: () => 1, clearInterval() {},
|
|
loadSim: async () => ({ spec: specMod, ds: DS }), logger: { error() {}, warn() {}, info() {}, debug() {} },
|
|
});
|
|
return {
|
|
runner, net, store,
|
|
advance(ms, step = 1000 / 60) { const end = t + ms; while (t < end) { t = Math.min(end, t + step); for (const fn of frames.splice(0)) fn(t); } },
|
|
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); for (const fn of frames.splice(0)) fn(t); } },
|
|
};
|
|
}
|
|
|
|
/** A real b.start of a normal round-2 battle (client-side combat), the board's bonds in its spec. */
|
|
function realStart(seed = 7302) {
|
|
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 === 2, { maxSteps: 3e6 });
|
|
const msg = h.lastTo('p_0', 'b.start');
|
|
h.m.dispose();
|
|
return JSON.parse(JSON.stringify(msg));
|
|
}
|
|
|
|
test('runner: state().bondLayers = the absolute live counts of the bonds that grew (≤ 999), published when one grows, kept through the round', async () => {
|
|
const start = realStart();
|
|
assert.ok(start && start.kind === 'normal');
|
|
const sp = start.spec.players.find((p) => p.playerId === 'p_0');
|
|
const ids = Object.keys(sp.bonds);
|
|
assert.ok(ids.length >= 2, 'the spec carries the bond snapshot');
|
|
const [b1, b2] = ids;
|
|
sp.bonds[b1].layers = 995; // near the cap
|
|
sp.bonds[b2].layers = 3;
|
|
const r = rig();
|
|
r.net.emit('b.start', start);
|
|
await r.settle();
|
|
const e = r.runner._entries.get(start.battleId);
|
|
assert.ok(e && e.battle.flags.layerGainsEnabled !== false);
|
|
assert.deepEqual(r.store.get().match.battle.bondLayers, {}, 'nothing grew yet');
|
|
let publishes = 0;
|
|
r.store.subscribe((s, prev) => { if (s.match.battle !== prev.match.battle) publishes++; });
|
|
e.battle.addLayers('p_0', b1, 10, 'test');
|
|
e.battle.addLayers('p_0', b2, 4, 'test');
|
|
r.advance(40);
|
|
assert.deepEqual(r.store.get().match.battle.bondLayers, { p_0: { [b1]: BOND_LAYER_CAP, [b2]: 7 } }, 'absolute counts, capped at 999');
|
|
assert.ok(publishes >= 1, 'published in the frame of the gain');
|
|
const before = publishes;
|
|
r.advance(200);
|
|
assert.ok(publishes - before <= 2, 'no publish per frame without a change');
|
|
// a display replica (a teammate's battle watched after the own one) reports its player's live counts too
|
|
const replica = JSON.parse(JSON.stringify(start));
|
|
replica.battleId = `${start.battleId}x`;
|
|
replica.authoritative = false;
|
|
replica.watch = true;
|
|
r.net.emit('b.start', replica);
|
|
await r.settle();
|
|
const e2 = r.runner._entries.get(replica.battleId);
|
|
e2.battle.addLayers('p_0', b2, 1, 'test');
|
|
r.advance(40);
|
|
assert.equal(r.store.get().match.battle.watch, true, 'the replica is on screen');
|
|
assert.ok(r.store.get().match.battle.bondLayers.p_0, 'the round\'s counts stay published while another battle is on screen');
|
|
// a new prep drops every battle: nothing to lay over any more
|
|
r.store.patch('match', { public: { phase: 'PREP' } });
|
|
assert.deepEqual(r.store.get().match.battle?.bondLayers ?? {}, {});
|
|
r.runner.dispose();
|
|
});
|
|
|
|
test('runner: ownerOps(ownerId) = that player\'s operators in the battle on screen (a teammate\'s popup members), [] elsewhere', async () => {
|
|
const start = realStart(7304);
|
|
const r = rig();
|
|
r.net.emit('b.start', start);
|
|
await r.settle();
|
|
const e = r.runner._entries.get(start.battleId);
|
|
const pid = start.spec.players[0].playerId;
|
|
const want = e.battle.allyUnits.filter((u) => u.kind === 'op' && u.ownerId === pid).map((u) => u.defId).sort();
|
|
assert.ok(want.length > 0, 'the board fields operators');
|
|
const got = r.runner.ownerOps(pid);
|
|
assert.deepEqual(got.map((u) => u.defId).sort(), want, 'before they deploy too (the field meta may not list them yet)');
|
|
assert.ok(got.every((u) => u.kind === 'op' && u.ownerId === pid), 'UnitInfo-like entries');
|
|
assert.deepEqual(r.runner.ownerOps(pid, start.fieldId).length, want.length);
|
|
assert.deepEqual(r.runner.ownerOps(pid, 'n:nobody'), [], 'another field: nothing');
|
|
assert.deepEqual(r.runner.ownerOps('ghost'), [], 'a player without operators there');
|
|
assert.deepEqual(r.runner.ownerOps(null), []);
|
|
r.runner.dispose();
|
|
});
|
|
|
|
test('runner: a battle that disables gains (联防 / boss) publishes no live layers', async () => {
|
|
const start = realStart(7303);
|
|
start.spec.flags = { ...(start.spec.flags || {}), layerGainsEnabled: false };
|
|
const r = rig();
|
|
r.net.emit('b.start', start);
|
|
await r.settle();
|
|
const e = r.runner._entries.get(start.battleId);
|
|
const id = Object.keys(start.spec.players[0].bonds)[0];
|
|
assert.equal(e.battle.addLayers(start.spec.players[0].playerId, id, 5, 'test'), 0);
|
|
r.advance(100);
|
|
assert.deepEqual(r.store.get().match.battle.bondLayers, {});
|
|
r.runner.dispose();
|
|
});
|
|
|
|
const DIY_SLOT = 'chess_char_5_diy1_a';
|
|
const HUANG_PICK = { charId: 'char_017_huang', skillIndex: 1, uniEquipId: 'uniequip_002_huang' };
|
|
const SIEGE_PICK = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
|
const DIY_DATA = { chess: DATA.chess, backups: DATA.backups };
|
|
const getChess = (id) => DATA.chess[id] || null;
|
|
|
|
/** The same record lookups as BondPopup: own picks from m.private, a teammate's from each field unit. */
|
|
function diyRows(bondId, priv) {
|
|
const get = diyGetter(getChess, priv, DIY_DATA);
|
|
const record = (p) => (p.diy ? diyRecordFor(getChess(p.id), p.diy, DIY_DATA) : null) || get(p.id);
|
|
return bondMembers(DATA.bonds[bondId], priv, [], get, () => null, record);
|
|
}
|
|
|
|
/** Controlled match fixture: legal seat picks, acquire/merge and g.move; the match builds the unmodified b.start. */
|
|
function diyWatchMatch(elite) {
|
|
const seats = [SIEGE_PICK, HUANG_PICK].map((pick, seat) => ({
|
|
seat, playerId: `p_${seat}`, name: `P${seat}`, isBot: false, connected: true, diy: { [DIY_SLOT]: pick },
|
|
}));
|
|
const h = makeMatch({ mode: 'coop', seats, spectators: ['s_spec'], seed: 7309, clientCombat: true, clients: false }).start();
|
|
h.toPrep(1);
|
|
const ps = h.ps('p_1');
|
|
let piece;
|
|
for (let i = 0; i < (elite ? 3 : 1); i++) piece = ps.acquireChess(DIY_SLOT);
|
|
assert.ok(piece);
|
|
const tile = legalTileFor(h.m, ps, piece.id);
|
|
assert.ok(tile);
|
|
assert.deepEqual(h.m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
|
return h;
|
|
}
|
|
|
|
for (const elite of [false, true]) test(`runner: watched ${elite ? 'elite' : 'normal'} DIY operator is a bond member before and after deployment`, async (t) => {
|
|
const h = diyWatchMatch(elite);
|
|
t.after(() => h.m.dispose());
|
|
const assertHuang = (priv) => {
|
|
for (const bondId of ['yanShip', 'victoriaShip']) {
|
|
const rows = diyRows(bondId, priv);
|
|
assert.equal(memberHeadCount(rows, false), 1, `${bondId}: the fielded DIY member counts once`);
|
|
const row = rows.find((x) => x.diy);
|
|
assert.deepEqual([row.id, row.rec.charId, row.name, row.onBoard, row.rec.isGolden], [DIY_SLOT, HUANG_PICK.charId, '煌', true, false]);
|
|
assert.deepEqual(row.pick, HUANG_PICK, 'the watched owner\'s skill and module, not our pick in the same slot');
|
|
}
|
|
};
|
|
assertHuang(ownerBoard(h.m.prepFieldMeta(h.ps('p_1')), 'p_1'));
|
|
assert.deepEqual(diyRows('victoriaShip', h.ps('p_0').privateView()).filter((x) => x.diy).map((x) => [x.rec.charId, x.owned]), [[SIEGE_PICK.charId, false]], 'our unbought pick stays our own');
|
|
assert.equal(diyRows('yanShip', h.ps('p_0').privateView()).some((x) => x.diy), false);
|
|
for (const pid of ['p_0', 'p_1']) assert.deepEqual(h.m.handle(pid, { t: 'g.ready', ready: true }), { ok: true });
|
|
h.runToPhase(PHASE.COMBAT, 1);
|
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
|
|
assert.deepEqual(h.m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
|
const start = JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start')));
|
|
assert.deepEqual([start.watch, start.authoritative, start.elapsed], [true, false, 0]);
|
|
assert.equal(start.spec.players[0].units[0].chessId, elite ? DIY_SLOT.replace(/_a$/, '_b') : DIY_SLOT);
|
|
assert.deepEqual(start.spec.players[0].units[0].diy, HUANG_PICK);
|
|
const r = rig();
|
|
t.after(() => r.runner.dispose());
|
|
r.net.emit('b.start', start);
|
|
await r.settle();
|
|
const field = r.store.get().match.field;
|
|
assert.equal(field.units.some((u) => u.kind === 'op'), false, 'the initial meta precedes deployment');
|
|
const board = () => ownerBoard(field, 'p_1', r.runner.ownerOps('p_1', start.fieldId));
|
|
t.diagnostic(JSON.stringify({ elite, specPick: start.spec.players[0].units[0].diy, initialMetaOps: field.units.filter((u) => u.kind === 'op').length, runnerOps: r.runner.ownerOps('p_1'), yanMembers: memberHeadCount(diyRows('yanShip', board()), false) }));
|
|
assertHuang(board());
|
|
r.advance(20000, 50);
|
|
const unit = r.runner._entries.get(start.battleId).battle.allyUnits.find((u) => u.kind === 'op');
|
|
assert.ok(unit.deployed, 'the real simulation deployed the operator');
|
|
assertHuang(board());
|
|
const op = r.runner.ownerOps('p_1')[0];
|
|
assert.notEqual(op.diy, unit.def.loadout.diy, 'the popup receives a copy of the frozen sim pick');
|
|
op.diy.charId = SIEGE_PICK.charId;
|
|
assert.deepEqual(r.runner.ownerOps('p_1')[0].diy, HUANG_PICK, 'a consumer cannot change the battle or the next read');
|
|
assert.deepEqual(r.runner.ownerOps('p_0'), [], 'another owner');
|
|
assert.deepEqual(r.runner.ownerOps('p_1', 'n:p_0'), [], 'another field');
|
|
// A fresh runner after a spectator reconnect, and switching back from another field, retain the member too.
|
|
const starts = h.allTo('s_spec', 'b.start').length;
|
|
h.sched.advance(1000);
|
|
h.m.addSpectator('s_spec');
|
|
assert.equal(h.allTo('s_spec', 'b.start').length, starts + 1, 'the spectator reconnect emits a fresh b.start');
|
|
assert.ok(h.lastTo('s_spec', 'b.start').elapsed > 0, 'the resumed replica catches up to the match clock');
|
|
const resumed = rig();
|
|
t.after(() => resumed.runner.dispose());
|
|
resumed.net.emit('b.start', JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start'))));
|
|
await resumed.settle();
|
|
assertHuang(ownerBoard(resumed.store.get().match.field, 'p_1', resumed.runner.ownerOps('p_1')));
|
|
r.net.emit('b.start', h.lastTo('p_0', 'b.start'));
|
|
await r.settle();
|
|
assert.deepEqual(r.runner.ownerOps('p_1'), [], 'switching fields drops the other field\'s members');
|
|
r.net.emit('b.start', start);
|
|
await r.settle();
|
|
assertHuang(ownerBoard(r.store.get().match.field, 'p_1', r.runner.ownerOps('p_1')));
|
|
});
|