// Bottom shop bar (research 06 §11.2 / D3): LEVEL card (upgrade price hex, 升级), 3–5 operator cards
// (tier chip, price hex with discount/markup colours, portrait, bonds — a bond the mode never activates struck through,
// 本局禁用 — class, frozen overlay, sold state,
// merge progress), the item card, the funds card with ✕ 收起; above it 剩余可放置角色, 冻结/解冻 and 刷新.
// Buying and upgrading take two taps (research 09 §5 / §6.5, official `EventOnFirstClick` → `EventOnConfirm` /
// `EventOnUpgrade`): the first tap selects a card — it lifts and enlarges, its detail opens and it shows 确认购买
// (无法购买 + the reason when it cannot be bought) — and a second tap on the same card buys it; the LEVEL card's first
// tap arms 确认升级 and the second upgrades (已满级 at level 6). A tap anywhere else, a shop change or the end of the
// editable phase disarms. A tap anywhere on a card is the card's tap — its 确认购买 strip included (it takes no
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
// 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). A chess the player does
// not own (0.2.0 补位, m.private.standIns) shows its stand-in — portrait, name, class and its backup skill, the one that
// fights — with the chess's bonds, tier and price and a small ice 「替补」 mark (the owner's recall of the official mode,
// 2026-10-06; the replaced operator's name is in the mark's title and on the detail card the first tap opens). 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, ownStandIn, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, diyGetter } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { hotkeyLabelOf } from './settings.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
function PriceHex({ slot, free, poor = false }) {
const tone = priceTone(slot);
const price = Number(slot?.price) || 0;
if (free || price === 0) return html`FREE`;
// like the original (img_bg_price_not_enough) the price turns grey while the funds don't cover it
return html`<${HexBadge} value=${price} tone=${poor ? 'dark' : tone} size="md" class=${cx('scard__price', poor && 'is-poor')}
title=${poor ? t('资金不足') : tone === 'discount' ? t('折扣价(原价 {basePrice})', { basePrice: slot.basePrice }) : tone === 'premium' ? t('加价(原价 {basePrice})', { basePrice: slot.basePrice }) : t('价格')} />`;
}
/** Data lookups (merge progress / target, the loadout; shopBlockReason ignores them — a full hand refuses every purchase). */
const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) };
/**
* Where a merge-completing buy sends the elite (DESIGN §20.11), null when the card completes no merge: the 可晋升 tag's
* title, and a line of the detail card the first tap opens — a touch screen never shows a title (QA 6b).
*/
export function mergeHint(priv, chessId) {
const prog = mergeProgress(priv, chessId, LOOKUPS.getChess);
if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null;
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区');
}
/**
* Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto
* every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off
* (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without
* per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone.
*/
export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle;
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) {
if (reason) return html`${t('无法购买')}${reason}`;
return html`${free ? t('确认选择') : t('确认购买')}${t('再次点击')}`;
}
/**
* Operator card (shop and merge reward). `armed`: first tap done (second tap = `onBuy(idx)`).
* @param {{ slot:any, idx:number, priv:any, frozen?:boolean, reason?:string|null, free?:boolean, armed?:boolean,
* 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 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;
// 0.2.0 补位: the player's not-owned chess shows its stand-in (art, name, class, its backup skill); the composed record
// keeps the chess's identity — tier, bonds, price (a DIY slot is never droppable: one or the other)
const si = c0 && !dr ? ownStandIn(c0, priv, data.get('backups')) : null;
const c = dr || si || c0;
const m = data.get('assets');
const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
const hint = mergeHint(priv, slot.id);
const willMerge = !!hint;
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess, { ops: priv?.ops ?? null, effects: data.get('effects') }) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`;
const card = html``;
return reason && reason !== t('已售出') && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}/>` : card;
}
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
function SkillBadge({ chess, lo, standIn = false }) {
const m = data.get('assets');
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 ? t('技能 {slot}:{name}', { slot, name: lo.skill.name || '' }) : t('技能:{name}', { name: lo.skill.name || '' })}${custom ? t('(已调配)') : ''}${mod ? t(' · 模组:{name}', { name: mod.name }) : lo.module?.none ? t(' · 未装备模组') : ''}`;
return html`
<${Img} src=${src} fallback=${slot ? html`${slot}` : html`<${GIcon} name="bolt" />`} />
${mod && mod.typeName ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}` : null}
`;
}
/**
* Item card (the bar's item slot): the same two taps as an operator card.
* @param {{ slot:any, idx:number, frozen?:boolean, reason?:string|null, armed?:boolean, onTap?:(idx:number)=>void,
* onBuy:Function, onDetail:Function }} props
*/
export function ItemCard({ slot, idx, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail }) {
const it = data.lookup('items', slot.id);
const m = data.get('assets');
const disabled = !!reason;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'item'); };
const card = html``;
return reason && reason !== t('已售出') && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}/>` : card;
}
function SoldCard({ item = false }) {
return html`
`;
}
/**
* A slot with no card in it: the one a 调度中心 upgrade has just opened (a `null` slot — server/match/player/economy.js
* _openLevelSlots; GitHub #332 / PR #333: the official shop shows the new slot, empty, until the next refresh or round
* start fills it). Never 已招募 / SOLD OUT: nothing was bought there.
*/
function EmptyCard({ item = false }) {
// `scard--sold` too: the same inert frame (no hover lift, not a buyable card for every `:not(.scard--sold)` rule); `scard--empty` only restyles it
return html``;
}
function LevelCard({ shop, reason, armed = false, onTap }) {
const lv = shop?.level ?? 1;
const max = lv >= (shop?.maxLevel ?? 6);
const price = shop?.upgradePrice ?? 0;
return html``;
}
/** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
/**
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null (a level card, nothing armed, a stale key).
* @param {string|null} key
* @param {any[]} slots the shop's slots
* @param {any[]|null} rewardSlots the shown reward offer's slots
*/
export function armedSlotOf(key, slots, rewardSlots = null) {
const m = typeof key === 'string' ? /^([cir]):(\d+):(.*)$/.exec(key) : null;
if (!m) return null;
const list = m[1] === 'r' ? rewardSlots : slots;
const s = Array.isArray(list) ? list[Number(m[2])] : null;
return s && !s.sold && s.id === m[3] ? s : null;
}
/**
* Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on
* Escape, when the armed card changes or disappears, and when the bar stops being editable.
* @param {{ editable: boolean, keys: Set }} o the keys that are currently valid
*/
export function useTwoTap({ editable, keys }) {
const [armed, setArmed] = useState(null);
const valid = armed == null || (editable && keys.has(armed));
useEffect(() => { if (!valid) setArmed(null); }, [valid]);
useEffect(() => {
if (armed == null) return undefined;
const onDown = (e) => {
const t = e.target;
if (t && t.closest && t.closest('.scard, .lvcard, .dpanel, .modal')) return;
setArmed(null);
};
const onKey = (e) => { if (e.key === 'Escape') setArmed(null); };
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); };
}, [armed]);
return [valid ? armed : null, setArmed];
}
/**
* A free pick-one offer in place of the bar's operator cards (pick 1 — g.reward idx — or put it off: 稍后选择 → the
* normal shop + a reminder pill): the promotion reward (research 00 §3: 3 free operators of tier min(level+1, 6)) and
* the special refreshes of strategies, items and 特质 — e.g. 凯瑟琳 【定向投放】 "在调度中心刷新随机3件装备,可以选择并获得
* 其中1件". Each slot is drawn by its kind: an item slot is an item card (icon, name, tier, description, FREE) — player
* report #6 after 0.1.0, the items used to be drawn as nameless operator cards — and the header names the offer
* (gameLogic.offerHeader: 晋升奖励 / the strategy's effect name …; "之后还有 N 项" while more offers wait behind it).
*/
export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap, offBonds = null }) {
const head = offerHeader(offer);
return html`