feedback7: merge fb7-g-small — PR #368 (AI teammates draft last, default off), #374 (double-click a teammate's avatar in prep), #321 (copy local diagnostics; whole-token / exact-value redaction) with the review's fixes

- Golden unchanged (union OK); broad node --test 5292 (5291 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 20:23:26 +08:00
commit fc2e57ebff
28 files changed
+1256 -35

No files matched your search

+56 -3
View File
@@ -3,17 +3,20 @@
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07).
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded).
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { createStore, useStore, loadPref, savePref } from '../store.js';
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
import { LangToggle, machineTranslationNote } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { errorCount, currentBattle, diagnosticsText } from '../diag.js';
import { copyText } from './clipboard.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -144,6 +147,55 @@ function HotkeySection({ keys, touchUi }) {
</section>`;
}
/** Where a report goes: the upstream issue tracker (shown as text the player can select; a link may open nothing). */
export const ISSUES_URL = 'https://github.com/sganggs/Stronghold-Protocol/issues';
/**
* 问题反馈: copy the diagnostics of this page (diag.js) for a GitHub issue — the errors recorded since the page opened,
* this browser and device, where the player is, and, switched on by default while a battle is on screen, that battle's
* spec (the dev tools replay it). Player names, the room code and the token are replaced; nothing is uploaded. Laid out
* like 快捷键 above it (a head with its button, a hint, the result line); the battle switch is the settings' Toggle, and
* when the clipboard refuses (some in-app browsers), the report is shown selected in a box styled like 干员调配's 导出.
*/
/** The result line of 复制诊断信息, by outcome (msgids: translated when shown, so a language switch reaches it). */
const DIAG_NOTES = { copied: N_('已复制诊断信息,可以粘贴到 GitHub issue 中'), refused: N_('无法写入剪贴板,请手动复制下面的内容') };
function DiagSection() {
const [attach, setAttach] = useState(true);
const [note, setNote] = useState(null); // 'copied' | 'refused' | null: the result line
const [manual, setManual] = useState(null); // the report, when the clipboard refused it
const boxRef = useRef(null);
// the box opens selected for a manual copy, scrolled to its first line (select() leaves it at the end)
useLayoutEffect(() => {
const el = boxRef.current;
if (manual && el) { el.focus(); el.select(); el.scrollTop = 0; }
}, [manual]);
const n = errorCount();
const battle = currentBattle();
const copy = async () => {
const text = diagnosticsText({ state: store.get(), settings: settingsStore.get(), attachBattle: attach && !!battle });
const ok = await copyText(text);
setManual(ok ? null : text);
setNote(ok ? 'copied' : 'refused');
};
return html`<section class="set-diag" aria-labelledby="set-diag-title">
<div class="set-keys__head">
<span class="set-row__label" id="set-diag-title">${t('问题反馈')}<${MicroLabel}>DIAGNOSTICS<//></span>
<${Button} variant="ghost" size="sm" icon="copy" class="set-diag__copy" data-testid="diag-copy" onClick=${copy}>${t('复制诊断信息')}<//>
</div>
<p class="set-hint">${t('遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。')}</p>
<div class="set-diag__meta">
<span class="set-diag__stat">${t('已记录的错误')}<b class=${cx('set-diag__count num', n > 0 && 'is-warn')} data-testid="diag-count">${n}</b></span>
<span class="set-diag__url">${ISSUES_URL.replace(/^https:\/\//, '')}</span>
</div>
${battle ? html`<${Toggle} label=${t('附上本场战斗')} micro="BATTLE DATA" value=${attach} onChange=${setAttach} />
<p class="set-hint set-diag__battle-hint">${t('开发者可以用附上的战斗数据重现这场战斗。')}</p>` : null}
${note ? html`<p class=${cx('set-keys__note', note === 'refused' && 'is-warn')} role="status" aria-live="polite">${t(DIAG_NOTES[note])}</p>` : null}
${manual ? html`<textarea ref=${boxRef} class="set-diag__text" data-testid="diag-text" spellcheck=${false} readOnly
aria-label=${t('诊断信息')} value=${manual}></textarea>` : null}
</section>`;
}
/**
* Settings modal.
* @param {{ open: boolean, onClose: Function }} props
@@ -176,6 +228,7 @@ export function SettingsModal({ open, onClose }) {
</div>
</div>
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
<${DiagSection} />
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
</div>
<//>`;
+71 -7
View File
@@ -5,7 +5,10 @@
// Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
// watches that player's field at once.
// watches that player's field at once, and a click while that field is already on screen does not ask again.
// In PHASE.PREP a double-click on a teammate's avatar opens their field (GitHub #131, the 「前往查看」 shortcut).
// It does not open a field the teammate does not have, and it does not open one the click already opened.
// Boss-prep phases (最终攻势 / 隐秘核心) are not PHASE.PREP, so they still use 「前往查看」.
// Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's
// leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row
// m.public players[].pendingLp (server/match/match/views.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the
@@ -15,7 +18,7 @@
// the player watches, so the number moves with the kills on screen), else m.public players[].uniteLeft (the authority's
// report, ~1 Hz).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
@@ -55,6 +58,45 @@ export function rowLp(p, pub, self = null, { uniteLocal = null, cap = 10 } = {})
return { lp, pending, unite: (pending > 0 || left != null) && (self ? !!self.unite : pub.phase === PHASE.UNITE), left };
}
/** True when `watching` is already this teammate's field (their live field, or the prep scout `n:<id>`). */
export function teammateFieldShown(watching, player) {
if (!watching || !player) return false;
return watching === player.fieldId || watching === `n:${player.playerId}`;
}
/**
* A single click on a teammate avatar calls onWatch only in server-run combat (no `observe`), and only when
* that field is not already on screen. A double-click fires click first; the second click, and any later click
* while the view is already theirs, must not open it again. Client combat never watches from the avatar click.
* `askedRecently`: this avatar's click already called onWatch in the last moment (the other click of a double-click,
* before `watching` has re-rendered).
* @param {{ observe?: unknown, watching?: string|null, player?: { playerId?: string, fieldId?: string }|null, askedRecently?: boolean }} [o]
* @returns {boolean}
*/
export function teammateClickWatches(o = {}) {
if (o.observe || o.askedRecently) return false;
if (teammateFieldShown(o.watching, o.player)) return false;
return !!o.player;
}
/**
* Double-click in PHASE.PREP opens a teammate's field, skipping 「前往查看」. False — do not call onWatch — when
* the phase is not PREP, the row is your own, there is no client-combat `observe` (the click already watched),
* `canObserve` has no field (do not force a view; the click toasts), or that field is already open.
* @param {{ phase?: string, self?: boolean, observe?: { canObserve?: (p: any) => { fieldId?: string }|null }|null,
* watching?: string|null, player?: any, askedRecently?: boolean }} [o]
* @returns {boolean}
*/
export function prepDblClickWatches(o = {}) {
if (o.phase !== PHASE.PREP || o.self || !o.player || !o.observe) return false;
if (o.askedRecently || teammateFieldShown(o.watching, o.player)) return false;
const view = (typeof o.observe.canObserve === 'function' ? o.observe.canObserve(o.player) : null) || {};
return !!view.fieldId;
}
/** How long the second click of a double-click counts as "already asked" before `watching` re-renders. */
const AVATAR_CLICK_GAP_MS = 500;
/**
* Tooltip of a row's LP tower with a pending loss (null without one).
* @param {{ lp: number|null, pending: number, unite: boolean, left: number|null } | null} lp rowLp(...)
@@ -76,6 +118,8 @@ export function rowLpTip(lp, cap = 10) {
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null, cap = 10, uniteLocal = null }) {
const [openPid, setOpenPid] = useState(null);
// the playerId whose avatar just called onWatch, so the other click of a double-click does not open the view again
const askedAt = useRef(/** @type {{ id: string|null, at: number }} */ ({ id: null, at: 0 }));
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
const players = sortedPlayers(pub);
@@ -84,19 +128,39 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
// tinted like the official green; a plain green ring without the local art)
const team = teamFrameIds(pub, myId);
const frameArt = team.size ? localAsset('ui/battle', 'bg_team_border') : null;
const askedRecently = (p) => askedAt.current.id === p?.playerId && Date.now() - askedAt.current.at < AVATAR_CLICK_GAP_MS;
const noteAsked = (p) => { askedAt.current = { id: p.playerId, at: Date.now() }; };
const click = (p, self) => {
if (!observe) { onWatch(p); return; }
if (!observe) {
// your own row still goes home; a teammate already on screen (or just opened by this double-click) does not
if (self || teammateClickWatches({ watching, player: p, askedRecently: askedRecently(p) })) {
if (!self) noteAsked(p);
onWatch(p);
}
return;
}
if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; }
const t = observe.canObserve(p) || {};
if (!t.fieldId) { setOpenPid(null); onWatch(p); return; } // the game screen toasts the reason
const view = observe.canObserve(p) || {};
if (!view.fieldId) {
setOpenPid(null);
if (!askedRecently(p)) { noteAsked(p); onWatch(p); } // the game screen toasts the reason; a double-click toasts once
return;
}
setOpenPid((cur) => (cur === p.playerId ? null : p.playerId));
};
const dblClick = (e, p, self) => {
if (!prepDblClickWatches({ phase: pub?.phase, self, observe, watching, player: p, askedRecently: askedRecently(p) })) return;
e.preventDefault();
setOpenPid(null);
noteAsked(p);
onWatch(p);
};
return html`<aside class=${cx('team', compact && 'team--compact')} aria-label=${t('同盟成员')}>
${players.map((p) => {
const self = p.playerId === myId;
const status = p.alive === false ? 'dead' : p.status;
const meta = STATUS_META[status] || STATUS_META.acting;
const watched = watching && (watching === p.fieldId || watching === `n:${p.playerId}`);
const watched = teammateFieldShown(watching, p);
const bubble = bubbles?.get(p.playerId);
const offline = p.connected === false && !p.isBot;
const open = !!observe && openPid === p.playerId && !self;
@@ -105,7 +169,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
const inTeam = team.has(p.playerId);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', inTeam && 'is-team', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} onDblClick=${(e) => dblClick(e, p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
${inTeam ? html`<span class=${cx('team__frame', !frameArt && 'team__frame--plain')} style=${frameArt ? `--frame:url("${frameArt}")` : undefined} aria-hidden="true"></span>` : null}
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
+2
View File
@@ -9,6 +9,7 @@ import { useEffect, useReducer } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { ERR_TEXT } from '../../../shared/constants.js';
import { t } from '../../../shared/i18n.js';
import { recordError } from '../diag.js';
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
function hFresh(type, props, ...children) {
@@ -108,6 +109,7 @@ export function describeError(err) {
* @returns {number}
*/
export function toastError(err) {
recordError('request', err);
return toast(describeError(err), 'error');
}