feedback1 draft-info: the strategy draft shows the briefing's disabled bonds and banned operators again (GitHub issue #8 item 1)

One component, public/js/ui/matchInfo.js: matchInfoModel (the drawn set D and
the mode's inactive bonds via disabledBondSets, the briefing's bond order,
banned operators by tier, bannedPerBond) and the briefing's bond rows, legend
and banned-operator grid. The briefing renders it; the band draft opens it
read-only in a dialog (查看禁用盟约与干员 under the order list; 关闭, a tap
outside or Esc close it; its footer repeats the turn and the countdown; a turn
change closes it, the end of the draft unmounts it); the in-game 本局信息 tab
reads the same model, so its banned operators are listed by tier too. In the
dialog every size has a px floor, so phones keep readable text and a
scrollable body.

Tests: test/ui/match-info.test.js; opt-in test/ui/match-info.e2e.test.js
(SP_E2E=1: solo 标准 on desktop, co-op 绝境 with a desktop and a phone).
This commit is contained in:
sganggs committed 2026-10-03 16:08:32 +08:00
1 parent 842e1e13c4
commit 7188e0af89
8 files changed
+847 -56

No files matched your search

+2
View File
@@ -1,6 +1,8 @@
/* ==========================================================================================
screens/briefing.css — INFO_CHECK briefing (js/screens/briefing.js) + the pre-game step header
shared with the band draft (js/ui/matchChrome.js StepHeader). Research 06 §4.1 / §11.6.
The bond rows, the legend and the banned operators (.brief-bonds / .brief-legend / .brief-banned) are
js/ui/matchInfo.js MatchInfo — also shown by the band draft's 本局信息 dialog (css/screens/draft.css .minfo-dlg).
========================================================================================== */
/* ---- shared pre-game backdrop + header ---- */
+49 -1
View File
@@ -1,6 +1,7 @@
/* ==========================================================================================
screens/draft.css — BAND_DRAFT strategy pick (js/screens/bandDraft.js). Research 06 §4.2:
draft order on the left, portrait grid, detail pane with 初始生命值 and 确认选择 / 跳过.
draft order on the left, portrait grid, detail pane with 初始生命值 and 确认选择 / 跳过; the 本局信息 entry under
the order list and its dialog (js/ui/matchInfo.js MatchInfoDialog, GitHub issue #8 item 1).
========================================================================================== */
.draft__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 3.9rem 1fr 5rem; gap: .3rem; padding: .3rem .42rem .36rem; }
@@ -75,3 +76,50 @@
box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .6);
}
.draft-detail__status--taken { color: var(--amber); }
/* ---- 本局信息 (GitHub issue #8 item 1): the entry under the order list and the read-only dialog ----------------------
The dialog shows the briefing's own blocks (.brief-bonds / .brief-legend / .brief-banned, css/screens/briefing.css)
at the briefing's size on desktop; every size is max(design rem, px floor), so on a phone (1rem = 40 px) the text
stays at ≥ 9–15 px and the dialog's body scrolls inside the screen (756×366, 844×390) while the header and the
footer (the turn's countdown, 关闭) stay in view. */
.draft-order__info {
margin-top: .04rem; justify-content: flex-start; padding: 0 .16rem;
height: max(.5rem, 28px); font-size: max(.18rem, 10px);
--btn-bd: var(--mint-800); --btn-bg: rgba(78, 216, 175, .06); --btn-bg-hover: rgba(78, 216, 175, .14);
}
.draft-order__info .btn__icon { color: var(--mint-400); }
.modal__box.minfo-dlg {
width: min(max(12rem, 600px), calc(100vw - .6rem - var(--sa-l, 0px) - var(--sa-r, 0px)));
max-height: calc(100vh - .6rem);
max-height: calc(100svh - .6rem);
}
.minfo-dlg .modal__head { padding: .16rem .3rem .02rem; }
.minfo-dlg .modal__head .micro { font-size: max(.11rem, 8px); }
.minfo-dlg .modal__title { font-size: max(.26rem, 15px); }
.minfo-dlg .modal__body { display: flex; flex-direction: column; gap: max(.16rem, 6px); padding: .1rem .3rem .2rem; }
.minfo-dlg .modal__actions { align-items: center; padding: .12rem .3rem; }
.minfo-dlg .modal__actions .btn { height: max(.5rem, 30px); font-size: max(.18rem, 12px); }
.minfo-dlg__status { margin-right: auto; min-width: 0; }
.minfo-dlg__turn { display: inline-flex; align-items: center; gap: .06rem; font-size: max(.17rem, 11px); font-weight: 700; color: var(--gold); white-space: nowrap; }
.minfo-dlg__turn .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); }
.minfo-dlg__turn b { margin-left: .04rem; font-size: max(.22rem, 14px); }
.minfo-dlg__turn.is-warn { color: var(--orange-hi); }
.minfo-dlg__turn.is-mint { color: var(--mint-400); }
.minfo-dlg__turn.is-dim { color: var(--text-lo); }
.minfo-dlg .brief-bonds, .minfo-dlg .brief-banned { padding: max(.16rem, 6px) max(.2rem, 8px) max(.2rem, 8px); }
.minfo-dlg .brief-h { font-size: max(.21rem, 12px); margin-bottom: max(.12rem, 5px); }
.minfo-dlg .brief-h::before { width: max(.06rem, 3px); height: max(.2rem, 11px); }
.minfo-dlg .brief-bonds__row { gap: max(.16rem, 6px) max(.1rem, 4px); }
.minfo-dlg .brief-bond { width: max(1rem, 50px); }
.minfo-dlg .brief-bond .bond { --disc: max(.66rem, 32px); }
.minfo-dlg .brief-bond .bond__name { font-size: max(.16rem, 10px); }
.minfo-dlg .brief-bond__ban { font-size: max(.12rem, 9px); padding: 0 max(.05rem, 2px); }
.minfo-dlg .brief-bond__ban .icon { width: max(.12rem, 9px); height: max(.12rem, 9px); }
.minfo-dlg .brief-legend { margin-top: 0; font-size: max(.14rem, 10px); }
.minfo-dlg .brief-legend__off { width: max(.16rem, 9px); height: max(.16rem, 9px); }
.minfo-dlg .brief-legend__ban { width: max(.2rem, 12px); height: max(.18rem, 11px); }
.minfo-dlg .brief-legend__ban .icon { width: max(.12rem, 8px); height: max(.12rem, 8px); }
.minfo-dlg .brief-banned__n { font-size: max(.22rem, 13px); }
.minfo-dlg .brief-banned__grid { grid-template-columns: repeat(auto-fill, minmax(max(.6rem, 34px), 1fr)); gap: max(.08rem, 3px); }
.minfo-dlg .brief-banned__grid .uthumb { --ut: max(.6rem, 34px); }
.minfo-dlg .t-dim { font-size: max(.16rem, 10px); }
+36
View File
@@ -9,11 +9,17 @@
// is reported (g.bandFocus) and the server assigns it while it is free, else 「华法琳」, else the first free strategy
// (timeoutBand). It starts on that default, so the tip under the order list always names what a timeout gives.
// Solo, and a co-op match with a single human (the server's soloUntimed: draft.untimed): no clock at all.
// 本局信息 (GitHub issue #8 item 1, "选策略时没法返回查看禁用的干员和盟约"): 查看禁用盟约与干员 under the order list opens the
// briefing's bond rows, legend and 本局禁用干员 again, read-only (ui/matchInfo.js MatchInfoDialog — the very blocks of the
// briefing). The draft runs on underneath: its status line repeats the current turn and the countdown (draftInfoStatus),
// a turn change (a pick, a skip, a turn that runs out, an AI pick) closes it, the end of the draft unmounts it, and it
// never touches the highlighted band or the buttons.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { MatchInfoDialog, matchInfoModel } from '../ui/matchInfo.js';
import { actions, act } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
import { useStore } from '../store.js';
@@ -124,6 +130,21 @@ export function draftClock(pub) {
return { deadline, total: Number(d.turnSeconds) > 0 ? Number(d.turnSeconds) : null };
}
/**
* The status line of the 本局信息 dialog: what the draft does while the dialog covers it — my pick, else whose turn it is
* with the turn's seconds left (the step header's number; none when untimed), warning at ≤ 10 s like the countdown.
* @param {{ myPick?: string|null, pickName?: string|null, myTurn: boolean, turnName?: string|null, secs?: number|null,
* waiting?: boolean }} o waiting: teammates still have to pick after my pick
* @returns {{ text: string, secs: number|null, tone: 'mint'|'gold'|'warn'|'dim' }}
*/
export function draftInfoStatus({ myPick = null, pickName = null, myTurn, turnName = null, secs = null, waiting = false }) {
if (myPick) return { text: `已选择「${pickName || ''}」${waiting ? ',等待其他博士' : ''}`, secs: null, tone: 'mint' };
const s = Number.isFinite(secs) ? Math.max(0, Math.round(secs)) : null;
const tone = s != null && s <= 10 ? 'warn' : 'gold';
if (myTurn) return { text: '轮到你决策', secs: s, tone };
return { text: turnName ? `${turnName} 决策中` : '等待轮到你', secs: s, tone: s != null ? tone : 'dim' };
}
/** BAND_DRAFT screen. */
export function BandDraftScreen() {
const pub = useStore((s) => s.match.public);
@@ -135,6 +156,7 @@ export function BandDraftScreen() {
const [busy, setBusy] = useState(null);
const [exit, setExit] = useState(false);
const [skipped, setSkipped] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const mode = gd.config?.modes?.[pub?.modeId];
const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single');
@@ -163,6 +185,10 @@ export function BandDraftScreen() {
}, [bands.length, myPick, myTurn, takenKey]);
// "your turn" cue
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
// the 本局信息 dialog never outlives the turn it was opened in: a turn change (a pick, a skip, a turn that ran out, an
// AI pick) or my pick closes it, so whoever's turn begins sees the draft
const turnKey = `${draft.turnPid || ''}|${myPick || ''}`;
useEffect(() => { setInfoOpen(false); }, [turnKey]);
// one countdown (user playtest #4 item 4): the current turn's — m.public.deadline, the same clock as the picker's row
const clock = solo ? null : draftClock(pub);
@@ -198,6 +224,10 @@ export function BandDraftScreen() {
// the picker's row shows the step header's number (both read the one turn deadline)
const turnSecs = clock ? secondsLeft(clock.deadline) : null;
const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null;
// 本局信息: the briefing's blocks (built only while the dialog is open) and the draft's state under it
const info = infoOpen ? matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode }) : null;
const infoStatus = infoOpen ? draftInfoStatus({ myPick, pickName: myPick ? gd.band(myPick)?.name : null, myTurn, turnName, secs: turnSecs,
waiting: !solo && !draft.done }) : null;
return html`<div class="screen draft">
<div class="brief__bg" aria-hidden="true"></div>
@@ -226,6 +256,8 @@ export function BandDraftScreen() {
</span>
</div>`;
})}
<${Button} variant="secondary" icon="search" block=${true} class="draft-order__info" data-testid="match-info-open"
aria-haspopup="dialog" onClick=${() => setInfoOpen(true)}>查看禁用盟约与干员<//>
${!solo ? html`<p class="draft-order__tip" data-testid="draft-tip">${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}</p>` : null}
</aside>
@@ -270,5 +302,9 @@ export function BandDraftScreen() {
</aside>
</main>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
<${MatchInfoDialog} open=${infoOpen} onClose=${() => setInfoOpen(false)} model=${info}
status=${infoStatus ? html`<span class=${cx('minfo-dlg__turn', `is-${infoStatus.tone}`)}>
<${Icon} name=${infoStatus.tone === 'mint' ? 'check' : 'hourglass'} />${infoStatus.text}${infoStatus.secs != null ? html`<b class="num">${infoStatus.secs}s</b>` : null}
</span>` : null} />
</div>`;
}
+11 -47
View File
@@ -1,44 +1,22 @@
// Briefing — INFO_CHECK "1/2 确认本局信息" (research 06 §4.1, D1): enemy leader (silhouette, name,
// abilities), stage (+ its pool: 战场固定 / 战场随机(共N张)), 特训敌人 factions (icon, name, description), 核心盟约 / 附加盟约 rows of bond discs
// (disabled bonds greyed with the banned-member badge), 本局禁用干员 avatars, difficulty tag, the ready
// count x/N with person pips, the 准备就绪 button (g.infoReady) and the countdown.
// abilities), stage (+ its pool: 战场固定 / 战场随机(共N张)), 特训敌人 factions (icon, name, description), the difficulty
// tag, the ready count x/N with person pips, the 准备就绪 button (g.infoReady) and the countdown. The right column — 核心盟约
// / 附加盟约 rows of bond discs (disabled bonds greyed with the banned-member badge), the legend and the 本局禁用干员
// avatars — is ui/matchInfo.js MatchInfo, the same blocks the strategy draft's 本局信息 dialog shows (screens/bandDraft.js).
import { useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, BondDisc, Tooltip } from '../ui/components.js';
import { useGameData, Img, UnitThumb, RichText } from '../ui/gameComponents.js';
import { html, Button, Icon, MicroLabel } from '../ui/components.js';
import { useGameData, Img, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
import { LoadoutButton } from './loadout.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, bannedPerBond, sortedPlayers, phaseTotalSeconds, disabledBondSets, briefingBondTip } from '../ui/gameLogic.js';
import { bondIconUrl, enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
import { useStore } from '../store.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
function BondRow({ title, micro, bonds, sets, perBond }) {
const m = data.get('assets');
return html`<div class="brief-bonds">
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
<div class="brief-bonds__row">
${bonds.map((b) => {
// greyed: the drawn set D (still activatable, roster incomplete) and the mode's static inactive bonds
const state = sets.off.has(b.bondId) ? 'off' : sets.drawn.has(b.bondId) ? 'drawn' : null;
const off = !!state;
const bannedN = perBond.get(b.bondId) || 0;
const tip = briefingBondTip(b.name, state, bannedN);
return html`<${Tooltip} key=${b.bondId} text=${tip}>
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
${bannedN > 0 ? html`<span class="brief-bond__ban num"><${Icon} name="user" />${bannedN}</span>` : null}
</div>
<//>`;
})}
</div>
</div>`;
}
/** INFO_CHECK screen. */
export function BriefingScreen() {
const pub = useStore((s) => s.match.public);
@@ -53,13 +31,7 @@ export function BriefingScreen() {
const me = players.find((p) => p.playerId === myId);
const readyN = players.filter((p) => p.ready).length;
const mode = gd.config?.modes?.[pub.modeId];
const sets = disabledBondSets(pub, mode?.inactiveBondIds);
const bonds = gd.list('bonds').sort((a, b) => (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || (a.identifier ?? 0) - (b.identifier ?? 0));
const core = bonds.filter((b) => b.isCore);
const addon = bonds.filter((b) => !b.isCore);
const banned = (Array.isArray(pub.bannedChess) ? pub.bannedChess : []).filter((id) => gd.chess(id));
banned.sort((a, b) => (gd.chess(a)?.tier ?? 0) - (gd.chess(b)?.tier ?? 0));
const perBond = bannedPerBond(bonds, banned);
const info = matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode });
const boss = pub.bossId ? gd.boss(pub.bossId) : null;
const bossEnemy = boss ? gd.enemy(boss.enemyKey) : null;
const stage = pub.stageId ? gd.stage(pub.stageId) : null;
@@ -111,15 +83,7 @@ export function BriefingScreen() {
</div>
</section>
<section class="brief__right">
<${BondRow} title="核心盟约" micro="CORE BONDS" bonds=${core} sets=${sets} perBond=${perBond} />
<${BondRow} title="附加盟约" micro="ADD-ON BONDS" bonds=${addon} sets=${sets} perBond=${perBond} />
<p class="brief-legend"><span class="brief-legend__off"></span>灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)${sets.off.size ? ',或本模式禁用' : ''} · <span class="brief-legend__ban"><${Icon} name="user" /></span>该盟约中无法出现的干员数</p>
<div class="brief-banned">
<h3 class="brief-h"><span>本局禁用干员</span><${MicroLabel}>BANNED OPERATORS</${MicroLabel}><b class="num brief-banned__n">${banned.length}</b></h3>
${banned.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)}
</div>` : html`<p class="t-dim">本局没有禁用干员</p>`}
</div>
<${MatchInfo} model=${info} />
</section>
</main>
<footer class="brief__foot">
+9 -8
View File
@@ -1,13 +1,16 @@
// 本局信息 dialog under the top bar (opened by the left 🔍, official btn_check_player / autochess_hud_player_info_dialog,
// research 09 §2.1) with two tabs:
// 本局信息 — own strategy, stage, disabled bonds with banned-member counts and the banned operator list (default);
// 本局信息 — own strategy, stage, disabled bonds with banned-member counts and the banned operator list (default) —
// the same bonds, states and banned operators (by tier) as the briefing and the strategy draft's 本局信息
// dialog: ui/matchInfo.js matchInfoModel, drawn here as compact chips and avatars;
// 敌方情报 — the secondary enemy list: m.private.nextEnemies (icons, names, counts, tags, 特训 faction tags) + this
// match's factions and the enemy leader on boss rounds. The primary enemy preview is the pen on the board
// (the right 🔍▶▶ pans the camera there; tapping an enemy in the pen opens its detail card) — research 09 §6.2.
import { html, Icon, Tabs, MicroLabel } from './components.js';
import { Img, UnitThumb, BondGlyph, BandIcon, RichText, GIcon } from './gameComponents.js';
import { groupEnemies, factionTypes, bannedPerBond, disabledBondSets, briefingBondTip } from './gameLogic.js';
import { groupEnemies, factionTypes, briefingBondTip } from './gameLogic.js';
import { matchInfoModel } from './matchInfo.js';
import { factionIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -57,12 +60,10 @@ function EnemiesTab({ pub, priv, onEnemy }) {
}
function InfoTab({ pub, priv, onChess }) {
const sets = disabledBondSets(pub, data.get('config')?.modes?.[pub?.modeId]?.inactiveBondIds);
const disabled = new Set([...sets.drawn, ...sets.off]);
const stateOf = (id) => (sets.off.has(id) ? 'off' : sets.drawn.has(id) ? 'drawn' : null);
const bonds = data.list('bonds');
const banned = Array.isArray(pub?.bannedChess) ? pub.bannedChess : [];
const perBond = bannedPerBond(bonds, banned);
const { bonds, banned, perBond, stateOf } = matchInfoModel(pub, {
bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: data.get('config')?.modes?.[pub?.modeId],
});
const disabled = new Set(bonds.filter((b) => stateOf(b.bondId)).map((b) => b.bondId));
const band = priv?.bandId ? data.lookup('bands', priv.bandId) : null;
const stage = pub?.stageId ? data.lookup('stages', pub.stageId) : null;
const withBans = bonds.filter((b) => disabled.has(b.bondId) || (perBond.get(b.bondId) || 0) > 0)
+125
View File
@@ -0,0 +1,125 @@
// Match info — the bonds and banned operators of a match (research 06 §4.1 "1/2 确认本局信息", research 01 A2 / 06
// addendum D), one implementation for every place that shows them, so they never drift:
// * the INFO_CHECK briefing's right column (screens/briefing.js);
// * the strategy draft's 本局信息 dialog (screens/bandDraft.js; GitHub issue #8 item 1 "选策略时没法返回查看禁用的干员和盟约":
// players choose a strategy partly by the bonds and operators the match leaves them);
// * the data of the in-game 本局信息 tab (ui/enemyDrawer.js InfoTab, its own compact chips and avatars).
// matchInfoModel (pure) derives everything from m.public and the static data: the two kinds of greyed bonds
// (gameLogic disabledBondSets over config.modes[modeId].inactiveBondIds — the drawn set D "部分盟约所含干员阵容不完整",
// still activatable through other bonds' operators or items, and the mode's static inactive bonds "本局禁用", e.g.
// 标准模拟's 10), every bond in the briefing order (bondOrder, then identifier) split into 核心 / 附加, the banned
// operators (m.public bannedChess, known chess only) sorted by tier, and the banned-member count per bond (gameLogic
// bannedPerBond). The blocks are hookless (unit-tested by calling them): the 核心盟约 / 附加盟约 rows of bond discs —
// greyed with the ✕, the red banned-member badge, the briefingBondTip tooltip — the legend and the 本局禁用干员 grid.
// MatchInfoDialog shows the same blocks read-only in a components.js Modal (关闭, a tap outside or Esc close it) with a
// status line from its caller (the draft's turn and countdown, so the running clock stays in view).
// Styles: css/screens/briefing.css (.brief-*), css/screens/draft.css (.minfo-dlg).
import { html, Button, Icon, MicroLabel, BondDisc, Tooltip, Modal } from './components.js';
import { UnitThumb } from './gameComponents.js';
import { bannedPerBond, disabledBondSets, briefingBondTip } from './gameLogic.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* @typedef {{ sets: { drawn: Set<string>, off: Set<string> }, stateOf: (bondId: string) => 'off'|'drawn'|null,
* bonds: any[], core: any[], addon: any[], banned: string[], perBond: Map<string, number> }} MatchInfoModel
*/
/**
* Everything the match-info blocks show.
* @param {any} pub m.public (drawnDisabledBonds / disabledBonds, bannedChess)
* @param {{ bonds?: any[], chess?: (id: string) => any, mode?: any }} [src]
* bonds: bonds.json records (data.list('bonds')); chess: the chess lookup; mode: config.json modes[pub.modeId]
* @returns {MatchInfoModel}
* stateOf: 'off' = the mode never activates the bond (本局禁用), 'drawn' = in the drawn set D (阵容不完整), null = normal;
* banned: the banned chess ids the data knows, by tier (ties keep the server's order); perBond: bondId → banned members
*/
export function matchInfoModel(pub, { bonds = [], chess = () => null, mode = null } = {}) {
const sets = disabledBondSets(pub, mode?.inactiveBondIds);
const stateOf = (id) => (sets.off.has(id) ? 'off' : sets.drawn.has(id) ? 'drawn' : null);
const list = (Array.isArray(bonds) ? bonds : []).filter((b) => !!b && typeof b === 'object' && typeof b.bondId === 'string')
.sort((a, b) => (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || (a.identifier ?? 0) - (b.identifier ?? 0));
const tierOf = (id) => chess(id)?.tier ?? 0;
const banned = (Array.isArray(pub?.bannedChess) ? pub.bannedChess : []).filter((id) => typeof id === 'string' && !!chess(id))
.sort((a, b) => tierOf(a) - tierOf(b));
return {
sets, stateOf, bonds: list, core: list.filter((b) => b.isCore), addon: list.filter((b) => !b.isCore),
banned, perBond: bannedPerBond(list, banned),
};
}
/**
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
* which (briefingBondTip).
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
*/
export function MatchBondRow({ title, micro, bonds, model }) {
const m = data.get('assets');
return html`<div class="brief-bonds">
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
<div class="brief-bonds__row">
${bonds.map((b) => {
const state = model.stateOf(b.bondId);
const off = !!state;
const bannedN = model.perBond.get(b.bondId) || 0;
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')} data-bond=${b.bondId}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
${bannedN > 0 ? html`<span class="brief-bond__ban num"><${Icon} name="user" />${bannedN}</span>` : null}
</div>
<//>`;
})}
</div>
</div>`;
}
/**
* The legend under the bond rows ("或本模式禁用" only when the mode switches bonds off).
* @param {{ model: MatchInfoModel }} props
*/
export function MatchLegend({ model }) {
return html`<p class="brief-legend"><span class="brief-legend__off"></span>灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活)${model.sets.off.size ? ',或本模式禁用' : ''} · <span class="brief-legend__ban"><${Icon} name="user" /></span>该盟约中无法出现的干员数</p>`;
}
/**
* 本局禁用干员: the count and the greyed avatars by tier.
* @param {{ model: MatchInfoModel }} props
*/
export function BannedOperators({ model }) {
const { banned } = model;
return html`<div class="brief-banned">
<h3 class="brief-h"><span>本局禁用干员</span><${MicroLabel}>BANNED OPERATORS</${MicroLabel}><b class="num brief-banned__n">${banned.length}</b></h3>
${banned.length ? html`<div class="brief-banned__grid">
${banned.map((id) => html`<${UnitThumb} key=${id} kind="chess" id=${id} size="sm" dim=${true} />`)}
</div>` : html`<p class="t-dim">本局没有禁用干员</p>`}
</div>`;
}
/**
* The briefing's bonds and banned operators: 核心盟约, 附加盟约, the legend, 本局禁用干员.
* @param {{ model: MatchInfoModel }} props
*/
export function MatchInfo({ model }) {
return html`<${MatchBondRow} title="核心盟约" micro="CORE BONDS" bonds=${model.core} model=${model} />
<${MatchBondRow} title="附加盟约" micro="ADD-ON BONDS" bonds=${model.addon} model=${model} />
<${MatchLegend} model=${model} />
<${BannedOperators} model=${model} />`;
}
/**
* The read-only match-info dialog (the strategy draft's 本局信息): MatchInfo in a Modal that 关闭, a tap outside or Esc
* closes. `status` (the caller's line, e.g. the draft's turn and countdown) sits left of 关闭.
* @param {{ open: boolean, onClose: Function, model: MatchInfoModel|null, status?: any }} props
*/
export function MatchInfoDialog({ open, onClose, model, status = null }) {
return html`<${Modal} open=${open && !!model} onClose=${onClose} title="本局信息" micro="MATCH INFO // BONDS & BANNED OPERATORS" class="minfo-dlg"
actions=${html`${status ? html`<div class="minfo-dlg__status" data-testid="match-info-status">${status}</div>` : null}
<${Button} variant="secondary" icon="close" data-autofocus data-testid="match-info-close" onClick=${onClose}>关闭<//>`}>
${open && model ? html`<${MatchInfo} model=${model} />` : null}
<//>`;
}
+406
View File
@@ -0,0 +1,406 @@
// GitHub issue #8 item 1 (v0.1.1) in the browser: the strategy draft's 本局信息 dialog shows the briefing's disabled bonds
// and banned operators again (ui/matchInfo.js — the briefing's own blocks), against the real server in headless Chrome
// (puppeteer-core + the system Chrome, one Chrome per player). Opt-in:
//
// SP_E2E=1 node --test test/ui/match-info.e2e.test.js
//
// 1. Solo 标准模拟 (the mode that switches 10 bonds off), desktop 1920×1080: the briefing's greyed bonds, badges, legend
// and banned operators are read; in the draft 查看禁用盟约与干员 opens the dialog with exactly the same (and the 本局禁用
// tooltip); Esc, a click outside — right over 确认选择 — and 关闭 close it without touching the highlighted band or
// sending an intent; a strategy is then confirmed; in the first prep the in-game 本局信息 tab lists the same.
// 2. Co-op 绝境 with two humans — desktop (mouse) and a phone (touch, 844×390, then 756×366): both briefings agree with
// the server; the waiting player's dialog shows whose turn it is with the live countdown and closes by itself when the
// picker — whose own dialog counted its seconds down and was closed (Esc / 关闭) — confirms; on the next turn the
// phone's dialog fits both screens, its text stays ≥ 9 px, a swipe scrolls its body to the banned operators and a tap
// outside closes it; the player who already picked keeps its dialog open while the last strategy is confirmed, and
// the end of the draft takes it away.
// Zero console errors / page errors / failed requests. Screenshots: test/e2e/out/match-info-*.png
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--mute-audio'];
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
const PHONE = (width, height) => ({ width, height, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
const OFF_FUNNY = ['lateranoShip', 'egirShip', 'kazimierzShip', 'skillfulShip', 'arcaneShip', 'miraShip', 'investShip', 'raidShip', 'soloShip', 'suntShip'];
describe('GitHub issue #8 item 1: 本局信息 in the strategy draft (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
let srv;
let base;
let puppeteer;
const browsers = [];
before(async () => {
const { startServer } = await import('../../server/index.js');
puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
mkdirSync(OUT, { recursive: true });
});
after(async () => {
for (const b of browsers) {
const proc = b.process();
await b.close().catch(() => {});
// Chrome's helpers (its crash handler, the macOS updater) can inherit its stderr and outlive it: drop our ends of
// its pipes, or this test process never exits
for (const s of [proc?.stdout, proc?.stderr]) s?.destroy();
}
await srv?.close();
});
/** One player: its own Chrome with one always-visible page, past the title screen, in the lobby. */
async function player(name, { phone = null } = {}) {
const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ARGS, protocolTimeout: 90000 });
browsers.push(browser);
const [first] = await browser.pages();
const page = first || await browser.newPage();
if (phone) await page.emulate({ viewport: phone, userAgent: ANDROID_UA });
else await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
const problems = [];
// the optional Google Fonts stylesheet (index.html: never render-blocking, system fallbacks) is the only external request
const external = (url) => /fonts\.(googleapis|gstatic)\.com/.test(url || '');
page.on('console', (m) => {
if (m.type() !== 'error') return;
const url = m.location()?.url || '';
if (/Failed to load resource/.test(m.text()) && external(url)) return;
problems.push(`console: ${m.text()} @ ${url}`);
});
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => {
const err = r.failure()?.errorText || '';
if (external(r.url())) return;
if (err === 'net::ERR_ABORTED' && /\.(mp3|ogg|wav|m4a)(\?|$)/.test(r.url())) return; // media element swaps
problems.push(`requestfailed: ${r.url()} ${err}`);
});
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.evaluateOnNewDocument((n) => { localStorage.setItem('sp.name', n); sessionStorage.setItem('sp.entered', '1'); }, name);
await page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
// record every intent the UI sends (gameActions looks net.request up per call)
await page.evaluate(() => {
const n = globalThis.__SP__.net;
const orig = n.request.bind(n);
globalThis.__req = [];
n.request = (t, f, o) => { globalThis.__req.push(t); return orig(t, f, o); };
});
return { browser, page, problems, phone: !!phone, name };
}
const st = (page) => page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
return {
me: s.me.playerId, room: s.room?.code ?? null, phase: pub?.phase ?? null, turn: pub?.draft?.turn ?? null,
picks: pub?.draft?.picks ?? {}, bandId: s.match.private?.bandId ?? null, modeId: pub?.modeId ?? null,
};
});
async function waitSt(page, pred, what, timeout = 30000) {
const t0 = Date.now();
let last = null;
while (Date.now() - t0 < timeout) { last = await st(page); if (pred(last)) return last; await sleep(150); }
throw new Error(`timed out waiting for ${what}: ${JSON.stringify(last)}`);
}
const shot = (page, name) => page.screenshot({ path: path.join(OUT, `match-info-${name}.png`) });
const intents = (page) => page.evaluate(() => (globalThis.__req || []).slice());
/** Centre of the first visible match (client px), or null. */
const centre = (page, sel, text = null) => page.evaluate((sel, text) => {
for (const el of document.querySelectorAll(sel)) {
if (text && !(el.textContent || '').includes(text)) continue;
const r = el.getBoundingClientRect();
if (r.width > 1 && r.height > 1) return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
return null;
}, sel, text);
/** A click with the mouse, or a tap on a touch phone. */
async function press(p, sel, text = null) {
let pt = null;
for (let i = 0; i < 60 && !pt; i++) { pt = await centre(p.page, sel, text); if (!pt) await sleep(100); }
assert.ok(pt, `${p.name}: ${sel}${text ? ` "${text}"` : ''} on screen`);
if (p.phone) await p.page.touchscreen.tap(pt.x, pt.y); else await p.page.mouse.click(pt.x, pt.y);
await sleep(250);
}
const dialogOpen = (page) => page.evaluate(() => !!document.querySelector('.minfo-dlg'));
/** The bonds, badges, legend and banned operators as drawn under `root` (the briefing's column or the dialog). */
const readInfo = (page, root) => page.evaluate((root) => {
const scope = document.querySelector(root);
if (!scope) return null;
return {
bonds: [...scope.querySelectorAll('.brief-bond')].map((el) => ({
id: el.dataset.bond, name: el.querySelector('.bond__name')?.textContent || '',
off: el.classList.contains('is-off'), incomplete: el.classList.contains('is-incomplete'), partial: el.classList.contains('is-partial'),
x: !!el.querySelector('.bond.is-disabled .bond__ban'), ban: el.querySelector('.brief-bond__ban')?.textContent.trim() || '',
})),
rows: [...scope.querySelectorAll('.brief-bonds .brief-h > span:first-child')].map((el) => el.textContent),
legend: scope.querySelector('.brief-legend')?.textContent.trim() || '',
count: scope.querySelector('.brief-banned__n')?.textContent.trim() || '',
banned: [...scope.querySelectorAll('.brief-banned__grid .uthumb')].map((el) => el.getAttribute('title')),
};
}, root);
/** What the server says: greyed = the drawn set ∪ the mode's inactive bonds; banned names by tier. */
const truth = (page) => page.evaluate(() => {
const { store, data } = globalThis.__SP__;
const pub = store.get().match.public;
const mode = data.get('config')?.modes?.[pub.modeId];
const greyed = [...new Set([...(pub.drawnDisabledBonds || []), ...(mode?.inactiveBondIds || [])])].sort();
const chess = pub.bannedChess.map((id) => data.lookup('chess', id)).filter(Boolean);
return { greyed, off: (mode?.inactiveBondIds || []).slice().sort(), tiers: chess.map((c) => c.tier), names: chess.slice().sort((a, b) => a.tier - b.tier).map((c) => c.name) };
});
const greyedOf = (info) => info.bonds.filter((b) => b.off).map((b) => b.id).sort();
const selBand = (page) => page.evaluate(() => document.querySelector('.dband.is-sel .dband__name')?.textContent || null);
async function openDialog(p) {
await press(p, '[data-testid="match-info-open"]');
await p.page.waitForSelector('.minfo-dlg .brief-banned', { visible: true, timeout: 5000 });
await sleep(350); // the dialog's pop-in
}
test('solo 标准模拟 (desktop): the dialog = the briefing; Esc / outside / 关闭 leave the draft alone; confirm; the in-game tab agrees', { timeout: 240000 }, async () => {
const p = await player('信息测试');
const { page } = p;
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'FUNNY' }));
await waitSt(page, (s) => !!s.room, 'solo room');
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
const s0 = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
assert.equal(s0.modeId, 'mode_single_funny');
await page.waitForSelector('.brief__right .brief-banned', { visible: true, timeout: 15000 });
await sleep(600);
const brief = await readInfo(page, '.brief__right');
const want = await truth(page);
assert.deepEqual(want.off, [...OFF_FUNNY].sort(), '标准模拟 switches the official 10 bonds off');
assert.deepEqual(greyedOf(brief), want.greyed, 'the briefing greys D ∪ the mode\'s inactive bonds');
assert.deepEqual(brief.banned, want.names, 'the briefing\'s banned operators, by tier');
assert.equal(brief.count, String(want.names.length));
assert.match(brief.legend, /或本模式禁用/);
await shot(page, 'solo-briefing');
await press(p, '.brief__foot .btn--primary', '准备就绪');
await waitSt(page, (s) => s.phase === 'BAND_DRAFT', 'band draft');
await page.waitForSelector('[data-testid="match-info-open"]', { visible: true, timeout: 10000 });
await shot(page, 'solo-draft');
const sel0 = await selBand(page);
assert.ok(sel0, 'a highlighted band');
const req0 = (await intents(page)).length;
// open: the very same blocks, the same tip; nothing sent
await openDialog(p);
const dlg = await readInfo(page, '.minfo-dlg');
assert.deepEqual(dlg, brief, 'the dialog shows exactly the briefing\'s bonds, badges, legend and banned operators');
assert.deepEqual(dlg.rows, ['核心盟约', '附加盟约']);
assert.ok(dlg.bonds.filter((b) => b.off).every((b) => b.x), 'every greyed disc carries the ✕');
const arcane = await centre(page, '.minfo-dlg .brief-bond[data-bond="arcaneShip"] .bond__disc');
await page.mouse.move(arcane.x, arcane.y);
await page.waitForSelector('.tooltip.is-shown', { visible: true, timeout: 3000 });
assert.equal(await page.$eval('.tooltip.is-shown', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
const status = await page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.equal(status, '轮到你决策', 'solo: untimed — no seconds');
const box = await page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; });
assert.ok(box.top >= 0 && box.bottom <= 1080 && box.left >= 0 && box.right <= 1920, `inside the screen ${JSON.stringify(box)}`);
const fits = await page.$eval('.minfo-dlg .modal__body', (el) => el.scrollHeight <= el.clientHeight + 1);
assert.ok(fits, 'desktop: everything visible without scrolling');
await shot(page, 'solo-dialog');
// Esc
await page.mouse.move(5, 5);
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
// a click outside the box, right over 确认选择: closes, confirms nothing
await openDialog(p);
const confirmPt = await centre(page, '.draft-detail__btns .btn--primary');
const inBox = await page.$eval('.minfo-dlg', (el, pt) => { const r = el.getBoundingClientRect(); return pt.x >= r.left && pt.x <= r.right && pt.y >= r.top && pt.y <= r.bottom; }, confirmPt);
assert.equal(inBox, false, '确认选择 lies outside the dialog box (on its backdrop)');
await page.mouse.click(confirmPt.x, confirmPt.y);
await page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
await sleep(400);
assert.equal((await st(page)).phase, 'BAND_DRAFT', 'the press on the backdrop confirmed nothing');
// 关闭
await openDialog(p);
await press(p, '[data-testid="match-info-close"]', '关闭');
await page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
assert.equal(await selBand(page), sel0, 'the highlighted band is untouched');
assert.deepEqual((await intents(page)).slice(req0), [], 'no intent while looking');
// the draft still works: highlight another strategy, confirm
await press(p, '.dband', '阿米娅');
assert.equal(await selBand(page), '阿米娅');
await press(p, '.draft-detail__btns .btn--primary', '确认选择');
const s1 = await waitSt(page, (s) => s.phase !== 'BAND_DRAFT', 'the draft to end');
assert.equal(s1.bandId, 'band_amiya');
// the in-game 本局信息 tab (left 🔍) lists the same greyed bonds, badges and banned operators (by tier)
await waitSt(page, (s) => s.phase === 'PREP', 'the first prep', 60000);
await page.waitForFunction(() => !document.querySelector('.pbanner'), { timeout: 10000 }); // the 休整期 banner has played
await press(p, '.gtop__iconbtn');
await page.waitForSelector('.edrawer .ibanned', { visible: true, timeout: 5000 });
const tab = await page.evaluate(() => ({
off: [...document.querySelectorAll('.edrawer .ibond.is-off')].map((el) => el.querySelector(':scope > span:not([class])')?.textContent || ''),
ban: Object.fromEntries([...document.querySelectorAll('.edrawer .ibond')].map((el) => [el.querySelector(':scope > span:not([class])')?.textContent || '', el.querySelector('.ibond__ban')?.textContent.trim() || ''])),
banned: [...document.querySelectorAll('.edrawer .ibanned .uthumb')].map((el) => el.getAttribute('title')),
count: document.querySelector('.edrawer .ihead small')?.textContent.trim() || '',
}));
assert.deepEqual(tab.off.sort(), brief.bonds.filter((b) => b.off).map((b) => b.name).sort(), 'the same greyed bonds');
for (const b of brief.bonds) if (b.ban) assert.equal(tab.ban[b.name], b.ban, `${b.name}: the same badge`);
assert.deepEqual(tab.banned, brief.banned, 'the same banned operators, in the same (tier) order');
assert.equal(tab.count, brief.count);
await shot(page, 'solo-ingame-tab');
assert.deepEqual(p.problems, []);
});
test('co-op 绝境, desktop + phone: live countdown in the dialog, a turn change closes it, phones fit and scroll, the draft completes', { timeout: 300000 }, async (t) => {
const a = await player('桌面博士');
const b = await player('手机博士', { phone: PHONE(844, 390) });
await a.page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'HARD' }));
const code = (await waitSt(a.page, (s) => !!s.room, 'co-op room')).room;
await b.page.evaluate((c) => globalThis.__SP__.net.request('room.join', { code: c }), code);
await waitSt(b.page, (s) => s.room === code, 'joined');
await b.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
await sleep(300);
await a.page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [a, b]) {
const s = await waitSt(p.page, (x) => x.phase === 'INFO_CHECK', `${p.name}: briefing`);
assert.equal(s.modeId, 'mode_multi_hard');
await p.page.waitForSelector('.brief__right .brief-banned', { timeout: 15000 });
}
await sleep(500);
const briefs = new Map();
for (const p of [a, b]) briefs.set(p, await readInfo(p.page, '.brief__right'));
const want = await truth(a.page);
assert.deepEqual(want.off, [], '绝境 switches no bond off');
assert.equal(want.greyed.length, 7, '3 core + 4 add-on bonds drawn');
for (const [p, info] of briefs) {
assert.deepEqual(greyedOf(info), want.greyed, `${p.name}: briefing greyed`);
assert.deepEqual(info.banned, want.names, `${p.name}: briefing banned`);
assert.doesNotMatch(info.legend, /本模式禁用/);
}
assert.deepEqual(briefs.get(b), briefs.get(a), 'both players see the same briefing');
await shot(b.page, 'coop-phone-briefing-844');
for (const p of [a, b]) await press(p, '.brief__foot .btn--primary', '准备就绪');
for (const p of [a, b]) await waitSt(p.page, (s) => s.phase === 'BAND_DRAFT' && !!s.turn, `${p.name}: band draft`);
const turn = (await st(a.page)).turn;
const meA = (await st(a.page)).me;
const picker = turn === meA ? a : b;
const waiter = picker === a ? b : a;
t.diagnostic(`first pick: the ${picker.phone ? 'phone' : 'desktop'} player`);
await waiter.page.waitForSelector('[data-testid="match-info-open"]', { visible: true, timeout: 10000 });
if (waiter.phone) await shot(waiter.page, 'coop-phone-draft-844');
// the waiting player looks first: the briefing's blocks, the picker's live countdown
await openDialog(waiter);
assert.deepEqual(await readInfo(waiter.page, '.minfo-dlg'), briefs.get(waiter), `${waiter.name}: the dialog = the briefing`);
const wStatus = await waiter.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.match(wStatus, new RegExp(`^${picker.name} 决策中\\s*\\d+s$`), `whose turn and its seconds: ${wStatus}`);
await shot(waiter.page, waiter.phone ? 'coop-phone-dialog-waiting-844' : 'coop-dialog-waiting');
// the picker: its own dialog says 轮到你决策 with the running clock; Esc; then it confirms with the dialog closed
const reqP = (await intents(picker.page)).length;
const selP = await selBand(picker.page);
await openDialog(picker);
assert.deepEqual(await readInfo(picker.page, '.minfo-dlg'), briefs.get(picker), `${picker.name}: the dialog = the briefing`);
const secsOf = async (p) => Number((await p.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent)).match(/(\d+)s/)?.[1]);
const s1 = await secsOf(picker);
assert.match(await picker.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim()), /^轮到你决策\s*\d+s$/);
await sleep(2200);
const s2 = await secsOf(picker);
assert.ok(s1 > 0 && s2 < s1, `the countdown keeps running in the dialog (${s1} → ${s2})`);
await shot(picker.page, picker.phone ? 'coop-phone-dialog-picker-844' : 'coop-dialog-picker');
if (picker.phone) {
await press(picker, '[data-testid="match-info-close"]', '关闭');
} else {
await picker.page.keyboard.press('Escape');
}
await picker.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
assert.equal(await selBand(picker.page), selP, 'the highlighted band survived the dialog');
assert.deepEqual((await intents(picker.page)).slice(reqP).filter((t) => t !== 'g.bandFocus'), [], 'nothing but the draft\'s own focus report');
assert.equal(await dialogOpen(waiter.page), true, 'the waiting player is still looking');
await press(picker, '.dband', '阿米娅');
await press(picker, '.draft-detail__btns .btn--primary', '确认选择');
await waitSt(picker.page, (s) => !!s.bandId, `${picker.name}: picked`);
// the turn moves on: the waiting player's dialog closes by itself, its draft is in front of it
await waitSt(waiter.page, (s) => s.turn === s.me, `${waiter.name}: its turn`);
await waiter.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 4000 });
const last = waiter;
const done = picker;
// the phone (whichever role it has now: deciding, or waiting after its pick) at 844×390, then 756×366: inside the
// screen, ≥ 9 px text, the countdown and 关闭 in view, the body scrolls to the banned operators; a tap outside closes it
const ph = b;
for (const [w, h] of [[844, 390], [756, 366]]) {
if (w === 756) { await ph.page.setViewport(PHONE(756, 366)); await sleep(500); }
await openDialog(ph);
const lay = await ph.page.evaluate(() => {
const boxEl = document.querySelector('.minfo-dlg');
const r = boxEl.getBoundingClientRect();
const body = boxEl.querySelector('.modal__body');
const px = (sel) => parseFloat(getComputedStyle(boxEl.querySelector(sel)).fontSize);
const vis = (sel) => { const e = boxEl.querySelector(sel).getBoundingClientRect(); return e.top >= r.top - 1 && e.bottom <= r.bottom + 1 && e.left >= r.left - 1 && e.right <= r.right + 1; };
return {
box: [r.left, r.top, r.right, r.bottom], vw: innerWidth, vh: innerHeight, scrolls: body.scrollHeight > body.clientHeight + 4,
overflow: getComputedStyle(body).overflowY, name: px('.bond__name'), legend: px('.brief-legend'), title: px('.modal__title'),
badge: px('.brief-bond__ban'), status: vis('[data-testid="match-info-status"]'), close: vis('[data-testid="match-info-close"]'),
};
});
assert.ok(lay.box[0] >= 0 && lay.box[1] >= 0 && lay.box[2] <= lay.vw && lay.box[3] <= lay.vh, `${w}×${h}: the dialog inside the screen ${JSON.stringify(lay.box)}`);
assert.ok(lay.name >= 9 && lay.legend >= 9 && lay.badge >= 9 && lay.title >= 14, `${w}×${h}: readable text ${JSON.stringify(lay)}`);
assert.ok(lay.status && lay.close, `${w}×${h}: the status line and 关闭 stay in view`);
assert.ok(lay.scrolls && lay.overflow === 'auto', `${w}×${h}: the body scrolls`);
assert.deepEqual(await readInfo(ph.page, '.minfo-dlg'), briefs.get(ph), `${w}×${h}: the dialog = the briefing`);
const phStatus = await ph.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
if (ph === last) assert.match(phStatus, /^轮到你决策\s*\d+s$/);
else assert.equal(phStatus, '已选择「阿米娅」,等待其他博士');
await shot(ph.page, `coop-phone-dialog-${w}`);
// a finger swipe up the body brings the banned operators into view
const bodyPt = await centre(ph.page, '.minfo-dlg .modal__body');
const top0 = await ph.page.$eval('.minfo-dlg .modal__body', (el) => el.scrollTop);
await ph.page.touchscreen.touchStart(bodyPt.x, bodyPt.y + 60);
for (let i = 1; i <= 8; i++) { await ph.page.touchscreen.touchMove(bodyPt.x, bodyPt.y + 60 - i * 22); await sleep(16); }
await ph.page.touchscreen.touchEnd();
await sleep(500);
const top1 = await ph.page.$eval('.minfo-dlg .modal__body', (el) => el.scrollTop);
assert.ok(top1 > top0, `${w}×${h}: the swipe scrolled the body (${top0} → ${top1})`);
await ph.page.$eval('.minfo-dlg .modal__body', (el) => { el.scrollTop = el.scrollHeight; });
await sleep(200);
const bannedVisible = await ph.page.evaluate(() => {
const body = document.querySelector('.minfo-dlg .modal__body').getBoundingClientRect();
const g = document.querySelector('.minfo-dlg .brief-banned__grid').getBoundingClientRect();
return g.top >= body.top - 1 && g.bottom <= body.bottom + 1;
});
assert.ok(bannedVisible, `${w}×${h}: the banned operators scrolled into view`);
await shot(ph.page, `coop-phone-dialog-${w}-scrolled`);
// a tap outside the box (the order list on the left) closes it and changes nothing
const sel = await selBand(ph.page);
const req = (await intents(ph.page)).length;
const outside = await ph.page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { x: Math.max(4, r.left / 2), y: r.top + r.height / 2 }; });
await ph.page.touchscreen.tap(outside.x, outside.y);
await ph.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
assert.equal(await selBand(ph.page), sel, `${w}×${h}: the highlighted band is untouched`);
assert.deepEqual((await intents(ph.page)).slice(req), [], `${w}×${h}: no intent`);
}
await shot(ph.page, 'coop-phone-draft-756');
// the player who already picked keeps its dialog open; the last picker confirms a free strategy (阿米娅 is 队友已选) —
// the end of the draft takes the open dialog away with the screen
await openDialog(done);
await press(last, '.dband', '华法琳');
await press(last, '.draft-detail__btns .btn--primary', '确认选择');
for (const p of [a, b]) {
const s = await waitSt(p.page, (x) => x.phase !== 'BAND_DRAFT', `${p.name}: the draft to end`);
assert.ok(s.bandId, `${p.name} has a strategy`);
}
assert.deepEqual([(await st(done.page)).bandId, (await st(last.page)).bandId], ['band_amiya', 'band_bldsk']);
await done.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 4000 });
assert.equal(await dialogOpen(a.page) || await dialogOpen(b.page), false, 'no dialog outlives the draft');
await sleep(500);
assert.deepEqual([...a.problems, ...b.problems], []);
});
});
+209
View File
@@ -0,0 +1,209 @@
// GitHub issue #8 item 1 (v0.1.1): "选策略时没法返回查看禁用的干员和盟约" — once the match moved on from the briefing to the
// strategy draft there was no way to see the disabled bonds and the banned operators again, though players choose a
// strategy partly by them. ui/matchInfo.js is now the one implementation of the briefing's bond rows (greyed: the drawn
// set D and the mode's static inactive bonds; the banned-member badge; briefingBondTip), the legend and the 本局禁用干员
// grid (by tier): the briefing renders it (MatchInfo), the draft's 本局信息 dialog renders the same blocks
// (MatchInfoDialog), and the in-game 本局信息 tab reads the same model (matchInfoModel). The dialog's status line repeats
// the turn and its countdown (bandDraft.js draftInfoStatus) and a turn change closes it. Browser: test/ui/match-info.e2e.test.js.
import { test } 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');
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 { matchInfoModel, MatchBondRow, MatchLegend, BannedOperators, MatchInfo, MatchInfoDialog } = await import('../../public/js/ui/matchInfo.js');
const { briefingBondTip } = await import('../../public/js/ui/gameLogic.js');
const { BondDisc, Tooltip, Modal, Button } = await import('../../public/js/ui/components.js');
const { UnitThumb } = await import('../../public/js/ui/gameComponents.js');
const { draftInfoStatus } = await import('../../public/js/screens/bandDraft.js');
const { data, getMode } = await import('../../public/js/data.js');
const { DATA, makeMatch } = await import('../match/harness.js');
await data.loadAll('chess', 'bonds', 'assets', 'config');
const SRC = (modeId) => ({ bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: getMode(modeId) });
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' && !v.props?.children) return;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
const OFF_FUNNY = ['lateranoShip', 'egirShip', 'kazimierzShip', 'skillfulShip', 'arcaneShip', 'miraShip', 'investShip', 'raidShip', 'soloShip', 'suntShip'];
// a synthetic match: 萨尔贡 + 坚守 drawn (D), 奥术 switched off by the mode, banned operators out of tier order + an unknown id
const PUB = {
modeId: 'mode_multi_hard', drawnDisabledBonds: ['sargonShip', 'steadShip'], disabledBonds: ['arcaneShip', 'sargonShip', 'steadShip'],
bannedChess: ['chess_char_5_02_a', 'chess_char_1_17_a', 'chess_nope_a', 'chess_char_3_09_a', 'chess_char_1_02_a'],
};
const MODE = { inactiveBondIds: ['arcaneShip'] };
const model = () => matchInfoModel(PUB, { bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: MODE });
test('matchInfoModel: the two greyed kinds, the briefing order, banned operators by tier (known only), banned members per bond', () => {
const m = model();
assert.deepEqual([...m.sets.drawn].sort(), ['sargonShip', 'steadShip'], 'D = drawnDisabledBonds minus the static list');
assert.deepEqual([...m.sets.off], ['arcaneShip'], 'the mode\'s inactive bonds');
assert.equal(m.stateOf('arcaneShip'), 'off');
assert.equal(m.stateOf('sargonShip'), 'drawn');
assert.equal(m.stateOf('yanShip'), null);
// bondOrder, then identifier: 调和 (bondOrder 1) leads the add-on row as in the official briefing
assert.deepEqual(m.core.map((b) => b.name), ['炎', '萨尔贡', '维多利亚', '谢拉格', '拉特兰', '阿戈尔', '叙拉古', '卡西米尔']);
assert.equal(m.addon[0].name, '调和');
assert.equal(m.core.length + m.addon.length, Object.keys(DATA.bonds).length, 'every bond, once');
assert.ok(m.core.every((b) => b.isCore) && m.addon.every((b) => !b.isCore));
// tiers 1, 1, 3, 5 — ties keep the server's order; the unknown id is dropped
assert.deepEqual(m.banned, ['chess_char_1_17_a', 'chess_char_1_02_a', 'chess_char_3_09_a', 'chess_char_5_02_a']);
assert.equal(m.perBond.get('arcaneShip'), 2, '深靛 + 海霓');
assert.equal(m.perBond.get('preciShip'), 2, '深靛 + 缇缇');
assert.equal(m.perBond.get('yanShip'), 0);
// an older payload without drawnDisabledBonds: disabledBonds minus the static list
const old = matchInfoModel({ disabledBonds: ['arcaneShip', 'sargonShip'] }, { bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: MODE });
assert.deepEqual([...old.sets.drawn], ['sargonShip']);
// nothing loaded yet: empty, never throws
const empty = matchInfoModel(null);
assert.deepEqual([empty.bonds.length, empty.banned.length, empty.sets.drawn.size, empty.sets.off.size], [0, 0, 0, 0]);
});
test('MatchBondRow: greyed by state (✕ disc), the banned-member badge, the red ring on an enabled bond that lost members, the tip', () => {
const m = model();
const row = MatchBondRow({ title: '附加盟约', micro: 'ADD-ON BONDS', bonds: m.addon, model: m });
assert.match(textOf(row), /附加盟约ADD-ON BONDS/);
const cell = (id) => [...walk(row)].find((v) => v.props?.['data-bond'] === id);
const disc = (id) => [...walk(cell(id))].find((v) => v.type === BondDisc);
const badge = (id) => [...walk(cell(id))].find((v) => hasClass(v, 'brief-bond__ban'));
const tipOf = (id) => [...walk(row)].find((v) => v.type === Tooltip && v.key === id);
// 奥术: switched off by the mode — greyed, ✕, no tier, badge 2, tip 本局禁用
assert.ok(hasClass(cell('arcaneShip'), 'is-off') && !hasClass(cell('arcaneShip'), 'is-incomplete'));
assert.deepEqual([disc('arcaneShip').props.disabled, disc('arcaneShip').props.active, disc('arcaneShip').props.tier], [true, false, 0]);
assert.match(textOf(badge('arcaneShip')), /^2$/);
assert.equal(tipOf('arcaneShip').props.text, '奥术:本局禁用(该盟约不会激活)');
// 坚守: drawn (D) — greyed as well, marked incomplete, the 阵容不完整 tip
assert.ok(hasClass(cell('steadShip'), 'is-off') && hasClass(cell('steadShip'), 'is-incomplete'));
assert.equal(tipOf('steadShip').props.text, briefingBondTip('坚守', 'drawn', m.perBond.get('steadShip')));
assert.match(tipOf('steadShip').props.text, /部分盟约所含干员阵容不完整/);
// 精准: enabled, but 2 of its members are banned — lit disc, red ring (is-partial), badge 2
assert.ok(!hasClass(cell('preciShip'), 'is-off') && hasClass(cell('preciShip'), 'is-partial'));
assert.deepEqual([disc('preciShip').props.disabled, disc('preciShip').props.active], [false, true]);
assert.match(textOf(badge('preciShip')), /^2$/);
assert.equal(tipOf('preciShip').props.text, '精准:部分盟约所含干员阵容不完整(2 名干员无法出现)');
// 助力: untouched — lit, no badge, the plain name
assert.ok(!hasClass(cell('deputShip'), 'is-off') && !hasClass(cell('deputShip'), 'is-partial'));
assert.equal(badge('deputShip'), undefined);
assert.equal(tipOf('deputShip').props.text, '助力');
assert.equal(disc('deputShip').props.tier, DATA.bonds.deputShip.thresholds.length);
});
test('MatchLegend: the grey and the badge; "或本模式禁用" only when the mode switches bonds off', () => {
const t = textOf(MatchLegend({ model: model() }));
assert.match(t, /灰色:部分盟约所含干员阵容不完整(仍可通过其他盟约的干员或装备激活),或本模式禁用 · /);
assert.match(t, /该盟约中无法出现的干员数$/);
const hard = matchInfoModel({ drawnDisabledBonds: ['sargonShip'] }, SRC('mode_multi_hard'));
assert.doesNotMatch(textOf(MatchLegend({ model: hard })), /本模式禁用/);
});
test('BannedOperators: the count and dimmed avatars in tier order; none ⇒ 本局没有禁用干员', () => {
const m = model();
const block = BannedOperators({ model: m });
assert.match(textOf(block), /本局禁用干员BANNED OPERATORS4/);
const thumbs = [...walk(block)].filter((v) => v.type === UnitThumb);
assert.deepEqual(thumbs.map((v) => v.props.id), m.banned);
assert.ok(thumbs.every((v) => v.props.kind === 'chess' && v.props.dim === true && v.props.size === 'sm'));
const none = BannedOperators({ model: matchInfoModel({}, SRC('mode_single_normal')) });
assert.match(textOf(none), /本局没有禁用干员/);
assert.equal([...walk(none)].filter((v) => v.type === UnitThumb).length, 0);
});
test('MatchInfo = 核心盟约, 附加盟约, the legend, 本局禁用干员 (one model); MatchInfoDialog = the same in a Modal with 关闭 and the status line', () => {
const m = model();
const parts = MatchInfo({ model: m }).filter((v) => v && typeof v === 'object');
assert.deepEqual(parts.map((v) => v.type), [MatchBondRow, MatchBondRow, MatchLegend, BannedOperators]);
assert.deepEqual(parts.slice(0, 2).map((v) => [v.props.title, v.props.bonds]), [['核心盟约', m.core], ['附加盟约', m.addon]]);
assert.ok(parts.every((v) => v.props.model === m));
const onClose = () => {};
const dlg = MatchInfoDialog({ open: true, onClose, model: m, status: '轮到你决策' });
assert.equal(dlg.type, Modal);
assert.equal(dlg.props.open, true);
assert.equal(dlg.props.onClose, onClose, 'Esc and a tap outside close it (components.js Modal)');
assert.equal(dlg.props.title, '本局信息');
assert.equal(dlg.props.class, 'minfo-dlg');
const inner = [...walk(dlg.props.children)].find((v) => v.type === MatchInfo);
assert.equal(inner?.props.model, m, 'the very blocks of the briefing');
const close = [...walk(dlg.props.actions)].find((v) => v.type === Button);
assert.equal(close.props.onClick, onClose);
assert.match(textOf(close), /关闭/);
assert.match(textOf(dlg.props.actions), /轮到你决策/);
const shut = MatchInfoDialog({ open: false, onClose, model: m });
assert.equal(shut.props.open, false);
assert.equal([...walk(shut.props.children)].find((v) => v.type === MatchInfo), undefined, 'nothing built while closed');
assert.equal(MatchInfoDialog({ open: true, onClose, model: null }).props.open, false, 'no model: stays closed');
});
test('a real 标准 match (solo and co-op): the mode\'s 10 inactive bonds are "off", banned operators are exactly the server\'s, by tier', () => {
for (const [mode, modeId] of [['solo', 'mode_single_funny'], ['coop', 'mode_multi_funny']]) {
const h = makeMatch({ mode, difficulty: 'FUNNY', humans: 1, seed: 11 }).start();
const pub = h.m.publicView();
assert.equal(pub.modeId, modeId);
const m = matchInfoModel(pub, SRC(pub.modeId));
assert.deepEqual([...m.sets.off].sort(), [...OFF_FUNNY].sort(), `${modeId}: 标准模拟's inactiveBondIdList`);
assert.equal(m.sets.drawn.size, 1, '标准 draws one add-on bond');
assert.ok([...m.sets.drawn].every((b) => !OFF_FUNNY.includes(b) && !DATA.bonds[b].isCore));
assert.deepEqual([...m.banned].sort(), [...pub.bannedChess].sort(), 'every banned operator is known');
assert.deepEqual(m.banned.map((id) => DATA.chess[id].tier), [...m.banned.map((id) => DATA.chess[id].tier)].sort((a, b) => a - b));
for (const id of m.banned) assert.ok(DATA.chess[id].bonds.every((b) => m.stateOf(b)), `${id}: every bond greyed`);
assert.equal(m.addon.filter((b) => m.stateOf(b.bondId)).length + m.core.filter((b) => m.stateOf(b.bondId)).length, 11);
assert.match(textOf(MatchLegend({ model: m })), /或本模式禁用/);
h.m.dispose();
}
// 绝境: 3 core + 4 add-on drawn, nothing switched off by the mode
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 4 }).start();
const m = matchInfoModel(h.m.publicView(), SRC('mode_multi_hard'));
assert.equal(m.sets.off.size, 0);
assert.deepEqual([m.core.filter((b) => m.stateOf(b.bondId)).length, m.addon.filter((b) => m.stateOf(b.bondId)).length], [3, 4]);
assert.deepEqual([...m.banned].sort(), [...h.m.bannedChess].sort());
h.m.dispose();
});
test('draftInfoStatus: the dialog repeats the turn and its seconds (warning at ≤ 10 s), or my pick', () => {
assert.deepEqual(draftInfoStatus({ myTurn: true, secs: 24 }), { text: '轮到你决策', secs: 24, tone: 'gold' });
assert.deepEqual(draftInfoStatus({ myTurn: true, secs: 7 }), { text: '轮到你决策', secs: 7, tone: 'warn' });
assert.deepEqual(draftInfoStatus({ myTurn: false, turnName: '凯尔希', secs: 30 }), { text: '凯尔希 决策中', secs: 30, tone: 'gold' });
assert.deepEqual(draftInfoStatus({ myTurn: true, secs: null }), { text: '轮到你决策', secs: null, tone: 'gold' }, 'untimed: no seconds');
assert.deepEqual(draftInfoStatus({ myTurn: false, turnName: null }), { text: '等待轮到你', secs: null, tone: 'dim' });
assert.deepEqual(draftInfoStatus({ myPick: 'band_bldsk', pickName: '华法琳', myTurn: false, waiting: true }),
{ text: '已选择「华法琳」,等待其他博士', secs: null, tone: 'mint' });
assert.equal(draftInfoStatus({ myPick: 'band_bldsk', pickName: '华法琳', myTurn: false }).text, '已选择「华法琳」');
});
test('the briefing, the strategy draft and the in-game 本局信息 tab all read ui/matchInfo.js (no second copy to drift)', () => {
const brief = read('public/js/screens/briefing.js');
assert.match(brief, /import \{ MatchInfo, matchInfoModel \} from '\.\.\/ui\/matchInfo\.js';/);
assert.match(brief, /<section class="brief__right">\s*<\$\{MatchInfo\} model=\$\{info\} \/>\s*<\/section>/);
assert.match(brief, /const info = matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode \}\);/);
assert.doesNotMatch(brief, /brief-legend|brief-banned|function BondRow|bannedPerBond|disabledBondSets/, 'no own copy of the blocks');
const draft = read('public/js/screens/bandDraft.js');
assert.match(draft, /import \{ MatchInfoDialog, matchInfoModel \} from '\.\.\/ui\/matchInfo\.js';/);
assert.match(draft, /const info = infoOpen \? matchInfoModel\(pub, \{ bonds: gd\.list\('bonds'\), chess: gd\.chess, mode \}\) : null;/);
assert.match(draft, /data-testid="match-info-open"[\s\S]*?onClick=\$\{\(\) => setInfoOpen\(true\)\}>查看禁用盟约与干员</);
assert.match(draft, /<\$\{MatchInfoDialog\} open=\$\{infoOpen\} onClose=\$\{\(\) => setInfoOpen\(false\)\} model=\$\{info\}/);
// a turn change or my pick closes it; the draft's end unmounts the screen
assert.match(draft, /const turnKey = `\$\{draft\.turnPid \|\| ''\}\|\$\{myPick \|\| ''\}`;\n\s*useEffect\(\(\) => \{ setInfoOpen\(false\); \}, \[turnKey\]\);/);
const drawer = read('public/js/ui/enemyDrawer.js');
assert.match(drawer, /import \{ matchInfoModel \} from '\.\/matchInfo\.js';/);
assert.match(drawer, /matchInfoModel\(pub, \{\s*bonds: data\.list\('bonds'\), chess: \(id\) => data\.lookup\('chess', id\), mode: data\.get\('config'\)\?\.modes\?\.\[pub\?\.modeId\],/);
assert.doesNotMatch(drawer, /bannedPerBond|disabledBondSets/, 'the drawer derives nothing on its own');
});