// Band draft — BAND_DRAFT "2/2 选择策略" (research 06 §4.2, D1): left = draft order (avatar, name, state: // … waiting / ⌛ 决策中 / chosen band ✓), current picker highlighted; centre = grid of every band allowed // for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked // 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right = // detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择. // One countdown (user playtest #4 item 4): every turn has the same clock (Match BAND_TURN_SECONDS, m.public.draft // turnSeconds) and the step header counts it down — m.public.deadline IS the turn's end, the same number as the // current picker's row. The highlighted band (the detail pane's) is what a turn that runs out takes: every change of it // 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. // A strategy built around a bond the mode switches off (bands.json bondIds ∩ the mode's inactive bonds — 标准: 潘格尼尼 // 拉特兰, 克莱门莎 阿戈尔, 玛恩纳 卡西米尔; the bot never picks one) reads 本局禁用 on its card and in the detail pane // (BandOffTag / BandOffNote, DESIGN §21.26, §21.7's look); it stays selectable — information only. // 本局信息 (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'; import { audio } from '../audio.js'; import { modeOffBonds, bandOffBonds, bandOffLine } from '../ui/gameLogic.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); /** 本局禁用 on a strategy card whose bonds `names` the mode switches off (bandOffBonds); nothing otherwise. */ export function BandOffTag({ names = [] }) { return names.length ? html`本局禁用` : null; } /** The detail pane's note for such a strategy: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥" (the bond names struck through). */ export function BandOffNote({ names = [] }) { if (!names.length) return null; return html`

<${Icon} name="info" />本局禁用${names.map((n, i) => html`【${n}】`)}盟约,此策略效果可能无法发挥

`; } /** * Bands selectable in a mode (modeTypeList contains the mode's type), sorted by sortId. * @param {any[]} bands * @param {string|null} modeType 'SINGLE'|'MULTI' */ export function allowedBands(bands, modeType) { const sid = (b) => (Number.isFinite(b.sortId) ? b.sortId : 99); return (Array.isArray(bands) ? bands : []) .filter((b) => b && (!modeType || !Array.isArray(b.modeTypeList) || b.modeTypeList.includes(modeType))) .sort((a, b) => sid(a) - sid(b) || (a.bandId < b.bandId ? -1 : a.bandId > b.bandId ? 1 : 0)); } /** The official default strategy of an automatic assignment (data/config.json bandDraft.timeoutBandId). */ export const DEFAULT_TIMEOUT_BAND = 'band_bldsk'; /** * The strategy the server assigns me when my turn times out (server/match/Match.js defaultBand): the official default * 「华法琳」 while no teammate holds it, else the first free strategy in draft order (sortId) — never one a teammate * already picked (队友已选). * @param {any[]} bands allowedBands(...) (sortId order) * @param {Map} taken teammateBands(...) * @param {string} [defaultId] * @returns {string|null} */ export function timeoutBand(bands, taken, defaultId = DEFAULT_TIMEOUT_BAND) { const list = Array.isArray(bands) ? bands : []; const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id)); if (defaultId && !has(defaultId) && (!list.length || list.some((b) => b.bandId === defaultId))) return defaultId; return list.find((b) => !has(b.bandId))?.bandId || defaultId || null; } /** * Bands taken by teammates (队友已选): bandId → the picking players (never the viewer). * @param {Map} picks normalizeDraft(...).picks (playerId → bandId) * @param {string} myId */ export function teammateBands(picks, myId) { const out = new Map(); for (const [pid, bid] of picks instanceof Map ? picks : []) { if (pid === myId || typeof bid !== 'string') continue; if (!out.has(bid)) out.set(bid, []); out.get(bid).push(pid); } return out; } /** * The band the detail pane shows (= the highlighted band a turn that runs out takes): the current one, else my pick, * else the band a timeout would give me (timeoutBand: 「华法琳」 while free, else the first free one). When it is my * turn and the shown band was taken meanwhile (队友已选), that default instead (confirm would be disabled). * @param {string|null} sel * @param {{ bands: any[], taken: Map, myPick: string|null, myTurn: boolean, defaultId?: string }} o */ export function draftSelection(sel, { bands, taken, myPick, myTurn, defaultId = DEFAULT_TIMEOUT_BAND }) { if (!Array.isArray(bands) || !bands.length) return sel; const free = timeoutBand(bands, taken, defaultId) || bands[0].bandId; if (!sel) return myPick || free; if (!myPick && myTurn && taken.has(sel)) return free; return sel; } /** * The strategy a turn that runs out assigns me (server Match.timeoutBand): the highlighted band while it is one of * the mode's and no teammate holds it, else timeoutBand. Null after my pick. * @param {string|null} sel the highlighted band * @param {{ bands: any[], taken: Map, myPick?: string|null, defaultId?: string }} o */ export function autoPickBand(sel, { bands, taken, myPick = null, defaultId = DEFAULT_TIMEOUT_BAND }) { if (myPick) return null; const list = Array.isArray(bands) ? bands : []; const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id)); if (sel && !has(sel) && list.some((b) => b.bandId === sel)) return sel; return timeoutBand(list, taken, defaultId); } /** * The tip under the co-op draft order: the one skip, the turn clock and what a turn that runs out assigns me (the * highlighted band while free — autoPickBand). Untimed drafts (a single human) name no clock. * @param {{ timed: boolean, turnSeconds?: number|null, autoName?: string|null, selected?: boolean }} o * selected: the auto pick is the highlighted band (not the default standing in for a band a teammate holds) */ export function draftTip({ timed, turnSeconds = null, autoName = null, selected = true }) { const skip = '联合模拟在选择策略时可以进行一次跳过'; if (!timed) return `${skip};本局不限时`; const clock = Number(turnSeconds) > 0 ? `每位博士有 ${Math.round(turnSeconds)} 秒` : '每位博士限时决策'; if (!autoName) return `${skip};${clock}`; return `${skip};${clock},超时将自动选择${selected ? '当前选中的' : ''}「${autoName}」`; } /** * The step header's countdown during the draft: the current turn's (m.public.deadline = draft.turnDeadline) with a * turn's length as the gauge total — null when the draft is untimed. * @param {any} pub m.public * @returns {{ deadline: number, total: number|null } | null} */ export function draftClock(pub) { const d = pub && typeof pub.draft === 'object' ? pub.draft : null; if (!d || d.untimed) return null; const deadline = Number(pub.deadline) > 0 ? Number(pub.deadline) : Number(d.turnDeadline) || 0; if (!(deadline > 0)) return null; 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); const priv = useStore((s) => s.match.private); const myId = useStore((s) => s.me.playerId); const roomSolo = useStore((s) => s.room?.mode === 'solo'); const gd = useGameData(); const [sel, setSel] = useState(null); 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 offBonds = modeOffBonds(mode); // the bonds this mode never activates (标准: 10 of 23) const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single'); const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo]); const players = sortedPlayers(pub); const draft = normalizeDraft(pub?.draft, players); const myPick = draft.picks.get(myId) || priv?.bandId || null; const myTurn = !myPick && (solo || draft.turnPid === myId); const skipsLeft = draft.skipsLeft.has(myId) ? draft.skipsLeft.get(myId) : (skipped ? 0 : 1); const canSkip = !solo && myTurn && skipsLeft > 0 && draft.order.length > 1; const taken = solo ? new Map() : teammateBands(draft.picks, myId); const pickers = new Map(); // bandId → players for (const [pid, bid] of draft.picks) { const p = players.find((x) => x.playerId === pid); if (!pickers.has(bid)) pickers.set(bid, []); pickers.get(bid).push(p || { playerId: pid, name: '?' }); } // default selection: my pick, else what a timeout gives me (华法琳 while free); when my turn comes while the selected // band has been taken by a teammate meanwhile (队友已选), move the selection back to that default const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND; const takenKey = [...taken.keys()].sort().join(','); useEffect(() => { const next = draftSelection(sel, { bands, taken, myPick, myTurn, defaultId }); if (next !== sel) setSel(next); }, [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); // the highlighted band is what a turn that runs out takes (Match.timeoutBand): report every change before my pick const timed = !solo && !!pub?.draft && !pub.draft.untimed; const focusSent = useRef(null); useEffect(() => { // (a spectator seat — community report #26 — is in no draft order: it never reports) if (!timed || myPick || !sel || focusSent.current === sel || !draft.order.includes(myId)) return; focusSent.current = sel; act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true }); }, [sel, timed, myPick]); // what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — Match.js timeoutBand) const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId }); const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳'; const band = sel ? gd.band(sel) : null; const selTaken = !!band && taken.has(band.bandId); const confirm = async () => { if (!band || busy || !myTurn || selTaken) return; setBusy('pick'); await actions.band(band.bandId); setBusy(null); }; const skip = async () => { if (busy || !canSkip) return; setBusy('skip'); if (await actions.bandSkip()) setSkipped(true); setBusy(null); }; const turnName = players.find((p) => p.playerId === draft.turnPid)?.name; useTicker(clock ? 250 : 0); // 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`
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }} total=${clock ? clock.total : null} onExit=${() => setExit(true)} />
${bands.map((b) => { const who = pickers.get(b.bandId) || []; const isTaken = taken.has(b.bandId); const offNames = bandOffBonds(b, offBonds).map((id) => gd.bond(id)?.name || id); // 本局禁用 (still selectable) return html``; })}
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} /> <${MatchInfoDialog} open=${infoOpen} onClose=${() => setInfoOpen(false)} model=${info} status=${infoStatus ? html` <${Icon} name=${infoStatus.tone === 'mint' ? 'check' : 'hourglass'} />${infoStatus.text}${infoStatus.secs != null ? html`${infoStatus.secs}s` : null} ` : null} />
`; }