From cba7b5fbcc513bf2f7e98cb621d31306cca90d21 Mon Sep 17 00:00:00 2001 From: Art_Sakura <1754798088@qq.com> Date: Thu, 8 Oct 2026 14:40:58 +0800 Subject: [PATCH] =?UTF-8?q?feat=20#=20=E6=96=B0=E5=A2=9E=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + diagnostics/README.md | 59 +++++ diagnostics/index.js | 327 ++++++++++++++++++++++++++++ diagnostics/serialize.js | 127 +++++++++++ diagnostics/storage.js | 206 ++++++++++++++++++ extension.js | 14 +- mengsan/battle/battle-flow.js | 71 ++++-- mengsan/battle/battle-resources.js | 5 +- mengsan/battle/battle-session.js | 18 +- mengsan/battle/battle-settlement.js | 26 ++- mengsan/mode.js | 46 +++- mengsan/progression/mode-storage.js | 56 ++++- mengsan/ui/bonds.js | 3 + mengsan/ui/hand/diagnostics.js | 9 + mengsan/ui/map/renderer.js | 4 + mengsan/ui/navigation.js | 7 +- menu.js | 16 ++ tools/manifest_config.json | 3 + update.js | 27 ++- 19 files changed, 978 insertions(+), 48 deletions(-) create mode 100644 diagnostics/README.md create mode 100644 diagnostics/index.js create mode 100644 diagnostics/serialize.js create mode 100644 diagnostics/storage.js diff --git a/.gitignore b/.gitignore index f6cabe9..8887391 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,9 @@ tools/*.mjs # Local updater downloads and runtime logs /.update_tmp/ +/.planning/ /mengsan/logs/ +/logs/ *.log # Local credentials; keep shareable environment templates diff --git a/diagnostics/README.md b/diagnostics/README.md new file mode 100644 index 0000000..ac8067c --- /dev/null +++ b/diagnostics/README.md @@ -0,0 +1,59 @@ +# 术樱包自动诊断日志 + +扩展加载后自动记录本扩展的关键业务步骤,发生错误时自动生成独立报告。 +无需开启控制台或手动开始录制;不上传任何数据。 + +## 文件位置 + +PC 和安卓客户端均写入游戏目录下的 `extension/术樱包/logs/`。 + +- `runtime-*.jsonl`:按行保存时间、模块、步骤、关联 ID 和上下文。 +- `report-*.json`:错误报告,包含首次/最近异常、错误栈、cause、内部异常、 + 运行版本、错误前操作、当前结算阶段和存档状态。 + +同一异常在存档层、结算层等多个入口被观察时,共用报告编号并更新修订号; +occurrences 表示被捕获的次数,不表示奖励发放次数。最近报告不会因普通日志 +轮转被删除。保存回执绑定对应修订和真实通道,旧版本写入成功不会误标新版本。 + +扩展设置中的“诊断日志位置”显示实际位置和最近报告保存状态。 +只需要保留相应报告和附近的 runtime 文件;如何发送由用户自行处理。 + +## 采集范围 + +- 主动记录术樱包加载、更新、梦三战斗、结算、存档、手牌、地图及菜单错误。 +- 全局错误仅接收来源文件或异常栈属于本扩展的情况,包含编码后的中文路径。 +- 不因为当前开启梦三就收集所有错误,也不根据错误消息包含“术樱包”归因。 +- 原引擎错误处理仍会执行;不替换引擎事件、Promise 或 console 的原型。 + +## 存档诊断 + +报告分别记录 storageStatus、memoryPublished 和 commitReceipt: + +- storageStatus=committed 表示存档事务完成,或 localStorage 写入已返回。 +- memoryPublished=true 表示运行内存已发布成功存档。 +- commitReceipt=true 表示结算协调器收到了提交成功结果。 + +资源清理、技能释放或地图展示随后失败时,不能据此判断存档失败。 +重试沿用现有去重和奖励提交规则,日志不参与存档事务,也不触发自动重试。 + +## 容量与兼容 + +默认每秒批量保存普通日志;错误报告立即排入写入队列。运行日志按约 +256 KiB 分段;内存保留最近约 512 KiB / 400 条记录。日志目录自动清理 +系统自己生成的旧文件,保留总量约 5 MiB、最多 64 个文件。报告包含截断和 +丢弃计数;“完整诊断”指受限的错误链与现场记录,不是无限保留整个游戏过程。 + +PC 使用异步文件操作,安卓使用 Cordova 目录和 FileWriter 回调,写入前截断旧 +内容,使用 UTF-8 Blob。单次保存操作设置 5 秒观察超时;超时后停止使用该 +文件通道,避免迟到写入与后续写入重叠。写入异常不会中断游戏业务。 + +原生文件不可用时,降级到独立的 `shuying-diagnostics` IndexedDB;它不与 +游戏存档共用数据库。若两种持久化方式都不可用,仅保留本次运行的内存 +记录,菜单会明确显示未持久化,不能宣称文件已生成。 + +切入后台或页面隐藏时尝试保存缓冲;被系统强制杀死或断电时,最后尚未完成的 +异步写入可能丢失。结算内部等待超过 15 秒会记录观察日志,选择奖励、地图节点 +和剧情等待不会被当作超时错误;观察器不会结束业务事件。 + +字段过滤会隐藏凭据字段、常见 URL 凭据参数和 Bearer 内容。报告不会复制完整 +玩家对象、DOM、引擎状态或整个存档。日志文件不进入发布清单和 Git。 \ No newline at end of file diff --git a/diagnostics/index.js b/diagnostics/index.js new file mode 100644 index 0000000..1666a90 --- /dev/null +++ b/diagnostics/index.js @@ -0,0 +1,327 @@ +import { + byteLength, cleanText, extensionError, extensionSource, serializeError, snapshot, +} from "./serialize.js"; +import { createDiagnosticStorage, isDiagnosticFile, LOG_DIRECTORY } from "./storage.js"; + +const noop = () => null; +const inactive = Object.freeze({ + info: noop, warn: noop, error: noop, scope: () => inactive, + registerContext: () => noop, flush: async () => {}, + step: async (_event, action) => action(), + status: () => ({ enabled: false }), dispose: noop, +}); +let current = inactive; + +export function getDiagnostics() { return current; } + +export function createDiagnostics(options = {}) { + const env = options.env || {}; + const now = options.now || Date.now; + const monotonic = () => { + try { return env.performance?.now?.() ?? now(); } catch { return now(); } + }; + const launchId = `${now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const maxMemoryBytes = options.maxMemoryBytes ?? 512 * 1024; + const maxJournalBytes = options.maxJournalBytes ?? 256 * 1024; + const maxReportBytes = options.maxReportBytes ?? 512 * 1024; + const maxDiskBytes = options.maxDiskBytes ?? 5 * 1024 * 1024; + const maxFiles = options.maxFiles ?? 64; + const maxPending = options.maxPending ?? 12; + const flushMs = options.flushMs ?? 1000; + const recent = [], contexts = new Map(), pending = new Map(); + const errors = new WeakMap(), fingerprints = new Map(), listeners = []; + let sequence = 0, reportSequence = 0, segment = 0, memoryBytes = 0; + let journal = [], journalBytes = 0, revision = 0, queuedRevision = -1; + let flushTimer = null, worker = null, stopped = false, unavailable = false; + let latestReport = null, droppedRecords = 0, droppedWrites = 0; + let inventory = null, inventoryKind = null; + const metadata = snapshot({ + extension: "术樱包", extensionVersion: options.version || "unknown", + engineVersion: env.lib?.version || "unknown", launchId, + platform: env.lib?.node ? "pc" : env.lib?.device ? "android" : "browser", + userAgent: env.window?.navigator?.userAgent || "unknown", + modules: { mengsan: env.lib?.config?.shuYing_mengsan_installed_version || null }, + }); + const roots = []; + try { + const root = new URL("../", import.meta.url); + if (["file:", "http:", "https:"].includes(root.protocol)) roots.push(root.href); + } catch {} + if (options.extensionRoot) { + try { + roots.push(new URL(options.extensionRoot.replace(/\/?$/, "/"), + env.document?.baseURI || env.window?.location?.href).href); + } catch {} + } + const storage = options.storage || createDiagnosticStorage(env, { + timeoutMs: options.storageTimeoutMs, + onFailure(error, phase) { + record("WARN", "diagnostics.storage.failure", { phase }, error, "diagnostics"); + }, + }); + const storageStatus = () => storage.status?.() || { kind: "file", location: LOG_DIRECTORY }; + const runtimeName = () => `runtime-${launchId}-${segment}.jsonl`; + + function contextFor(extra) { + const values = {}; + for (const [name, provider] of contexts) { + try { values[name] = snapshot(provider()); } + catch { values[name] = { diagnosticContextUnavailable: true }; } + } + return { ...values, ...snapshot(extra || {}) }; + } + + function schedule() { + if (flushTimer || stopped || unavailable) return; + flushTimer = setTimeout(() => { flushTimer = null; void flush(); }, flushMs); + flushTimer?.unref?.(); + } + + function record(level, event, extra, error, module = "extension") { + if (stopped) return null; + try { + const entry = { + schemaVersion: 1, sequence: ++sequence, time: new Date(now()).toISOString(), + launchId, level, module: cleanText(module, 128), event: cleanText(event, 256), + context: contextFor(extra), + }; + if (error !== undefined) entry.error = serializeError(error); + const item = { entry, bytes: 0 }; + recent.push(item); + if (level === "ERROR") createReport(entry, error); + const text = JSON.stringify(entry) + "\n"; + const bytes = byteLength(text); + item.bytes = bytes; memoryBytes += bytes; + if (journalBytes + bytes > maxJournalBytes && journal.length) { + enqueue(runtimeName(), journal.join("")); + segment++; journal = []; journalBytes = 0; queuedRevision = -1; + } + journal.push(text); journalBytes += bytes; revision++; + while (recent.length > 400 || memoryBytes > maxMemoryBytes) { + memoryBytes -= recent.shift().bytes; droppedRecords++; + } + schedule(); + if (level === "ERROR") void flush(); + return entry; + } catch { + return null; + } + } + + function createReport(entry, rawError) { + let known; + if (rawError && typeof rawError === "object") known = errors.get(rawError); + const fingerprint = `${entry.module}:${entry.event}:${entry.error?.name}:${entry.error?.message}`; + if (!known) { + const prior = fingerprints.get(fingerprint); + if (prior && now() - prior.time < 5000) known = prior; + } + if (!known) known = { id: `${launchId}-${++reportSequence}`, count: 0, + first: entry.time, time: now(), firstFailure: entry }; + known.count++; known.time = now(); + if (rawError && typeof rawError === "object") errors.set(rawError, known); + fingerprints.set(fingerprint, known); + while (fingerprints.size > 64) fingerprints.delete(fingerprints.keys().next().value); + entry.reportId = known.id; + const file = `report-${known.id}.json`; + const report = { + schemaVersion: 1, reportId: known.id, firstSeen: known.first, + lastSeen: entry.time, occurrences: known.count, environment: metadata, + storageAtCapture: snapshot(storageStatus()), firstFailure: known.firstFailure, + failure: entry, records: recent.map(item => item.entry), + droppedRecords, droppedWrites, + }; + let text = JSON.stringify(report, null, 2); + while (byteLength(text) > maxReportBytes && report.records.length > 1) { + report.records.shift(); report.droppedRecords++; + text = JSON.stringify(report, null, 2); + } + latestReport = { id: known.id, file, revision: known.count, + persisted: false, event: entry.event }; + enqueue(file, text, true, known.count); + } + + function enqueue(name, text, priority = false, reportRevision = null) { + if (unavailable || stopped) return; + if (!pending.has(name) && pending.size >= maxPending) { + const victim = [...pending].find(([, item]) => !item.priority)?.[0]; + if (!victim) { droppedWrites++; return; } + pending.delete(victim); droppedWrites++; + } + pending.set(name, { text, priority, reportRevision }); + startWorker(); + } + + async function prune(protect, text) { + const kind = storageStatus().kind; + if (!inventory || inventoryKind !== kind) { + inventory = new Map((await storage.list()).filter(file => + isDiagnosticFile(file.name)).map(file => [file.name, file])); + inventoryKind = kind; + } + inventory.set(protect, { name: protect, bytes: byteLength(text), time: now() }); + const files = [...inventory.values()]; + files.sort((a, b) => (a.time || 0) - (b.time || 0)); + let bytes = files.reduce((sum, file) => sum + (file.bytes || 0), 0); + let count = files.length; + for (const file of files) { + if (bytes <= maxDiskBytes && count <= maxFiles) break; + if (file.name === protect || file.name === runtimeName() + || file.name === latestReport?.file) continue; + await storage.remove(file.name); + inventory.delete(file.name); + bytes -= file.bytes || 0; count--; + } + } + + function startWorker() { + if (worker || !pending.size) return; + worker = Promise.resolve().then(async () => { + while (pending.size && !unavailable) { + const name = [...pending].find(([, item]) => item.priority)?.[0] + || pending.keys().next().value; + const item = pending.get(name); + pending.delete(name); + try { + await storage.write(name, item.text); + if (latestReport?.file === name && latestReport.revision === item.reportRevision) { + latestReport.persisted = true; + latestReport.savedStorage = snapshot(storageStatus()); + } + try { await prune(name, item.text); } catch { inventory = null; } + } catch { + unavailable = true; pending.clear(); + } + } + }).catch(() => { unavailable = true; pending.clear(); }).finally(() => { + worker = null; + if (pending.size && !unavailable) startWorker(); + }); + } + + async function flush() { + try { + clearTimeout(flushTimer); flushTimer = null; + if (journal.length && queuedRevision !== revision) { + queuedRevision = revision; + enqueue(runtimeName(), journal.join("")); + } + while (worker) await worker; + } catch {} + } + + function scope(module, provider = () => ({})) { + const details = extra => { + try { + const context = typeof provider === "function" ? provider() : provider; + return { ...snapshot(context), ...snapshot(extra || {}) }; + } catch { return { diagnosticContextUnavailable: true }; } + }; + return Object.freeze({ + info: (event, extra) => record("INFO", event, details(extra), undefined, module), + warn: (event, extra, error) => record("WARN", event, details(extra), error, module), + error: (event, error, extra) => record("ERROR", event, details(extra), error, module), + async step(event, action, extra = {}, options = {}) { + const started = monotonic(); + record("INFO", `${event}.start`, details(extra), undefined, module); + let timer; + if (options.observeWait !== false) { + timer = setTimeout(() => { + if (env.document?.hidden) return; + record("WARN", `${event}.waiting`, details({ ...extra, + elapsedMs: monotonic() - started }), undefined, module); + }, options.waitMs ?? 15000); + timer?.unref?.(); + } + try { + const result = await action(); + record("INFO", `${event}.complete`, details({ ...extra, + elapsedMs: monotonic() - started }), undefined, module); + return result; + } catch (error) { + record("ERROR", `${event}.failed`, details({ ...extra, + elapsedMs: monotonic() - started }), error, module); + throw error; + } finally { clearTimeout(timer); } + }, + }); + } + + function listen(target, event, handler, capture = false) { + if (typeof target?.addEventListener !== "function") return; + target.addEventListener(event, handler, capture); + listeners.push(() => target.removeEventListener(event, handler, capture)); + } + const globalLogger = scope("extension.global"); + const globalSeen = new WeakMap(); + function captureGlobal(error, source, extra = {}) { + if (!extensionSource(source, roots) && !extensionError(error, roots)) return; + if (error && typeof error === "object") { + const last = globalSeen.get(error); + if (last !== undefined && now() - last < 20) return; + globalSeen.set(error, now()); + } + globalLogger.error("uncaught", error, { source, ...extra }); + } + listen(env.window, "error", event => { + try { + if (extensionSource(event.filename, roots) || extensionError(event.error, roots)) { + captureGlobal(event.error || event.message, event.filename, + { line: event.lineno, column: event.colno }); + } else { + const source = event.target?.src || event.target?.href; + if (extensionSource(source, roots)) globalLogger.error("resource.load", + new Error("术樱包资源加载失败"), { source }); + } + } catch {} + }, true); + listen(env.window, "unhandledrejection", event => { + try { + if (extensionError(event.reason, roots)) captureGlobal(event.reason, "", + { origin: "unhandledrejection" }); + } catch {} + }); + if (env.window) { + const previous = env.window.onerror; + const chained = function(message, source, line, column, error) { + try { captureGlobal(error || message, source, { line, column }); } catch {} + return typeof previous === "function" ? previous.apply(this, arguments) : undefined; + }; + env.window.onerror = chained; + listeners.push(() => { + if (env.window.onerror === chained) env.window.onerror = previous; + }); + } + listen(env.document, "visibilitychange", () => { + if (env.document.hidden) void flush(); + }); + listen(env.window, "pagehide", () => { void flush(); }); + const api = Object.freeze({ + ...scope("extension"), scope, + registerContext(name, provider) { + contexts.set(name, provider); + return () => contexts.delete(name); + }, + flush, + status: () => snapshot({ enabled: true, ...storageStatus(), + unavailable, latestReport, droppedRecords, droppedWrites, + runtimeFile: runtimeName(), launchId }), + dispose() { + void flush(); stopped = true; clearTimeout(flushTimer); + for (const remove of listeners) remove(); + if (current === api) current = inactive; + }, + }); + api.info("diagnostics.started", { environment: metadata }); + return api; +} + +export function configureDiagnostics({ lib, game, window, document, version, extensionRoot }) { + if (current !== inactive) return current; + try { + current = createDiagnostics({ version, extensionRoot, + env: { lib, game, window, document, performance: window?.performance, + localStorage: window?.localStorage, indexedDB: window?.indexedDB } }); + } catch {} + return current; +} diff --git a/diagnostics/serialize.js b/diagnostics/serialize.js new file mode 100644 index 0000000..83d6d98 --- /dev/null +++ b/diagnostics/serialize.js @@ -0,0 +1,127 @@ +const secretKey = /password|passwd|secret|token|cookie|authorization|api[_-]?key|credential/i; + +export function cleanText(value, limit = 8192) { + let text; + try { text = String(value); } catch { return "[unreadable]"; } + const truncated = text.length > limit; + text = text.slice(0, limit); + text = text.replace(/([?&](?:token|key|api[_-]?key|access_token|password|secret|auth)[a-z0-9_-]*=)[^&#\s]*/gi, + "$1[redacted]"); + text = text.replace(/((?:Bearer|Basic)\s+)[\w.+\/=-]+/gi, "$1[redacted]"); + text = text.replace(/\b(?:https?|ftp):\/\/[^\s/?#]*/gi, (url, offset, source) => { + const schemeEnd = url.indexOf("://") + 3; + const at = url.lastIndexOf("@"); + if (at >= schemeEnd) return url.slice(0, schemeEnd) + "[redacted]@" + url.slice(at + 1); + if (truncated && offset + url.length === source.length + && url.slice(schemeEnd).includes(":")) return url.slice(0, schemeEnd) + "[redacted]"; + return url; + }); + return !truncated && text.length <= limit ? text : text.slice(0, limit) + "[truncated]"; +} + +function read(object, key) { + try { return object[key]; } catch { return "[unreadable]"; } +} + +export function snapshot(value, depth = 0, seen = new WeakSet()) { + if (value === null || value === undefined) return value ?? null; + if (typeof value === "string") return cleanText(value, 2048); + if (typeof value === "boolean") return value; + if (typeof value === "number") return Number.isFinite(value) ? value : String(value); + if (typeof value !== "object") return cleanText(value, 256); + if (seen.has(value)) return "[circular]"; + if (depth >= 5) return "[depth limit]"; + seen.add(value); + if (Array.isArray(value)) { + const result = value.slice(0, 32).map(item => snapshot(item, depth + 1, seen)); + if (value.length > 32) result.push(`[${value.length - 32} more items]`); + return result; + } + let keys; + try { keys = Object.keys(value); } catch { return "[unreadable]"; } + const result = {}; + for (const key of keys.slice(0, 40)) { + if (key === "__proto__" || key === "constructor") continue; + result[key] = secretKey.test(key) ? "[redacted]" + : snapshot(read(value, key), depth + 1, seen); + } + if (keys.length > 40) result.truncatedKeys = keys.length - 40; + return result; +} + +export function serializeError(error, depth = 0, seen = new WeakSet(), + budget = { nodes: 0, characters: 32768 }) { + if (++budget.nodes > 32 || budget.characters <= 0) { + return { name: "TruncatedError", message: "[error size limit]" }; + } + const text = (value, limit = 8192) => { + const result = cleanText(value, Math.max(0, Math.min(limit, budget.characters))); + budget.characters -= result.length; + return result; + }; + if (!error || typeof error !== "object") { + return { name: "ThrownValue", message: text(error) }; + } + if (seen.has(error)) return { name: "CircularError", message: "[circular]" }; + if (depth >= 5) return { name: "TruncatedError", message: "[depth limit]" }; + seen.add(error); + const result = { + name: text(read(error, "name") || "Error", 128), + message: text(read(error, "message") || error), + stack: text(read(error, "stack") || "", 16384), + }; + for (const key of ["code", "operationUncertain"]) { + const value = read(error, key); + if (value !== undefined) result[key] = snapshot(value); + } + const cause = read(error, "cause"); + if (cause !== undefined) result.cause = serializeError(cause, depth + 1, seen, budget); + const errors = read(error, "errors"); + if (Array.isArray(errors)) { + result.errors = errors.slice(0, 16).map(item => + serializeError(item, depth + 1, seen, budget)); + if (errors.length > 16) result.truncatedErrors = errors.length - 16; + } + return result; +} + +function decode(value) { + let text = cleanText(value, 32768).replace(/\\/g, "/"); + for (let count = 0; count < 2; count++) { + try { text = decodeURIComponent(text); } catch { break; } + } + return text; +} + +export function extensionSource(value, roots = []) { + if (typeof value !== "string" || !value) return false; + const source = cleanText(value, 32768).replace(/\\/g, "/").trim(); + const url = source.match(/(?:file|https?):\/\/[^\n]+/i)?.[0]; + const native = source.match(/[a-z]:\/[^\n]+/i)?.[0]; + const candidate = decode((url || native || source) + .replace(/\)\s*$/, "").split(/[?#]/, 1)[0]); + if (roots.some(root => { + const base = decode(root).split(/[?#]/, 1)[0]; + return base && (candidate.startsWith(base) + || (base.startsWith("file:") && candidate.startsWith(base.replace(/^file:\/+/, "")))); + })) return true; + if (/^(?:\.?\/)?extension\/术樱包\//.test(candidate)) return true; + return !roots.length && /\/extension\/术樱包\//.test(candidate); +} + +export function extensionError(error, roots = [], depth = 0) { + if (!error || typeof error !== "object" || depth >= 5) return false; + const stack = read(error, "stack"); + if (typeof stack === "string" && stack.split("\n").slice(1).some(line => + /(?:\bat\s|@)/.test(line) && extensionSource(line, roots))) return true; + if (extensionSource(read(error, "fileName"), roots)) return true; + if (extensionError(read(error, "cause"), roots, depth + 1)) return true; + const errors = read(error, "errors"); + return Array.isArray(errors) && errors.slice(0, 16).some(item => + extensionError(item, roots, depth + 1)); +} + +export function byteLength(text) { + if (typeof TextEncoder === "function") return new TextEncoder().encode(text).length; + return new Blob([text]).size; +} diff --git a/diagnostics/storage.js b/diagnostics/storage.js new file mode 100644 index 0000000..8be17c8 --- /dev/null +++ b/diagnostics/storage.js @@ -0,0 +1,206 @@ +import { byteLength } from "./serialize.js"; + +export const LOG_DIRECTORY = "extension/术樱包/logs"; +const ownedName = /^(?:runtime|report)-[a-zA-Z0-9-]+\.(?:jsonl|json)$/; + +export function isDiagnosticFile(name) { + return typeof name === "string" && ownedName.test(name); +} + +function checkedName(name) { + if (!isDiagnosticFile(name)) throw new Error("Invalid diagnostic filename"); + return name; +} + +function callback(start) { + return new Promise((resolve, reject) => { + try { start(resolve, reject); } catch (error) { reject(error); } + }); +} + +function nodeStorage(env) { + const fs = env.lib?.node?.fs; + const root = env.window?.__dirname; + if (!fs || typeof root !== "string" || !root) return null; + const directory = `${root.replace(/[\\/]$/, "")}/${LOG_DIRECTORY}`; + const invoke = (method, ...args) => callback((resolve, reject) => { + fs[method](...args, (error, value) => error ? reject(error) : resolve(value)); + }); + const ready = () => invoke("mkdir", directory, { recursive: true }); + return { + kind: "file", location: directory, + async write(name, text) { + await ready(); + await invoke("writeFile", `${directory}/${checkedName(name)}`, text, "utf8"); + }, + async list() { + await ready(); + const names = (await invoke("readdir", directory)).filter(isDiagnosticFile); + const entries = []; + for (const name of names) { + const stat = await invoke("lstat", `${directory}/${name}`); + if (stat.isFile()) entries.push({ name, bytes: stat.size, time: stat.mtimeMs }); + } + return entries; + }, + remove: name => invoke("unlink", `${directory}/${checkedName(name)}`), + }; +} + +function cordovaStorage(env) { + const host = env.window; + if (typeof host?.resolveLocalFileSystemURL !== "function") return null; + const root = env.localStorage?.getItem("noname_inited"); + if (!root) return null; + let directoryPromise; + const directory = () => { + if (!directoryPromise) directoryPromise = callback((resolve, reject) => { + host.resolveLocalFileSystemURL(root, resolve, reject); + }).then(async entry => { + for (const part of LOG_DIRECTORY.split("/")) { + entry = await callback((resolve, reject) => + entry.getDirectory(part, { create: true }, resolve, reject)); + } + return entry; + }); + return directoryPromise; + }; + const file = async name => { + const entry = await directory(); + return callback((resolve, reject) => + entry.getFile(checkedName(name), { create: true }, resolve, reject)); + }; + return { + kind: "file", location: `${root.replace(/\/$/, "")}/${LOG_DIRECTORY}`, + async write(name, text) { + const entry = await file(name); + await callback((resolve, reject) => entry.createWriter(writer => { + let truncated = false; + writer.onerror = () => reject(writer.error || new Error("Diagnostic write failed")); + writer.onwriteend = () => { + if (writer.error) { reject(writer.error); return; } + if (truncated) { resolve(); return; } + truncated = true; + try { writer.write(new Blob([text], { type: "text/plain;charset=utf-8" })); } + catch (error) { reject(error); } + }; + try { writer.truncate(0); } catch (error) { reject(error); } + }, reject)); + }, + async list() { + const entry = await directory(); + const reader = entry.createReader(); + const entries = []; + while (true) { + const batch = await callback((resolve, reject) => + reader.readEntries(resolve, reject)); + if (!batch.length) break; + for (const item of batch) { + if (!item.isFile || !isDiagnosticFile(item.name)) continue; + const metadata = await callback((resolve, reject) => + item.getMetadata(resolve, reject)); + entries.push({ name: item.name, bytes: metadata.size, + time: new Date(metadata.modificationTime).getTime() }); + } + } + return entries; + }, + async remove(name) { + const entry = await file(name); + return callback((resolve, reject) => entry.remove(resolve, reject)); + }, + }; +} + +function databaseStorage(env) { + const indexedDB = env.indexedDB; + if (!indexedDB) return null; + let database; + const open = () => database ||= callback((resolve, reject) => { + const request = indexedDB.open("shuying-diagnostics", 1); + let failed = false; + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains("files")) db.createObjectStore("files", { keyPath: "name" }); + }; + request.onsuccess = () => { + if (failed) { request.result.close(); return; } + request.result.onversionchange = () => request.result.close(); + resolve(request.result); + }; + request.onerror = () => { failed = true; reject(request.error); }; + request.onblocked = () => { failed = true; reject(new Error("Diagnostic database blocked")); }; + }); + const transaction = async action => { + const db = await open(); + return callback((resolve, reject) => { + const tx = db.transaction(["files"], "readwrite"); + let value, failure; + tx.oncomplete = () => resolve(value); + tx.onerror = event => { failure = event.target?.error; }; + tx.onabort = () => reject(tx.error || failure || new Error("Diagnostic transaction aborted")); + try { action(tx.objectStore("files"), result => { value = result; }); } + catch (error) { try { tx.abort(); } catch {} reject(error); } + }); + }; + return { + kind: "indexeddb", location: "本机独立诊断数据库", + write(name, text) { + return transaction(store => store.put({ name: checkedName(name), text, + bytes: byteLength(text), time: Date.now() })); + }, + list() { + return transaction((store, done) => { + const entries = []; + const cursor = store.openCursor(); + cursor.onsuccess = () => { + const item = cursor.result; + if (!item) { done(entries); return; } + if (isDiagnosticFile(item.value.name)) entries.push({ + name: item.value.name, bytes: item.value.bytes, time: item.value.time, + }); + item.continue(); + }; + }); + }, + remove: name => transaction(store => store.delete(checkedName(name))), + }; +} + +export function createDiagnosticStorage(env, options = {}) { + const timeoutMs = options.timeoutMs ?? 5000; + const onFailure = options.onFailure || (() => {}); + const candidates = []; + try { + const native = nodeStorage(env) || cordovaStorage(env); + if (native) candidates.push(native); + } catch (error) { onFailure(error, "file.init"); } + try { + const database = databaseStorage(env); + if (database) candidates.push(database); + } catch (error) { onFailure(error, "database.init"); } + let index = 0; + const timed = action => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Diagnostic storage timed out")), timeoutMs); + Promise.resolve().then(action).then(resolve, reject).finally(() => clearTimeout(timer)); + }); + const execute = async (method, ...args) => { + while (index < candidates.length) { + const backend = candidates[index]; + try { return await timed(() => backend[method](...args)); } + catch (error) { + try { onFailure(error, `${backend.kind}.${method}`); } catch {} + if (method !== "write") throw error; + index++; + } + } + throw new Error("Diagnostic persistence unavailable; records kept in memory"); + }; + return { + write: (name, text) => execute("write", name, text), + list: () => execute("list"), + remove: name => execute("remove", name), + status: () => ({ kind: candidates[index]?.kind || "memory", + location: candidates[index]?.location || "仅当前运行内存,未能持久化" }), + }; +} diff --git a/extension.js b/extension.js index 112160d..2c2be28 100644 --- a/extension.js +++ b/extension.js @@ -2,10 +2,11 @@ import { lib, game, ui, get, ai, _status } from "../../noname.js"; import updater from "./update.js" import func from "./function.js" import initShuYingMenu from "./menu.js"; +import { configureDiagnostics } from "./diagnostics/index.js"; export const type = "extension"; export let shuYing = new Object(); -const shuYingLocalVersion = "2.0.2.3-rc.2"; +const shuYingLocalVersion = "2.0.2.3-rc.3"; shuYing.name = "术樱包"; shuYing.editable = false; @@ -16,6 +17,11 @@ shuYing.text = {}; //中上方文本 shuYing.text_style = {}; //中上方文本样式 shuYing.moduleCache = {}; //动态加载模块缓存,避免同一文件重复import shuYing.voices = {}; //语音台词表,voices.js缺失时保持为空 +shuYing.diagnostics = configureDiagnostics({ + lib, game, window, document, version: shuYingLocalVersion, + extensionRoot: shuYing.url, +}); +const diagnostics = shuYing.diagnostics.scope("extension"); //按需加载扩展内部模块。optional为true时,文件缺失只关闭对应功能,不中断整个扩展入口。 shuYing.loadModule = async (path, label, optional = true) => { @@ -24,6 +30,7 @@ shuYing.loadModule = async (path, label, optional = true) => { .then(module => module.default ?? module) .catch(error => { const message = `术樱包:${label || path}加载失败`; + diagnostics.error("module.load.failed", error, { path, label, optional }); if (!optional) throw error; console.warn(message, error); return null; @@ -125,6 +132,7 @@ shuYing.getVersion = (callback) => { .catch(error => { if (requestId != shuYing.versionRequestId) return null; console.error("术樱包获取在线版本失败:", error); + diagnostics.error("version.lookup.failed", error); shuYing.updateOnlineVersionMenu(null, "获取失败"); return null; }); @@ -245,7 +253,7 @@ shuYing.initCharacter = async () => { } //初始化游戏时 -shuYing.precontent = async () => { +shuYing.precontent = () => diagnostics.step("precontent", async () => { await shuYing.loadVoices(); shuYing.initCSS(); shuYing.initFunction(); @@ -273,7 +281,7 @@ shuYing.precontent = async () => { initShuYingMenu({ lib, game, ui, shuYing, updater }); shuYing.getVersion(); await shuYing.initCharacter(); -}; +}); shuYing.config = () => { diff --git a/mengsan/battle/battle-flow.js b/mengsan/battle/battle-flow.js index f789496..42ccf29 100644 --- a/mengsan/battle/battle-flow.js +++ b/mengsan/battle/battle-flow.js @@ -1,67 +1,89 @@ // Candidate external coordinator. Finish callbacks return a boolean, NEVER a drain promise. +import { getDiagnostics } from "../../diagnostics/index.js"; const copy = value => JSON.parse(JSON.stringify(value)); -export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) { +export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, releaseBattle = () => {}, presentVictory = async () => {}}) { let state = "map", active = null, work = null, job = null, error = null; + let phase = "map", attempt = 0; + const diagnostics = getDiagnostics().scope("mengsan.flow", () => ({ + state, phase, attempt, sessionId: active?.session.id, + sessionState: active?.session.state, + pendingTasks: active?.session.pendingCount, + runId: active?.run.runId, revision: active?.run.revision, + nodeId: active?.node.id, settlementId: job?.pending?.id, + outcome: job?.input?.outcome, commitReceipt: !!job?.committed, + skillsReleased: !!job?.skillsReleased, delivered: !!job?.delivered, + })); + const step = (name, action, wait = true) => { + phase = name; + return diagnostics.step(name, action, {}, { observeWait: wait }); + }; async function drive() { try { state = "draining"; - await active.session.drain(); + await step("drain", () => active.session.drain()); // Allow mode-start/parent frames and foreign pauses to unwind; never resume them. - await quiesce(); - if (!job.pending) job.pending = await settlement.prepare(job.input); + await step("quiesce.beforePrepare", quiesce); + if (!job.pending) job.pending = await step("prepare", () => settlement.prepare(job.input)); if (job.pending.outcome === "victory" && !job.victoryPresented) { - await presentVictory(copy(job.pending)); + await step("victoryDialogue", () => presentVictory(copy(job.pending)), false); job.victoryPresented = true; } if (job.pending.state !== "chosen") { state = "choosing"; if (!job.hasChoice) { - job.choiceId = job.pending.outcome === "defeat" ? await chooseDefeat(copy(job.pending.base)) - : await chooseReward(copy(job.pending.choices), { + job.choiceId = await step("rewardSelection", () => job.pending.outcome === "defeat" ? chooseDefeat(copy(job.pending.base)) + : chooseReward(copy(job.pending.choices), { boss: job.pending.boss, elite: job.pending.elite, fixedRewards: copy(job.pending.fixedRewards || []), rewardPackage: copy(job.pending.rewardPackage || null), - }); + }), false); if (job.pending.outcome !== "defeat" && !job.pending.rewardPackage && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId && c.kind !== "gold")) throw new Error("Invalid reward selection"); job.hasChoice = true; } - job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId); + job.pending = await step("applyChoice", () => settlement.choose(job.input.run.runId, job.pending.id, job.choiceId)); } if (!job.committed) { state = "committing"; - job.committed = await settlement.commit(job.input.run.runId, job.pending.id); + job.committed = await step("commit", () => settlement.commit(job.input.run.runId, job.pending.id)); } if (!job.skillsReleased) { state = "releasingSkills"; - await releaseSkills(); + await step("releaseSkills", releaseSkills); job.skillsReleased = true; - await quiesce(); + await step("quiesce.afterSkills", quiesce); } state = "cleaning"; - await active.session.dispose(); // Failed cleanup retry does NOT replay a committed reward. - await quiesce(); + await step("dispose", () => active.session.dispose()); // Failed cleanup retry does NOT replay a committed reward. + await step("quiesce.afterDispose", quiesce); + if (!job.battleReleased) { + await step("releaseBattle", releaseBattle); + job.battleReleased = true; + } if (!job.delivered) { state = "presenting"; if (job.committed.route === "map") { const mapRun = copy(job.committed.run); - job.nextNode = await showMap(mapRun); + job.nextNode = await step("showMap", () => showMap(mapRun), false); job.committed.run = mapRun; } else if (job.committed.route !== "new") { - await showEnding(job.committed.route, copy(job.committed.run)); + await step("showEnding", () => showEnding(job.committed.route, copy(job.committed.run)), false); } job.delivered = true; } state = job.committed.route === "map" ? "map" : "ended"; + diagnostics.info("settlement.complete", { route: job.committed.route }); return {...copy(job.committed), nextNode:copy(job.nextNode ?? null)}; } catch (cause) { + diagnostics.error("settlement.failed", cause, { failedPhase: phase }); error = cause; state = "retryable"; throw cause; } } function launch() { + attempt++; error = null; state = "draining"; work = Promise.resolve().then(drive); // The engine callback is fire-and-forget; keep failure observable without unhandled rejection. @@ -76,24 +98,33 @@ export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMa if (!saved || saved.runId !== run.runId || saved.revision !== run.revision) throw new Error("Stale battle input"); state = "starting"; try { - await quiesce(); + await step("quiesce.beforeBattle", quiesce); active = {session, run, node:copy(node), encounter:copy(encounter)};job = null;work = null; // start MUST schedule only; it must not await the battle root's completion. - state = "battle"; start(); - } catch (cause) { state = "blocked"; error = cause; throw cause; } + state = "battle"; attempt = 0; start(); + diagnostics.info("battle.started"); + } catch (cause) { + diagnostics.error("battle.start.failed", cause, { runId: run.runId, nodeId: node.id }); + state = "blocked"; error = cause; throw cause; + } }, requestFinish({session, hp, outcome = "victory", defeatedEnemies = 1}) { - if (state !== "battle" || session !== active.session) return false; + if (state !== "battle" || session !== active.session) { + diagnostics.info("finish.ignored", { requestedSessionId: session?.id, outcome }); + return false; + } if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown outcome"); if (!Number.isInteger(defeatedEnemies) || defeatedEnemies < 0) throw new Error("Invalid defeated enemy count"); job = {input:{run:copy(active.run),node:copy(active.node),encounter:{...copy(active.encounter),defeatedEnemies},hp,outcome}}; state = "stopping"; + diagnostics.info("finish.accepted", { hp, defeatedEnemies }); active.session.requestStop(); launch(); return true; // No game.pause/resume: stop loop, let current body unwind, then open rewards. }, retry() { if (state !== "retryable") throw new Error("No failed settlement to retry"); + diagnostics.info("settlement.retry"); launch(); return work; }, wait() { if (!work) throw new Error("No settlement requested"); return work; }, diff --git a/mengsan/battle/battle-resources.js b/mengsan/battle/battle-resources.js index 6e6d7f3..3ad1912 100644 --- a/mengsan/battle/battle-resources.js +++ b/mengsan/battle/battle-resources.js @@ -20,11 +20,13 @@ export function createBattleResources(session, {game, ui, _status}) { session.ownResource({}, () => { for (const card of cards) card.remove(); for (const [node,parent] of nodes) if (node.parentNode === parent) node.remove(); - for (const {object,key,value,descriptor} of fields.reverse()) { + for (const {object,key,value,descriptor} of fields.slice().reverse()) { if (object[key] !== value) continue; // A replacement owned by another subsystem survives. if (descriptor) Object.defineProperty(object,key,descriptor); else delete object[key]; } game.phaseNumber = counters.phaseNumber; game.roundNumber = counters.roundNumber; + cards.clear(); nodes.clear(); fields.length = 0; + before.clear(); uiBefore.clear(); }); return Object.freeze({ field, @@ -49,6 +51,7 @@ export function createBattleResources(session, {game, ui, _status}) { for (const key of ["currentPhase","lastPhasedPlayer","roundStart"]) { if (owned.has(_status[key])) delete _status[key]; } + owned.clear(); }); }, }); diff --git a/mengsan/battle/battle-session.js b/mengsan/battle/battle-session.js index 6c41950..1fcd40d 100644 --- a/mengsan/battle/battle-session.js +++ b/mengsan/battle/battle-session.js @@ -1,4 +1,5 @@ // Candidate only. No engine globals, event prototype patches, or save writes. +import { getDiagnostics } from "../../diagnostics/index.js"; const owners = new WeakMap(); export function createBattleSession(id) { @@ -7,6 +8,11 @@ export function createBattleSession(id) { let state = "running", disposePromise = null; const pending = new Set(), events = new Set(), resources = new Map(), errors = []; const waiters = new Set(); + const diagnostics = getDiagnostics().scope("mengsan.session", () => ({ + sessionId: id, state, pendingTasks: pending.size, + eventCount: events.size, resourceCount: resources.size, + recordedErrors: errors.length, + })); const notify = () => { for (const wake of waiters) wake(); waiters.clear(); }; const ensureRunning = () => { if (state !== "running") throw new Error("Session is stopping"); }; const claim = object => { @@ -20,6 +26,7 @@ export function createBattleSession(id) { if (!(promise instanceof Promise)) throw new TypeError("Native completion Promise required"); pending.add(promise); promise.then(() => { pending.delete(promise); notify(); }, error => { + diagnostics.error("task.failed", error); errors.push(error); pending.delete(promise); notify(); }); return promise; @@ -58,9 +65,13 @@ export function createBattleSession(id) { requestStop() { if (state !== "running") return false; state = "stopping"; + diagnostics.info("stop.requested"); // Only explicitly registered events. Do not erase global next/after or guess ownership. for (const event of events) { - try { event.finish(); } catch (error) { errors.push(error); } + try { event.finish(); } catch (error) { + diagnostics.error("event.finish.failed", error, { eventName: event.name }); + errors.push(error); + } } notify(); return true; @@ -80,7 +91,10 @@ export function createBattleSession(id) { disposePromise = (async () => { // Reverse acquisition order; stop at first failure to preserve dependent resources. for (const [resource, release] of [...resources].reverse()) { - await release(resource); + await diagnostics.step("resource.release", () => release(resource), { + resourceType: resource?.constructor?.name, + playerId: resource?.playerid, nodeName: resource?.nodeName, + }); resources.delete(resource); owners.delete(resource); } for (const event of events) owners.delete(event); diff --git a/mengsan/battle/battle-settlement.js b/mengsan/battle/battle-settlement.js index 77ff6ce..bf7ac17 100644 --- a/mengsan/battle/battle-settlement.js +++ b/mengsan/battle/battle-settlement.js @@ -4,6 +4,7 @@ import { applySharedBattleEndCards } from "../cards/shared-battle-end.js"; import { settleBondBattle } from "../bonds/state.js"; import { recordNormalBattleVictory } from "../progression/encounter-progress.js"; import { stripBattleProgress } from "../progression/map-checkpoint.js"; +import { getDiagnostics } from "../../diagnostics/index.js"; import { prepareRewardPackage, applyRewardPackage } from "../progression/reward-package.js"; @@ -14,6 +15,11 @@ export function createBattleSettlement({ enterNextAct, createRun, now = Date.now, logRewardPackage = () => {}, }) { let item = null, committed = null, commitPromise = null; + const diagnostics = getDiagnostics().scope("mengsan.settlement", () => ({ + settlementId: item?.id, runId: item?.base.runId, + nodeId: item?.nodeId, pendingState: item?.state, + route: item?.route, commitReceipt: !!committed, + })); const readRun = () => store.read()[config.saveKey]; function current(storage, runId) { const run = storage[config.saveKey]; @@ -27,6 +33,11 @@ export function createBattleSettlement({ function validateCheckpoint(run, snapshot, nodeId) { if (run.actIndex !== snapshot.actIndex || run.revision !== snapshot.revision || run.map?.nodes.find(node => node.id === nodeId)?.completed !== false) { + diagnostics.warn("checkpoint.mismatch", { + expectedRevision: snapshot.revision, actualRevision: run.revision, + expectedAct: snapshot.actIndex, actualAct: run.actIndex, + nodeId, completed: run.map?.nodes.find(node => node.id === nodeId)?.completed, + }); throw new Error("Stale node or revision"); } } @@ -75,6 +86,10 @@ export function createBattleSettlement({ getRandomRewardChoices) : null; item = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(fixedRewards), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, elite, outcome, relicRecovery, bondGrowth}; item.rewardPackage = copy(rewardPackage); + diagnostics.info("prepared", { + outcome, choices: choices.map(choice => choice.id), fixedRewards, + expectedRevision: snapshot.revision, actualRevision: run.revision, + }); if (outcome === "victory" && !rewardPackage) { item.rewardMode = "one-or-skip"; } @@ -149,6 +164,7 @@ export function createBattleSettlement({ // 在内存锁定最终结果(包括随机牌实例),只在 commit 写入地图进度。 item.state = "chosen"; item.choiceId = copy(choiceId); item.result = stripBattleProgress(result); item.route = route; item.completedAt = now(); + diagnostics.info("choice.applied", { choiceId, route }); if (item.rewardPackage && item.outcome === "victory") { logRewardPackage(item.rewardPackage, choiceId); } @@ -157,8 +173,11 @@ export function createBattleSettlement({ async commit(runId, id) { const item = pending(runId, id); if (item.state !== "chosen") throw new Error("Player choice required before saving"); - if (committed) return {...copy(committed), duplicate:true}; - if (commitPromise) return commitPromise; + if (committed) { + diagnostics.info("commit.duplicate"); + return {...copy(committed), duplicate:true}; + } + if (commitPromise) { diagnostics.info("commit.inflight"); return commitPromise; } commitPromise = store.update(storage => { const saved = storage[config.saveKey]; const prior = storage[config.profileKey]?.completedRuns?.find(r => r.runId === runId && r.settlementId === id); @@ -188,8 +207,9 @@ export function createBattleSettlement({ else delete storage[config.saveKey]; // Same transaction as profile; never a second clear write. } return {route:item.route, run:item.route === "new" ? copy(item.nextRun) : result, duplicate:false}; - }).then(result => { + }, { runId, settlementId: id, nodeId: item.nodeId }).then(result => { committed = copy(result); + diagnostics.info("commit.receipt", { duplicate: result.duplicate, route: result.route }); return copy(committed); }).finally(() => { commitPromise = null; }); return commitPromise; diff --git a/mengsan/mode.js b/mengsan/mode.js index 6d8f122..e9679d5 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -94,6 +94,7 @@ import { mountGMManager } from "./ui/gm-manager.js"; import { createPlayerTeardown } from "./battle/skill-teardown.js"; import { createBattleResources } from "./battle/battle-resources.js"; import { createModeStorage } from "./progression/mode-storage.js"; +import { getDiagnostics } from "../diagnostics/index.js"; import { createBattleSession } from "./battle/battle-session.js"; import { observeFreshEvent } from "./battle/engine-session.js"; import { runPhaseLoop, stopBattleTurn } from "./battle/battle-loop.js"; @@ -141,7 +142,26 @@ const clearRun = modeStorage.clearRun; const showMap = run => renderMap(run, { saveRun }); let battleSequence = 0; let activeBattle = null; -const reportFlowError = error => { console.error("梦三流程暂停,未自动清档:", error); }; +const diagnosticService = getDiagnostics(); +const diagnostics = diagnosticService.scope("mengsan.mode"); +diagnosticService.registerContext("mengsan", () => { + const run = _status.mengsanRun_shuying; + const manager = _status.eventManager; + return { + runId: run?.runId, revision: run?.revision, actIndex: run?.actIndex, + character: run?.player?.character, hp: run?.player?.hp, + sessionId: activeBattle?.session.id, + flowState: activeBattle?.flow.state, pendingTasks: activeBattle?.session.pendingCount, + save: modeStorage.lastWrite, + engine: { paused: _status.paused, paused2: _status.paused2, + tempEvent: manager?.tempEvent?.name, + eventStack: manager?.eventStack?.slice(-8).map(event => ({ name: event?.name })) }, + }; +}); +const reportFlowError = error => { + diagnostics.error("journey.failed", error); + console.error("梦三流程暂停,未自动清档:", error); +}; const chooseRun = async savedRun => { const available = savedRun?.status === "running"; @@ -718,8 +738,9 @@ const registerPlayers = (session, players) => { }); } }; -const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({ +const makeFlow = (releaseSkills = async () => {}, releaseBattle = () => {}) => createBattleFlow({ releaseSkills, + releaseBattle, presentVictory: pending => { if (pending.bondGrowth) { const growth = pending.bondGrowth; @@ -764,7 +785,10 @@ const requestBattleFinish = outcome => { const current = activeBattle; if (!current) return false; // Complete the original card and all queued replays before snapshotting a victory. - if(outcome==="victory" && current.sharedPendingUses?.size){current.sharedDeferredVictory=true;return false;} + if(outcome==="victory" && current.sharedPendingUses?.size){ + diagnostics.info("victory.deferred", { pendingUses: current.sharedPendingUses.size }); + current.sharedDeferredVictory=true;return false; + } for (const player of game.dead) { recordBondDeath(player, _status.mengsanRun_shuying, message => game.log(message)); @@ -787,10 +811,12 @@ const awaitSettlement = async (flow, initial = flow.wait()) => { try { return await operation; } catch (error) { if (flow.state !== "retryable") throw error; + diagnostics.error("settlement.prompt", error, { flowState: flow.state }); const action = await chooseButtons("结算未完成", [ {id:"retry",name:"重试保存与结算"}, {id:"exit",name:"保留已保存进度并返回模式选择"}, ], "已提交的奖励不会重发。技能释放失败时请返回模式选择,避免重复调用释放回调。"); + diagnostics.info("settlement.prompt.action", { action }); if (action === "exit") { await openModeSelection(); throw error; } operation = flow.retry(); } @@ -801,14 +827,20 @@ const playBattle = async (run, node, encounter) => { if (lib.onfree) await new Promise(resolve => lib.onfree.push(resolve)); await quiesceEngine(_status); const session = createBattleSession(`${run.runId}:${++battleSequence}`); - const resources = createBattleResources(session, {game,ui,_status}); - let current; - const flow = makeFlow(() => current.releaseSkills?.()); + let resources = createBattleResources(session, {game,ui,_status}); + let current, root; + const flow = makeFlow(() => current.releaseSkills?.(), () => { + if (activeBattle === current) activeBattle = null; + for (const key of Object.keys(current)) { + if (key !== "session" && key !== "flow") delete current[key]; + } + root = null; resources = null; + }); let finished; const signal = new Promise(resolve => { finished = resolve; }); current = {session, flow, finished, resources, players: new Set(), monsterPiles: new Map(), energyUI: new Map(), intentUI: new Map()}; activeBattle = current; - const root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager); + root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager); current.root = root; const previousRoot = _status.eventManager.rootEvent; session.ownResource({}, () => { diff --git a/mengsan/progression/mode-storage.js b/mengsan/progression/mode-storage.js index 94735a8..13cbe4a 100644 --- a/mengsan/progression/mode-storage.js +++ b/mengsan/progression/mode-storage.js @@ -1,8 +1,11 @@ // Candidate factory. Storage writer body reused from batch2 MODIFIED_STORAGE.js. import { migrateRetiredCards } from "../cards/retired-cards.js"; import { stripBattleProgress } from "./map-checkpoint.js"; +import { getDiagnostics } from "../../diagnostics/index.js"; export function createModeStorage({lib, game, config, localStorage, alert = () => {}, console = globalThis.console}) { const MODE_ID = config.modeId; +const diagnostics = getDiagnostics().scope("mengsan.storage"); +let writeSequence = 0, lastWrite = null; const copy = value => JSON.parse(JSON.stringify(value)); @@ -16,10 +19,14 @@ const copyStorage = () => { // Keep one mode-level writer; a failed write must not poison later retries. let modeStorageQueue = Promise.resolve(); -const writeModeStorage = storage => { +const writeModeStorage = (storage, receipt) => { + receipt.phase = "storage.write"; + receipt.storageStatus = "pending"; if (!lib.db) { // Do not switch backends after an IndexedDB error: that creates two saves. localStorage.setItem(`${lib.configprefix}${MODE_ID}`, JSON.stringify(storage)); + receipt.storageStatus = "committed"; + diagnostics.info("storage.committed", receipt); return Promise.resolve(); } return new Promise((resolve, reject) => { @@ -28,11 +35,18 @@ const writeModeStorage = storage => { try { // game.putDB resolves at request success, which is before commit. transaction = lib.db.transaction(["data"], "readwrite"); - transaction.oncomplete = () => resolve(); + transaction.oncomplete = () => { + receipt.storageStatus = "committed"; + diagnostics.info("transaction.committed", receipt); + resolve(); + }; transaction.onerror = event => { requestError = event.target?.error; }; - transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三存档事务已中止")); + transaction.onabort = () => { + receipt.storageStatus = "aborted"; + reject(transaction.error || requestError || new Error("梦三存档事务已中止")); + }; transaction.objectStore("data").put(storage, MODE_ID); } catch (error) { @@ -45,18 +59,39 @@ const writeModeStorage = storage => { }); }; -const persistModeStorage = (update = () => {}) => { +const persistModeStorage = (update = () => {}, context = {}) => { + const receipt = { ...context, writeId: ++writeSequence, phase: "queued", + backend: lib.db ? "indexeddb" : "localStorage", + storageStatus: "notStarted", memoryPublished: false }; + diagnostics.info("save.queued", receipt); // Hold the engine reload guard from enqueue through commit and publication. // reload2 also drains the engine's queued database operations on release. lib.status.reload++; const operation = modeStorageQueue.then(async () => { + lastWrite = receipt; + receipt.phase = "snapshot"; const storage = copyStorage(); + receipt.phase = "mutator"; update(storage); stripBattleProgress(storage[config.saveKey]); storage.version = lib.version; - await writeModeStorage(storage); + await writeModeStorage(storage, receipt); + receipt.phase = "memory.publish"; lib.storage = storage; - }).finally(() => game.reload2()); + receipt.memoryPublished = true; + diagnostics.info("memory.published", receipt); + }).catch(error => { + diagnostics.error("save.failed", error, receipt); + throw error; + }).finally(() => { + receipt.phase = "reloadGuard.release"; + try { game.reload2(); } + catch (error) { + diagnostics.error("reloadGuard.failed", error, receipt); + throw error; + } + diagnostics.info("save.finished", receipt); + }); modeStorageQueue = operation.catch(() => {}); return operation; }; @@ -67,9 +102,11 @@ const saveRun = async run => { const snapshot = migrateRetiredCards(stripBattleProgress(copy(run))); await persistModeStorage(storage => { storage[config.saveKey] = snapshot; - }); + }, { runId: snapshot.runId, revision: snapshot.revision }); } catch (error) { + diagnostics.error("saveRun.failed", error, + { runId: run?.runId, revision: run?.revision, lastWrite }); // Neither delete the previous save nor attempt a second destructive write. alert("梦三存档失败,未覆盖上一次成功保存的进度。当前进度尚未保存,请勿刷新,并查看控制台错误。"); console.error("梦三存档失败:", error); @@ -87,13 +124,14 @@ const clearRun = async () => { return Object.freeze({ read: copyStorage, + get lastWrite() { return lastWrite ? { ...lastWrite } : null; }, saveRun, clearRun, - async update(mutator) { + async update(mutator, context = {}) { let value; await persistModeStorage(storage => { value = mutator(storage); if (value && typeof value.then === "function") throw new TypeError("Storage mutator must be synchronous"); - }); + }, context); return value === undefined ? undefined : copy(value); }, }); diff --git a/mengsan/ui/bonds.js b/mengsan/ui/bonds.js index 23f9c8e..72e453e 100644 --- a/mengsan/ui/bonds.js +++ b/mengsan/ui/bonds.js @@ -2,6 +2,7 @@ import { bondDefinitions, getBondCombat, getBondDeck } from "../bonds/definitions.js"; import { getBondIntents } from "../bonds/intents.js"; import { bondProbability, ensureBonds, selectBond } from "../bonds/state.js"; +import { getDiagnostics } from "../../diagnostics/index.js"; export function renderBonds(parent, run, saveRun, onBusy = () => {}) { const bonds = ensureBonds(run); @@ -33,6 +34,8 @@ export function renderBonds(parent, run, saveRun, onBusy = () => {}) { updated.querySelector('button[aria-pressed="true"]') ?.focus({ preventScroll: true }); } catch (error) { + getDiagnostics().scope("mengsan.bonds").error("selection.save.failed", error, + { runId: run.runId, revision: run.revision, bondId: id }); selectBond(run, previous); status.textContent = "保存失败,请重试。"; console.error("梦三助战选择保存失败", error); diff --git a/mengsan/ui/hand/diagnostics.js b/mengsan/ui/hand/diagnostics.js index 085fbd2..b28d2b1 100644 --- a/mengsan/ui/hand/diagnostics.js +++ b/mengsan/ui/hand/diagnostics.js @@ -1,8 +1,17 @@ +import { getDiagnostics } from "../../../diagnostics/index.js"; + const lines = []; let pendingWrite = Promise.resolve(); export function createHandDiagnostics(game, sessionId) { + const service = getDiagnostics(); + const diagnostics = service.scope("mengsan.hand", { sessionId }); return (message, error) => { + if (service.status().enabled) { + if (error) diagnostics.error("hand.failed", error, { message }); + else diagnostics.info("hand.message", { message }); + return; + } const line = `${new Date().toISOString()} [${sessionId}] ${message}` + (error ? ` ${error.message || error}` : ""); console[error ? "warn" : "info"](`梦三手牌 UI:${line}`); diff --git a/mengsan/ui/map/renderer.js b/mengsan/ui/map/renderer.js index a7ae0b9..67c2488 100644 --- a/mengsan/ui/map/renderer.js +++ b/mengsan/ui/map/renderer.js @@ -5,6 +5,7 @@ import config from "../../config.js"; import { getRelic } from "../../relics/definitions.js"; import { renderBonds } from "../bonds.js"; import { getSelectableNodes } from "../../progression/state.js"; +import { getDiagnostics } from "../../../diagnostics/index.js"; // Original vector pictograms; no external image/font requests. const iconPaths = { @@ -186,6 +187,8 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => { if (result === "exit") await openModeSelection(); } catch (error) { console.error(error); + getDiagnostics().scope("mengsan.map").error("map.operation.failed", error, + { runId: run.runId, revision: run.revision }); await askMenu("暂时无法返回", "征程未被清除,请重试。", [{ id: "close", name: "关闭提示" }]); } finally { exitButton.disabled = false; } }); @@ -324,6 +327,7 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => { try { await openNonameMenu(); } catch (error) { console.error("梦三打开 Noname 菜单失败:", error); + getDiagnostics().scope("mengsan.map").error("menu.open.failed", error); await askMenu("暂时无法显示菜单", error.message || "请稍后重试。", [{ id: "close", name: "回到行军图" }]); } }, diff --git a/mengsan/ui/navigation.js b/mengsan/ui/navigation.js index dceff32..5aaa875 100644 --- a/mengsan/ui/navigation.js +++ b/mengsan/ui/navigation.js @@ -1,5 +1,6 @@ import { lib, game, ui, _status } from "../../../../noname.js"; import { save } from "../../../../noname/util/config.js"; +import { getDiagnostics } from "../../diagnostics/index.js"; let selectingMode = false; let nonameMenuTask = null; @@ -50,7 +51,10 @@ export const openNonameMenu = async () => { if (initiallyHidden) { try { if (!container.classList.contains("hidden")) ui.click.configMenu(); - } catch (closeError) { console.error("Noname 菜单关闭失败:", closeError); } + } catch (closeError) { + getDiagnostics().scope("mengsan.navigation").error("menu.close.failed", closeError); + console.error("Noname 菜单关闭失败:", closeError); + } if (wasPaused) game.pause2(); else game.resume2(); } @@ -170,6 +174,7 @@ export const mountMenu = (overlay, options = {}) => { if (confirmed === "confirm") await openModeSelection(); } catch (error) { console.error("梦三菜单操作失败:", error); + getDiagnostics().scope("mengsan.navigation").error("menu.operation.failed", error); await askMenu(action === "noname" ? "暂时无法显示菜单" : "暂时无法返回", action === "noname" ? error.message || "请关闭提示后重试。" : "没有清除征程。请关闭提示后重试。", [{ id: "close", name: "回到当前界面" }]); diff --git a/menu.js b/menu.js index 62396a5..f62797f 100644 --- a/menu.js +++ b/menu.js @@ -383,6 +383,21 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) { }, }; + menu.diagnosticLogs = { + name: "诊断日志位置", + clear: true, + async onclick() { + await shuYing.diagnostics?.flush(); + const status = shuYing.diagnostics?.status(); + if (!status?.enabled) { alert("诊断日志尚未初始化。"); return; } + const report = status.latestReport; + alert(`日志位置:${status.location}\n` + + (report ? `最近报告:${report.file}\n保存状态:${report.persisted ? "已保存" : "最新报告尚未保存"}` + + (report.savedStorage ? `\n报告位置:${report.savedStorage.location}` : "") : "本次运行尚无错误报告。") + + (status.unavailable ? "\n持久化失败,后续诊断记录仅保留在当前运行内存。" : "")); + }, + }; + const mengsanLabelId = "shuying_mengsan_module_status"; const mengsanStates = { checking: ["检测中", "请稍候"], @@ -416,6 +431,7 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) { try { status = await updater.getMengsanModuleStatus(); } catch (error) { console.error("读取梦三安装状态失败:", error); + shuYing.diagnostics?.scope("menu").error("module.status.failed", error); status = "unknown"; } if (request == mengsanStatusRequest) renderMengsanStatus(status); diff --git a/tools/manifest_config.json b/tools/manifest_config.json index 98a3b22..801a1a1 100644 --- a/tools/manifest_config.json +++ b/tools/manifest_config.json @@ -4,6 +4,9 @@ "menu.js", "function.js", "update.js", + "diagnostics/index.js", + "diagnostics/serialize.js", + "diagnostics/storage.js", "tianshu/extension.js", "tianshu/config.js", "pve/boss/index.js", diff --git a/update.js b/update.js index b0604f7..a1f0863 100644 --- a/update.js +++ b/update.js @@ -292,6 +292,8 @@ async function writeLog(shuYing) { function addLog(shuYing, message) { if (!shuYing) return; + try { shuYing.diagnostics?.scope("updater").info("update.message", { message }); } + catch {} if (!Array.isArray(shuYing._updateLogs)) { shuYing._updateLogs = []; @@ -305,6 +307,14 @@ function addLog(shuYing, message) { } } +function recordUpdateError(shuYing, event, error, context = {}) { + try { + shuYing?.diagnostics?.scope("updater").error(event, error, { + channel: getUpdateChannel(), source: getUpdateSource(), ...context, + }); + } catch {} +} + async function flushLog(shuYing) { clearTimeout(shuYing._updateLogTimer); shuYing._updateLogTimer = null; @@ -316,6 +326,7 @@ async function flushLog(shuYing) { clearTimeout(shuYing._updateLogTimer); shuYing._updateLogTimer = null; console.error("写入术樱包日志失败:", error); + recordUpdateError(shuYing, "legacyLog.write.failed", error); }); shuYing._updateLogWrite = operation; await operation; @@ -1226,7 +1237,10 @@ async function installMengsanFiles(shuYing, manifest, files, previousManifest = else if (!previousManifest && !oldFiles.length) recovery = { status: "disabled", files: [], version: "disabled" }; } try { await saveMengsanRecord(recovery); } - catch (saveError) { console.error("安装中断记录仍保持待处理状态:", saveError); } + catch (saveError) { + recordUpdateError(shuYing, "installation.receipt.failed", saveError); + console.error("安装中断记录仍保持待处理状态:", saveError); + } throw error; } } @@ -1377,6 +1391,7 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera catch (rollbackError) { failedRollback.push(file); console.error("文件回滚失败:", file, rollbackError); + recordUpdateError(shuYing, "rollback.failed", rollbackError, { file }); } } retainRecovery = !!failedRollback.length || !!error.operationUncertain; @@ -1391,7 +1406,10 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera error: getErrorMessage(error), }, null, 2), `${operationRoot}/recovery.json`); } - catch (saveError) { console.error("恢复说明写入失败,旧文件仍保留:", operationRoot, saveError); } + catch (saveError) { + recordUpdateError(shuYing, "recovery.receipt.failed", saveError, { operationRoot }); + console.error("恢复说明写入失败,旧文件仍保留:", operationRoot, saveError); + } await addLog(shuYing, `回滚未完成,恢复文件已保留在:${operationRoot}`); } throw error; @@ -1405,6 +1423,7 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera } catch (cleanupError) { console.error("临时文件清理失败,不改变安装结果:", file, cleanupError); + recordUpdateError(shuYing, "temporary.cleanup.failed", cleanupError, { file }); break; // An unavailable backend must not cause one timeout per file. } } @@ -1530,6 +1549,7 @@ async function checkVersion(shuYing, options = {}) { } catch (error) { console.error(error); + recordUpdateError(shuYing, "version.update.failed", error); await addLog(shuYing, `版本更新失败:${getErrorMessage(error)}`); alert("版本检测或下载失败,请检查网络或服务器配置。"); return null; @@ -1611,6 +1631,7 @@ async function repairMissingFiles(shuYing) { } catch (error) { console.error(error); + recordUpdateError(shuYing, "missing.repair.failed", error); await addLog(shuYing, `查漏补缺失败:${getErrorMessage(error)}`); alert("查漏补缺失败,请查看 extension/术樱包/log.txt"); } @@ -1682,6 +1703,7 @@ async function manageMengsanModule(shuYing) { } catch (error) { console.error(error); + recordUpdateError(shuYing, "module.manage.failed", error); await addLog(shuYing, `梦三模块操作失败:${getErrorMessage(error)}`); const status = lib.config[mengsanRecordKey]?.status; const hint = status == "uninstalling" ? "卸载未完成,重启后梦三暂停加载,可再次点击此按钮继续卸载。" @@ -1716,6 +1738,7 @@ async function repairCoreFiles(shuYing) { } catch (error) { console.error(error); + recordUpdateError(shuYing, "core.repair.failed", error); await addLog(shuYing, `核心文件修复失败:${getErrorMessage(error)}`); alert("核心文件下载或校验失败,请检查服务器文件与 manifest.json 是否一致。"); }