Bond strip follows the watched teammate (v2.5.2): prep scouting, after the own battle, 联防, Final Assault, eliminated observers

- the strip, its counts / layers / tiers and the bond popups show the player whose field or half is on screen, with an
  amber "👁 name" tag; live in-battle layers (runner bondLayers, ≤ 999) and the finished battle's gains until SETTLE
- shared fields follow the ‹ › half's player; 全景 shows your own when you fight there, else the picked teammate [ASSUMED]
- a card's bond chips and popups belong to the unit's owner; a teammate's popup lists their operators on the field
- m.public players[].bonds for living players only; a reload while watching after the own battle returns to it
- DESIGN §20.15, META, PLAYING; tests (unit, UI logic, 4 browser scenarios)
This commit is contained in:
sganggs committed 2026-10-02 12:11:48 +08:00
1 parent 620f88c0d8
commit 82c64086d5
21 files changed
+2087 -57

No files matched your search

+1 -1
View File
@@ -394,7 +394,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
const S20 = sec(20);
assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/);
for (let i = 1; i <= 13; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
for (let i = 1; i <= 15; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
const intro = S20.slice(0, S20.indexOf('### 20.1'));
for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`);
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
+7 -1
View File
@@ -30,7 +30,9 @@ export function freePort() {
* Start the real server (`node server/index.js`, or test/e2e/fastServer.mjs with `fast` speed-ups) on a free port (or
* `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to).
* @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[],
* items?: string[], idleBots?: boolean } }} [opts]
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[] } }} [opts]
* kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the
* first prep; eliminate: humans (seat order) eliminated at the jump — fastServer.mjs hooks
* @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
*/
export async function startRealServer(opts = {}) {
@@ -49,6 +51,10 @@ export async function startRealServer(opts = {}) {
if (opts.fast.items?.length) env.SP_START_ITEMS = opts.fast.items.join(',');
if (opts.fast.shop?.length) env.SP_START_SHOP = opts.fast.shop.join(',');
if (opts.fast.idleBots) env.SP_IDLE_BOTS = '1';
if (Array.isArray(opts.fast.kits)) env.SP_START_KITS = JSON.stringify(opts.fast.kits);
if (opts.fast.botChess?.length) env.SP_BOT_CHESS = opts.fast.botChess.join(',');
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
}
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = [];
+72 -6
View File
@@ -12,10 +12,20 @@
// distinct equipment items → the equip-replace dialog).
// SP_START_SHOP=<chessId,…>: the starter kit also stocks the first shop slots with these chess at their normal price
// (test/ui/playtest6-elite.e2e.test.js: the copy that completes a merge).
// SP_START_KITS=<JSON [[chessId,…], …]>: per-human starter kits in seat order, overriding SP_START_CHESS for the humans
// listed ([] = no operators: a leaker without a board — test/ui/watch-bonds.e2e.test.js, 联防).
// SP_BOT_CHESS=<chessId,…>: AI seats get these chess too (kept with SP_IDLE_BOTS). SP_AUTO_PLACE=1: the first prep after
// the jump puts every player's hand operators on the board (the bots' layout planner: tiles and facings; no drag & drop)
// — except a human whose SP_START_KITS entry is [] (operators a band hands out at that prep, e.g. 老鲤's two, stay in
// the hand: the seat keeps no board and leaks).
// SP_ELIMINATE=<humanIdx,…>: those humans (seat order) are eliminated at the jump, before the round's boss pairing — an
// eliminated spectator from the first round on (test/ui/watch-bonds.e2e.test.js, the Final Assault).
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js';
import { Match } from '../../server/match/Match.js';
import { legalTiles, positionClass, tileKey } from '../../server/match/board.js';
import { planLayout } from '../../server/match/bot.js';
if (!process.env.NODE_TEST_CONTEXT) {
const timerScale = Number(process.env.SP_TIMER_SCALE) || 1;
@@ -28,19 +38,74 @@ if (!process.env.NODE_TEST_CONTEXT) {
const kitIds = String(process.env.SP_START_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
const kitItems = String(process.env.SP_START_ITEMS || '').split(',').map((x) => x.trim()).filter(Boolean);
const kitShop = String(process.env.SP_START_SHOP || '').split(',').map((x) => x.trim()).filter(Boolean);
let humanKits = [];
try { humanKits = JSON.parse(process.env.SP_START_KITS || '[]'); } catch { humanKits = []; }
const botChess = String(process.env.SP_BOT_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
const autoPlace = process.env.SP_AUTO_PLACE === '1';
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
class FastMatch extends Match {
constructor(opts) {
super({ ...opts, timerScale, combatSpeed });
this._jumped = !startAt;
this._placeRound = null;
}
startRound(r) {
if (this._jumped) { super.startRound(r); return; }
this._jumped = true;
const target = startAt === 'boss' ? this.gd.bossRound : startAt === 'hidden' ? (this.gd.hiddenRound || this.gd.bossRound) : Number(startAt);
const humans = this.order.filter((ps) => !ps.isBot);
for (const i of eliminate) {
const ps = humans[i];
if (ps && ps.alive) { ps.lp = 0; ps.eliminate(r); ps.dirty(); }
}
super.startRound(Number.isInteger(target) && target > r ? target : r);
for (const ps of this.alivePlayers()) if (!ps.isBot) this._starterKit(ps);
humans.forEach((ps, i) => { if (ps.alive) this._starterKit(ps, Array.isArray(humanKits[i]) ? humanKits[i] : null); });
if (botChess.length) for (const ps of this.alivePlayers()) if (ps.isBot) { this._give(ps, botChess); ps.recompute(); }
if (autoPlace) this._placeRound = this.round;
}
/** Chess ids into the hand's free slots (copies taken from the pool when it has them). */
_give(ps, ids) {
for (const id of ids) {
const rec = this.data.chess?.[id];
const slot = ps.hand.findIndex((x) => x == null);
if (!rec || slot < 0) continue;
try { ps.hand[slot] = ps.newPiece('chess', id, { poolCopies: this.pool.take(this.gd.baseIdOf(id), rec.isGolden ? this.gd.goldenCopies : 1) }); } catch { /* best effort */ }
}
}
enterPrep() {
super.enterPrep();
if (this._placeRound == null || this.round !== this._placeRound) return;
this._placeRound = null;
// every player's hand operators onto the field they deploy on: the bots' layout planner (bot.js planLayout —
// tiles and facings), any piece it leaves out on the first free legal tile
const humans = this.order.filter((x) => !x.isBot);
for (const ps of this.alivePlayers()) {
const kit = ps.isBot ? null : humanKits[humans.indexOf(ps)];
if (Array.isArray(kit) && kit.length === 0) continue; // "no operators": a band's gift stays in the hand
const pieces = ps.hand.filter((p) => p && p.kind === 'chess');
let plan = null;
try { plan = planLayout(this, ps, pieces); } catch { plan = null; }
for (const piece of pieces) {
const key = plan && plan.get(piece.uid);
if (key) {
const [row, col] = key.split(',').map(Number);
const res = ps.move(piece.uid, { area: 'board', row, col }, plan.dirs.get(piece.uid) || 'RIGHT');
if (!res || !res.error) continue;
}
for (const [row, col] of legalTiles(ps.deployMap(), positionClass(this.gd.chess(piece.id)))) {
if (ps.board.has(tileKey(row, col))) continue;
const res = ps.move(piece.uid, { area: 'board', row, col }, 'RIGHT');
if (!res || !res.error) break;
}
}
ps.recompute();
ps.dirty();
}
this.markPublic();
}
scheduleBotPrep(ps, i = 0) {
@@ -53,14 +118,15 @@ if (!process.env.NODE_TEST_CONTEXT) {
});
}
/** A few cheap operators in the hand + funds (a test hook: the rounds before were skipped). */
_starterKit(ps) {
/** A few cheap operators in the hand + funds (a test hook: the rounds before were skipped). `own`: this human's kit. */
_starterKit(ps, own = null) {
const ids = own || kitIds;
const all = Object.values(this.data.chess || {}).filter((c) => c && c.visible && !c.isGolden && c.tier <= 3)
.sort((a, b) => (a.tier - b.tier) || String(a.chessId).localeCompare(String(b.chessId)));
const melee = all.filter((c) => c.position === 'MELEE');
const ranged = all.filter((c) => c.position !== 'MELEE');
const picks = kitIds.map((id) => this.data.chess?.[id]).filter(Boolean);
for (let i = 0; !kitIds.length && picks.length < kitSize && (melee[i] || ranged[i]); i++) {
const picks = ids.map((id) => this.data.chess?.[id]).filter(Boolean);
for (let i = 0; !own && !kitIds.length && picks.length < kitSize && (melee[i] || ranged[i]); i++) {
if (melee[i]) picks.push(melee[i]);
if (ranged[i] && picks.length < kitSize) picks.push(ranged[i]);
}
@@ -69,7 +135,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
if (slot < 0) break;
try {
const taken = this.pool.take(this.gd.baseIdOf(rec.chessId), rec.isGolden ? this.gd.goldenCopies : 1);
if (!taken && !kitIds.length) continue; // an explicit SP_START_CHESS piece is handed out even when this match bans it
if (!taken && !ids.length) continue; // an explicit SP_START_CHESS piece is handed out even when this match bans it
ps.hand[slot] = ps.newPiece('chess', rec.chessId, { poolCopies: taken });
} catch { /* keep going: the kit is best effort */ }
}
+36 -2
View File
@@ -1,9 +1,10 @@
// Observing rules of the client (public/js/battle/observe.js, research 09 §3.1 / §6.3) — the mirror of the server's
// Match._watchClient: prep boards, no observing while the own normal battle runs, a teammate's field once it is over,
// the other pair's boss field hidden, eliminated players free; the 联防 / 最终攻势 camera halves and their captions.
// the other pair's boss field hidden, eliminated players free; the 联防 / 最终攻势 camera halves and their captions;
// a reloaded screen that watched a teammate's battle takes the watch back (resumedWatch).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { observeTarget, teammateProgress, cameraLayers, layerCamera, isClientCombat } from '../../public/js/battle/observe.js';
import { observeTarget, teammateProgress, cameraLayers, layerCamera, isClientCombat, resumedWatch } from '../../public/js/battle/observe.js';
const P = (id, seat, extra = {}) => ({ playerId: id, seat, name: id.toUpperCase(), alive: true, ...extra });
const pubOf = (phase, fields, players) => ({ phase, combatMode: 'client', fields, players });
@@ -43,3 +44,36 @@ test('cameraLayers: ‹ LEFT / 全景 / RIGHT › with "你自己" / name / "无
assert.deepEqual(layerCamera(unite, 'R', 'R'), { rect: unite.rect, side: 'R', half: true });
assert.deepEqual(layerCamera(unite, 'ALL', 'R'), { rect: unite.rect, side: 'R' });
});
test('resumedWatch: a reload while watching a teammate\'s battle adopts the resent field once per battle (review, DESIGN §20.15)', () => {
const players = [P('a', 0), P('b', 1), P('c', 2)];
const pub = pubOf('COMBAT', [
{ fieldId: 'n:a', kind: 'normal', players: ['a'], live: false },
{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true },
], players);
const watched = { battleId: 'r3-n:b', fieldId: 'n:b', kind: 'normal', watch: true, done: false };
const own = { battleId: 'r3-n:a', fieldId: 'n:a', kind: 'normal', watch: false, done: true };
const o = { pub, myId: 'a', alive: true, watching: null, seen: null };
// the reload: the server resends b's battle (b.start watch: true) to a screen at home
assert.deepEqual(resumedWatch(watched, o), { seen: 'r3-n:b', fieldId: 'n:b' });
// once per battle: after 返回战场 (watching null again) the same battle, still shown until the own b.start, stays put
assert.deepEqual(resumedWatch(watched, { ...o, seen: 'r3-n:b' }), { seen: 'r3-n:b', fieldId: null });
// first seen while watching (a 前往查看 under way): marked, never adopted later
assert.deepEqual(resumedWatch(watched, { ...o, watching: 'n:b' }), { seen: 'r3-n:b', fieldId: null });
// loading (no `watch` in that state yet): undecided while watching nothing, marked when already watching
const loading = { loading: true, battleId: 'r3-n:b', fieldId: 'n:b', kind: 'normal' };
assert.deepEqual(resumedWatch(loading, o), { seen: null, fieldId: null });
assert.deepEqual(resumedWatch(loading, { ...o, watching: 'n:b' }), { seen: 'r3-n:b', fieldId: null });
// the own battle, no battle, no m.public yet (undecided)
assert.deepEqual(resumedWatch(own, o), { seen: 'r3-n:a', fieldId: null });
assert.deepEqual(resumedWatch({ ...watched, fieldId: 'n:a' }, o), { seen: 'r3-n:b', fieldId: null }, 'a field listing the viewer');
assert.deepEqual(resumedWatch(null, o), { seen: null, fieldId: null });
assert.deepEqual(resumedWatch(watched, { ...o, pub: null }), { seen: null, fieldId: null });
// other rules keep theirs: an eliminated player's auto-observe, 联防 / boss fields, other phases, server-run combat
assert.deepEqual(resumedWatch(watched, { ...o, alive: false }), { seen: 'r3-n:b', fieldId: null });
const unite = pubOf('UNITE', [{ fieldId: 'u', kind: 'unite', players: ['b', 'c'], live: true }], players);
assert.deepEqual(resumedWatch({ battleId: 'r3-u', fieldId: 'u', kind: 'unite', watch: true }, { ...o, pub: unite }), { seen: 'r3-u', fieldId: null }, 'a 联防 leaker');
assert.deepEqual(resumedWatch(watched, { ...o, pub: { ...pub, phase: 'UNITE' } }), { seen: 'r3-n:b', fieldId: null });
assert.deepEqual(resumedWatch(watched, { ...o, pub: { ...pub, combatMode: 'server' } }), { seen: 'r3-n:b', fieldId: null });
assert.deepEqual(resumedWatch({ ...watched, fieldId: 'n:x' }, o), { seen: 'r3-n:b', fieldId: null }, 'a field not listed');
});
+243
View File
@@ -0,0 +1,243 @@
// 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 — 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 } from './harness.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();
});
// ---- 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();
});
+625
View File
@@ -0,0 +1,625 @@
// Real-server browser E2E (DESIGN §20.15, user report after playtest #6): the bond strip follows the watched teammate.
// Two humans — the host on a 1280×720 desktop, the guest deploying at 1280×720 and then on the user's phone (756×366 CSS
// px) — start a co-op match at the Final Assault (fastServer SP_START_ROUND=boss) with a known starter kit; the host
// deploys 古米 + 艾丝黛尔 (坚守 / 萨尔贡), the guest 惊蛰 + 跃跃 (炎 / 精准), so their strips hold different bonds.
// * prep: the host scouts the guest (前往查看) → the strip shows the GUEST's bonds and counts with the amber
// "👁 阿米娅" tag, the popup of one of them says "阿米娅 的盟约"; 返回自己 → the host's own bonds again;
// * Final Assault (one pair field): 全景 → your own bonds; the ‹ › pill on the partner's half → the partner's bonds
// (tagged), on your own half → yours; the guest on the phone does the same — the tagged strip stays on screen, clear
// of the team panel, the name at ≥ 9 px (≥ 10 px on the desktop); a card's bond chip opens the UNIT owner's popup
// whichever player the strip follows (the guest's operator on 全景 → "阿米娅 的盟约", the host's own on the partner's
// half → the host's own), with the count the chip shows.
// 联防 observer (2 humans + 1 AI from round 3, fastServer hooks: per-human kits auto-placed, the guest without a board):
// the guest leaks, the host and the AI help → the guest (phone) on 全景 sees helper 1's bonds (never its own), taps
// each helper's avatar (前往查看) → the camera goes to that helper's half, the ‹ › pill and the strip name the same
// helper, 返回战场 sits in the pill and leaves no error; the host (helper, desktop) keeps its own bonds on 全景.
// Final Assault spectator (2 humans + 2 AI at the boss round, the guest eliminated at the jump): the guest auto-observes
// the pair field → its first player's bonds (never its own), taps the second player's operator and its card's bond
// chip → the SECOND player's popup (the strip stays on the first), taps each pair player → their half + bonds, taps
// the lone field's player → that field and its player's bonds.
// After the own battle (2 humans + 1 idle AI from round 3, 1× combat): the host's battle ends first, the host watches a
// teammate still fighting → the strip shows that teammate's bonds with the observing pill "👁 name" + 返回战场; a
// reload keeps all three (the resent field is adopted — battle/observe.js resumedWatch); 返回战场 → the own bonds.
// Unit counterparts: test/ui/watch-bonds.test.js (selection logic incl. 联防), test/match/watch-bonds.test.js (views),
// test/match/observe.test.js (resumedWatch).
//
// SP_E2E=1 node --test test/ui/watch-bonds.e2e.test.js
//
// Screenshots: test/e2e/out/watch-bonds-*.png.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const HOST_KIT = ['chess_char_1_10_a', 'chess_char_1_12_a']; // 古米 (坚守), 艾丝黛尔 (萨尔贡)
const GUEST_KIT = ['chess_char_1_03_a', 'chess_char_1_09_a']; // 惊蛰 (炎), 跃跃 (精准)
// a 联防 needs two perfect players: the host fields six 萨尔贡 operators (百炼嘉维尔, 卡涅利安, 菲莱, 至简, 艾丝黛尔, 莎草),
// the AI seats five 拉特兰 ones + 古米 (信仰搅拌机, 莫斯提马, 能天使, 空弦, 送葬人; 坚守) — different bonds on each strip
const UNITE_HOST_KIT = ['chess_char_4_23_a', 'chess_char_4_24_a', 'chess_char_3_06_a', 'chess_char_3_13_a', 'chess_char_1_12_a', 'chess_char_2_06_a'];
const BOT_KIT = ['chess_char_4_01_a', 'chess_char_4_02_a', 'chess_char_3_01_a', 'chess_char_3_21_a', 'chess_char_2_01_a', 'chess_char_1_10_a'];
/** The strip as shown: whose (data-owner), the bonds (data-bond) with their counts, the tag's text. */
const stripOf = (c) => c.page.evaluate(() => {
const el = document.querySelector('.gm__bonds .bstrip');
if (!el) return null;
const r = el.getBoundingClientRect();
const tag = el.querySelector('.bstrip__owner');
const tr = tag ? tag.getBoundingClientRect() : null;
return {
owner: el.getAttribute('data-owner'), other: el.classList.contains('is-other'),
bonds: [...el.querySelectorAll('.bslot')].map((s) => s.getAttribute('data-bond')).sort(),
counts: Object.fromEntries([...el.querySelectorAll('.bslot')].map((s) => [s.getAttribute('data-bond'), s.querySelector('.bslot__count')?.firstChild?.textContent ?? null])),
tag: tag ? tag.textContent.trim() : null,
tagFont: tag ? parseFloat(getComputedStyle(tag.querySelector('.micro')).fontSize) : null,
pill: document.querySelector('.chud__layers .vswitch__label')?.textContent?.trim() ?? null,
pillBack: !!document.querySelector('.chud__layers .chud__back'),
observe: document.querySelector('.chud__observe .vswitch__label')?.textContent?.trim() ?? null,
tagRect: tr ? { left: tr.left, top: tr.top, right: tr.right, bottom: tr.bottom } : null,
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
vw: innerWidth, vh: innerHeight,
team: (() => { const t = document.querySelector('.team'); if (!t) return null; const b = t.getBoundingClientRect(); return { left: b.left, top: b.top, right: b.right, bottom: b.bottom }; })(),
};
});
/** A player's bond ids with members as the views carry them (yours: m.private; a teammate's: m.public). */
const bondsOf = (c, name) => c.page.evaluate((name) => {
const s = globalThis.__SP__.store.get();
const p = s.match.public.players.find((x) => x.name === name);
const own = p && p.playerId === s.me.playerId;
const list = own ? s.match.private.bonds : p.bonds;
return { ids: list.filter((b) => b.count > 0 || b.layers > 0 || b.active).map((b) => b.bondId).sort(), counts: Object.fromEntries(list.map((b) => [b.bondId, String(b.count)])) };
}, name);
/**
* Tap one of `ownerId`'s operators on the battle field on screen (a point the renderer's own picking maps to that unit,
* not covered by the HUD), then its card's first bond chip: the popup that chip opens (whose, its count) + the chip's
* count. null when no such operator could be tapped.
*/
async function chipPopup(c, ownerId) {
for (let attempt = 0; attempt < 8; attempt++) {
const pt = await c.page.evaluate((ownerId) => {
const d = globalThis.__SP_VIEW__?.raw?.debug;
if (!d?.views || !d.pick?.battleUnitAt || !d.app?.view) return null;
const canvas = d.app.view;
const r = canvas.getBoundingClientRect();
for (const v of d.views.values()) {
const info = v.info;
if (v.destroyed || !info || info.ownerId !== ownerId || info.side === 'enemy' || typeof v.bounds !== 'function') continue;
const b = v.bounds();
for (const at of [0.85, 0.65, 0.95, 0.45]) {
const x = b.x + b.width / 2;
const y = b.y + b.height * at;
if (d.pick.battleUnitAt(x, y) !== v || document.elementFromPoint(r.left + x, r.top + y) !== canvas) continue;
return { x: r.left + x, y: r.top + y, defId: info.defId };
}
}
return null;
}, ownerId);
if (!pt) { await sleep(400); continue; }
await c.page.mouse.click(pt.x, pt.y);
const card = await c.page.waitForSelector('.dpanel button.dbond[data-bond]', { timeout: 2500 }).then(() => true).catch(() => false);
if (!card) continue;
const chip = await c.page.evaluate(() => {
const el = document.querySelector('.dpanel button.dbond[data-bond]');
return { bondId: el.getAttribute('data-bond'), count: el.querySelector('.dbond__count')?.firstChild?.textContent ?? null };
});
await c.page.click(`.dpanel button.dbond[data-bond="${chip.bondId}"]`);
await c.page.waitForSelector('.bpop', { timeout: 3000 });
const pop = await c.page.evaluate(() => {
const el = document.querySelector('.bpop');
return { owner: el.getAttribute('data-owner'), label: el.getAttribute('aria-label'), tag: el.querySelector('.bpop__owner')?.textContent || '',
count: el.querySelector('.bpop__facts b.num')?.textContent ?? null, on: el.querySelectorAll('.bpop__member.is-on').length };
});
return { ...pop, bondId: chip.bondId, chipCount: chip.count, defId: pt.defId };
}
return null;
}
/** Close the bond popup and the card (Esc closes the popup first). */
async function closeCard(c) {
for (let i = 0; i < 3 && (await c.page.$('.bpop, .dpanel')); i++) {
await c.page.keyboard.press('Escape');
await sleep(250);
}
}
/** Hand piece `chessId` → a legal board tile through the direction wheel. */
async function place(c, chessId) {
const piece = (await c.handPieces('chess')).find((p) => p.id === chessId);
assert.ok(piece, `${c.label}: ${chessId} in the hand`);
const n0 = (await c.boardPieces()).length;
const tile = await c.freeTileFor(piece.uid);
assert.ok(tile, `${c.label}: a legal tile for ${chessId}`);
await c.drag(await c.piecePoint(piece.uid), await c.tilePoint(tile.row, tile.col));
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await c.page.waitForFunction((n0) => (globalThis.__SP__.store.get().match.private?.board || []).length > n0, { timeout: 8000 }, n0);
}
/** Two humans in a co-op room with `bots` AI seats: the host on a 1280×720 desktop, the guest on the user's phone
* (756×366 CSS px, touch), through the briefing and the band draft. */
async function coopMatch(P, base, { bots = 1, prefix }) {
const host = new Client(P, base, 'host', { prefix: `${prefix}-host`, w: 1280, h: 720 });
const guest = new Client(P, base, 'guest', { prefix: `${prefix}-phone`, w: 756, h: 366 });
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
for (let i = 0; i < bots; i++) {
await host.click('button', '添加 AI 队友');
await sleep(600);
}
await guest.open(`?room=${room.code}`);
await guest.page.setViewport({ width: 756, height: 366, hasTouch: true, isMobile: true });
await guest.page.reload({ waitUntil: 'domcontentloaded' });
await guest.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
const guestReady = () => guest.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return !!s.room?.seats?.find((x) => x && x.playerId === s.me.playerId)?.ready;
});
for (let i = 0; i < 6 && !(await guestReady()); i++) {
await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 });
await sleep(600);
}
assert.ok(await guestReady(), 'guest ready');
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
for (const c of [host, guest]) {
const s = await c.st();
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await sleep(200);
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
}
// error toasts from here on (返回战场 of a leaker must not raise one)
for (const c of [host, guest]) {
await c.page.evaluate(() => {
globalThis.__toastErrors = [];
new MutationObserver(() => {
for (const t of document.querySelectorAll('.toast--error .toast__text')) {
if (!t.dataset.seen) { t.dataset.seen = '1'; globalThis.__toastErrors.push(t.textContent); }
}
}).observe(document.body, { childList: true, subtree: true });
});
}
return { host, guest };
}
/** A teammate's row → 前往查看 (team panel). */
async function watchMate(c, name) {
const pt = await c.page.evaluate((name) => {
for (const row of document.querySelectorAll('.team__row')) {
if (!(row.querySelector('.team__name')?.textContent || '').includes(name)) continue;
const b = row.querySelector('.team__btn').getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
}
return null;
}, name);
assert.ok(pt, `${c.label}: ${name}'s row`);
await c.page.mouse.click(pt.x, pt.y);
assert.ok(await c.click('.team__ob', '前往查看', { timeout: 4000, optional: true }), `${c.label}: 前往查看 on ${name}`);
}
/** Every player's name and bond ids (count, layers or active), the field listing, the 联防 helpers. */
const matchView = (c) => c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
const ids = (list) => (list || []).filter((b) => b.count > 0 || b.layers > 0 || b.active).map((b) => b.bondId).sort();
return {
me: s.me.playerId,
names: Object.fromEntries(pub.players.map((p) => [p.playerId, p.name])),
bonds: Object.fromEntries(pub.players.map((p) => [p.name, ids(p.playerId === s.me.playerId && s.match.private ? s.match.private.bonds : p.bonds)])),
fields: (pub.fields || []).map((f) => ({ fieldId: f.fieldId, kind: f.kind, players: f.players })),
field: s.match.field ? { fieldId: s.match.field.fieldId, sides: s.match.field.sides || null } : null,
};
});
const ownerIs = (c, name) => c.page.waitForFunction((name) => (document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') ?? null) === name, { timeout: 6000 }, name);
describe('DESIGN §20.15 — the bond strip shows the watched teammate\'s bonds (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('prep scouting and the Final Assault ‹ › halves (desktop host, phone guest)', { timeout: 8 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', chess: [...HOST_KIT, ...GUEST_KIT] } });
const P = (await import('puppeteer-core')).default;
const host = new Client(P, srv.base, 'host', { prefix: 'watch-bonds', w: 1280, h: 720 });
// the guest deploys on a desktop-sized window (the phone's shop bar covers the bench), then turns into the phone
const guest = new Client(P, srv.base, 'guest', { prefix: 'watch-bonds-phone', w: 1280, h: 720 });
try {
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
await guest.open(`?room=${room.code}`);
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
const guestReady = () => guest.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return !!s.room?.seats?.find((x) => x && x.playerId === s.me.playerId)?.ready;
});
for (let i = 0; i < 6 && !(await guestReady()); i++) {
await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 });
await sleep(600);
}
assert.ok(await guestReady(), 'guest ready');
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
for (const c of [host, guest]) {
const s = await c.st();
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await sleep(200);
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
}
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand >= 4, 'boss-round prep with the kit', 60000);
await sleep(1800);
for (const id of HOST_KIT) await place(host, id);
for (const id of GUEST_KIT) await place(guest, id);
await sleep(800);
// ---- prep: the host's own strip, then the guest's board (前往查看) -------------------------------------------
const hostOwn = await bondsOf(host, '凯尔希');
const guestPub = await bondsOf(host, '阿米娅');
assert.ok(hostOwn.ids.length && guestPub.ids.length, `both have bonds (${hostOwn.ids} / ${guestPub.ids})`);
assert.notDeepEqual(hostOwn.ids, guestPub.ids, 'different kits, different bonds');
let st = await stripOf(host);
assert.deepEqual([st.owner, st.other, st.bonds], [null, false, hostOwn.ids], 'prep, own board: the host\'s own bonds');
await host.click('.team__row:not(.is-self) .team__btn', null, { nth: 0 });
assert.ok(await host.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }), 'host: 前往查看 in prep');
await host.page.waitForSelector('.gm__watching', { timeout: 6000 });
await host.page.waitForFunction(() => document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') === '阿米娅', { timeout: 6000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.other, st.bonds], ['阿米娅', true, guestPub.ids], 'scouting: the GUEST\'s bonds');
for (const id of st.bonds) assert.equal(st.counts[id], guestPub.counts[id], `${id}: the guest's member count`);
assert.match(st.tag, /阿米娅/, 'the 👁 name tag');
// the popup of one of them is the guest's too
await host.click(`.gm__bonds .bslot[data-bond="${st.bonds[0]}"] .bond`, null, { any: true });
await host.page.waitForSelector('.bpop[data-owner="阿米娅"]', { timeout: 4000 });
const pop = await host.page.evaluate(() => ({ owner: document.querySelector('.bpop__owner')?.textContent || '', facts: document.querySelector('.bpop__facts')?.textContent || '' }));
assert.match(pop.owner, /阿米娅.*的盟约/);
await host.shot('prep-scout');
await host.page.keyboard.press('Escape');
await host.click('.gm__watching button', '返回自己');
await host.page.waitForFunction(() => !document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner'), { timeout: 6000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], [null, hostOwn.ids], '返回自己: the host\'s own bonds again');
// ---- Final Assault: the pair field's halves (the guest on the user's phone, 756×366 CSS px) ----------------------
await guest.page.setViewport({ width: 756, height: 366 });
guest.w = 756;
guest.h = 366;
for (const c of [host, guest]) {
if (!(await c.st()).ready) await c.click('.readybtn');
}
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'FINAL_ASSAULT', 'Final Assault', 60000);
for (const c of [host, guest]) await c.page.waitForSelector('.chud__layers', { timeout: 20000 });
await sleep(1200);
const pair = await host.page.evaluate(() => {
const f = globalThis.__SP__.store.get().match.field;
return { fieldId: f?.fieldId, sides: f?.sides || null, me: globalThis.__SP__.store.get().me.playerId };
});
assert.ok(pair.sides && Object.keys(pair.sides).length === 2, `one pair field with both (${JSON.stringify(pair)})`);
const hostSide = pair.sides[pair.me];
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], [null, hostOwn.ids], '全景: the host\'s own bonds');
// a card's bond chip on the shared field (review): on 全景 (the strip is the host's) the GUEST's operator's chip
// opens the GUEST's bond — labelled, with the guest's count, the same number the chip shows
const guestId = Object.keys(pair.sides).find((id) => id !== pair.me);
let chip = await chipPopup(host, guestId);
assert.ok(chip, 'host: a card of the guest\'s operator and its bond chip');
const guestNow = await bondsOf(host, '阿米娅');
assert.equal(chip.owner, '阿米娅', `全景: the guest's unit chip opens the guest's popup (${JSON.stringify(chip)})`);
assert.match(chip.tag, /阿米娅.*的盟约/);
assert.equal(chip.count, guestNow.counts[chip.bondId] ?? '0', `the guest's member count (${JSON.stringify({ chip, guestNow })})`);
assert.equal(chip.chipCount, chip.count, 'the chip and its popup agree');
if (Number(chip.count) > 0) assert.ok(chip.on >= 1, `the guest's operators in play are listed as members (${JSON.stringify(chip)})`);
st = await stripOf(host);
assert.equal(st.owner, null, 'the strip stays the host\'s on 全景');
await host.shot('fa-chip-partner');
await closeCard(host);
/** Step the ‹ › pill to `key` ('L' | 'ALL' | 'R') from 全景. */
const toLayer = async (c, key) => {
const arrow = key === 'L' ? '左侧战场' : '右侧战场';
if (key !== 'ALL') await c.click(`.chud__layers .vswitch__arrow[aria-label="${arrow}"]`);
await sleep(500);
};
const back = async (c, key) => { if (key !== 'ALL') await c.click(`.chud__layers .vswitch__arrow[aria-label="${key === 'L' ? '右侧战场' : '左侧战场'}"]`); await sleep(400); };
const partnerSide = hostSide === 'L' ? 'R' : 'L';
await toLayer(host, partnerSide);
await host.page.waitForFunction(() => document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') === '阿米娅', { timeout: 4000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], ['阿米娅', guestPub.ids], 'the partner\'s half: the GUEST\'s bonds');
assert.ok(st.tagFont >= 10, `desktop: the name is legible (${st.tagFont} px)`);
await host.shot('fa-partner-half');
// the reverse: on the partner's half (the strip is the guest's) the host's OWN operator's chip opens the host's bond
chip = await chipPopup(host, pair.me);
assert.ok(chip, 'host: a card of its own operator and its bond chip');
const hostNow = await bondsOf(host, '凯尔希');
assert.equal(chip.owner, null, `the partner's half: an own unit's chip opens the host's own popup (${JSON.stringify(chip)})`);
assert.equal(chip.tag, '');
assert.equal(chip.count, hostNow.counts[chip.bondId] ?? '0', `the host's member count (${JSON.stringify({ chip, hostNow })})`);
assert.equal(chip.chipCount, chip.count, 'the chip and its popup agree');
st = await stripOf(host);
assert.equal(st.owner, '阿米娅', 'the strip stays the guest\'s on that half');
await closeCard(host);
await back(host, partnerSide);
await toLayer(host, hostSide);
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], [null, hostOwn.ids], 'the own half ("你自己"): the host\'s bonds');
// the phone: the guest looks at the host's half
const guestSide = hostSide === 'L' ? 'R' : 'L';
st = await stripOf(guest);
assert.equal(st.owner, null, 'guest, 全景: own bonds');
await toLayer(guest, hostSide);
await guest.page.waitForFunction(() => document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') === '凯尔希', { timeout: 4000 });
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds], ['凯尔希', hostOwn.ids], 'phone: the partner\'s half shows the HOST\'s bonds');
assert.ok(st.tagRect && st.tagRect.left >= 0 && st.tagRect.right <= st.vw && st.tagRect.top >= 0 && st.tagRect.bottom <= st.vh, `phone: the tag is on screen (${JSON.stringify(st.tagRect)})`);
assert.ok(st.rect.right <= st.vw + 1, `phone: the strip stays inside the viewport (${JSON.stringify(st.rect)})`);
assert.ok(st.tagFont >= 9, `phone: the name is legible (${st.tagFont} px)`);
if (st.team) {
const overlap = !(st.tagRect.right <= st.team.left || st.tagRect.left >= st.team.right || st.tagRect.bottom <= st.team.top || st.tagRect.top >= st.team.bottom);
assert.ok(!overlap, `phone: the tag is clear of the team panel (${JSON.stringify({ tag: st.tagRect, team: st.team })})`);
}
await guest.shot('fa-partner-half');
await back(guest, hostSide);
await toLayer(guest, guestSide);
st = await stripOf(guest);
assert.equal(st.owner, null, 'phone, own half: own bonds');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host.close();
await guest.close();
await srv.stop();
}
});
test('联防 observer: helper 1 on 全景, the picked helper\'s half + bonds, 返回战场 in the pill (phone leaker, desktop helper)', { timeout: 15 * 60 * 1000 }, async () => {
// the host fights with a 萨尔贡 kit, the AI with a 拉特兰 kit, the guest has no board (every enemy leaks)
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 3, idleBots: true, autoPlace: true,
kits: [UNITE_HOST_KIT, []], botChess: BOT_KIT } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopMatch(P, srv.base, { bots: 1, prefix: 'watch-bonds-unite' }));
// up to three rounds: the guest (no board — fastServer keeps a band's gift operators in its hand) leaks; a 联防
// needs the host and the AI perfect (random waves). Only a 联防 of exactly those two helpers counts (review: the
// guest must be the leaker, not a helper)
let after = null;
let found = false;
for (let round = 3; round <= 5 && !found; round++) {
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && s.round === round, `round-${round} prep`, 120000);
await sleep(1500);
for (const c of [host, guest]) if (!(await c.st()).ready) await c.click('.readybtn');
after = await guest.waitFor((s) => s.phase === 'UNITE' || s.phase === 'SETTLE' || (s.phase === 'PREP' && s.round > round), '联防', 300000);
const mv0 = after.phase === 'UNITE' ? await matchView(guest) : null;
const u0 = mv0 ? mv0.fields.find((f) => f.fieldId === 'u') : null;
if (u0 && u0.players.length === 2 && !u0.players.includes(mv0.me)) { found = true; break; }
console.log(`round ${round}: no 2-helper 联防 without the guest (${after.phase} ${JSON.stringify(mv0 ? mv0.fields : after.fields)}), next round`);
await guest.waitFor((s) => s.phase !== 'UNITE', '联防 over', 300000);
}
assert.ok(found && after.phase === 'UNITE', `the guest leaked and two helpers were perfect (${JSON.stringify(after.fields)})`);
await guest.page.waitForSelector('.chud__layers', { timeout: 15000 });
const mv = await matchView(guest);
const u = mv.fields.find((f) => f.fieldId === 'u');
assert.ok(u && u.players.length === 2 && !u.players.includes(mv.me), `a 2-helper 联防 without the guest (${JSON.stringify(mv.fields)})`);
const [h1, h2] = u.players.map((pid) => mv.names[pid]);
// 全景: helper 1's bonds, tagged — not the leaker's own
await ownerIs(guest, h1);
let st = await stripOf(guest);
assert.deepEqual([st.owner, st.other, st.bonds, st.pill], [h1, true, mv.bonds[h1], '全景'], 'leaker on 全景: helper 1');
await sleep(800);
await guest.shot('overview');
// the host (helper 1 or 2) on 全景: its own bonds
const hs = await stripOf(host);
assert.equal(hs.owner, null, 'the helper on 全景: own bonds');
// the guest picks each helper: the camera goes to their half, pill and strip agree, 返回战场 in the pill
for (const name of [h2, h1]) {
assert.equal((await guest.st()).phase, 'UNITE', '联防 still running');
await watchMate(guest, name);
await ownerIs(guest, name);
await guest.page.waitForFunction((name) => (document.querySelector('.chud__layers .vswitch__label')?.textContent || '').includes(name), { timeout: 6000 }, name);
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.observe], [name, mv.bonds[name], null], `picked ${name}: their bonds, no separate observing pill`);
assert.ok(st.pill.includes(name) && st.pillBack, `the ‹ › pill names ${name} and carries 返回战场 (${st.pill})`);
assert.ok(st.tagRect && st.tagRect.left >= 0 && st.tagRect.right <= st.vw && st.tagFont >= 9, `phone: the tag on screen, legible (${JSON.stringify(st.tagRect)} ${st.tagFont}px)`);
await sleep(900); // the camera flies to the half (750 ms)
await guest.shot(`picked-${name === mv.names[u.players[0]] ? 'helper1' : 'helper2'}`);
}
// 返回战场: no refused g.watch (no own field in 联防), the 联防 view stays with a helper's bonds
await guest.click('.chud__layers .chud__back', '返回战场');
await sleep(1200);
st = await stripOf(guest);
assert.ok(st.owner === h1 || st.owner === h2, `back: still a helper's bonds (${st.owner})`);
assert.equal(st.pillBack, false, 'back: the pill without 返回战场');
assert.deepEqual(await guest.page.evaluate(() => globalThis.__toastErrors), [], 'no error toast');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
test('after the own battle: the watched teammate\'s bonds, kept through a reload with the observing pill + 返回战场 (desktop)', { timeout: 12 * 60 * 1000 }, async () => {
// the host's six 萨尔贡 operators end their battle early; the guest (no board) and the idle AI leak for the enemies'
// whole walk (combat at 1×: a window for the reload) — the host watches one of them, reloads, then goes back
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 1, startRound: 3, idleBots: true, autoPlace: true,
kits: [UNITE_HOST_KIT, []] } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopMatch(P, srv.base, { bots: 1, prefix: 'watch-bonds-after' }));
let checked = false;
for (let round = 3; round <= 5 && !checked; round++) {
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && s.round === round, `round-${round} prep`, 180000);
await sleep(1500);
for (const c of [host, guest]) if (!(await c.st()).ready) await c.click('.readybtn');
await host.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
// the own battle over while a teammate's still runs
const target = await host.page.waitForFunction(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
if (pub?.phase !== 'COMBAT') return { gone: true };
const me = s.me.playerId;
const b = s.match.battle;
const mine = pub.players.find((p) => p.playerId === me);
if (!(mine?.status === 'done' || (b && b.fieldId === `n:${me}` && b.done))) return false;
const live = pub.fields.filter((f) => f.kind === 'normal' && f.live && f.players[0] !== me);
if (!live.length) return { gone: true };
const pick = live.find((f) => !pub.players.find((p) => p.playerId === f.players[0])?.isBot) || live[0];
return { fieldId: pick.fieldId, playerId: pick.players[0], name: pub.players.find((p) => p.playerId === pick.players[0]).name };
}, { timeout: 240000, polling: 250 }).then((h) => h.jsonValue());
if (target.gone) { console.log(`round ${round}: no teammate battle left to watch, next round`); continue; }
const name = target.name;
await watchMate(host, name);
await ownerIs(host, name);
const pubBonds = async () => (await matchView(host)).bonds[name];
let st = await stripOf(host);
// every bond the teammate's view lists is on the strip (live in-battle gains may add more)
for (const id of await pubBonds()) assert.ok(st.bonds.includes(id), `${name}'s ${id} on the strip (${JSON.stringify(st.bonds)})`);
assert.ok((st.observe || '').includes(name), `the observing pill names ${name} (${st.observe})`);
assert.ok(await host.page.$('.chud__observe .chud__back'), '返回战场 in the observing pill');
// the replica on screen (the strip follows the watch at once; the field arrives with its b.start)
const onScreen = await host.page.waitForFunction((fid) => {
const s = globalThis.__SP__.store.get();
if (s.match.public?.phase !== 'COMBAT') return { gone: true };
return s.match.battle && !s.match.battle.loading && s.match.battle.fieldId === fid ? { ok: true } : false;
}, { timeout: 30000, polling: 250 }, target.fieldId).then((h) => h.jsonValue());
if (onScreen.gone) { console.log(`round ${round}: the battles ended while the replica loaded, next round`); continue; }
assert.equal((await stripOf(host)).owner, name, 'the replica on screen: still their bonds');
await sleep(600);
await host.shot('watching');
// a reload while watching (pre-existing gap, review): the resent field is adopted — strip, pill and 返回战场
const before = host.problems.length;
await host.page.reload({ waitUntil: 'domcontentloaded' });
await host.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
const resumed = await host.page.waitForFunction((fid) => {
const s = globalThis.__SP__.store.get();
if (s.match.public && s.match.public.phase !== 'COMBAT') return { gone: true };
return s.match.battle && !s.match.battle.loading && s.match.battle.fieldId === fid && document.querySelector('.gm__bonds .bstrip') ? { ok: true } : false;
}, { timeout: 60000, polling: 250 }, target.fieldId).then((h) => h.jsonValue());
host.problems.splice(before, Infinity, ...host.problems.slice(before).filter((p) => !/ERR_ABORTED/.test(p)));
if (resumed.gone) { console.log(`round ${round}: the battles ended during the reload, next round`); continue; }
await ownerIs(host, name);
await host.page.waitForFunction((name) => (document.querySelector('.chud__observe .vswitch__label')?.textContent || '').includes(name), { timeout: 8000 }, name);
assert.ok(await host.page.$('.chud__observe .chud__back'), 'after the reload: 返回战场 again');
assert.equal(await host.page.$('.chud__wait'), null, 'after the reload: no "等待队友" message in place of the observing pill');
st = await stripOf(host);
for (const id of await pubBonds()) assert.ok(st.bonds.includes(id), `after the reload: ${name}'s ${id} on the strip (${JSON.stringify(st.bonds)})`);
await sleep(1500); // the phase banner of the fresh screen
await host.shot('reloaded');
// 返回战场 → the own bonds, untagged
await host.click('.chud__observe .chud__back', '返回战场');
await host.page.waitForFunction(() => !document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner'), { timeout: 8000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.observe], [null, null], 'back: own bonds, no observing pill');
const fid = await host.page.evaluate(() => globalThis.__SP__.store.get().match.battle?.fieldId ?? null);
const me = (await host.st()).me;
if ((await host.st()).phase === 'COMBAT') assert.equal(fid, `n:${me}`, 'back: the own battle on screen');
checked = true;
}
assert.ok(checked, 'a teammate\'s battle was watched, reloaded and left');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
test('Final Assault spectator (eliminated): the pair\'s first player, each picked player\'s half + bonds, the lone field', { timeout: 8 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', idleBots: true, autoPlace: true,
kits: [UNITE_HOST_KIT, []], botChess: BOT_KIT, eliminate: [1] } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopMatch(P, srv.base, { bots: 2, prefix: 'watch-bonds-fa' }));
await host.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 60000);
assert.equal((await guest.st()).alive, false, 'the guest is eliminated');
await sleep(1500);
await host.click('.readybtn');
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'FINAL_ASSAULT', 'Final Assault', 90000);
await guest.page.waitForSelector('.chud__layers', { timeout: 20000 });
await sleep(1000);
const mv = await matchView(guest);
const pair = mv.fields.find((f) => f.players.length === 2);
const lone = mv.fields.find((f) => f.players.length === 1);
assert.ok(pair && lone && mv.field?.fieldId === mv.fields[0].fieldId, `a pair field and a lone one, the first auto-observed (${JSON.stringify(mv)})`);
assert.equal(pair.fieldId, mv.fields[0].fieldId, 'the pair field is listed first');
const first = mv.names[pair.players[0]];
await ownerIs(guest, first);
let st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.pill], [first, mv.bonds[first], '全景'], 'auto-observed pair, 全景: the first player (never the spectator\'s own)');
await guest.shot('auto');
// a card's bond chip (review): the strip follows the first player, the SECOND player's operator's chip opens the
// second player's bond (labelled, their count — the number the chip shows), the strip stays on the first
const second = mv.names[pair.players[1]];
const chip = await chipPopup(guest, pair.players[1]);
assert.ok(chip, `spectator: a card of ${second}'s operator and its bond chip`);
const secondNow = await bondsOf(guest, second);
assert.equal(chip.owner, second, `全景: ${second}'s unit chip opens ${second}'s popup (${JSON.stringify(chip)})`);
assert.equal(chip.count, secondNow.counts[chip.bondId] ?? '0', `${second}'s member count (${JSON.stringify({ chip, secondNow })})`);
assert.equal(chip.chipCount, chip.count, 'the chip and its popup agree');
if (Number(chip.count) > 0) assert.ok(chip.on >= 1, `${second}'s operators in play are listed as members (${JSON.stringify(chip)})`);
assert.equal((await stripOf(guest)).owner, first, 'the strip stays on the first player');
await guest.shot('chip-second');
await closeCard(guest);
for (const pid of [pair.players[1], pair.players[0]]) {
const name = mv.names[pid];
await watchMate(guest, name);
await ownerIs(guest, name);
await guest.page.waitForFunction((name) => (document.querySelector('.chud__layers .vswitch__label')?.textContent || '').includes(name), { timeout: 6000 }, name);
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.pillBack], [name, mv.bonds[name], false], `picked ${name}: their half and bonds (no 返回战场 when eliminated)`);
}
await sleep(900); // the camera flies to the half (750 ms)
await guest.shot('picked');
const loneName = mv.names[lone.players[0]];
await watchMate(guest, loneName);
await ownerIs(guest, loneName);
await guest.page.waitForFunction((name) => (document.querySelector('.chud__observe .vswitch__label')?.textContent || '').includes(name), { timeout: 8000 }, loneName);
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.observe], [loneName, mv.bonds[loneName], loneName], 'the lone field: its player, the observing pill names them');
// the host on its pair field, 全景: own
st = await stripOf(host);
assert.equal(st.owner, null, 'the host on 全景: own bonds');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
});
+437
View File
@@ -0,0 +1,437 @@
// The bond strip follows the watched player (DESIGN §20.15, user report after playtest #6: "观看队友时(不仅是休整时候,
// 还有自己战斗结束时,联防时,最终boss战)盟约栏应该变成当前队友的盟约以及他的层数"). Node tests of the selection logic
// (public/js/ui/watchBonds.js): which field is on screen, whose bonds the strip shows in every watch situation — prep
// scouting, a teammate's battle after the own one, an eliminated player's auto-observed field, 联防 and the Final Assault
// (the ‹ › half the camera shows; 全景 = yourself when you fight there, else the teammate picked with 前往查看 / the
// field's first player — never the viewer's own bonds on a field they do not fight on), the game screen's state →
// strip (screenStrip, incl. a shared field watched with 前往查看), the camera half of a picked player, the live layers
// laid over (capped at 999), the teammate's member list, the strip / popup labelling and the ‹ › pill with 返回战场,
// and the popup of a card's bond chip (the UNIT owner's bond, not the strip's: detailBondOwner / toggleBond / popupView).
// Server side: test/match/watch-bonds.test.js; on screen: test/ui/watch-bonds.e2e.test.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { screenFieldId, bondOwnerId, ownerBonds, withLiveLayers, ownerBoard, stripView, playerBonds, watchedPlayer, playerLayer, screenStrip,
detailBondOwner, toggleBond, popupView } = await import('../../public/js/ui/watchBonds.js');
const { BondStrip, BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { CombatHud } = await import('../../public/js/ui/combatHud.js');
const { cameraLayers } = await import('../../public/js/battle/observe.js');
const { BOND_LAYER_CAP } = await import('../../shared/constants.js');
const { data } = await import('../../public/js/data.js');
await data.loadAll('bonds', 'chess', 'assets');
/** Every vnode of a preact tree (htm output), depth first; function components are expanded. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
if (typeof v.type === 'function' && /^[A-Z]/.test(v.type.name) && ['OwnerTag'].includes(v.type.name)) yield* walk(v.type(v.props));
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
if (typeof v.type === 'function' && v.type.name === 'OwnerTag') return textOf(v.type(v.props));
return textOf(v.props?.children);
};
// ---- a 4-player co-op match as m.public / m.private carry it ----------------------------------------------------------
const ME = 'p1';
const bond = (bondId, count, layers, tier = count >= 2 ? 1 : 0) => ({ bondId, count, active: tier > 0, tier, layers });
const PUB = (extra = {}) => ({
phase: 'PREP',
players: [
{ playerId: 'p1', seat: 0, name: '凯尔希', alive: true, bonds: [bond('yanShip', 3, 12, 1)] },
{ playerId: 'p2', seat: 1, name: '阿米娅', alive: true, bonds: [bond('sargonShip', 2, 40, 1), bond('kjeragShip', 0, 7, 0)] },
{ playerId: 'p3', seat: 2, name: '华法琳', alive: true, bonds: [bond('lateranoShip', 4, 120, 2)] },
{ playerId: 'ai_4', seat: 3, name: 'AI·煌', alive: true, isBot: true, bonds: [] },
],
fields: [],
...extra,
});
const PRIV = { playerId: ME, bonds: [{ ...bond('yanShip', 3, 12, 1), thresholds: [3, 6, 9], countsHand: false }], board: [{ kind: 'chess', id: 'x' }], hand: [], temp: [] };
describe('§20.15 screenFieldId: the field on screen', () => {
test('prep: the own board (null) unless a teammate is scouted', () => {
assert.equal(screenFieldId({ combat: false, watchingOther: false, watching: null, home: 'n:p1' }), null);
assert.equal(screenFieldId({ combat: false, watchingOther: true, watching: 'n:p2', home: 'n:p1' }), 'n:p2');
});
test('battle: the watched field, else the battle the view shows (the auto-observed one of an eliminated player), else home', () => {
assert.equal(screenFieldId({ combat: true, watchingOther: true, watching: 'n:p3', battleFieldId: 'n:p1', home: 'n:p1' }), 'n:p3');
assert.equal(screenFieldId({ combat: true, battleFieldId: 'n:p2', home: 'n:p1' }), 'n:p2', 'eliminated: the runner\'s auto-observed field');
assert.equal(screenFieldId({ combat: true, battleFieldId: 'u', home: 'n:p1' }), 'u', '联防 observer');
assert.equal(screenFieldId({ combat: true, battleFieldId: null, home: 'b1' }), 'b1', 'before the runner shows anything: home');
});
test('SETTLE: the field the last battle left on screen', () => {
assert.equal(screenFieldId({ settle: true, battleFieldId: 'n:p2', home: 'n:p1' }), 'n:p2');
assert.equal(screenFieldId({ settle: true, home: 'n:p1' }), 'n:p1');
});
});
describe('§20.15 bondOwnerId: whose bonds the strip shows', () => {
test('own board / own battle → yourself', () => {
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: null }), ME);
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: 'n:p1' }), ME);
});
test('prep scouting, a teammate\'s battle after the own one, an eliminated player\'s auto-observed field → that teammate', () => {
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: 'n:p2' }), 'p2');
assert.equal(bondOwnerId({ pub: PUB({ phase: 'COMBAT' }), myId: ME, fieldId: 'n:ai_4' }), 'ai_4', 'an AI teammate too');
});
test('an unknown field owner falls back to yourself', () => {
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: 'n:ghost' }), ME);
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: '' }), ME);
});
// 联防 with two helpers: the first helper holds the right half (runner sides), research 09 §3.1
const unite = (helpers, sides) => ({
pub: PUB({ phase: 'UNITE', fields: [{ fieldId: 'u', kind: 'unite', players: helpers, live: true }], unite: { helpers, leakers: ['p3'] } }),
field: { fieldId: 'u', kind: 'unite', players: helpers, sides, local: true },
});
test('联防 helper: the ‹ › half → the player on it ("你自己" → yours, "👁 name" → theirs); 全景 → yours', () => {
const { pub, field } = unite(['p2', 'p1'], { p2: 'R', p1: 'L' });
const at = (layer) => bondOwnerId({ pub, myId: ME, fieldId: 'u', field, layer, halves: true });
assert.equal(at('R'), 'p2');
assert.equal(at('L'), ME);
assert.equal(at('ALL'), ME);
});
test('联防 observer (a leaker, not on the field): a half → its helper; 全景 → helper 1 (two helpers) / the lone helper (one) — never yours', () => {
const two = unite(['p2', 'ai_4'], { p2: 'R', ai_4: 'L' });
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'L', halves: true }), 'ai_4');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'R', halves: true }), 'p2');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'ALL', halves: true }), 'p2', '全景: the first helper listed, not the leaker\'s own');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'ALL', halves: true, watched: 'ai_4' }), 'ai_4', '全景: the helper picked with 前往查看');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'L', halves: true, watched: 'p2' }), 'ai_4', 'a ‹ › half beats the pick');
// the avatar watch of the 联防 field before the pill is offered (halves = false): the picked helper, else helper 1
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'ALL', halves: false, watched: 'ai_4' }), 'ai_4');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: null, halves: false }), 'p2', 'no meta yet: the m.public listing');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, halves: false, watched: 'p1' }), 'p2', 'a pick not on that field is ignored');
const one = unite(['p2'], { p2: 'R' });
assert.equal(bondOwnerId({ pub: one.pub, myId: 'p3', fieldId: 'u', field: one.field, layer: 'ALL', halves: true }), 'p2', 'the lone helper\'s field');
assert.equal(bondOwnerId({ pub: one.pub, myId: 'p3', fieldId: 'u', field: one.field, layer: 'L', halves: true }), 'p2', 'an empty half (无人在家) → as 全景');
assert.equal(bondOwnerId({ pub: one.pub, myId: 'p2', fieldId: 'u', field: one.field, layer: 'L', halves: true }), 'p2', 'the lone helper himself');
});
test('Final Assault pair: the partner\'s half → the partner; own half / 全景 → yours; an eliminated spectator on 全景 → the picked / first player', () => {
const pub = PUB({ phase: 'FINAL_ASSAULT', fields: [{ fieldId: 'b1', kind: 'boss', players: ['p1', 'p3'], live: true }, { fieldId: 'b2', kind: 'boss', players: ['p2', 'ai_4'], live: true }] });
const field = { fieldId: 'b1', kind: 'boss', players: ['p1', 'p3'], sides: { p1: 'L', p3: 'R' }, local: true };
const at = (layer, myId = ME, halves = true) => bondOwnerId({ pub, myId, fieldId: 'b1', field, layer, halves });
assert.equal(at('R'), 'p3');
assert.equal(at('L'), ME);
assert.equal(at('ALL'), ME);
assert.equal(at('R', ME, false), ME, 'no ‹ › pill (a lone field / server-run combat): the field rule');
assert.equal(at('ALL', 'p2'), 'p1', 'eliminated spectator, not on the pair, 全景: the pair\'s first player — never your own');
assert.equal(at('R', 'p2'), 'p3', 'eliminated spectator on a half: that player');
assert.equal(bondOwnerId({ pub, myId: 'p2', fieldId: 'b1', field, layer: 'ALL', halves: true, watched: 'p3' }), 'p3', 'the player picked with 前往查看');
assert.equal(bondOwnerId({ pub, myId: 'p2', fieldId: 'b1', field, halves: false, watched: 'p3' }), 'p3', 'no ‹ › pill: the pick still decides');
assert.equal(bondOwnerId({ pub, myId: 'p1', fieldId: 'b1', field, halves: false, watched: 'p3' }), ME, 'a pair member on 全景: own (a member never picks a player of its own field)');
// a stale field meta (another field) never decides the half
assert.equal(bondOwnerId({ pub, myId: ME, fieldId: 'b1', field: { ...field, fieldId: 'b2' }, layer: 'R', halves: true }), ME);
});
test('a lone player\'s boss field watched by an eliminated player → that player', () => {
const pub = PUB({ phase: 'FINAL_ASSAULT', fields: [{ fieldId: 'b2', kind: 'boss', players: ['p3'], live: true }] });
assert.equal(bondOwnerId({ pub, myId: 'p2', fieldId: 'b2', field: null }), 'p3');
});
});
describe('§20.15 the picked teammate and the camera half', () => {
test('watchedPlayer: the row tapped while that watch is on screen, else null', () => {
assert.equal(watchedPlayer({ fieldId: 'u', playerId: 'p2' }, 'u'), 'p2');
assert.equal(watchedPlayer({ fieldId: 'u', playerId: 'p2' }, 'b1'), null, 'another field is watched now');
assert.equal(watchedPlayer({ fieldId: 'u', playerId: 'p2' }, null), null, 'back home');
assert.equal(watchedPlayer(null, 'u'), null);
assert.equal(watchedPlayer({ fieldId: 'u' }, 'u'), null);
});
test('playerLayer: the ‹ › half of a picked player on a two-half field; null for a normal / lone field or a stranger', () => {
const pub = PUB({ phase: 'UNITE' });
const two = { fieldId: 'u', kind: 'unite', players: ['p2', 'ai_4'], sides: { p2: 'R', ai_4: 'L' }, local: true };
assert.equal(playerLayer(two, pub, 'p3', 'p2'), 'R');
assert.equal(playerLayer(two, pub, 'p3', 'ai_4'), 'L');
assert.equal(playerLayer(two, pub, 'p3', 'p1'), null, 'not on that field');
const pair = { fieldId: 'b1', kind: 'boss', players: ['p1', 'p3'], sides: { p1: 'L', p3: 'R' }, local: true };
assert.equal(playerLayer(pair, pub, 'p2', 'p3'), 'R');
assert.equal(playerLayer({ fieldId: 'b2', kind: 'boss', players: ['p3'], sides: { p3: 'L' } }, pub, 'p2', 'p3'), null, 'a lone boss field has no halves');
assert.equal(playerLayer({ fieldId: 'n:p2', kind: 'normal', players: ['p2'] }, pub, ME, 'p2'), null);
assert.equal(playerLayer(null, pub, ME, 'p2'), null);
});
});
describe('§20.15 screenStrip: the game screen\'s state → whose bonds (every watch situation)', () => {
const helpers = ['p2', 'ai_4'];
const UNITE = PUB({ phase: 'UNITE', fields: [{ fieldId: 'u', kind: 'unite', players: helpers, live: true }], unite: { helpers, leakers: ['p1', 'p3'] } });
const uField = { fieldId: 'u', kind: 'unite', players: helpers, sides: { p2: 'R', ai_4: 'L' }, local: true };
const FA = PUB({ phase: 'FINAL_ASSAULT', players: PUB().players.map((p) => (p.playerId === ME ? { ...p, alive: false } : p)),
fields: [{ fieldId: 'b1', kind: 'boss', players: ['p2', 'p3'], live: true }, { fieldId: 'b2', kind: 'boss', players: ['ai_4'], live: true }] });
const b1 = { fieldId: 'b1', kind: 'boss', players: ['p2', 'p3'], sides: { p2: 'L', p3: 'R' }, local: true };
// what game.js passes: layers = cameraLayers(field) on the field on screen (also a watched one), [] otherwise
const S = (o) => screenStrip({ pub: o.pub, priv: PRIV, myId: ME, home: 'n:p1', ...o, layers: o.layers ?? (o.field ? cameraLayers(o.field, o.pub, ME) : []) });
test('prep: the own board, then the scouted teammate (前往查看), back home → own again', () => {
assert.deepEqual([S({ pub: PUB() }).ownerId, S({ pub: PUB() }).name], [ME, null]);
const scout = S({ pub: PUB(), watchingOther: true, watching: 'n:p2', who: { fieldId: 'n:p2', playerId: 'p2' } });
assert.deepEqual([scout.fieldId, scout.ownerId, scout.name], ['n:p2', 'p2', '阿米娅']);
assert.deepEqual(scout.bonds.map((b) => b.bondId), ['sargonShip', 'kjeragShip']);
});
test('own battle over → a teammate\'s battle watched; an eliminated player\'s auto-observed normal field', () => {
const pub = PUB({ phase: 'COMBAT', fields: [{ fieldId: 'n:p1', kind: 'normal', players: ['p1'], live: false }, { fieldId: 'n:p3', kind: 'normal', players: ['p3'], live: true }] });
const live = { p3: { lateranoShip: 130 } };
const w = S({ pub, combat: true, watchingOther: true, watching: 'n:p3', battleFieldId: 'n:p3', who: { fieldId: 'n:p3', playerId: 'p3' }, bondLayers: live });
assert.deepEqual([w.ownerId, w.bonds[0].layers], ['p3', 130], 'their live layers');
const auto = S({ pub, combat: true, battleFieldId: 'n:p3', bondLayers: live });
assert.equal(auto.ownerId, 'p3', 'eliminated: the runner\'s auto-observed field');
});
test('联防 leaker auto-observing (全景): helper 1, tagged — not the leaker\'s own bonds', () => {
const st = S({ pub: UNITE, combat: true, battleFieldId: 'u', field: uField, layer: 'ALL' });
assert.deepEqual([st.fieldId, st.ownerId, st.self, st.name], ['u', 'p2', false, '阿米娅']);
assert.equal(S({ pub: UNITE, combat: true, battleFieldId: 'u', field: uField, layer: 'L' }).ownerId, 'ai_4', 'the ‹ › pill on helper 2\'s half');
});
test('联防 leaker taps a helper\'s avatar (前往查看 → watching \'u\'): that helper, with or without the ‹ › pill', () => {
const watch = (pid, o = {}) => S({ pub: UNITE, combat: true, watchingOther: true, watching: 'u', battleFieldId: 'u', field: uField, who: { fieldId: 'u', playerId: pid }, ...o });
assert.equal(watch('ai_4', { layer: 'ALL' }).ownerId, 'ai_4', '全景 after the pick: the picked helper');
assert.equal(watch('ai_4', { layer: 'L' }).ownerId, 'ai_4', 'the camera moved to the picked helper\'s half');
assert.equal(watch('p2', { layer: 'R' }).ownerId, 'p2');
assert.equal(watch('ai_4', { layers: [], layer: 'ALL' }).ownerId, 'ai_4', 'no ‹ › pill (halves = false): the pick decides');
assert.equal(watch('ai_4', { layer: 'R' }).ownerId, 'p2', 'the viewer stepped the pill to the other half: that helper');
// a pick of another watch (stale) never decides
assert.equal(S({ pub: UNITE, combat: true, watchingOther: true, watching: 'u', battleFieldId: 'u', field: uField, layer: 'ALL', who: { fieldId: 'n:ai_4', playerId: 'ai_4' } }).ownerId, 'p2');
});
test('联防 helper: 全景 / own half → own; the partner\'s half → the partner', () => {
const at = (layer) => screenStrip({ pub: UNITE, priv: PRIV, myId: 'p2', home: 'u', combat: true, battleFieldId: 'u', field: uField, layers: cameraLayers(uField, UNITE, 'p2'), layer }).ownerId;
assert.deepEqual([at('ALL'), at('R'), at('L')], ['p2', 'p2', 'ai_4']);
});
test('Final Assault, eliminated spectator: auto-observed pair 全景 → the first player; a picked player → them; the lone field → its player', () => {
assert.equal(S({ pub: FA, combat: true, battleFieldId: 'b1', field: b1, layer: 'ALL' }).ownerId, 'p2', 'never the spectator\'s own');
assert.equal(S({ pub: FA, combat: true, battleFieldId: 'b1', field: b1, layer: 'R' }).ownerId, 'p3');
const pick = S({ pub: FA, combat: true, watchingOther: true, watching: 'b1', battleFieldId: 'b1', field: b1, layer: 'ALL', who: { fieldId: 'b1', playerId: 'p3' } });
assert.deepEqual([pick.ownerId, pick.name], ['p3', '华法琳']);
const lone = { fieldId: 'b2', kind: 'boss', players: ['ai_4'], sides: { ai_4: 'L' }, local: true };
assert.equal(S({ pub: FA, combat: true, watchingOther: true, watching: 'b2', battleFieldId: 'b2', field: lone, who: { fieldId: 'b2', playerId: 'ai_4' } }).ownerId, 'ai_4');
});
test('live layers only in the battle phases and SETTLE (prep shows the views)', () => {
const live = { p2: { sargonShip: 99 } };
assert.equal(S({ pub: PUB(), watchingOther: true, watching: 'n:p2', bondLayers: live }).bonds[0].layers, 40, 'prep: the view');
assert.equal(S({ pub: PUB({ phase: 'SETTLE' }), settle: true, battleFieldId: 'n:p2', bondLayers: live }).bonds[0].layers, 99, 'SETTLE: live');
});
});
describe('§20.15 the ‹ › pill of a shared field watched with 前往查看 (ui/combatHud.js)', () => {
const layers = [{ key: 'L', label: '华法琳', self: false, watch: true }, { key: 'ALL', label: '全景', self: false, watch: false }, { key: 'R', label: '阿米娅', self: false, watch: true }];
test('observing + halves: the ‹ › pill (the picked player\'s half) with 返回战场 — no separate observing pill', () => {
const v = CombatHud({ pub: {}, myId: ME, watching: 'u', hud: null, myDone: false, client: { observing: { name: '阿米娅' }, onBack() {}, layers, layer: 'R', onLayer() {} } });
const pill = [...walk(v)].find((x) => hasClass(x, 'chud__layers'));
assert.ok(pill && hasClass(pill, 'is-observing'));
assert.match(textOf(pill), /阿米娅/);
assert.match(textOf(pill), /返回战场/);
assert.ok(![...walk(v)].some((x) => hasClass(x, 'chud__observe')));
});
test('observing a normal field: the "👁 name" pill + 返回战场; a helper\'s own pill has no 返回战场', () => {
const v = CombatHud({ pub: {}, myId: ME, watching: 'n:p2', hud: null, myDone: true, client: { observing: { name: '阿米娅' }, onBack() {}, layers: [], layer: 'ALL' } });
const pill = [...walk(v)].find((x) => hasClass(x, 'chud__observe'));
assert.ok(pill && /阿米娅/.test(textOf(pill)) && /返回战场/.test(textOf(pill)));
const own = CombatHud({ pub: {}, myId: ME, watching: null, hud: null, myDone: false, client: { observing: null, onBack() {}, layers, layer: 'ALL', onLayer() {} } });
const p2 = [...walk(own)].find((x) => hasClass(x, 'chud__layers'));
assert.ok(p2 && !hasClass(p2, 'is-observing') && !/返回战场/.test(textOf(p2)));
});
});
describe('§20.15 the bonds: views + live layers', () => {
test('ownerBonds: your m.private list, a teammate\'s m.public list', () => {
assert.equal(ownerBonds({ pub: PUB(), priv: PRIV, myId: ME, ownerId: ME }), PRIV.bonds);
assert.deepEqual(ownerBonds({ pub: PUB(), priv: null, myId: ME, ownerId: ME }), PUB().players[0].bonds, 'no m.private yet: the public row');
assert.deepEqual(ownerBonds({ pub: PUB(), priv: PRIV, myId: ME, ownerId: 'p2' }).map((b) => b.bondId), ['sargonShip', 'kjeragShip']);
assert.deepEqual(ownerBonds({ pub: PUB(), priv: PRIV, myId: ME, ownerId: 'nobody' }), []);
});
test('withLiveLayers: the battle\'s absolute counts lift the view\'s, never lower them, never past 999; a new bond joins', () => {
const list = [bond('sargonShip', 2, 40, 1), bond('kjeragShip', 0, 7, 0)];
assert.equal(withLiveLayers(list, null), list, 'nothing to lay over: the same list');
assert.equal(withLiveLayers(list, {}), list);
const out = withLiveLayers(list, { sargonShip: 46, kjeragShip: 3, victoriaShip: 2 });
assert.deepEqual(out.map((b) => [b.bondId, b.layers]), [['sargonShip', 46], ['kjeragShip', 7], ['victoriaShip', 2]]);
assert.deepEqual(out[2], { bondId: 'victoriaShip', count: 0, active: false, tier: 0, layers: 2 });
assert.equal(list[0].layers, 40, 'the view is not mutated');
assert.equal(withLiveLayers(list, { sargonShip: 5000 })[0].layers, BOND_LAYER_CAP, 'capped at 999');
assert.equal(withLiveLayers(list, { sargonShip: 40 }), list, 'equal counts: the same list');
});
test('stripView / playerBonds: the owner, the name (null for yourself) and the list with the owner\'s live layers', () => {
const pub = PUB({ phase: 'COMBAT', fields: [{ fieldId: 'n:p1', kind: 'normal', players: ['p1'], live: false }, { fieldId: 'n:p2', kind: 'normal', players: ['p2'], live: true }] });
const live = { p1: { yanShip: 15 }, p2: { sargonShip: 44 } };
const own = stripView({ pub, priv: PRIV, myId: ME, fieldId: 'n:p1', live });
assert.deepEqual([own.ownerId, own.self, own.name], [ME, true, null]);
assert.equal(own.bonds[0].layers, 15, 'the own battle\'s live count');
assert.deepEqual(own.bonds[0].thresholds, [3, 6, 9], 'm.private extras kept');
const mate = stripView({ pub, priv: PRIV, myId: ME, fieldId: 'n:p2', live });
assert.deepEqual([mate.ownerId, mate.self, mate.name], ['p2', false, '阿米娅']);
assert.deepEqual(mate.bonds.map((b) => [b.bondId, b.layers]), [['sargonShip', 44], ['kjeragShip', 7]], 'THEIR bonds, THEIR live count');
assert.deepEqual(playerBonds({ pub, priv: PRIV, myId: ME, ownerId: 'p3', live }).map((b) => b.layers), [120]);
});
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list (no hand)', () => {
const field = { fieldId: 'n:p2', prep: true, units: [
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a' },
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok' },
{ id: 3, kind: 'op', side: 'ally', ownerId: 'p3', defId: 'chess_other' },
{ id: 4, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'enemy_1' },
] };
assert.deepEqual(ownerBoard(field, 'p2'), { board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [], temp: [] });
assert.equal(ownerBoard(null, 'p2'), null);
});
test('ownerBoard: under client-side combat the battle on screen\'s operators join (the runner\'s meta predates their deploy)', () => {
const meta = { fieldId: 'b1', local: true, units: [{ id: 90, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'boss' }] };
const ops = [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_09_a' }, { kind: 'op', ownerId: 'p2', defId: 'chess_char_1_03_a' }];
assert.deepEqual(ownerBoard(meta, 'p2'), { board: [], hand: [], temp: [] }, 'the meta alone: nobody in play');
assert.deepEqual(ownerBoard(meta, 'p2', ops).board.map((x) => x.id), ['chess_char_1_09_a', 'chess_char_1_03_a']);
assert.deepEqual(ownerBoard(null, 'p2', ops).board.length, 2, 'the battle alone');
assert.deepEqual(ownerBoard(meta, 'p3', ops).board, [], 'only that owner\'s');
const v = popupView({ open: { id: 'sargonShip', ownerId: 'p2' }, pub: PUB(), priv: PRIV, myId: ME, field: meta, units: ops });
assert.deepEqual(v.priv.board.map((x) => x.id), ['chess_char_1_09_a', 'chess_char_1_03_a'], 'popupView passes them on');
assert.equal(popupView({ open: { id: 'yanShip', ownerId: ME }, pub: PUB(), priv: PRIV, myId: ME, field: meta, units: ops }).priv, PRIV, 'your own: your pieces');
});
});
describe('§20.15 the strip and the popup say whose bonds they show', () => {
const ids = Object.keys(data.get('bonds') || {}).slice(0, 2);
test('a teammate\'s strip: the amber "👁 name" tag, data-owner, aria-label; your own: none', () => {
assert.equal(ids.length, 2, 'bonds.json loaded');
const list = ids.map((id, i) => bond(id, 2 + i, 10 * (i + 1), 1));
const other = BondStrip({ bonds: list, owner: '阿米娅', onOpen() {} });
const root = [...walk(other)].find((v) => hasClass(v, 'bstrip'));
assert.ok(root && hasClass(root, 'is-other'));
assert.equal(root.props['data-owner'], '阿米娅');
assert.equal(root.props['aria-label'], '阿米娅 的盟约');
const tag = [...walk(other)].find((v) => hasClass(v, 'bstrip__owner'));
assert.ok(tag, 'the owner tag');
assert.match(textOf(tag), /阿米娅/);
assert.deepEqual([...walk(other)].filter((v) => hasClass(v, 'bslot')).map((v) => v.props['data-bond']).sort(), [...ids].sort(), 'one slot per bond (data-bond)');
const mine = BondStrip({ bonds: list, owner: null, onOpen() {} });
const r2 = [...walk(mine)].find((v) => hasClass(v, 'bstrip'));
assert.ok(!hasClass(r2, 'is-other') && r2.props['data-owner'] == null);
assert.ok(![...walk(mine)].some((v) => hasClass(v, 'bstrip__owner')));
});
test('a teammate without bonds: "name 尚未激活盟约" with the tag', () => {
const v = BondStrip({ bonds: [], owner: 'AI·煌', onOpen() {} });
assert.match(textOf(v), /AI·煌 尚未激活盟约/);
assert.ok([...walk(v)].some((x) => hasClass(x, 'bstrip__owner')));
assert.match(textOf(BondStrip({ bonds: [], onOpen() {} })), /部署干员以激活盟约/);
});
test('the popup of a teammate\'s bond: "👁 name 的盟约", their count / layers, members from their board', () => {
const id = ids[0];
const rec = data.lookup('bonds', id);
const member = (rec.visibleMembers && rec.visibleMembers[0]) || rec.members[0];
const v = BondPopup({ bondId: id, entry: bond(id, 2, 333, 1), priv: { board: [{ kind: 'chess', id: member }], hand: [], temp: [] }, owner: '阿米娅', onClose() {} });
const root = [...walk(v)].find((x) => hasClass(x, 'bpop'));
assert.ok(hasClass(root, 'is-other') && root.props['data-owner'] === '阿米娅');
assert.match(root.props['aria-label'], /^阿米娅 的盟约:/);
assert.match(textOf([...walk(v)].find((x) => hasClass(x, 'bpop__owner'))), /阿米娅.*的盟约/);
assert.match(textOf([...walk(v)].find((x) => hasClass(x, 'bpop__facts'))), /333/);
const on = [...walk(v)].filter((x) => hasClass(x, 'bpop__member') && hasClass(x, 'is-on'));
assert.equal(on.length, 1, 'the teammate\'s operator on the field is the one member in play');
const own = BondPopup({ bondId: id, entry: bond(id, 2, 5, 1), priv: PRIV, onClose() {} });
assert.ok(![...walk(own)].some((x) => hasClass(x, 'bpop__owner')));
});
});
test('§20.15 wiring: the game screen feeds the strip, the popup and the detail card from screenStrip (no viewer data for a teammate)', () => {
const src = read('public/js/screens/game.js');
assert.match(src, /const strip = screenStrip\(\{[^}]*field, layers, layer, who: watchWho, bondLayers:/, 'the field on screen, the ‹ › layers, the picked teammate');
assert.match(src, /<\$\{BondStrip\} bonds=\$\{stripBonds\}[^>]*owner=\$\{strip\.name\}/);
// the popup carries the player it was opened for: the strip's owner from the strip, the unit owner from a card's chip
assert.match(src, /const bondPop = popupView\(\{ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers \}\)/);
assert.match(src, /battleRunner\.ownerOps\(bondOpen\.ownerId, field\.fieldId\)/, 'a teammate\'s members in play: the battle on screen\'s operators');
assert.match(src, /<\$\{BondPopup\} bondId=\$\{bondPop\.bondId\} entry=\$\{bondPop\.entry\} priv=\$\{bondPop\.priv\}[^>]*owner=\$\{bondPop\.name\}/);
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
assert.match(src, /onMember=\$\{\(id\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId \}\)\}/, 'a member card keeps the popup\'s player');
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');
assert.match(src, /requestWatch\(t\.fieldId, p\.playerId\)/, 'a team-row pick remembers the player');
assert.match(src, /\(!watchingOther \|\| field\.fieldId === watching\) \? cameraLayers\(field, pub, myId\)/, 'the ‹ › pill on a watched shared field');
// a reload while watching a teammate's battle: the resent field becomes the watched one again (observing pill, 返回战场)
assert.match(src, /const r = resumedWatch\(battleState, \{ pub, myId, alive, watching, seen: seenBattleRef\.current \}\);\s*seenBattleRef\.current = r\.seen;\s*if \(r\.fieldId\) setWatching\(r\.fieldId\);/);
});
describe('§20.15 the popup of a card\'s bond chip: the UNIT owner\'s bond (a shared field shows both halves\' units)', () => {
// a Final Assault pair: p1 (the viewer, left) and p2 (the partner, right) on 'b1'
const pub = PUB({ phase: 'FINAL_ASSAULT', fields: [{ fieldId: 'b1', kind: 'boss', players: ['p1', 'p2'], live: true }, { fieldId: 'b2', kind: 'boss', players: ['p3', 'ai_4'], live: true }] });
const units = [
{ id: 11, uid: 1, ownerId: 'p1', side: 'player', kind: 'op', defId: 'chess_char_1_10_a' },
{ id: 21, uid: 1, ownerId: 'p2', side: 'player', kind: 'op', defId: 'chess_char_1_12_a' },
{ id: 22, uid: 2, ownerId: 'p2', side: 'player', kind: 'op', defId: 'chess_char_1_09_a' },
{ id: 90, side: 'enemy', defId: 'enemy_1007_slime' },
];
const b1 = { fieldId: 'b1', kind: 'boss', players: ['p1', 'p2'], sides: { p1: 'L', p2: 'R' }, local: true, units };
const unit = (id) => ({ kind: 'unit', unitId: id, unit: units.find((u) => u.id === id) });
test('detailBondOwner: a unit → its owner (a known player); your piece → yours; a member card → the popup\'s player; else the strip\'s', () => {
assert.equal(detailBondOwner(unit(21), { pub, myId: ME, stripOwnerId: ME }), 'p2', '全景 (strip = yours): the partner\'s unit is the partner\'s');
assert.equal(detailBondOwner(unit(11), { pub, myId: ME, stripOwnerId: 'p2' }), ME, 'the partner\'s half (strip = theirs): your own unit is yours');
assert.equal(detailBondOwner(unit(90), { pub, myId: ME, stripOwnerId: 'p2' }), 'p2', 'an enemy (no owner): the strip\'s');
assert.equal(detailBondOwner({ kind: 'unit', unit: { id: 5, ownerId: 'ghost', defId: 'x' } }, { pub, myId: ME, stripOwnerId: 'p2' }), 'p2', 'an unknown owner: the strip\'s');
assert.equal(detailBondOwner({ kind: 'unit', unitId: 99, unit: null }, { pub, myId: ME, stripOwnerId: 'p3' }), 'p3', 'an unresolved unit: the strip\'s');
assert.equal(detailBondOwner({ kind: 'piece', uid: 3 }, { pub, myId: ME, stripOwnerId: 'p2' }), ME, 'your own piece (board / bench)');
assert.equal(detailBondOwner({ kind: 'chess', id: 'c', owner: 'p2' }, { pub, myId: ME, stripOwnerId: ME }), 'p2', 'a member card of the partner\'s popup');
assert.equal(detailBondOwner({ kind: 'chess', id: 'c' }, { pub, myId: ME, stripOwnerId: 'p3' }), 'p3', 'a shop / drawer card: the strip\'s');
assert.equal(detailBondOwner(null, { pub, myId: ME, stripOwnerId: null }), ME);
});
test('toggleBond: the same bond of the same player closes; another player\'s or another bond switches', () => {
const a = toggleBond(null, 'sargonShip', 'p2', 'detail');
assert.deepEqual(a, { id: 'sargonShip', ownerId: 'p2', from: 'detail' });
assert.equal(toggleBond(a, 'sargonShip', 'p2', 'strip'), null);
assert.deepEqual(toggleBond(a, 'sargonShip', ME, 'strip'), { id: 'sargonShip', ownerId: ME, from: 'strip' }, 'the same bond of the viewer: their popup instead');
assert.deepEqual(toggleBond(a, 'kjeragShip', 'p2', 'detail'), { id: 'kjeragShip', ownerId: 'p2', from: 'detail' });
});
test('popupView: the partner\'s bond (entry + live layers, name, their operators as members), never the viewer\'s', () => {
const v = popupView({ open: { id: 'sargonShip', ownerId: 'p2', from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: { p2: { sargonShip: 55 }, p1: { sargonShip: 3 } } });
assert.equal(v.ownerId, 'p2');
assert.equal(v.self, false);
assert.equal(v.name, '阿米娅');
assert.deepEqual([v.entry.bondId, v.entry.count, v.entry.active, v.entry.layers], ['sargonShip', 2, true, 55], 'the partner\'s count / tier, their live layers');
assert.notEqual(v.priv, PRIV, 'not the viewer\'s pieces');
assert.deepEqual(v.priv.board.map((x) => x.id).sort(), ['chess_char_1_09_a', 'chess_char_1_12_a'], 'the partner\'s operators on the field on screen');
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'their hand is never sent');
});
test('popupView: your own bond (m.private entry with thresholds, your pieces, no name); a bond not listed → no entry; nothing open → null', () => {
const own = popupView({ open: { id: 'yanShip', ownerId: ME, from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: null });
assert.deepEqual([own.self, own.name, own.priv], [true, null, PRIV]);
assert.deepEqual(own.entry.thresholds, [3, 6, 9], 'the m.private entry');
assert.equal(popupView({ open: { id: 'lateranoShip', ownerId: 'p2' }, pub, priv: PRIV, myId: ME, field: b1 }).entry, null, 'the partner has no such bond');
assert.equal(popupView({ open: null, pub, priv: PRIV, myId: ME }), null);
assert.equal(popupView({ open: { id: 'yanShip' }, pub, priv: PRIV, myId: ME }).ownerId, ME, 'no owner: yours');
const capped = popupView({ open: { id: 'sargonShip', ownerId: 'p2' }, pub, priv: PRIV, myId: ME, field: b1, live: { p2: { sargonShip: 5000 } } });
assert.equal(capped.entry.layers, BOND_LAYER_CAP, 'never above the 999 cap');
assert.equal(popupView({ open: { id: 'sargonShip', ownerId: 'p2' }, pub, priv: PRIV, myId: ME, field: null }).priv, null, 'no field on screen: no members');
});
test('the reported case end to end: on 全景 the strip is yours, the partner\'s unit chip opens the PARTNER\'s popup — and back', () => {
const layers = cameraLayers(b1, pub, ME);
const S = (layer) => screenStrip({ pub, priv: PRIV, myId: ME, combat: true, battleFieldId: 'b1', home: 'b1', field: b1, layers, layer });
const all = S('ALL');
assert.deepEqual([all.ownerId, all.self], [ME, true], '全景: your strip');
const owner = detailBondOwner(unit(21), { pub, myId: ME, stripOwnerId: all.ownerId });
const chips = owner === all.ownerId ? all.bonds : playerBonds({ pub, priv: PRIV, myId: ME, ownerId: owner });
assert.deepEqual(chips.map((b) => b.bondId), ['sargonShip', 'kjeragShip'], 'the card\'s chips: the partner\'s bonds');
const pop = popupView({ open: toggleBond(null, 'sargonShip', owner, 'detail'), pub, priv: PRIV, myId: ME, field: b1 });
assert.deepEqual([pop.name, pop.entry.count, pop.entry.layers], ['阿米娅', 2, 40], 'the popup: the partner\'s, labelled');
// the partner's half (strip = theirs): your own unit's chip opens YOUR popup, unlabelled
const right = S('R');
assert.equal(right.ownerId, 'p2');
const mine = detailBondOwner(unit(11), { pub, myId: ME, stripOwnerId: right.ownerId });
const pop2 = popupView({ open: toggleBond(null, 'yanShip', mine, 'detail'), pub, priv: PRIV, myId: ME, field: b1 });
assert.deepEqual([pop2.self, pop2.name, pop2.entry.layers], [true, null, 12]);
});
});