// 干员调配 (Operator loadout, DESIGN §16): choose the equipped skill of every chess and the module of its elite before a
// match (official rule: "开始游戏前无法调整干员的等级,但可调整其所携带的技能和模组"). A full-screen overlay opened
// from the lobby, the room and the briefing (INFO_CHECK) — `openLoadout(from)` / ; is
// mounted once by main.js. Styles: css/screens/loadout.css (official preset sprites from the local-client art when
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
// groups.module — lettered tiles without them).
//
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
import { chessLoadout } from '../ui/gameLogic.js';
import { data, useData, localAsset } from '../data.js';
import { useStore } from '../store.js';
import { PHASE } from '../../../shared/constants.js';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries } from '../ui/loadoutSync.js';
import { copyText } from '../ui/clipboard.js';
import { toast } from '../ui/toasts.js';
export { openLoadout, closeLoadout };
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
// ---- export / import (干员调配 presets) -----------------------------------------------------------------------------
//
// The payload is the versioned envelope of ui/loadoutModel.js (exportPayload / parseImport): a downloaded file and a
// pasted string are the SAME object, so 导出 and 导入 both funnel through applyLoadoutEntries
// (sanitise → persist → room.loadout). The dialog is a shared Modal rendered next to the overlay, not inside it.
/** Save `text` as a download. Silent no-op when the browser refuses downloads — 复制 stays available. */
function downloadText(filename, text) {
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch { /* ignore */ }
}
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
function readFileText(file) {
if (typeof file?.text === 'function') return file.text();
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result ?? ''));
fr.onerror = () => reject(fr.error || new Error('read failed'));
fr.readAsText(file);
});
}
/** `stronghold-loadout-20261003-1245.json` */
function exportFilename(now = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `stronghold-loadout-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
}
/** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */
function profGlyphUrl(m, prof) {
const k = String(prof || '').toLowerCase();
const v = m && m.prof && m.prof.large ? m.prof.large[k] : null;
return typeof v === 'string' ? v : null;
}
/** Skill icon URL for a SkillRecord (assets manifest `skills[iconId]` / `skillsById`), null when absent. */
function skillIconOf(m, rec) {
const skills = m && typeof m === 'object' ? m.skills : null;
if (!skills || !rec) return null;
const id = rec.iconId || rec.skillId;
if (id && typeof skills[id] === 'string') return skills[id];
const alt = m.skillsById && rec.skillId ? m.skillsById[rec.skillId] : null;
return alt && typeof skills[alt] === 'string' ? skills[alt] : null;
}
/**
* Module icon URL: the official type icon from the local-client art (`local-assets.json` groups.module, matched
* case-insensitively by typeName), else manifest `modules[icon]` / `uniequip[icon]` when the asset pipeline provides one.
*/
function moduleIconOf(m, rec) {
if (!rec) return null;
const local = moduleTypeIconUrl(data.get('local'), rec.typeName || rec.type);
if (local) return local;
if (!m) return null;
for (const group of ['modules', 'uniequip', 'equip']) {
const g = m[group];
if (g && typeof g === 'object') {
const v = g[rec.icon] || g[rec.uniEquipId] || g[rec.typeIcon];
if (typeof v === 'string') return v;
}
}
return null;
}
/** Skill icon with the official selection outline; falls back to a lettered tile. */
function SkillIcon({ m, rec, index, on = false, size = 'md' }) {
const src = skillIconOf(m, rec);
return html`
<${Img} src=${src} fallback=${html`${skillLabel(index)}`} />
${on && size !== 'xs' ? html`<${Img} src=${localAsset('ui/outer', 'image_skill_select_outline')} class="lo-sicon__outline" fallback=${html``} />` : null}
`;
}
/** Module badge tile: the official type icon when the local-client art has it, else the type letter; ⊘ for 不装备. */
export function ModuleGlyph({ m, rec, id, size = 'md' }) {
const src = rec ? moduleIconOf(m, rec) : null;
if (id === MODULE_NONE || !rec) {
return html`
<${Img} src=${localAsset('ui/outer', 'icon_equip_non')} fallback=${html``} />
`;
}
return html`
<${Img} src=${src} fallback=${html`${moduleBadge(rec)}`} />
`;
}
// ---- roster card ---------------------------------------------------------------------------------------------------
function RosterCard({ m, chess, golden, entries, selected, onPick }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
const modChanged = golden && choice.module !== opt.defaultModule;
return html``;
}
// ---- detail --------------------------------------------------------------------------------------------------------------
function SkillOption({ m, opt, on, level, onPick }) {
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = skillTags(rec);
return html``;
}
function ModuleInfo({ m, golden, opt }) {
if (!golden) return null;
const rec = opt.id === MODULE_NONE ? null : opt.rec;
if (!rec) {
const traitBase = golden.traitBase || null;
return html`
`;
}
const getChessRec = (id) => data.lookup('chess', id);
/**
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
* stats. Nothing is recomputed here.
* @param {any} base normal chess record @param {any} golden its elite record or null
* @param {Record} entries the stored loadout @param {'normal'|'elite'} level
* @param {(id: string) => any} getChess
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
*/
export function statsPreview(base, golden, entries, level, getChess) {
const elite = level === 'elite' && !!golden;
const chess = elite ? golden : base;
if (!chess) return null;
const lo = chessLoadout(chess, entries, getChess);
const record = lo?.record || chess;
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
}
/**
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
* @param {{ base: any, golden: any, entries: Record, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
*/
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) {
const pv = statsPreview(base, golden, entries, level, getChess);
if (!pv) return null;
return html`
/>` : null}
/>`;
}
/**
* Whether the overlay must close because its context moved on: opened from the briefing and the match left INFO_CHECK
* (the strategy draft needs the player), or opened from the lobby / room and a match started (its briefing takes over).
* @param {{ open: boolean, from: string|null }} st @param {string|null} phase @param {boolean} inMatch @param {boolean} wasInMatch
*/
export function shouldAutoClose(st, phase, inMatch, wasInMatch) {
if (!st || !st.open) return false;
if (st.from === 'briefing') return !!phase && phase !== PHASE.INFO_CHECK;
return inMatch && !wasInMatch;
}
/** Mounted once (main.js): renders the overlay while open. */
export function LoadoutHost() {
const st = useStore((s) => s, Object.is, loadoutStore);
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
const wasInMatch = useRef(inMatch);
useEffect(() => {
if (shouldAutoClose(st, phase, inMatch, wasInMatch.current)) closeLoadout();
wasInMatch.current = inMatch;
}, [phase, inMatch, st.open]);
useEffect(() => {
if (st.open) document.documentElement.classList.add('sp-loadout-open');
else document.documentElement.classList.remove('sp-loadout-open');
}, [st.open]);
if (!st.open) return null;
return html`<${LoadoutScreen} st=${st} />`;
}
/**
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
* download). Review fix: it counted stale entries the screen does not.
* @param {Record} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
*/
export function badgeCount(entries, getChess) {
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
}
/**
* Entry button (lobby / room / briefing).
* @param {{ from: 'lobby'|'room'|'briefing', size?: string, variant?: string, class?: string, label?: string }} props
*/
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) {
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
return html``;
}