// 自选编队 (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`
onOpen(slot.slotId)} aria-label=${t('{slot}:选择干员', { slot: label })}>
<${TierChip} tier=${slot.tier} size="sm" />
<${Icon} name="plus" />
${t('选择干员')}
${t('调度中心等级 {level} 起出现在商店', { level: slot.shopLevel })}
${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` onSel(opt.charId)}
title=${takenBy ? t('已在{tier}阶自选中', { tier: takenBy.tier }) : name}>
<${Img} src=${avatarOf(m, u)} fallback=${html`${[...name][0]} `} />
${name} <${KindTag} proto=${opt.proto} />
<${Img} src=${profIconUrl(m, u?.profession)} class="diy-opt__prof" />${classLine(u)}
<${Bonds} bonds=${opt.bonds} />
${takenBy ? html`${t('已在{tier}阶自选中', { tier: takenBy.tier })} ` : null}
`;
}
/** 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` onPick(s.index)}>
<${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`${skillLabel(s.index)} `} />
${skillLabel(s.index)} ${rec?.name || ''} ${on ? html`<${Icon} name="check" />${locked ? t('锁定') : t('已选择')} ` : null}
${tags.sp} ${!tags.passive ? html`${t('初始')} ${tags.init} · ${t('消耗')} ${tags.cost} ` : null}${tags.duration ? html`${tags.duration} ` : null}
<${RichText} as="span" class="diy-choice__desc" text=${rec?.descRaw || rec?.desc || ''} />
`;
}
/** 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` onPick(id)}>
${none ? html`— ` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`${moduleBadge(rec)} `} />`}
${none ? html`${t('不装备')} ` : html`${rec.typeName || ''} ${rec.name || id} `}${on ? html`<${Icon} name="check" />${locked ? t('锁定') : t('已选择')} ` : null}
${trait ? html`<${RichText} as="span" class="diy-choice__desc" text=${trait} />` : null}
${talents.map((x, i) => html`<${RichText} key=${i} as="span" class="diy-choice__desc" text=${x.descRaw || x.desc} />`)}
`;
}
/**
* 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`
<${TierChip} tier=${slot.tier} size="sm" />${label}
${slot.tier === 5 ? t('6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)') : t('6★ 原型干员与持有的 6★ 干员')}
<${Icon} name="close" />
${kitted == null ? html`
${t('连接服务器后才能选择自选干员')}
`
: list.length ? list.map((o) => html`<${OptionCard} key=${o.charId} m=${m} opt=${o} selected=${draft?.charId === o.charId} onSel=${sel} />`)
: html`
${t('没有符合条件的干员')}
`}
${ch && unit ? html`
<${Img} src=${avatarOf(m, unit, true)} fallback=${html`${[...(unit.name || '?')][0]} `} />
${unit.name} ${classLine(unit)}
<${KindTag} proto=${ch.proto} />
${!ch.proto && onOps ? html`
<${CultivationSelects} charId=${draft.charId} ops=${ops} onSet=${onOps} /> ` : null}
<${LoadoutStats} base=${base} golden=${golden} entries=${previewEntries} level=${previewLevel}
onLevel=${onPreviewLevel} getChess=${previewChess} ops=${ch.proto ? null : ops} />
${t('技能')}${ch.proto ? html`${t('原型干员的技能与补位时一致,不可更改')} ` : null}
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} level=${previewLevel} on=${s.index === skillOn} locked=${ch.proto}
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
${t('模组')}${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}
${ch.proto
? html`<${ModuleRow} id=${modOn} rec=${modOn ? ch.elite.modules?.find((x) => x.uniEquipId === modOn) || null : null} on=${true} locked=${true} onPick=${() => {}} />`
: [html`<${ModuleRow} key="none" id=${null} rec=${null} on=${modOn == null} locked=${false} onPick=${() => setDraft({ ...draft, uniEquipId: null })} />`,
...ch.modules.map((x) => html`<${ModuleRow} key=${x.uniEquipId} id=${x.uniEquipId} rec=${x.rec} on=${modOn === x.uniEquipId} locked=${false}
onPick=${(id) => setDraft({ ...draft, uniEquipId: id })} />`)]}
` : html`
${t('从左侧选择一名干员')}
`}
`;
}
/**
* 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}
`;
}