diff --git a/public/js/screens/game/standInTags.js b/public/js/screens/game/standInTags.js
index 92ad2837..7e49e264 100644
--- a/public/js/screens/game/standInTags.js
+++ b/public/js/screens/game/standInTags.js
@@ -1,12 +1,15 @@
// public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand,
// 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows
// the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which
-// chess it is either way. Anchored to each piece's drawn body (view.pieceScreenRect, followed every frame like the
-// direction wheel follows its tile); never takes the pointer, so the pieces stay draggable.
+// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
+// (m.private.diy) — the model is the operator's everywhere. Anchored to each piece's drawn body (view.pieceScreenRect,
+// followed every frame like the direction wheel follows its tile); never takes the pointer, so the pieces stay
+// draggable.
import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
import { html } from '../../ui/components.js';
-import { fieldsStandIn, standInOf, standInLabel } from '../../ui/gameLogic.js';
+import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js';
+import { t } from '../../../../shared/i18n.js';
const rawOf = (view) => (view && view.raw) || view || null;
@@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) {
return out;
}
-/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */
-export function StandInTags({ view, priv, getChess, backups }) {
- const items = standInPieces(priv, getChess, backups);
+/**
+ * The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }].
+ * @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess: any, backups: any }} data
+ */
+export function diyPieces(priv, getChess, data) {
+ const out = [];
+ if (!priv || !priv.diy || typeof priv.diy !== 'object' || !Object.keys(priv.diy).length) return out;
+ const add = (p, area) => {
+ if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
+ const rec = ownDiyRecord(getChess(p.id), priv, data);
+ if (rec) out.push({ uid: p.uid, label: t('自选'), name: rec.name, area, diy: true });
+ };
+ for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand');
+ for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp');
+ for (const p of Array.isArray(priv.board) ? priv.board : []) add(p, 'board');
+ return out;
+}
+
+/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any, diyData?: { chess: any, backups: any } | null }} props */
+export function StandInTags({ view, priv, getChess, backups, diyData = null }) {
+ const items = [...standInPieces(priv, getChess, backups), ...(diyData ? diyPieces(priv, getChess, diyData) : [])];
const [pos, setPos] = useState({});
const last = useRef('');
const key = items.map((it) => it.uid).join(',');
@@ -64,7 +85,8 @@ export function StandInTags({ view, priv, getChess, backups }) {
return html`
${items.map((it) => {
const p = pos[it.uid];
- return p ? html`${it.label}` : null;
})}
`;
diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js
index de01b249..c3c51534 100644
--- a/public/js/screens/loadout.js
+++ b/public/js/screens/loadout.js
@@ -14,6 +14,8 @@
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
+// The third tab, 自选编队 (0.2.0 DIY, screens/diy.js): the operators of the four DIY slots (room.diy; ui/diyModel.js) —
+// with its own 导出 / 导入 / 全部清空.
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab).
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
@@ -29,9 +31,12 @@ 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, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js';
+import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
+import { DiyPanel, diyData } from './diy.js';
+import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
+import { t } from '../../../shared/i18n.js';
import { copyText } from '../ui/clipboard.js';
import { toast } from '../ui/toasts.js';
@@ -71,7 +76,7 @@ function readFileText(file) {
});
}
-/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */
+/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list, `stronghold-diy-…` for 自选编队) */
function exportFilename(now = new Date(), what = 'loadout') {
const p = (n) => String(n).padStart(2, '0');
return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
@@ -383,10 +388,12 @@ const SYNC_TEXT = {
};
/** The 干员持有 tab's status line: the setting never applies to a running match. */
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
+/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */
+const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT };
/** The overlay screen. */
function LoadoutScreen({ st }) {
- const ready = useData('chess', 'bonds', 'assets', 'local');
+ const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
@@ -411,9 +418,18 @@ function LoadoutScreen({ st }) {
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
- const tab = st.tab === 'ownership' ? 'ownership' : 'loadout';
+ const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
const ownRoster = useOwnershipRoster(ready);
const nNotOwned = notOwnedCount(st.notOwned, ownRoster);
+ // 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome)
+ const nDiy = diyCount(st.diy);
+ const diyLegal = ready ? sanitizeDiyPicks(st.diy, diyData(), st.diyKitted || []) : {};
+ const setDiySlot = (slotId, pick) => setDiyPicks(setPick(loadoutStore.get().diy, slotId, pick));
+ const clearDiy = async () => {
+ if (!nDiy) return;
+ const ok = await confirmDialog({ title: t('全部清空'), text: t('清空全部 {n} 个自选名额?', { n: nDiy }), okText: t('全部清空') });
+ if (ok) setDiyPicks({});
+ };
const setTab = (t) => loadoutStore.set({ tab: t });
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
const ownAll = async () => {
@@ -434,27 +450,38 @@ function LoadoutScreen({ st }) {
// the clipboard, or the textarea); `io.kind` says which
const ioText = io?.text ?? '';
const ioOwn = io?.kind === 'ownership';
+ const ioDiy = io?.kind === 'diy';
const openExport = () => setIo(tab === 'ownership'
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
- : { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
+ : tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
+ : { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
const ioCopy = async () => {
const ok = await copyText(ioText);
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
};
- const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText);
+ const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : 'loadout'), ioText);
const ioPick = () => fileRef.current?.click();
const ioFile = async (e) => {
const f = e.currentTarget.files && e.currentTarget.files[0];
e.currentTarget.value = ''; // picking the same file twice must fire again
if (!f) return;
// refuse a huge pick before reading it into memory (a real payload is a few KB)
+ if (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; }
if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; }
try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
};
const ioApply = () => {
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
+ if (ioDiy) {
+ const r = parseDiyImport(ioText);
+ if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; }
+ const { applied, dropped } = applyDiyImport(r.picks, diyData(), loadoutStore.get().diyKitted || []);
+ setIo(null);
+ toast(applied ? t('已导入 {n} 个自选名额', { n: applied }) + (dropped ? t('(另有 {n} 项不可用,未导入)', { n: dropped }) : '') : t('已导入:全部名额为空'), dropped ? 'warn' : 'success');
+ return;
+ }
if (ioOwn) {
const r = parseOwnershipImport(ioText);
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
@@ -482,7 +509,7 @@ function LoadoutScreen({ st }) {
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
if (typing) return;
- if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership') {
+ if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
if (!ids.length) return;
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
@@ -503,12 +530,15 @@ function LoadoutScreen({ st }) {
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
}, [selId]);
- const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
+ const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
+ : tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
// 干员持有 is out of match: a running match keeps the list its seat had at its start
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
+ const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效')
+ : t('局外设置,下一局生效 · 联机时只进入你自己的商店');
return html`<${Fragment}>
@@ -518,15 +548,23 @@ function LoadoutScreen({ st }) {
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回/>
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js
index eaf509f9..00a3019d 100644
--- a/public/js/ui/detailPanel.js
+++ b/public/js/ui/detailPanel.js
@@ -33,13 +33,14 @@
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
-import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
+import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { abilityRows } from './abilityLines.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
+import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -354,10 +355,12 @@ export function chessStatsBlock({ rec, chess, live = null }) {
`;
}
-export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null }) {
+export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
- const getChess = (id) => data.lookup('chess', id);
+ // 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
+ // the pick's — the lookups resolve the slot's ids to the same pick (shared/diy.js), so the loadout reads them as defaults
+ const getChess = diy ? pickGetter((id) => data.lookup('chess', id), diy, { chess: data.get('chess'), backups: data.get('backups') }) : (id) => data.lookup('chess', id);
// 0.2.0 补位: a chess fielded as its stand-in keeps its identity (name, tier, bonds, 特质, sell price) and shows the
// stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout)
const si = standIn && standIn.standInFor ? standIn : null;
@@ -392,6 +395,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
${golden ? html`精锐` : null}
${piece?.kind === 'token' ? html`召唤物` : null}
${si ? html`${standInLabel(si)}` : null}
+ ${diy ? html`${t('自选')}` : null}
${c.name}
${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}
@@ -613,11 +617,18 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
* @param {Map} pieces indexPieces(priv)
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
* m.private.standIns — and a unit carrying `standInFor` — resolve with `standIn` (the composed stand-in record the
- * card shows as the body)
+ * card shows as the body); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) —
+ * and a unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
*/
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
if (!target) return null;
const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : null);
+ const dd = { chess: data.get('chess'), backups };
+ /** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
+ const ownDiy = (c) => {
+ const rec = c ? ownDiyRecord(c, priv, dd) : null;
+ return rec ? { chess: rec, diy: ownDiyPick(priv, c) } : null;
+ };
if (target.kind === 'piece') {
const e = pieces?.get(target.uid);
if (!e) return null;
@@ -625,6 +636,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p, ownerId: tokenOwnerId(p, pieces) } : null; }
const c = data.lookup('chess', p.id);
+ const d = ownDiy(c);
+ if (d) return { type: 'chess', chess: d.chess, piece: p, standIn: null, diy: d.diy };
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
}
if (target.kind === 'chess') {
@@ -634,6 +647,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
// (a bond popup's member card of a teammate's strip — `owner` another player — and the mode's banned list (`foreign`)
// show the chess as it is: the viewer's 补位 list is not theirs)
const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId);
+ const d = foreign ? null : ownDiy(c);
+ if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: foreign ? null : ownStandIn(c), ...(items.length ? { unitItems: items } : {}) } : null;
}
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
@@ -651,6 +666,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
let si = null;
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
else if (c && own?.piece) si = ownStandIn(c);
+ // 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
+ // m.private.diy like the piece
+ const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
+ const dr = pick ? diyRecordFor(c, pick, dd) : null;
+ if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick };
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
@@ -695,7 +715,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
- standIn=${detail.standIn || null} />` : null}
+ standIn=${detail.standIn || null} diy=${detail.diy || null} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
diff --git a/public/js/ui/diyModel.js b/public/js/ui/diyModel.js
new file mode 100644
index 00000000..adcb2299
--- /dev/null
+++ b/public/js/ui/diyModel.js
@@ -0,0 +1,200 @@
+// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05) — pure logic of the 自选编队 tab of the
+// 干员调配 overlay (screens/diy.js), shared with the sync (ui/loadoutSync.js), the match UI (ui/gameLogic/diy.js) and
+// the tests. Four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6, sold from 调度中心 level 5 / 6);
+// each takes an operator: a 6★ the player owns outside the chess pool (`diy.ownedPool`) — any of its three skills and
+// any module of its elite form but a 集成战略 one (shared/diy.js isDiyModule) — or a prototype (原型干员) with its locked
+// skill / module; only operators with a kit are offered (`kitted`: the server's `welcome.diyKitted`). A prototype may
+// fill a tier-5 and a tier-6 slot, an owned operator one slot; the picks of a tier differ (shared/diy.js
+// validateDiyPicks).
+//
+// The per-browser setting is `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } }`, persisted in localStorage
+// (`sp.pref.diy` = { v: 1, picks }) and sent with C2S `room.diy { picks }` (the server keeps the legal picks and drops
+// the rest — shared/protocol.js checkDiyPicks — so a stale pick never gets the roster refused). Out of match: the next
+// match takes it. 导出 / 导入 use a versioned envelope like the 干员持有 list.
+
+import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js';
+import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js';
+import { unitForm } from '../../../shared/standIn.js';
+
+/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
+export const DIY_PREF = 'diy';
+export const DIY_VERSION = 1;
+/** `kind` of an exported 自选编队 envelope. */
+export const DIY_EXPORT_KIND = 'stronghold.diy';
+/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 1 KB). */
+export const DIY_IMPORT_MAX_BYTES = 64 * 1024;
+
+const ID_RE = /^[A-Za-z0-9_\-.:]{1,64}$/;
+const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
+const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+const isId = (v) => typeof v === 'string' && ID_RE.test(v) && !UNSAFE_IDS.has(v);
+
+/**
+ * The structurally valid picks of a raw map (junk dropped, empty slots left out; at most DIY_LIMITS.slots): each pick
+ * `{ charId, skillIndex?, uniEquipId? }` (skill 0–9, module an id; absent / null = the locked or no module).
+ * @param {any} raw
+ * @returns {Record}
+ */
+export function cleanPicks(raw) {
+ const out = {};
+ if (!isObj(raw)) return out;
+ for (const [slotId, p] of Object.entries(raw)) {
+ if (Object.keys(out).length >= DIY_LIMITS.slots) break;
+ if (!isId(slotId) || !isObj(p) || !isId(p.charId)) continue;
+ const pick = { charId: p.charId };
+ if (Number.isInteger(p.skillIndex) && p.skillIndex >= 0 && p.skillIndex <= 9) pick.skillIndex = p.skillIndex;
+ if (isId(p.uniEquipId)) pick.uniEquipId = p.uniEquipId;
+ else if (p.uniEquipId === null) pick.uniEquipId = null;
+ out[slotId] = pick;
+ }
+ return out;
+}
+
+/** Parse a stored setting (any junk → {}): `{ v, picks }`. @param {any} raw */
+export function parseStoredDiy(raw) {
+ return isObj(raw) ? cleanPicks(raw.picks) : {};
+}
+
+/** Serialised form for localStorage. */
+export const toStoredDiy = (picks) => ({ v: DIY_VERSION, picks: cleanPicks(picks) });
+
+/** Number of filled slots. */
+export const diyCount = (picks) => Object.keys(cleanPicks(picks)).length;
+
+/**
+ * The legal picks of a stored roster against the loaded data and the kit list (checkDiyPicks: what the server keeps).
+ * @param {any} picks @param {any} data `{ chess, backups }` @param {Iterable|null} kitted
+ * @returns {Record}
+ */
+export function sanitizeDiyPicks(picks, data, kitted) {
+ const res = checkDiyPicks(cleanPicks(picks), { data, kitted });
+ return res && 'ok' in res ? res.picks : {};
+}
+
+/**
+ * The four slots in data order: [{ slotId, goldenId, tier, shopLevel }].
+ * @param {any} data
+ */
+export function diySlotList(data) {
+ return diySlotIds(data).map((id) => diySlot(id, data)).filter(Boolean)
+ .map((s) => ({ slotId: s.baseId, goldenId: s.goldenId, tier: s.tier, shopLevel: s.shopLevel }));
+}
+
+const backupsOf = (data) => (isObj(data?.backups) ? data.backups : null);
+
+/**
+ * What a slot's tier offers of an operator: its two forms (normal E2 Lv1 skill rank 4; elite E2 Lv60 rank 7, module
+ * stage 1 at tier 5 / 3 at tier 6), the skills (normal + elite record per index), the modules it may carry (the elite
+ * form's, 集成战略 ones left out) and, for a prototype, its locked selection. Null when the data lacks a form.
+ * @param {string} charId @param {string} slotId a slot base id @param {any} data
+ */
+export function pickChoices(charId, slotId, data) {
+ const slot = diySlot(slotId, data);
+ const backups = backupsOf(data);
+ if (!slot || !backups || !slot.normal || !slot.golden) return null;
+ const normal = unitForm(backups, charId, slot.normal.status);
+ const elite = unitForm(backups, charId, slot.golden.status);
+ if (!normal || !elite) return null;
+ const proto = isPrototypePick(data, slot.tier, charId);
+ const skills = (normal.skills || []).filter(Boolean).map((s) => ({ index: s.index, normal: s, elite: (elite.skills || []).find((e) => e && e.index === s.index) || s }));
+ const modules = (elite.modules || []).filter(isDiyModule).map((m) => ({ uniEquipId: m.uniEquipId, rec: m }));
+ return { tier: slot.tier, stage: slot.golden.status?.equipLevel ?? 0, proto, locked: proto ? lockedSelection(data, slot.tier, charId) : null, skills, modules, normal, elite };
+}
+
+/**
+ * The pick a newly chosen operator starts with: a prototype's locked selection (`{ charId }` — the server completes it),
+ * an owned operator's third skill (its last) and the first module it may carry (none when it has none).
+ * @param {string} charId @param {string} slotId @param {any} data
+ */
+export function defaultPick(charId, slotId, data) {
+ const ch = pickChoices(charId, slotId, data);
+ if (!ch) return { charId };
+ if (ch.proto) return { charId };
+ const last = ch.skills.length ? ch.skills[ch.skills.length - 1].index : 0;
+ return { charId, skillIndex: last, uniEquipId: ch.modules[0]?.uniEquipId ?? null };
+}
+
+/**
+ * The operators a slot lists (shared/diy.js diyPool of its tier with the kit list — prototypes first, then the owned
+ * 6★), each with what the picker card shows and whether another slot holds it: `taken` names that slot (an owned
+ * operator fills one slot; the two picks of a tier differ).
+ * @param {string} slotId @param {Record} picks the current roster @param {any} data
+ * @param {Iterable|null} kitted
+ * @returns {Array<{ charId: string, unit: any, bonds: string[], proto: boolean, taken: string|null }>}
+ */
+export function pickOptions(slotId, picks, data, kitted) {
+ const slot = diySlot(slotId, data);
+ const backups = backupsOf(data);
+ if (!slot || !backups) return [];
+ const kit = kitted ? [...kitted] : [];
+ const tierOf = (id) => diySlot(id, data)?.tier ?? null;
+ const roster = cleanPicks(picks);
+ return diyPool(slot.tier, { data, kitted: kit }).map((charId) => {
+ const proto = isPrototypePick(data, slot.tier, charId);
+ let taken = null;
+ for (const [other, p] of Object.entries(roster)) {
+ if (other === slot.baseId || p.charId !== charId) continue;
+ // the same tier: never twice; another tier: an owned operator only once (a prototype may repeat across tiers)
+ if (tierOf(other) === slot.tier || !isPrototypePick(data, tierOf(other), charId)) { taken = other; break; }
+ }
+ return { charId, unit: backups.units?.[charId] ?? null, bonds: backups.diy?.operators?.[charId]?.bonds ?? [], proto, taken };
+ });
+}
+
+/**
+ * The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
+ * @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
+ */
+export function slotRecord(slotId, pick, data, { elite = false } = {}) {
+ if (!isObj(pick)) return null;
+ try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
+}
+
+/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
+export function setPick(picks, slotId, pick) {
+ const next = { ...cleanPicks(picks) };
+ if (isObj(pick) && isId(pick.charId)) next[slotId] = cleanPicks({ [slotId]: pick })[slotId];
+ else delete next[slotId];
+ return next;
+}
+
+// ---- export / import -------------------------------------------------------------------------------------------------
+
+/** Portable payload of a roster, as downloaded / copied by 导出. */
+export function exportDiy(picks, { now = Date.now() } = {}) {
+ const clean = cleanPicks(picks);
+ return {
+ kind: DIY_EXPORT_KIND,
+ v: DIY_VERSION,
+ exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
+ count: Object.keys(clean).length,
+ picks: clean,
+ };
+}
+
+/** Pretty JSON of `exportDiy`. */
+export const serializeDiy = (picks, opts) => JSON.stringify(exportDiy(picks, opts), null, 2);
+
+/**
+ * Parse an imported roster: the envelope, the stored `{ v, picks }` form, or the text of either. STRUCTURAL only — the
+ * caller sanitises against the loaded data (sanitizeDiyPicks). An envelope may hold no picks (= every slot empty).
+ * Errors are msgids (shown through t()).
+ * @param {any} input payload or text
+ * @returns {{ ok: true, picks: Record } | { ok: false, error: string, params?: Record }}
+ */
+export function parseDiyImport(input) {
+ let raw = input;
+ if (typeof raw === 'string') {
+ if (raw.length > DIY_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
+ const text = raw.trim();
+ if (!text) return { ok: false, error: '没有可导入的内容' };
+ try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
+ }
+ if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
+ const v = Number.isInteger(raw.v) ? raw.v : null;
+ if (v != null && v > DIY_VERSION) return { ok: false, error: '这份数据来自更新的版本(v{v}),请先更新游戏', params: { v } };
+ const kind = typeof raw.kind === 'string' ? raw.kind : null;
+ if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: '这不是自选编队的数据' };
+ if (!isObj(raw.picks)) return { ok: false, error: '里面没有自选编队的数据' };
+ return { ok: true, picks: cleanPicks(raw.picks) };
+}
diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js
index c9f70a23..690785f3 100644
--- a/public/js/ui/fallbackField.js
+++ b/public/js/ui/fallbackField.js
@@ -21,7 +21,7 @@ import { render } from '../../vendor/preact.module.js';
import { html, TierChip } from './components.js';
import { GEO } from '../../../shared/constants.js';
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
-import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf } from './gameLogic.js';
+import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf, ownDiyRecord } from './gameLogic.js';
const DRAG_PX = 6;
const DMG_TTL = 900;
@@ -126,12 +126,17 @@ export function createFallbackView(host, opts = {}) {
const chess = lookup('chess', p.id);
// 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo)
const si = area === 'board' && fieldsStandIn(st.priv, chess) ? standInOf(chess, dataStore?.get?.('backups') ?? null) : null;
- return chessAvatarUrl(mm, si || chess);
+ return chessAvatarUrl(mm, si || ownDiy(chess) || chess);
+ }
+ /** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */
+ function ownDiy(chess) {
+ return chess ? ownDiyRecord(chess, st.priv, { chess: dataStore?.get?.('chess') ?? null, backups: dataStore?.get?.('backups') ?? null }) : null;
}
function pieceName(p) {
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
- return lookup('chess', p.id)?.name || '干员';
+ const chess = lookup('chess', p.id);
+ return (ownDiy(chess) || chess)?.name || '干员';
}
function Piece({ p, x, y, L, area }) {
diff --git a/public/js/ui/gameComponents.js b/public/js/ui/gameComponents.js
index d0bc46f4..6ebc22f2 100644
--- a/public/js/ui/gameComponents.js
+++ b/public/js/ui/gameComponents.js
@@ -93,15 +93,16 @@ export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGold
/**
* Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame.
* @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number,
- * showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string }} props
+ * showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props
+ * `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js)
*/
-export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) {
+export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) {
const m = data.get('assets');
let src = null;
let name = '';
let t = tier;
if (kind === 'chess') {
- const c = data.lookup('chess', id);
+ const c = rec || data.lookup('chess', id);
src = chessAvatarUrl(m, c);
name = c?.name || '';
t = t ?? c?.tier;
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index 4c62eca3..ff50239f 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -42,4 +42,5 @@ export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.js';
+export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.js';
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
diff --git a/public/js/ui/gameLogic/bonds.js b/public/js/ui/gameLogic/bonds.js
index c8e281fe..dca2ba9d 100644
--- a/public/js/ui/gameLogic/bonds.js
+++ b/public/js/ui/gameLogic/bonds.js
@@ -155,14 +155,18 @@ export function harmonyMembers(priv, getChess = () => null) {
* operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that
* wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member,
* like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount).
+ * 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the
+ * own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy`
+ * pick) are rows too (`diy: true`, the operator's name and record `rec`).
* `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count.
* @param {any} bond bonds.json record
* @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
* @param {Set|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess]
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
+ * @param {((p: any) => any)|null} [pieceRecord] the record of one of the pieces (default: getChess(p.id))
*/
-export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) {
+export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null, pieceRecord = null) {
const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []);
const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []);
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
@@ -173,11 +177,22 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
/** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
const granted = new Map();
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
+ /** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */
+ const diy = new Map();
+ const diyOf = (p, on, hand) => {
+ const rec = pieceRecord ? pieceRecord(p) : getChess(p.id);
+ if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false;
+ const g = diy.get(rec.diyFor);
+ if (!g) diy.set(rec.diyFor, { on, hand, rec });
+ else { g.on = g.on || on; g.hand = g.hand || hand; }
+ return true;
+ };
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
if (p?.kind !== 'chess') continue;
const base = baseOf(p.id);
onBoard.add(base); owned.add(base);
+ if (diyOf(p, true, false)) continue;
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) });
}
for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) {
@@ -186,6 +201,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
const base = baseOf(p.id);
owned.add(base);
if (hand) inHand.add(base);
+ if (diyOf(p, false, hand)) continue;
if (memberSet.has(base) || !grants(p)) continue;
const g = granted.get(base);
if (!g) granted.set(base, { on: false, hand, items: itemIds(p) });
@@ -200,6 +216,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
const c = getChess(id);
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items });
}
+ for (const [id, g] of diy) rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, diy: true, rec: g.rec });
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
}
diff --git a/public/js/ui/gameLogic/diy.js b/public/js/ui/gameLogic/diy.js
new file mode 100644
index 00000000..545c01f4
--- /dev/null
+++ b/public/js/ui/gameLogic/diy.js
@@ -0,0 +1,94 @@
+// ui/gameLogic/diy.js — 自选编队 pieces in the match UI (0.2.0 DIY; the owner's decisions of 2026-10-05). Re-exported
+// from ../gameLogic.js.
+//
+// A DIY slot (chess_char_5_diy1_a …) has no body of its own (甄选干员): each player's piece of it is the operator that
+// player slotted — its name, art, class, bonds (from its factions), the pick's skill and module, no 特质; the slot's tier,
+// price and merge. Whose pick: the player's own pieces and cards follow m.private.diy (the picks its seat had when the
+// match started — the server's, not this browser's current setting, which applies from the next match); another
+// player's units say it themselves (UnitInfo `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
+// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
+// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
+
+import { diyRecordOf } from '../../../../shared/diy.js';
+import { isObj } from './shared.js';
+
+/** The player's 自选 picks in this match (m.private.diy; {} when absent). */
+export function diyPicks(priv) {
+ return isObj(priv) && isObj(priv.diy) ? priv.diy : {};
+}
+
+/**
+ * The pick the player's own piece / card of chess record `chess` fields: its slot's in m.private.diy, or null (not a DIY
+ * slot, or one the player did not fill). A composed record (`diyFor`) names its slot itself.
+ * @param {any} priv @param {any} chess
+ */
+export function ownDiyPick(priv, chess) {
+ if (!isObj(chess) || !chess.isDiy) return null;
+ const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
+ const p = diyPicks(priv)[slot];
+ return isObj(p) && typeof p.charId === 'string' ? p : null;
+}
+
+/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
+const CACHE = new WeakMap();
+
+/**
+ * The 自选 record of DIY slot record `chess` (normal or elite) filled with `pick` (shared/diy.js diyRecordOf), cached
+ * per data; null for an illegal pick or missing data.
+ * @param {any} chess the slot's own chess.json record @param {any} pick `{ charId, skillIndex, uniEquipId }`
+ * @param {{ chess?: any, backups?: any }} data
+ */
+export function diyRecordFor(chess, pick, data) {
+ if (!isObj(chess) || !chess.isDiy) return null;
+ if (chess.diyFor) return chess; // already composed
+ if (!isObj(pick) || !isObj(data?.backups)) return null;
+ let m = CACHE.get(data.backups);
+ if (!m) { m = new Map(); CACHE.set(data.backups, m); }
+ const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
+ if (m.has(key)) return m.get(key);
+ let rec;
+ try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; }
+ m.set(key, rec);
+ return rec;
+}
+
+/** The record the player's own piece / card of `chess` shows: its 自选 record (own pick), or null. */
+export function ownDiyRecord(chess, priv, data) {
+ const pick = ownDiyPick(priv, chess);
+ return pick ? diyRecordFor(chess, pick, data) : null;
+}
+
+/**
+ * The 自选 record a card of `chess` shows for this viewer, or null: a unit that carries `diy` (another player's — the
+ * sim's UnitInfo, prep scouting, a result lineup entry), else the player's own piece / card (m.private.diy).
+ * @param {any} chess @param {{ priv?: any, unit?: any, data?: any }} o
+ */
+export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
+ if (!isObj(chess) || !chess.isDiy) return null;
+ if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null;
+ return ownDiyRecord(chess, priv, data);
+}
+
+/**
+ * id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
+ * pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
+ * resolveLoadout then reads the pick's skill and module as the record's defaults).
+ * @param {(id: string) => any} getChess @param {any} priv m.private (or a getter of the current one) @param {any} data
+ */
+export function diyGetter(getChess, priv, data) {
+ return (id) => {
+ const c = getChess(id);
+ return ownDiyRecord(c, typeof priv === 'function' ? priv() : priv, typeof data === 'function' ? data() : data) || c;
+ };
+}
+
+/** id → the 自选 record of a slot filled with `pick` (another player's unit), else the chess record. */
+export function pickGetter(getChess, pick, data) {
+ return (id) => {
+ const c = getChess(id);
+ return (isObj(pick) && c && c.isDiy ? diyRecordFor(c, pick, data) : null) || c;
+ };
+}
+
+/** Whether a record is a composed 自选 record. */
+export const isDiyRecord = (rec) => isObj(rec) && typeof rec.diyFor === 'string';
diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js
index fa4199b5..72abe481 100644
--- a/public/js/ui/loadoutSync.js
+++ b/public/js/ui/loadoutSync.js
@@ -1,23 +1,28 @@
-// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位).
+// Operator loadout + ownership + 自选编队 state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位; 自选编队 — 0.2.0 DIY).
//
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref), the
-// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise) and the overlay's screen state (open / origin /
-// tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` (called once by main.js) keep the
-// server's copies current — one sync engine (installPrefSync), two settings: after every `welcome` (new or resumed
+// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise), the 自选编队 picks (`diy`, likewise; with
+// `diyKitted` = the operators the server lets a DIY slot field, from every `welcome`) and the overlay's screen state
+// (open / origin / tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` / `installDiySync()`
+// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after
+// every `welcome` (new or resumed
// session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }`
// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never
-// the whole loadout) and `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
-// the rest, so no data is needed). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps
-// what it took (the loadout locks when INFO_CHECK ends; the ownership never changes during a match) — stored for the
-// next match, not an error for the player; anything else is logged. `sync` / `ownSync` ∈ 'idle' | 'pending' |
-// 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line.
+// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
+// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
+// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
+// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
+// next match, not an error for the player; anything else is logged. `sync` / `ownSync` / `diySync` ∈ 'idle' |
+// 'pending' | 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line.
import { createStore, loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
+import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
import { toast } from './toasts.js';
+import { t } from '../../../shared/i18n.js';
export const SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500;
@@ -28,18 +33,24 @@ function readStored() {
function readStoredOwnership() {
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
}
+function readStoredDiy() {
+ try { return parseStoredDiy(loadPref(DIY_PREF, null)); } catch { return {}; }
+}
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
export const loadoutStore = createStore({
entries: readStored(),
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
+ diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
+ diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome)
open: false,
from: null, // 'lobby' | 'room' | 'briefing'
- tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有)
+ tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) | 'diy' (自选编队)
sel: null, // selected base chess id
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
sync: 'idle',
ownSync: 'idle',
+ diySync: 'idle',
});
/** Replace the stored entries (persisted at once; the sync picks the change up). */
@@ -86,9 +97,30 @@ export function applyOwnershipImport(list, lookup) {
return { applied: clean.length, dropped: Math.max(0, asked - clean.length) };
}
+/** Replace the stored 自选编队 picks (persisted at once, the sync picks the change up). */
+export function setDiyPicks(picks) {
+ const next = cleanPicks(picks);
+ try { savePref(DIY_PREF, toStoredDiy(next)); } catch { /* private mode: the session keeps it */ }
+ loadoutStore.set({ diy: next });
+}
+
+/**
+ * Apply an imported roster (parseDiyImport(...).picks), sanitised against the loaded data and the kit list (the picks
+ * the server would keep), persisted and synced like an edit. An empty roster is a valid import (= every slot empty).
+ * @param {Record} picks @param {any} data `{ chess, backups }` @param {Iterable|null} kitted
+ * @returns {{ applied: number, dropped: number }}
+ */
+export function applyDiyImport(picks, data, kitted) {
+ const asked = Object.keys(cleanPicks(picks)).length;
+ const clean = sanitizeDiyPicks(picks, data, kitted);
+ setDiyPicks(clean);
+ const applied = Object.keys(clean).length;
+ return { applied, dropped: Math.max(0, asked - applied) };
+}
+
/**
* Open the 干员调配 overlay. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel]
- * @param {'loadout'|'ownership'|null} [tab] the tab to show (default: the last one)
+ * @param {'loadout'|'ownership'|'diy'|null} [tab] the tab to show (default: the last one)
*/
export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('chess');
@@ -96,7 +128,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('assets');
data.load('local');
data.load('backups');
- loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) });
+ loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
}
export const closeLoadout = () => loadoutStore.set({ open: false });
@@ -107,7 +139,8 @@ export const closeLoadout = () => loadoutStore.set({ open: false });
*/
function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) {
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
- const tell = notify || ((text) => toast(text, 'warn'));
+ // (`lockedText` is a msgid: the toast is translated when it shows — docs/I18N.md)
+ const tell = notify || ((text) => toast(t(text), 'warn'));
let timer = null;
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
let pendingJson = null; // JSON of the newest request still awaiting its reply
@@ -228,3 +261,24 @@ export function installOwnershipSync({ net, timers, target = loadoutStore, notif
prepare: async () => cleanIds(target.get().notOwned),
});
}
+
+/**
+ * Wire the 自选编队 sync once (0.2.0 DIY): `room.diy { picks }` after every welcome and edit. The picks go as stored
+ * (structurally clean); the server keeps the legal ones. During a match the server stores them for the next one
+ * (ROOM_STARTED → 'locked'): the setting is out of match. Every `welcome` also brings the operators a DIY slot may field
+ * (`diyKitted`, the picker's list).
+ * @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType,
+ * notify?: (text: string) => void }} deps
+ */
+export function installDiySync({ net, timers, target = loadoutStore, notify } = {}) {
+ const offKit = net.on('welcome', (msg) => {
+ const list = msg && Array.isArray(msg.diyKitted) ? msg.diyKitted.filter((x) => typeof x === 'string') : null;
+ target.set({ diyKitted: list });
+ });
+ const sync = installPrefSync({
+ net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy',
+ lockedText: '自选编队是局外设置,修改将在下一局生效',
+ prepare: async () => cleanPicks(target.get().diy),
+ });
+ return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } };
+}
diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js
index 967bbdb2..b60fd627 100644
--- a/public/js/ui/shopBar.js
+++ b/public/js/ui/shopBar.js
@@ -19,14 +19,17 @@
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
// module type of an elite card, with its official type icon when the local-client art has it). A chess the player does
// not own (0.2.0 补位, m.private.standIns) keeps its card — name, portrait, bonds, price — with an ice 「替补:X」 badge,
-// and the skill icon is the stand-in's (its backup skill: the one that fights).
+// and the skill icon is the stand-in's (its backup skill: the one that fights). A 自选 piece (0.2.0, m.private.diy — a DIY
+// slot the player filled, only ever in its own shop) shows the operator — name, portrait, class, the bonds of its
+// factions, the pick's skill and module — with a mint 「自选」 badge.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
-import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
+import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, diyGetter } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
+import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -64,7 +67,11 @@ function ArmedTag({ reason, free }) {
* onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props
*/
export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) {
- const c = data.lookup('chess', slot.id);
+ const c0 = data.lookup('chess', slot.id);
+ // 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy)
+ const diyData = { chess: data.get('chess'), backups: data.get('backups') };
+ const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null;
+ const c = dr || c0;
const m = data.get('assets');
const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
@@ -74,7 +81,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const disabled = !!reason;
// 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill
const si = c && fieldsStandIn(priv, c) ? standInOf(c, data.get('backups')) : null;
- const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
+ const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
const card = html`