Files
ark-stronghold-protocol/test/ui/watch-bonds.e2e.test.js
T
sganggs 82c64086d5 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)
2026-10-02 12:11:48 +08:00

626 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
});
});