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:
commit
fc2e57ebff
28 files changed
+1256
-35
No files matched your search
@@ -72,6 +72,7 @@ import { net as appNet } from '../net.js';
|
||||
import { store as appStore } from '../store.js';
|
||||
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
|
||||
import { spectateEffects } from './observe.js';
|
||||
import { recordError, setBattleSource } from '../diag.js';
|
||||
|
||||
const TICK = 1 / 30;
|
||||
/** Fast-forward budget per frame (ticks) when far behind. */
|
||||
@@ -156,9 +157,12 @@ export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetc
|
||||
return { spec, ds: new simdata.DataSource(raw, null) };
|
||||
}
|
||||
|
||||
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
|
||||
/**
|
||||
* Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own) and keep
|
||||
* them for the diagnostics a player copies (diag.js).
|
||||
*/
|
||||
const SIM_LOGGER = Object.freeze({
|
||||
error: (...a) => console.warn('[sim]', ...a),
|
||||
error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); },
|
||||
warn: (...a) => console.warn('[sim]', ...a),
|
||||
info() {},
|
||||
debug() {},
|
||||
@@ -351,6 +355,7 @@ export function createBattleRunner(deps) {
|
||||
} catch (err) {
|
||||
stats.errors++;
|
||||
console.warn('[runner] battle step failed', err);
|
||||
recordError('runner', err, 'battle step failed');
|
||||
try { b.forceEnd('timeout'); } catch { /* ignore */ }
|
||||
}
|
||||
const dt = now() - t;
|
||||
@@ -393,7 +398,7 @@ export function createBattleRunner(deps) {
|
||||
const list = catchingUp ? s.ev.filter(keepsState) : s.ev;
|
||||
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list });
|
||||
}
|
||||
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
|
||||
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,7 +462,7 @@ export function createBattleRunner(deps) {
|
||||
result = e.sim.spec.compactResult(e.battle.result());
|
||||
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
|
||||
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
|
||||
} catch (err) { console.warn('[runner] result failed', err); }
|
||||
} catch (err) { console.warn('[runner] result failed', err); recordError('runner', err, 'result failed'); }
|
||||
if (result) {
|
||||
e.result = result;
|
||||
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
|
||||
@@ -491,6 +496,7 @@ export function createBattleRunner(deps) {
|
||||
} else {
|
||||
e.delivery = 'delivered';
|
||||
console.warn('[runner] b.result refused', code);
|
||||
recordError('runner', code, 'b.result refused');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -670,6 +676,7 @@ export function createBattleRunner(deps) {
|
||||
let sim;
|
||||
try { sim = await ensureSim(); } catch (err) {
|
||||
console.warn('[runner] simulation unavailable', err);
|
||||
recordError('runner', err, 'simulation unavailable');
|
||||
return;
|
||||
}
|
||||
if (!wanted()) return;
|
||||
@@ -678,6 +685,7 @@ export function createBattleRunner(deps) {
|
||||
battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger });
|
||||
} catch (err) {
|
||||
console.warn('[runner] battle construction failed', err);
|
||||
recordError('runner', err, 'battle construction failed');
|
||||
return;
|
||||
}
|
||||
stats.battles++;
|
||||
@@ -806,6 +814,16 @@ export function createBattleRunner(deps) {
|
||||
return () => listeners.get(type)?.delete(fn);
|
||||
},
|
||||
state,
|
||||
/**
|
||||
* The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one.
|
||||
* @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null}
|
||||
*/
|
||||
currentBattle() {
|
||||
const e = cur;
|
||||
if (!e || !e.spec) return null;
|
||||
const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null;
|
||||
return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time };
|
||||
},
|
||||
stats() {
|
||||
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
|
||||
},
|
||||
@@ -869,3 +887,4 @@ export const battleRunner = typeof window !== 'undefined' && typeof document !==
|
||||
? createBattleRunner({ net: appNet, store: appStore })
|
||||
: null;
|
||||
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection
|
||||
if (battleRunner) setBattleSource(() => battleRunner.currentBattle());
|
||||
@@ -0,0 +1,269 @@
|
||||
// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈).
|
||||
//
|
||||
// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted
|
||||
// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names,
|
||||
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
|
||||
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
|
||||
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
|
||||
// tools replay.
|
||||
//
|
||||
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
|
||||
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
|
||||
// (a failed request or error push the player was shown)
|
||||
// errorLog() / errorCount() / clearErrorLog()
|
||||
// setBattleSource(fn) the battle runner registers how to read the battle on screen
|
||||
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
|
||||
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
|
||||
//
|
||||
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
|
||||
|
||||
import { APP_VERSION } from '../../shared/constants.js';
|
||||
|
||||
export const LOG_MAX = 40;
|
||||
export const STACK_LINES = 6;
|
||||
const MESSAGE_MAX = 300;
|
||||
|
||||
/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */
|
||||
const log = [];
|
||||
let total = 0;
|
||||
let dropped = 0;
|
||||
const pageStart = Date.now();
|
||||
|
||||
/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */
|
||||
export function messageOf(err) {
|
||||
let s;
|
||||
if (err == null) s = String(err);
|
||||
else if (typeof err === 'string') s = err;
|
||||
else if (typeof err === 'object') {
|
||||
const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : '';
|
||||
const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : '';
|
||||
const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : '';
|
||||
if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`;
|
||||
else if (code) s = `${name}${code}`;
|
||||
else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } }
|
||||
} else s = String(err);
|
||||
s = s.replace(/\s+/g, ' ').trim();
|
||||
return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s;
|
||||
}
|
||||
|
||||
/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */
|
||||
export function stackOf(err) {
|
||||
const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : '';
|
||||
if (!raw) return null;
|
||||
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
// V8 starts with "Name: message"; Firefox / Safari list frames only
|
||||
const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l)));
|
||||
const out = frames.slice(0, STACK_LINES).map(stripHosts);
|
||||
return out.length ? out.join('\n') : null;
|
||||
}
|
||||
|
||||
/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */
|
||||
export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, '');
|
||||
|
||||
/**
|
||||
* Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead.
|
||||
* @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms
|
||||
*/
|
||||
export function recordError(source, err, detail = null, at = Date.now()) {
|
||||
const message = stripHosts(messageOf(err));
|
||||
const stack = stackOf(err);
|
||||
const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX);
|
||||
total++;
|
||||
const last = log[log.length - 1];
|
||||
if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) {
|
||||
last.count++;
|
||||
last.lastAt = at;
|
||||
return last;
|
||||
}
|
||||
const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 };
|
||||
log.push(e);
|
||||
while (log.length > LOG_MAX) { log.shift(); dropped++; }
|
||||
return e;
|
||||
}
|
||||
|
||||
/** The log, oldest first (copies). */
|
||||
export const errorLog = () => log.map((e) => ({ ...e }));
|
||||
/** Errors recorded on this page (repeats included). */
|
||||
export const errorCount = () => total;
|
||||
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
|
||||
|
||||
let battleSource = null;
|
||||
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
|
||||
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
|
||||
/** The battle on screen for a report, or null. */
|
||||
export function currentBattle() {
|
||||
try { return battleSource ? battleSource() || null : null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */
|
||||
function secretMap(pairs) {
|
||||
const map = new Map();
|
||||
for (const [v, ph] of pairs) {
|
||||
if (typeof v === 'string' && v.trim()) map.set(v, ph);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again).
|
||||
* A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so
|
||||
* "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is
|
||||
* two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari
|
||||
* before 16.4.
|
||||
* @param {string} text @param {[string, string][]} pairs [value, placeholder]
|
||||
*/
|
||||
export function redact(text, pairs) {
|
||||
const map = secretMap(pairs);
|
||||
const list = [...map.keys()].sort((a, b) => b.length - a.length);
|
||||
if (!list.length) return String(text);
|
||||
const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const word = '[\\p{L}\\p{N}_]';
|
||||
const re = new RegExp(`(?<pre>^|(?!${word})[\\s\\S])(?<hit>${list.map(esc).join('|')})(?!${word})`, 'gu');
|
||||
return String(text).replace(re, (...args) => {
|
||||
const g = args[args.length - 1];
|
||||
return `${g.pre}${map.get(g.hit)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
|
||||
* merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
|
||||
* token replacement would still rewrite `"charge the char"` when the name is `char`.
|
||||
* @param {any} value @param {[string, string][]} pairs
|
||||
*/
|
||||
export function redactValues(value, pairs) {
|
||||
const map = secretMap(pairs);
|
||||
const walk = (v) => {
|
||||
if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
if (v && typeof v === 'object') {
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return walk(value);
|
||||
}
|
||||
|
||||
/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
|
||||
export function secretsOf(state) {
|
||||
const pairs = [];
|
||||
const names = new Map();
|
||||
const addName = (n) => {
|
||||
if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
|
||||
names.set(n, n === state?.me?.name ? '<me>' : `<player${names.size + 1}>`);
|
||||
};
|
||||
addName(state?.me?.name);
|
||||
for (const s of state?.room?.seats || []) addName(s?.name);
|
||||
for (const s of state?.room?.spectators || []) addName(s?.name);
|
||||
for (const p of state?.match?.public?.players || []) addName(p?.name);
|
||||
for (const [n, ph] of names) pairs.push([n, ph]);
|
||||
if (state?.room?.code) pairs.push([String(state.room.code), '<room>']);
|
||||
if (state?.me?.token) pairs.push([String(state.me.token), '<token>']);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
|
||||
const spanOf = (ms) => {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
|
||||
};
|
||||
const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
|
||||
|
||||
/**
|
||||
* The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
|
||||
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
|
||||
* prose and exact string values in the JSON.
|
||||
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
|
||||
* now?: number, pageStart?: number, version?: string }} input
|
||||
*/
|
||||
export function buildDiagnostics(input) {
|
||||
const { state = {}, env = {}, settings = null, battle = null } = input;
|
||||
const entries = input.entries ?? errorLog();
|
||||
const now = input.now ?? Date.now();
|
||||
const lines = [];
|
||||
lines.push('Stronghold-Protocol diagnostics');
|
||||
lines.push(`version: ${input.version ?? APP_VERSION}`);
|
||||
lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
|
||||
if (env.ua) lines.push(`browser: ${env.ua}`);
|
||||
if (env.gpu) lines.push(`gpu: ${env.gpu}`);
|
||||
lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
|
||||
env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
|
||||
const c = state.connection || {};
|
||||
lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
|
||||
c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
|
||||
const pub = state.match?.public || null;
|
||||
const room = state.room || null;
|
||||
lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
|
||||
Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
|
||||
room?.difficulty ? `difficulty ${room.difficulty}` : null,
|
||||
Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
|
||||
const b = state.match?.battle || null;
|
||||
if (b) {
|
||||
lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
|
||||
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
|
||||
}
|
||||
if (settings) {
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
}
|
||||
const shown = entries.slice().reverse();
|
||||
const tot = input.total ?? total;
|
||||
const drop = input.dropped ?? dropped;
|
||||
lines.push('');
|
||||
lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
|
||||
shown.forEach((e, i) => {
|
||||
lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
|
||||
if (e.detail) lines.push(` ${e.detail}`);
|
||||
if (e.stack) for (const l of e.stack.split('\n')) lines.push(` ${l}`);
|
||||
});
|
||||
const pairs = secretsOf(state);
|
||||
let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
|
||||
if (battle && battle.spec) {
|
||||
const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
|
||||
Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
|
||||
const payload = redactValues({
|
||||
t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
|
||||
}, pairs);
|
||||
text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
let gpuName;
|
||||
/** What the report says about this browser and device (null fields stay out). */
|
||||
export function browserEnv() {
|
||||
const g = globalThis;
|
||||
if (gpuName === undefined) {
|
||||
gpuName = null;
|
||||
try {
|
||||
const cv = g.document?.createElement('canvas');
|
||||
const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
|
||||
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
|
||||
gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
|
||||
} catch { gpuName = null; }
|
||||
}
|
||||
let uiLang = null;
|
||||
try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
|
||||
return {
|
||||
ua: g.navigator?.userAgent ?? null,
|
||||
gpu: gpuName,
|
||||
viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
|
||||
dpr: g.devicePixelRatio ?? null,
|
||||
touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
|
||||
cores: g.navigator?.hardwareConcurrency ?? null,
|
||||
memoryGB: g.navigator?.deviceMemory ?? null,
|
||||
lang: g.navigator?.language ?? null,
|
||||
uiLang,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
|
||||
* battle on screen.
|
||||
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
|
||||
*/
|
||||
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
import { t, N_, translateWire } from '../../shared/i18n.js';
|
||||
import { recordError } from './diag.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
const JOIN_DELAY_MS = 350;
|
||||
@@ -311,12 +312,14 @@ function installGlobalErrorHandlers() {
|
||||
// expected browser behaviour, not app errors: log quietly, never toast.
|
||||
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
|
||||
console.error('[app] unhandled rejection', err);
|
||||
recordError('rejection', err);
|
||||
if (err instanceof NetError) toastError(err);
|
||||
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
|
||||
});
|
||||
window.addEventListener('error', (ev) => {
|
||||
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
|
||||
console.error('[app] uncaught error', ev.error || ev.message);
|
||||
recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
|
||||
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
|
||||
// copy link, ready toggle and leave.
|
||||
// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
|
||||
// copy code / copy link, ready toggle and leave.
|
||||
//
|
||||
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
|
||||
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
|
||||
@@ -69,6 +69,19 @@ export function roomFacts(room, myId) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
|
||||
* 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
|
||||
* when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
|
||||
* @param {any} room room.state payload
|
||||
* @param {any} myId
|
||||
* @returns {{ on: boolean, editable: boolean } | null}
|
||||
*/
|
||||
export function aiLastOption(room, myId) {
|
||||
if (!room || room.mode === 'solo') return null;
|
||||
return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
|
||||
}
|
||||
|
||||
/** Invite link for a room code (current page URL with ?room=CODE). */
|
||||
export function inviteLink(code) {
|
||||
const loc = globalThis.location;
|
||||
@@ -189,6 +202,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
|
||||
function AiLastToggle({ option, busy, onToggle }) {
|
||||
if (!option) return null;
|
||||
return html`<div class="ailast">
|
||||
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
|
||||
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
|
||||
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
|
||||
onClick=${() => option.editable && onToggle(!option.on)}>
|
||||
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
|
||||
</button>
|
||||
<//>
|
||||
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Room screen component. */
|
||||
export function RoomScreen() {
|
||||
const room = useStore((s) => s.room);
|
||||
@@ -229,6 +257,7 @@ export function RoomScreen() {
|
||||
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
|
||||
};
|
||||
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
|
||||
const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on }));
|
||||
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
|
||||
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
|
||||
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
|
||||
@@ -307,7 +336,10 @@ export function RoomScreen() {
|
||||
<footer class="room-bar">
|
||||
<div class="room-bar__left">
|
||||
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
<div class="room-bar__opts">
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="room-bar__center">
|
||||
<div class="ready-count" hidden=${!coop}>
|
||||
|
||||
@@ -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>
|
||||
<//>`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user