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:
1 parent
620f88c0d8
commit
82c64086d5
21 files changed
+2087
-57
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user