feedback5 WK: 自选编队 in the browser — the 干员调配 overlay's third tab 自选编队 (5阶 ×2, 6阶 ×2 slots; the slot picker lists the kitted picks of the tier with avatar, name, class, derived bonds and a 原型 / 已持有 tag; an owned pick chooses its skill and module — 集成战略 modules left out —, a prototype shows its locked skill; sp.pref.diy, export / import, 全部清空, synced as room.diy through the shared engine, welcome.diyKitted kept), the operator on shop / reward cards, own pieces (「自选」 tag, its model on bench and board), the detail card (pick's skill and module), placement and range previews, bond popups (own and a teammate's 自选 member), the result screen; new UI strings through t() with English; the e2e fast server's SP_START_LEVEL hook

Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged.
Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
This commit is contained in:
sganggs committed 2026-10-06 00:13:31 +08:00
1 parent e480b1cb26
commit 0def7babfd
31 files changed
+1637 -78

No files matched your search

+9 -4
View File
@@ -21,10 +21,11 @@
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js';
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -99,8 +100,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount);
const active = !off && (entry ? !!entry.active : tier > 0);
const getChess = (id) => data.lookup('chess', id);
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id));
// (自选 pieces are their operators: the own ones by m.private.diy, a watched teammate's by the pick its unit carries)
const dd = { chess: data.get('chess'), backups: data.get('backups') };
const getChess = diyGetter((id) => data.lookup('chess', id), priv, dd);
const pieceRecord = (p) => (p && p.diy && typeof p.diy === 'object' ? diyRecordFor(data.lookup('chess', p.id), p.diy, dd) : null) || getChess(p.id);
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id), pieceRecord);
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
@@ -149,10 +153,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted')}
onClick=${() => onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${mb.diy ? mb.rec : null} />
<span class="bpop__mname">${mb.name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
</button>`)}
</div>
</section>
+25 -5
View File
@@ -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 }) {
</div>`;
}
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`<span class="dtag-elite">精锐</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</span>` : null}
${si ? html`<span class="dtag-standin" data-standin=${si.charId} title=${`未持有${c.name}:由替补干员 ${si.name} 上场(盟约、特质、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
${diy ? html`<span class="dtag-diy" data-diy=${c.charId || ''} title=${t('自选编队:所选技能与模组,没有特质,盟约按所属阵营分配')}>${t('自选')}</span>` : null}
</div>
<h3 class="dhead__name">${c.name}</h3>
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
@@ -613,11 +617,18 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
* @param {Map<number, any>} 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
<div class="dpanel__scroll">
${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}
+200
View File
@@ -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<string, { charId: string, skillIndex?: number, uniEquipId?: string|null }>}
*/
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<string>|null} kitted
* @returns {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>}
*/
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<string, any>} picks the current roster @param {any} data
* @param {Iterable<string>|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<string, any> } | { ok: false, error: string, params?: Record<string, any> }}
*/
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) };
}
+8 -3
View File
@@ -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 }) {
+4 -3
View File
@@ -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;
+1
View File
@@ -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';
+18 -1
View File
@@ -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>|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));
}
+94
View File
@@ -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';
+67 -13
View File
@@ -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<string, any>} picks @param {any} data `{ chess, backups }` @param {Iterable<string>|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<typeof createStore>,
* 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(); } };
}
+12 -4
View File
@@ -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`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
@@ -90,6 +97,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
</span>` : null}
</span>
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
${dr ? html`<span class="scard__diy" data-diy=${dr.charId} title=${t('自选编队:{name}(只在你的商店出现)', { name: dr.name })}>${t('自选')}</span>` : null}
<span class="scard__body">
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
<span class="scard__name">${c?.name || slot.id}</span>
+6 -1
View File
@@ -162,7 +162,12 @@ export function ownerBoard(field, ownerId, extra = null) {
if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
.map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }));
.map((u) => {
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
return p;
});
return { board, hand: [], temp: [] };
}