// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05): the third tab of the 干员调配 overlay // (screens/loadout.js). Four slots — 5阶 ×2, 6阶 ×2 — each filled with an operator: a 6★ the player owns outside the // chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (原型干员, its skill // and module locked as when it stands in). Only operators with a kit are listed (the server's welcome.diyKitted). The // slot picker lists the legal picks of the slot's tier — avatar, name, class, the bonds derived from its factions, a // 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold // only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player // /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy); // the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 / // 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither. import { useLayoutEffect, useState } from '../../vendor/hooks.module.js'; import { html, Icon, Button, TierChip } from '../ui/components.js'; import { Img, RichText, BondGlyph } from '../ui/gameComponents.js'; import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js'; import { data } from '../data.js'; import { PROF_NAME, skillLabel, moduleBadge, fullTraitText, skillTags } from '../ui/loadoutModel.js'; import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js'; import { OperatorVoice } from '../ui/operatorVoice.js'; import { LoadoutStats } from '../ui/loadoutStats.js'; import { CultivationSelects } from './cultivation.js'; import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI']; /** The data the 自选 rules read (shared/diy.js): chess.json + backups.json (localized copies). */ export const diyData = () => ({ chess: data.get('chess'), backups: data.get('backups') }); /** A unit summary's avatar / portrait URL (a backups.json unit or a composed record). */ const avatarOf = (m, unit, elite = false) => chessAvatarUrl(m, unit && { charId: unit.charId, assets: { avatar: elite ? unit.assets?.avatarGolden : unit.assets?.avatar } }); const classLine = (u) => `${t(PROF_NAME[u?.profession] || '') || ''}${u?.subProfessionName ? ` · ${u.subProfessionName}` : ''}`; const bondName = (id) => data.lookup('bonds', id)?.name || id; /** The 原型 / 已持有 tag of a pick. */ function KindTag({ proto }) { return html`${proto ? t('原型') : t('已持有')}`; } /** Bond chips (the bonds derived from the operator's factions; 协防 when none matches). */ function Bonds({ bonds }) { return html`${(bonds || []).map((b) => html`<${BondGlyph} bondId=${b} />${bondName(b)}`)}`; } /** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */ function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) { const D = diyData(); const rec = pick ? slotRecord(slot.slotId, pick, D) : null; const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null; const label = t('{tier}阶 自选', { tier: slot.tier }); if (!rec) { return html`
${pick ? html`

<${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}

` : null}
`; } const proto = !!pick && D.backups?.diy?.prototypes?.[slot.tier]?.includes(pick.charId); const sk = rec.skill; const mod = elite?.module?.active ? elite.module : null; return html`
<${Img} src=${chessPortraitUrl(m, elite || rec)} fallback=${html`${[...(rec.name || '?')][0]}`} />
<${TierChip} tier=${slot.tier} size="sm" />${label}<${KindTag} proto=${proto} />
${rec.name} ${classLine(rec)} <${Bonds} bonds=${rec.bonds} /> <${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="diy-slot__sicon" fallback=${html`${skillLabel(sk?.index)}`} /> ${skillLabel(sk?.index)} ${sk?.name || ''}${proto ? html` ${t('(锁定)')}` : null} ${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`${moduleBadge({ typeName: mod.type })}`} />` : html`—`} ${mod ? html`${mod.type || ''} ${mod.name || ''}` : t('不装备模组')} ${t('(精锐时生效)')} ${proto ? html`${t('原型干员没有潜能与练度')}` : onOps ? html`<${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} />` : null} <${OperatorVoice} charId=${rec.charId} /> ${illegal ? html`

<${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}

` : null}
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-change" onClick=${() => onOpen(slot.slotId)}>${t('更换')} <${Button} variant="ghost" size="sm" icon="close" data-testid="diy-clear" onClick=${() => onClear(slot.slotId)}>${t('清空')}
`; } /** One operator of the picker's list. */ function OptionCard({ m, opt, selected, onSel }) { const u = opt.unit; const name = u?.name || opt.charId; const takenBy = opt.taken ? diySlotList(diyData()).find((s) => s.slotId === opt.taken) : null; return html``; } /** A skill choice of an owned pick (or the locked skill of a prototype). */ function SkillRow({ m, s, on, locked, onPick, level }) { const rec = level === 'elite' ? s.elite || s.normal : s.normal || s.elite; const tags = skillTags(rec); return html``; } /** A module choice of an owned pick (不装备 or a module of the elite form). */ function ModuleRow({ id, rec, on, locked, onPick }) { const none = id == null; const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : []; // the trait this module gives: the class trait (or the module's rewrite), then its extra line (item 16.2) const trait = rec?.traitOverride ? fullTraitText(rec.traitOverride) || null : null; return html``; } /** * The slot picker: the legal operators of the slot's tier (prototypes first, then the owned 6★; a 原型 / 已持有 filter and * a search), the chosen one's skill / module (an owned pick) or its locked selection (a prototype), 确认 / 取消. The * state (filter, search, the draft pick) lives here; DiyPickerView draws it. * @param {{ m: any, slot: any, picks: Record, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props */ export function DiyPicker(props) { const [previewLevel, setPreviewLevel] = useState('elite'); const cur = props.picks[props.slot.slotId] || null; const [filter, setFilter] = useState('all'); const [query, setQuery] = useState(''); const [draft, setDraft] = useState(cur ? { ...cur } : null); // Esc cancels only the picker, like its 取消 (GitHub #284, idea from PR #286); the 干员调配 overlay's own Esc skips // while a picker is open, and a dialog over the picker (导入) still takes Esc first. A layout effect: the listener is // attached in the same commit as the picker (a plain effect waits for the next frame, and an Esc pressed in between // was swallowed — the overlay skipped it, the picker did not hear it yet; the 0.2.2 full browser pass) useLayoutEffect(() => { const onKey = (e) => { if (e.key !== 'Escape' || e.ctrlKey || e.metaKey || e.altKey || document.querySelector('.modal')) return; e.preventDefault(); e.stopImmediatePropagation(); props.onClose(); }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [props.onClose]); return DiyPickerView({ ...props, previewLevel, onPreviewLevel: setPreviewLevel, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }); } /** * The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being * made (null = none chosen yet) and their setters. */ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft, ops = {}, onOps = null, previewLevel = 'elite', onPreviewLevel = () => {} }) { const D = diyData(); const cur = picks[slot.slotId] || null; const options = pickOptions(slot.slotId, picks, D, kitted); const q = query.trim().toLowerCase(); const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto) && (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q)))); const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null; const sel = (charId) => { if (draft?.charId === charId) return; setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D)); }; const lk = ch?.locked || null; const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null; const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null; const unit = draft ? D.backups?.units?.[draft.charId] : null; const label = t('{tier}阶 自选', { tier: slot.tier }); const potential = ops[draft?.charId]?.potential ?? 6; const base = draft ? slotRecord(slot.slotId, draft, D, { potential }) : null; const golden = draft ? slotRecord(slot.slotId, draft, D, { elite: true, potential }) : null; const previewEntries = base ? { [base.baseId || base.chessId]: { skill: skillOn, module: modOn || 'none' } } : {}; const previewChess = (id) => id === golden?.chessId ? golden : base; return html``; } /** * The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here). * @param {{ m: any, picks: Record, legal: Record, kitted: string[]|null, * onSet: (slotId: string, pick: any) => void, ops?: Record, * onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props * `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects) */ export function DiyPanel(props) { const [picking, setPicking] = useState(null); return DiyPanelView({ ...props, picking, onPicking: setPicking }); } /** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) { const slots = diySlotList(diyData()); const slot = picking ? slots.find((s) => s.slotId === picking) : null; const tiers = [...new Set(slots.map((s) => s.tier))]; return html`

${t('5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。')}

${tiers.map((tier) => html`

<${TierChip} tier=${tier} size="sm" />${ROMAN[tier]}${t('阶')}

${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null} illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
`)}
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} ops=${ops} onOps=${onOps} onClose=${() => setPicking(null)} onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
`; }