0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+35 -12
View File
@@ -534,24 +534,27 @@ export function DialogHost() {
// ---- Tooltip -----------------------------------------------------------------------------------
let tipState = null; // { id, content, rect, placement }
let tipState = null; // { id, content, aside, rect, placement }
let tipSeq = 0;
const tipListeners = new Set();
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
/**
* Hover/focus tooltip around its children. Content renders in <TooltipLayer/> (fixed, viewport-clamped).
* @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
* `aside`: a second section in the same clamped, scrollable box; stacked on narrow screens.
* @param {{ text: any, aside?: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
*/
const TOUCH_TIP_MS = 450;
export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
export function Tooltip({ text, aside = null, placement = 'top', delay = 120, block = false, class: cls, children }) {
const ref = useRef(null);
const idRef = useRef(0);
const timer = useRef(null);
const textRef = useRef(text);
const asideRef = useRef(aside);
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
textRef.current = text;
asideRef.current = aside;
if (!idRef.current) idRef.current = ++tipSeq;
const show = () => {
clearTimeout(timer.current);
@@ -560,7 +563,8 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
const el = ref.current;
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
if (!el || !el.isConnected || content == null || content === '') return;
setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
setTip({ id: idRef.current, content, aside: asideRef.current, rect: el.getBoundingClientRect(), placement,
hold: () => clearTimeout(timer.current), hide });
}, delay);
};
const hide = () => {
@@ -570,7 +574,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
// A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back
// (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it.
// Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press
// (≥ 450 ms) does, and it goes away shortly after the finger lifts.
// (≥ 450 ms) does. Ordinary tips expire after release; compound lists stay open for scrolling until dismissed.
const touchAt = useRef(0);
const longTimer = useRef(null);
const onPointerDown = (e) => {
@@ -586,18 +590,24 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
if (!e || e.pointerType === 'mouse') return;
touchAt.current = Date.now();
clearTimeout(longTimer.current);
if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
if (tipState?.id === idRef.current && !asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
};
const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); };
const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); };
const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); };
const onLeave = () => {
pressed.current = false;
if (Date.now() - touchAt.current > 900) {
if (asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 250); }
else hide();
}
};
useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []);
// Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away).
useEffect(() => {
if (tipState?.id !== idRef.current) return;
if (text == null || text === '') setTip(null);
else setTip({ ...tipState, content: text });
}, [text]);
else setTip({ ...tipState, content: text, aside: asideRef.current });
}, [text, aside]);
return html`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
@@ -612,7 +622,12 @@ export function TooltipLayer() {
const [pos, setPos] = useState(null);
useEffect(() => {
tipListeners.add(force);
return () => tipListeners.delete(force);
const outside = (e) => { if (tipState?.aside && !e.target?.closest?.('.tooltip')) tipState.hide?.(); };
const escape = (e) => { if (e.key === 'Escape') tipState?.hide?.(); };
document.addEventListener('pointerdown', outside);
document.addEventListener('keydown', escape);
window.addEventListener('resize', force);
return () => { tipListeners.delete(force); document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); window.removeEventListener('resize', force); };
}, []);
useLayoutEffect(() => {
const el = boxRef.current;
@@ -628,13 +643,21 @@ export function TooltipLayer() {
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
let left = r.left + r.width / 2 - b.width / 2;
left = Math.max(m, Math.min(vw - b.width - m, left));
top = Math.max(m, Math.min(vh - b.height - m, top));
const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
});
if (!tipState) return null;
const ready = pos && pos.id === tipState.id;
return html`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
const hasAside = tipState.aside != null && tipState.aside !== '';
const off = 'top:-9999px;left:-9999px';
return html`<div ref=${boxRef} class=${cx('tooltip', hasAside && 'tooltip--compound', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
tabIndex=${hasAside ? 0 : undefined} onMouseEnter=${() => tipState?.hold?.()} onMouseLeave=${() => tipState?.hide?.()}
onFocusIn=${() => tipState?.hold?.()} onPointerDown=${() => tipState?.hold?.()}
onFocusOut=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) tipState?.hide?.(); }}
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : off}>
${hasAside ? html`<div class="tooltip__main">${tipState.content}</div><div class="tooltip--aside">${tipState.aside}</div>` : tipState.content}
</div>`;
}
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
+23 -1
View File
@@ -20,6 +20,22 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
// call actions.refresh) until the request settles (success or failure). This is not server-side cancellation or
// idempotency: a timed-out request may already have executed. The shop bar receives onRefreshBusy via the game screen.
let refreshPending = false;
const refreshBusyListeners = new Set();
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
/**
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
* @param {(busy: boolean) => void} fn
* @returns {() => void} unsubscribe
*/
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
/**
* Send an intent. Resolves true on `ok`, false on error (already toasted).
* @param {string} t
@@ -55,7 +71,13 @@ export const actions = {
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
refresh: () => act('g.refresh'),
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
refresh: () => {
if (refreshPending) return Promise.resolve(false);
refreshPending = true;
emitRefreshBusy();
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
},
freeze: () => act('g.freeze'),
levelUp: () => act('g.levelUp'),
sell: (uid) => act('g.sell', { uid }),
+4 -3
View File
@@ -201,8 +201,9 @@ export function GIcon({ name, class: cls, title }) {
/**
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
* playtest #3 item 2) — the number keeps the settled LP (owner-approved #479 / #504),
* shown in red with a −N tick that pops again on every change (keyed); `note` a
* small tag after it (联防中); `tip` the title text.
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
*/
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
@@ -211,7 +212,7 @@ export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, not
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
data-pending=${p > 0 ? p : null}>
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
<b class="num lp__val">${ok ? Math.max(0, value) : '--'}</b>
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
${note ? html`<span class="lp__note">${note}</span>` : null}
</span>`;
+15 -8
View File
@@ -4,12 +4,23 @@ import { GEO, PHASE } from '../../../../shared/constants.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
import { int, isObj } from './shared.js';
/** Server-owned random pairs. Seat inference is only for an older server without the field. */
function prepPair(pub, myId) {
const alive = (Array.isArray(pub?.players) ? pub.players : []).filter(p => isObj(p) && p.alive !== false);
if (!alive.some(p => p.playerId === myId)) return [];
if (pub.bossPairs !== undefined) {
return (Array.isArray(pub.bossPairs) ? pub.bossPairs : []).find(g => Array.isArray(g) && g.includes(myId)) || [];
}
const ids = alive.sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map(p => p.playerId);
const i = ids.indexOf(myId);
return ids.slice(i - i % 2, i - i % 2 + 2);
}
/**
* Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round
* (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared
* boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/
* finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored
* finalAssault.js pairPlayers, sent in m.public.bossPairs; the second of a pair is on the mirrored
* right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`.
* @param {any} pub m.public
* @param {string|null} myId
@@ -19,9 +30,7 @@ export function prepCamera(pub, myId) {
const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } };
const r = pub?.round;
if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal;
const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false)
.sort((a, b) => int(a.seat, 99) - int(b.seat, 99));
const idx = alive.findIndex((p) => p.playerId === myId);
const idx = prepPair(pub, myId).indexOf(myId);
if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
}
@@ -32,7 +41,7 @@ const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
* in the server's saved pairs — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
* round. PR #191 framed the partner alone, in gold (not merged).
* @param {any} pub m.public @param {string|null} myId
@@ -51,9 +60,7 @@ export function teamFrameIds(pub, myId) {
return f && f.players.length > 1 ? new Set(f.players) : none;
}
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
const idx = alive.indexOf(myId);
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
const pair = prepPair(pub, myId);
return pair.length > 1 ? new Set(pair) : none;
}
+9 -7
View File
@@ -12,13 +12,14 @@
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
// Normal rounds (user playtest #3 item 2): the LP tower marks the own battle's leaks live as its enemies enter the blue
// gate — the settled lp stays until settlement, in red with a −min(lpCapPerRound, counted leaks) tick (GitHub #479: the
// official keeps the current value until settlement), 联防中 while a 联防 may still save part of it (liveLp;
// the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS
// 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official
// runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防
// field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N).
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower marks lp with −min(lpCapPerRound, N).
// The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip).
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
@@ -183,9 +184,10 @@ export function uniteRemaining(local, server) {
}
/**
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is marked at once (red, −N) instead
* of only at settlement — the number itself keeps the settled LP until settlement (GitHub #479: the official shows the
* current value, not the deduction, until the round settles). `base` — kept by the caller between renders — is the
* settled m.private state the pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its
@@ -352,7 +354,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
* live: the own battle's pending LP loss (liveLp): the tower keeps the settled lp and adds a red −N tick, 联防中 during 联防;
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
+27 -2
View File
@@ -69,14 +69,38 @@ export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}</p>`;
}
/**
* The box beside a bond's tooltip: its banned members (the badge's count), each with avatar, name and bonds (the hovered
* one highlighted); null when none are banned.
* @param {any} bond @param {MatchInfoModel} model @param {Set<string>} banned
*/
function BondBannedAside(bond, model, banned) {
const ids = (Array.isArray(bond.visibleMembers) ? bond.visibleMembers : []).filter((id) => banned.has(id));
if (!ids.length) return null;
const nameOf = new Map(model.bonds.map((b) => [b.bondId, b.name]));
return html`<div class="brief-bantip">
<div class="brief-bantip__h">${t('本局禁用干员')}</div>
${ids.map((id) => {
const c = data.lookup('chess', id);
const bonds = (Array.isArray(c?.bonds) ? c.bonds : []).filter((b) => nameOf.has(b));
return html`<div key=${id} class="brief-bantip__op" data-chess=${id}>
<${UnitThumb} kind="chess" id=${id} size="xs" showTier=${false} />
<div class="brief-bantip__txt"><b>${c?.name || id}</b>
<span>${bonds.map((b, i) => html`${i ? ' · ' : ''}<span class=${cx(b === bond.bondId && 'is-this')}>${nameOf.get(b)}</span>`)}</span></div>
</div>`;
})}
</div>`;
}
/**
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
* which (briefingBondTip).
* which (briefingBondTip). A bond with banned members lists them in a second box beside the tooltip (BondBannedAside).
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
*/
export function MatchBondRow({ title, micro, bonds, model }) {
const m = data.get('assets');
const banned = new Set(model.banned);
return html`<div class="brief-bonds">
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
<div class="brief-bonds__row">
@@ -84,7 +108,8 @@ export function MatchBondRow({ title, micro, bonds, model }) {
const state = model.stateOf(b.bondId);
const off = !!state;
const bannedN = model.perBond.get(b.bondId) || 0;
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}
aside=${bannedN > 0 ? BondBannedAside(b, model, banned) : null}>
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')} data-bond=${b.bondId}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
+7 -5
View File
@@ -10,7 +10,9 @@
// 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
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
// onRefreshBusy, ui/shopBar.js only draws it). 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
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
/**
* The bar.
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
*/
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
const shop = priv?.shop || {};
const slots = Array.isArray(shop.slots) ? shop.slots : [];
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
</button>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>${t('刷新')}</span>
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
<kbd>${hk.refresh}</kbd>
</button>
</div>