feedback5 WG: 补位 in the browser — the 干员持有 tab next to 干员调配 (the 53 NORMAL chess by tier, operator → stand-in, a switch each, 全部持有, export / import, localStorage + room.ownership through the shared sync engine), 「替补:X」 on shop / reward cards and own prep pieces, the stand-in's model on the board, its body on the detail card (own pieces, cards, any unit with standInFor), placement and ranges by the stand-in

This commit is contained in:
sganggs committed 2026-10-05 19:34:52 +08:00
1 parent 24205581e1
commit 4b57f8120c
23 files changed
+1227 -126

No files matched your search

+38 -21
View File
@@ -33,7 +33,7 @@
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 } from './gameLogic.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel } 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';
@@ -354,17 +354,22 @@ 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 }) {
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
const getChess = (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;
const body = si || chess;
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
const fr = lo?.record || c;
const fr = lo?.record || body;
const golden = !!(c.isGolden || piece?.golden);
const sk = lo?.skill || c.skill || null;
const sk = lo?.skill || body.skill || null;
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, body);
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
const items = Array.isArray(piece?.items) ? piece.items : [];
@@ -379,36 +384,38 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
blocks.head = html`
<div key="head" class="dhead">
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
<${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
</div>
<div class="dhead__info">
<div class="dhead__chips">
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
${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}
</div>
<h3 class="dhead__name">${c.name}</h3>
<span class="dhead__en">${c.appellation || ''}</span>
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
<div class="dhead__class">
<${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" />
<span>${PROF_NAME[c.profession] || c.profession || ''}</span>
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" />
<span>${PROF_NAME[body.profession] || body.profession || ''}</span>
<i class="sep"></i>
<${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" />
<span>${c.subProfessionName || ''}</span>
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
<${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" />
<span>${body.subProfessionName || ''}</span>
<span class="dhead__pos">${body.position === 'MELEE' ? '近战位' : '远程位'}</span>
</div>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
<${BondChips} bondIds=${bondIds} bonds=${bonds} off=${offBonds} onBond=${onBond} granted=${grantedIds} />
</div>
</div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
blocks.trait = body.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(body, golden, lo)} /></p>` : null;
// (`fr` is the body the unit fights with — a stand-in's record for a 补位 chess; `chess` is only the range's fallback)
blocks.stats = chessStatsBlock({ rec: fr, chess: c, live });
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
<div class="dskill" data-skill=${sk.skillId || ''}>
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
<div class="dskill__meta">
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
<div class="dskill__tags">
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${SP_TYPE[sk.spType] || '技力'}</span>
<span class="dsp dsp--trig">${SKILL_TYPE[sk.skillType] || '自动触发'}</span>
@@ -426,7 +433,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
<b class="dmodule__name">${lo.module.name}</b>
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
${!lo.defaultModule && !si ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
</div>
<//>` : null;
// (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`)
@@ -604,9 +611,13 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
* @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)
*/
export function resolveDetail(target, pieces) {
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);
if (target.kind === 'piece') {
const e = pieces?.get(target.uid);
if (!e) return null;
@@ -614,13 +625,13 @@ export function resolveDetail(target, pieces) {
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);
return c ? { type: 'chess', chess: c, piece: p } : null;
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
}
if (target.kind === 'chess') {
// a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip
const c = data.lookup('chess', target.id);
const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : [];
return c ? { type: 'chess', chess: c, hint: target.hint || null, ...(items.length ? { unitItems: items } : {}) } : null;
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: target.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; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
@@ -632,7 +643,12 @@ export function resolveDetail(target, pieces) {
// a hand item on a scouted prep board (m.field units, kind 'item'): the item's own card
if (u.kind === 'item') { const it = data.lookup('items', u.defId); return it ? { type: 'item', item: it } : null; }
const c = data.lookup('chess', u.defId);
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null };
// a unit says itself whether it is a stand-in (UnitInfo standInFor: the sim's, prep scouting's); an own piece's unit
// follows m.private.standIns like the piece
let si = null;
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
else if (c && own?.piece) si = ownStandIn(c);
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) };
const en = data.lookup('enemies', u.defId);
@@ -675,7 +691,8 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<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} />` : null}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
standIn=${detail.standIn || 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}
+4 -3
View File
@@ -11,15 +11,15 @@ import {
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Data files the in-match screens use. */
/** Data files the in-match screens use (`backups`: the 补位 stand-ins' bodies — cards, the board model, the detail card). */
export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens',
'choices', 'effects', 'garrisons', 'factions', 'local'];
'choices', 'effects', 'garrisons', 'factions', 'local', 'backups'];
/**
* Load every in-match data file; returns lookups (sync, null until loaded).
* @returns {{ ready: boolean, m: any, config: any, chess: (id:string)=>any, bond: (id:string)=>any, item: (id:string)=>any,
* band: (id:string)=>any, enemy: (k:string)=>any, boss: (id:string)=>any, stage: (id:string)=>any, token: (id:string)=>any,
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, list: (name:string)=>any[] }}
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, backups: any, list: (name:string)=>any[] }}
*/
export function useGameData() {
const ready = useData(...GAME_FILES);
@@ -44,6 +44,7 @@ export function makeLookups(ready = true) {
garrison: (id) => data.lookup('garrisons', id),
factions: data.get('factions'),
choices: data.get('choices'),
backups: data.get('backups'),
list: (name) => data.list(name),
};
}
+1
View File
@@ -41,4 +41,5 @@ export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/sh
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 { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
+8 -8
View File
@@ -1,13 +1,12 @@
// ui/gameLogic/placement.js — deploy maps and the canPlace mirror of the server rules. Re-exported from ../gameLogic.js.
import { GEO } from '../../../../shared/constants.js';
import { resolveLoadout } from '../../../../shared/protocol.js';
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { meleeOnHighGround } from '../../../../shared/highGround.js';
import { pieceDir, rangeTiles } from '../facing.js';
import { isObj, tileKey } from './shared.js';
import { boardTileOf, fieldTile } from './camera.js';
import { chessLoadout } from './loadout.js';
import { deployedRecord, deployedModuleId, fieldsStandIn, standInOf } from './standIn.js';
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
@@ -194,7 +193,7 @@ export function indexPieces(priv) {
* @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any,
* getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o
*/
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) {
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null, backups = null }) {
const pieces = indexPieces(priv);
const boardAt = new Map();
for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e);
@@ -210,7 +209,7 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
const deploy = field === 'bossL' || field === 'bossR'
? deploySets(stage, field, ov)
: deploySets(effectiveStage(stage, ov));
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem };
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem, backups };
}
/**
@@ -221,9 +220,10 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
export function piecePosition(ctx, piece) {
if (!isObj(piece)) return null;
if (piece.kind === 'chess') {
const rec = ctx.getChess(piece.id);
let moduleId = null;
try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; }
const chess = ctx.getChess(piece.id);
// 0.2.0 补位: a chess the player fields as its stand-in is placed by the stand-in's position (server placeClass)
const rec = (fieldsStandIn(ctx.priv, chess) && standInOf(chess, ctx.backups)) || chess;
const moduleId = deployedModuleId(chess, ctx.priv, ctx.getChess, ctx.backups);
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
}
@@ -256,7 +256,7 @@ export function summonRange(ctx, piece, owner = null) {
const rec = at && ctx.getChess(at.piece.id);
if (!rec) return null;
let grid = null;
try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ }
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
}
+110
View File
@@ -0,0 +1,110 @@
// ui/gameLogic/standIn.js — 补位 stand-ins in the match UI (0.2.0, the approved plan — owner's decision 2026-10-05).
// Re-exported from ../gameLogic.js.
//
// A chess the player marked as not owned (干员持有) keeps its card — name, bonds, price, the shop / reward / hand art —
// with a 「替补:<stand-in>」 badge, and is deployed as its official stand-in: the board model, the battle unit and the
// detail card's body (portrait, class, 特性, stats, range, skill, talents, module) are the stand-in's, its identity
// (name, tier, bonds, 特质) the chess's. Whose pieces: the player's own come from m.private.standIns (the list the 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 `standInFor`: the sim's, and Match.prepFieldMeta's for scouting).
// The composed record is shared/standIn.js standInRecord over data/backups.json — the one the sim fields.
import { standInRecord } from '../../../../shared/standIn.js';
import { resolveLoadout } from '../../../../shared/protocol.js';
import { chessLoadout } from './loadout.js';
import { isObj } from './shared.js';
/** Base chess ids the player fields as stand-ins in this match (m.private.standIns; [] when absent). */
export function standInIds(priv) {
const list = isObj(priv) && Array.isArray(priv.standIns) ? priv.standIns : [];
return list.filter((x) => typeof x === 'string');
}
/** Whether the player's own piece / card of chess record `chess` (normal or elite) fields its stand-in. */
export function fieldsStandIn(priv, chess) {
if (!isObj(chess) || typeof chess.chessId !== 'string') return false;
const ids = standInIds(priv);
return ids.length > 0 && ids.includes(chess.baseId || chess.chessId);
}
/** Per backups object: chess record → composed stand-in record (or null). */
const CACHE = new WeakMap();
/**
* The composed 补位 record of a chess record (shared/standIn.js standInRecord: the chess's identity, the stand-in's
* body; `standInFor` = the replaced charId), cached per data object; null for a PRESET / 自选 chess or missing data.
* @param {any} chess data/chess.json record @param {any} backups data/backups.json
*/
export function standInOf(chess, backups) {
if (!isObj(chess) || !isObj(backups)) return null;
let m = CACHE.get(backups);
if (!m) { m = new WeakMap(); CACHE.set(backups, m); }
if (m.has(chess)) return m.get(chess);
let rec;
try { rec = standInRecord(chess, backups); } catch { rec = null; }
m.set(chess, rec);
return rec;
}
/**
* The stand-in record the card of a chess shows for this viewer, or null: the player's own piece / card (m.private
* standIns), or a unit that carries `standInFor` (another player's — the sim's UnitInfo, prep scouting).
* @param {any} chess @param {{ priv?: any, unit?: any, backups?: any }} o
*/
export function cardStandIn(chess, { priv = null, unit = null, backups = null } = {}) {
if (!isObj(chess)) return null;
const mine = unit ? typeof unit.standInFor === 'string' && !!unit.standInFor : fieldsStandIn(priv, chess);
return mine ? standInOf(chess, backups) : null;
}
/** id → stand-in record (else the chess record): the lookup a stand-in's loadout / options resolve against. */
export function standInGetter(getChess, backups) {
return (id) => {
const c = getChess(id);
return standInOf(c, backups) || c;
};
}
/**
* The skill / module / record a stand-in fights with (chessLoadout of the composed record, no player loadout —
* "对于补位干员其技能不可更改"): its backup skill, the elite's backup module (or none).
* @param {any} rec standInOf(...) @param {(id: string) => any} getChess @param {any} backups
*/
export function standInLoadout(rec, getChess, backups) {
return rec ? chessLoadout(rec, null, standInGetter(getChess, backups)) : null;
}
/**
* The record the player's own piece of `chess` is deployed with — the stand-in record when the player fields its
* stand-in, else the chess as the player's loadout makes it (chessLoadout `.record`): its range (board overlay, deploy
* wheel), its position (legal tiles), the board model.
* @param {any} chess @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups
*/
export function deployedRecord(chess, priv, getChess, backups) {
if (!isObj(chess)) return chess;
if (fieldsStandIn(priv, chess)) {
const si = standInOf(chess, backups);
if (si) return standInLoadout(si, getChess, backups)?.record || si;
}
return chessLoadout(chess, priv?.loadout ?? null, getChess)?.record || chess;
}
/**
* The module id a deployed piece carries (the placement rule of elite 歌蕾蒂娅's HOK-Y reads it): a stand-in's backup
* module, else the player's loadout.
*/
export function deployedModuleId(chess, priv, getChess, backups) {
if (!isObj(chess)) return null;
try {
if (fieldsStandIn(priv, chess)) {
const si = standInOf(chess, backups);
if (si) return resolveLoadout(null, si, standInGetter(getChess, backups))?.moduleId ?? null;
}
return resolveLoadout(priv?.loadout ?? null, chess, getChess)?.moduleId ?? null;
} catch { return null; }
}
/** The 「替补:X」 badge text of a stand-in record (null without one). */
export function standInLabel(rec) {
return isObj(rec) && rec.standInFor && rec.name ? `替补:${rec.name}` : null;
}
+107 -43
View File
@@ -1,19 +1,22 @@
// Operator loadout state + server sync (DESIGN §16).
// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位).
//
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref) and the
// 干员调配 screen state (open / origin / selection / filters). `installLoadoutSync()` (called once by main.js) keeps the
// server's copy current: after every `welcome` (new or resumed session — the server keeps it 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), it sends `room.loadout { entries }` with
// the entries sanitised against the loaded data/chess.json (ui/loadoutModel.js sanitizeEntries: a stale entry is
// dropped, never the whole loadout). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match has
// locked its loadout (it applies to the next match, the server stored it) — not an error for the player; anything else
// is logged. `sync.state` ∈ 'idle' | 'pending' | 'sending' | 'synced' | 'locked' | 'error' is mirrored into the store
// for the screen's status line.
// `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
// 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.
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 { toast } from './toasts.js';
export const SYNC_DEBOUNCE_MS = 500;
@@ -22,15 +25,21 @@ export const RETRY_MS = 1500;
function readStored() {
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
}
function readStoredOwnership() {
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
}
/** Loadout + screen state (separate from the app store: it must survive room / match resets). */
/** 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)
open: false,
from: null, // 'lobby' | 'room' | 'briefing'
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有)
sel: null, // selected base chess id
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
sync: 'idle',
ownSync: 'idle',
});
/** Replace the stored entries (persisted at once; the sync picks the change up). */
@@ -57,35 +66,56 @@ export function applyLoadoutEntries(entries, lookup) {
return { applied, dropped: Math.max(0, asked - applied) };
}
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
export function openLoadout(from = 'lobby', sel = null) {
/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */
export function setNotOwned(list) {
const next = cleanIds(list);
savePref(OWNERSHIP_PREF, toStoredOwnership(next));
loadoutStore.set({ notOwned: next });
}
/**
* Apply an imported not-owned list (parseOwnershipImport(...).notOwned), sanitised against the loaded data (droppable
* chess only), persisted and synced like an edit. An empty list is a valid import (= every operator owned).
* @param {string[]} list @param {(id: string) => any} lookup chess lookup
* @returns {{ applied: number, dropped: number }}
*/
export function applyOwnershipImport(list, lookup) {
const asked = cleanIds(list).length;
const clean = sanitizeNotOwned(list, lookup);
setNotOwned(clean);
return { applied: clean.length, dropped: Math.max(0, asked - clean.length) };
}
/**
* 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)
*/
export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('chess');
data.load('bonds');
data.load('assets');
data.load('local');
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}) });
data.load('backups');
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) });
}
export const closeLoadout = () => loadoutStore.set({ open: false });
/**
* Wire the sync once. Dependencies are injectable for tests.
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
* One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field,
* `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing:
* nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player.
*/
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
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 ready = getChessReady || (() => data.load('chess'));
const lookup = lookupChess || ((id) => data.lookup('chess', id));
const tell = notify || ((text) => toast(text, 'warn'));
let timer = null;
let seq = 0; // room.loadout requests sent (the reply of an older one never overrides a newer one's state)
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
let lastSent = null; // JSON of the last entries the server accepted (on this session)
let lastSent = null; // JSON of the last payload the server accepted (on this session)
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
let disposed = false;
const setState = (sync) => { if (target.get().sync !== sync) target.set({ sync }); };
const setState = (sync) => { if (target.get()[stateKey] !== sync) target.set({ [stateKey]: sync }); };
const schedule = (ms = SYNC_DEBOUNCE_MS) => {
if (disposed) return;
@@ -95,22 +125,17 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
};
// Review fix: a send is never held back behind one still in flight. The socket is ordered and the server applies
// room.loadout frames in order, so the newest entries always win; holding the edit until the previous reply arrived let
// a click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
// the frames in order, so the newest payload always wins; holding the edit until the previous reply arrived let a
// click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
async function flush() {
if (disposed) return;
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
try {
const current = target.get().entries;
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
// chess.json in the lobby just for this
const empty = !current || Object.keys(current).length === 0;
const loaded = empty ? true : await ready();
const payload = await prepare();
if (disposed) return;
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
if (loaded == null) { setState('error'); return; }
const entries = empty ? {} : sanitizeEntries(target.get().entries, lookup);
const json = JSON.stringify(entries);
if (payload == null) { setState('error'); return; }
const json = JSON.stringify(payload);
if (json === pendingJson) return; // the same content is already on its way
if (json === lastSent && pendingJson == null) { edited = false; setState('synced'); return; }
const my = ++seq;
@@ -119,7 +144,7 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
pendingJson = json;
setState('sending');
try {
await net.request('room.loadout', { entries });
await net.request(msgType, { [field]: payload });
if (my !== seq) return;
pendingJson = null;
lastSent = json;
@@ -129,29 +154,29 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
pendingJson = null;
const code = err && err.code;
if (code === 'WRONG_PHASE' || code === 'ROOM_STARTED') {
// the server stored it for the next match; the running one keeps the loadout it locked
// the server stored it for the next match; the running one keeps what it took
lastSent = json;
setState('locked');
if (wasEdit) tell('本局的干员调配已锁定,修改将在下一局生效');
if (wasEdit) tell(lockedText);
} else if (code === 'RATE' || code === 'TIMEOUT' || code === 'OFFLINE') { edited = edited || wasEdit; schedule(RETRY_MS); }
else { console.warn('[loadout] room.loadout refused', code, err && err.detail); setState('error'); }
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
}
} catch (e) {
console.warn('[loadout] sync failed', e);
console.warn(`[${tag}] sync failed`, e);
setState('error');
}
}
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
const offStore = target.subscribe((s, prev) => {
if (s.entries !== prev.entries) { edited = true; schedule(); }
if (s[key] !== prev[key]) { edited = true; schedule(); }
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
// briefing, 开始模拟 in the room — must not overtake the debounced room.loadout (the match locks its loadout when
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
if (prev.open && !s.open && timer != null) { T.clearTimeout(timer); timer = null; void flush(); }
});
// a match leaving INFO_CHECK locks the loadout; a new match (the room back in LOBBY / a new INFO_CHECK) accepts it again
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get().sync === 'locked') { lastSent = null; schedule(); } });
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get()[stateKey] === 'locked') { lastSent = null; schedule(); } });
return {
flush,
@@ -164,3 +189,42 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
},
};
}
/**
* Wire the loadout sync once. Dependencies are injectable for tests.
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
*/
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
const ready = getChessReady || (() => data.load('chess'));
const lookup = lookupChess || ((id) => data.lookup('chess', id));
return installPrefSync({
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
lockedText: '本局的干员调配已锁定,修改将在下一局生效',
async prepare() {
const current = target.get().entries;
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
// chess.json in the lobby just for this
if (!current || Object.keys(current).length === 0) return {};
const loaded = await ready();
if (loaded == null) return null;
return sanitizeEntries(target.get().entries, lookup);
},
});
}
/**
* Wire the 干员持有 sync once (0.2.0 补位): `room.ownership { notOwned }` after every welcome and edit. The list goes as
* stored (structurally clean); the server keeps its droppable chess. During a match the server stores it for the next
* one (ROOM_STARTED → 'locked'): the setting is out of match.
* @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore>,
* notify?: (text: string) => void }} deps
*/
export function installOwnershipSync({ net, timers, target = loadoutStore, notify } = {}) {
return installPrefSync({
net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership',
lockedText: '干员持有是局外设置,修改将在下一局生效',
prepare: async () => cleanIds(target.get().notOwned),
});
}
+168
View File
@@ -0,0 +1,168 @@
// 干员持有 (operator ownership, 0.2.0 补位) — pure logic of the 干员持有 tab of the 干员调配 overlay (screens/ownership.js),
// shared with the sync (ui/loadoutSync.js) and the tests. The approved plan (owner's decision 2026-10-05): a player marks
// operators as not owned ("下掉"); only the 55 NORMAL chess can be dropped (shared/standIn.js isDroppableChess — the 74
// PRESET chess always field their own operator); default = everything owned; out of match (the next match takes it);
// co-op: the player's own pieces only; no friend borrowing. A dropped chess keeps its card (name, bonds, price) and is
// deployed as its official stand-in (data/backups.json).
//
// The per-browser setting is a sorted list of base chess ids, persisted in localStorage (`sp.pref.ownership` =
// { v: 1, notOwned }) and sent with C2S `room.ownership { notOwned }` (the server keeps the droppable ids and drops the
// rest — shared/protocol.js checkNotOwned — so a stale list never gets refused). 导出 / 导入 use a versioned envelope
// like the loadout presets.
import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js';
import { isDroppableChess, standInRecord } from '../../../shared/standIn.js';
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const OWNERSHIP_PREF = 'ownership';
export const OWNERSHIP_VERSION = 1;
/** `kind` of an exported ownership envelope. */
export const OWNERSHIP_EXPORT_KIND = 'stronghold.ownership';
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 4 KB). */
export const OWNERSHIP_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);
/**
* Structurally valid, deduplicated, sorted ids of a raw list (junk dropped; at most OWNERSHIP_LIMITS.notOwned).
* @param {any} list
* @returns {string[]}
*/
export function cleanIds(list) {
const out = new Set();
for (const id of Array.isArray(list) ? list : []) {
if (out.size >= OWNERSHIP_LIMITS.notOwned) break;
if (typeof id === 'string' && ID_RE.test(id) && !UNSAFE_IDS.has(id)) out.add(id);
}
return [...out].sort();
}
/**
* Parse a stored setting (any junk → []): `{ v, notOwned }` or a bare array from an older build.
* @param {any} raw
* @returns {string[]}
*/
export function parseStoredOwnership(raw) {
if (Array.isArray(raw)) return cleanIds(raw);
return isObj(raw) ? cleanIds(raw.notOwned) : [];
}
/** Serialised form for localStorage. */
export const toStoredOwnership = (notOwned) => ({ v: OWNERSHIP_VERSION, notOwned: cleanIds(notOwned) });
/**
* The ids to keep / send against the loaded data: droppable chess only (checkNotOwned), sorted.
* @param {any} notOwned @param {(id: string) => any} getChess
* @returns {string[]}
*/
export function sanitizeNotOwned(notOwned, getChess) {
const res = checkNotOwned(cleanIds(notOwned), getChess);
return res && res.ok ? res.notOwned : [];
}
/** Whether a chess is owned under a not-owned list (its base id is absent). */
export function isOwned(notOwned, chessId) {
return !(Array.isArray(notOwned) && notOwned.includes(chessId));
}
/**
* Toggle one chess: owned ↔ not owned. Returns a new sorted list (the input is not changed).
* @param {string[]} notOwned @param {string} chessId base chess id
* @param {boolean} [owned] the wanted state (default: the other one)
*/
export function setOwned(notOwned, chessId, owned = !isOwned(notOwned, chessId)) {
const set = new Set(cleanIds(notOwned));
if (owned) set.delete(chessId); else set.add(chessId);
return [...set].sort();
}
// ---- roster -------------------------------------------------------------------------------------------------------
/**
* The chess the screen lists: droppable chess in this season's shop (visible), in shop order (tier, shopSortId).
* @param {any[]} list data.list('chess')
*/
export function ownershipRoster(list) {
return (Array.isArray(list) ? list : [])
.filter((c) => isDroppableChess(c) && c.visible !== false && !c.isHidden)
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId)));
}
/** The roster grouped by tier: [{ tier, list }] in tier order. */
export function rosterByTier(roster) {
const by = new Map();
for (const c of roster) {
if (!by.has(c.tier)) by.set(c.tier, []);
by.get(c.tier).push(c);
}
return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([tier, list]) => ({ tier, list }));
}
/**
* What the screen shows of a chess's stand-in: the composed record (shared/standIn.js — the stand-in at the chess's
* normal status with its backup skill) reduced to the card's fields; null without the data.
* @param {any} chess normal chess record @param {any} backups data/backups.json
* @returns {{ charId: string, name: string, appellation: string, profession: string, subProfessionName: string,
* rarity: number, skill: any, record: any } | null}
*/
export function standInSummary(chess, backups) {
let rec;
try { rec = standInRecord(chess, backups); } catch { rec = null; }
if (!rec) return null;
return {
charId: rec.charId, name: rec.name, appellation: rec.appellation || '', profession: rec.profession,
subProfessionName: rec.subProfessionName || '', rarity: rec.rarity, skill: rec.skill, record: rec,
};
}
/** Number of listed chess marked as not owned (ids of retired / unknown chess do not count). */
export function notOwnedCount(notOwned, roster) {
const ids = new Set(Array.isArray(notOwned) ? notOwned : []);
let n = 0;
for (const c of roster || []) if (ids.has(c.chessId)) n++;
return n;
}
// ---- export / import -------------------------------------------------------------------------------------------------
/** Portable payload of a not-owned list, as downloaded / copied by 导出. */
export function exportOwnership(notOwned, { now = Date.now() } = {}) {
const list = cleanIds(notOwned);
return {
kind: OWNERSHIP_EXPORT_KIND,
v: OWNERSHIP_VERSION,
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
count: list.length,
notOwned: list,
};
}
/** Pretty JSON of `exportOwnership`. */
export const serializeOwnership = (notOwned, opts) => JSON.stringify(exportOwnership(notOwned, opts), null, 2);
/**
* Parse an imported list: the envelope, the stored `{ v, notOwned }` form, a bare array of ids, or the text of any of
* them. STRUCTURAL only — the caller sanitises against the loaded data (sanitizeNotOwned). An envelope may hold an empty
* list (= every operator owned).
* @param {any} input payload or text
* @returns {{ ok: true, notOwned: string[] } | { ok: false, error: string }}
*/
export function parseOwnershipImport(input) {
let raw = input;
if (typeof raw === 'string') {
if (raw.length > OWNERSHIP_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 (Array.isArray(raw)) return { ok: true, notOwned: cleanIds(raw) };
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
const v = Number.isInteger(raw.v) ? raw.v : null;
if (v != null && v > OWNERSHIP_VERSION) return { ok: false, error: `这份数据来自更新的版本(v${v}),请先更新游戏` };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: '这不是干员持有的数据' };
if (!Array.isArray(raw.notOwned)) return { ok: false, error: '里面没有干员持有的数据' };
return { ok: true, notOwned: cleanIds(raw.notOwned) };
}
+12 -7
View File
@@ -17,12 +17,14 @@
// item slot drawn as an item card (player report #6 after 0.1.0).
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
// §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).
// 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).
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 } from './gameLogic.js';
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -70,7 +72,9 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const willMerge = !!hint;
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
// 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 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); }}
@@ -85,8 +89,9 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
</span>` : null}
</span>
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
<span class="scard__body">
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
<span class="scard__name">${c?.name || slot.id}</span>
<span class="scard__bonds">
${bonds.slice(0, 3).map((b) => {
@@ -108,14 +113,14 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
}
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
function SkillBadge({ chess, lo }) {
function SkillBadge({ chess, lo, standIn = false }) {
const m = data.get('assets');
const custom = !lo.defaultSkill;
const custom = !lo.defaultSkill && !standIn;
// a chosen skill without an icon in the manifest: its slot letter (S1–S3) instead of the blank skill sprite
const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess);
const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
const mod = lo.module && !lo.module.none ? lo.module : null;
const tip = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
const tip = `${standIn ? `替补 ${chess.name} · ` : ''}技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
return html`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}