mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-17 04:30:00 +08:00
Merge origin/main: preserve relic system and diagnostics updates
This commit is contained in:
+2
-4
@@ -19,24 +19,22 @@ tools/*.mjs
|
||||
!mengsan/tools/check-sts2-relics.mjs
|
||||
!mengsan/tools/write-sts2-relics-report.mjs
|
||||
|
||||
# Local updater downloads and runtime logs
|
||||
/.update_tmp/
|
||||
/.planning/
|
||||
/mengsan/logs/
|
||||
/logs/
|
||||
*.log
|
||||
|
||||
# Local credentials; keep shareable environment templates
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
!.env.template
|
||||
|
||||
# Dependency and interpreter caches
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Editor temporary files and OS metadata
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 术樱包自动诊断日志
|
||||
|
||||
扩展加载后自动记录本扩展的关键业务步骤,发生错误时自动生成独立报告。
|
||||
无需开启控制台或手动开始录制;不上传任何数据。
|
||||
|
||||
## 文件位置
|
||||
|
||||
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 个文件。报告包含截断和
|
||||
丢弃计数;“完整诊断”指受限的错误链与现场记录,不是无限保留整个游戏过程。
|
||||
|
||||
待写队列默认最多 12 项、合计 2 MiB,优先保存错误报告;另有最多一项正在写入。
|
||||
错误报告复用已脱敏的记录文本,按字节预算一次裁剪,保留首次和最近异常。
|
||||
|
||||
PC 使用异步文件操作,安卓使用 Cordova 目录和 FileWriter 回调,写入前截断旧
|
||||
内容,使用 UTF-8 Blob。单次保存操作设置 5 秒观察超时;超时后停止使用该
|
||||
文件通道,避免迟到写入与后续写入重叠。写入异常不会中断游戏业务。
|
||||
|
||||
原生文件不可用时,降级到独立的 `shuying-diagnostics` IndexedDB;它不与
|
||||
游戏存档共用数据库。若两种持久化方式都不可用,仅保留本次运行的内存
|
||||
记录,菜单会明确显示未持久化,不能宣称文件已生成。
|
||||
|
||||
切入后台或页面隐藏时尝试保存缓冲;被系统强制杀死或断电时,最后尚未完成的
|
||||
异步写入可能丢失。结算内部等待超过 15 秒会记录观察日志,选择奖励、地图节点
|
||||
和剧情等待不会被当作超时错误;观察器不会结束业务事件。
|
||||
|
||||
字段过滤会隐藏凭据字段、常见 URL 凭据参数和 Bearer 内容。报告不会复制完整
|
||||
玩家对象、DOM、引擎状态或整个存档。日志文件不进入发布清单和 Git。
|
||||
@@ -0,0 +1,348 @@
|
||||
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 maxPendingBytes = options.maxPendingBytes ?? 2 * 1024 * 1024;
|
||||
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;
|
||||
let pendingBytes = 0;
|
||||
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, text: "" };
|
||||
recent.push(item);
|
||||
if (level === "ERROR") createReport(entry, error);
|
||||
const text = JSON.stringify(entry);
|
||||
const bytes = byteLength(text) + 1;
|
||||
item.text = text;
|
||||
delete item.entry;
|
||||
item.bytes = bytes; memoryBytes += bytes;
|
||||
if (journalBytes + bytes > maxJournalBytes && journal.length) {
|
||||
enqueue(runtimeName(), journal.join(""));
|
||||
segment++; journal = []; journalBytes = 0; queuedRevision = -1;
|
||||
}
|
||||
journal.push(text + "\n"); 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,
|
||||
droppedRecords, droppedWrites,
|
||||
};
|
||||
const records = recent.map(item => {
|
||||
const text = item.text || JSON.stringify(item.entry);
|
||||
return { text, bytes: item.text ? item.bytes - 1 : byteLength(text) };
|
||||
});
|
||||
let start = 0;
|
||||
let bytes = byteLength(JSON.stringify(report)) + 14
|
||||
+ records.reduce((total, item) => total + item.bytes + 1, 0);
|
||||
while (bytes + 32 > maxReportBytes && start < records.length - 1) {
|
||||
bytes -= records[start++].bytes + 1;
|
||||
}
|
||||
report.droppedRecords += start;
|
||||
const text = JSON.stringify(report).slice(0, -1) + ',"records":['
|
||||
+ records.slice(start).map(item => item.text).join(",") + "]}";
|
||||
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;
|
||||
const bytes = byteLength(text);
|
||||
const previous = pending.get(name);
|
||||
if (previous) { pendingBytes -= previous.bytes; pending.delete(name); }
|
||||
if (bytes > maxPendingBytes) { droppedWrites++; return; }
|
||||
while (pending.size >= maxPending || pendingBytes + bytes > maxPendingBytes) {
|
||||
const victim = [...pending].find(([, item]) => !item.priority)?.[0]
|
||||
|| (priority ? pending.keys().next().value : null);
|
||||
if (!victim) { droppedWrites++; return; }
|
||||
pendingBytes -= pending.get(victim).bytes;
|
||||
pending.delete(victim); droppedWrites++;
|
||||
}
|
||||
pending.set(name, { text, bytes, priority, reportRevision });
|
||||
pendingBytes += bytes;
|
||||
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);
|
||||
pendingBytes -= item.bytes;
|
||||
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(); pendingBytes = 0;
|
||||
}
|
||||
}
|
||||
}).catch(() => { unavailable = true; pending.clear(); pendingBytes = 0; }).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,
|
||||
pendingFiles: pending.size, pendingBytes,
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || "仅当前运行内存,未能持久化" }),
|
||||
};
|
||||
}
|
||||
Vendored
+418
-383
File diff suppressed because it is too large
Load Diff
Vendored
+3724
-669
File diff suppressed because it is too large
Load Diff
Vendored
+306
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.0.2.3-rc.2",
|
||||
"version": "2.0.2.3-rc.3",
|
||||
"module_id": "mengsan",
|
||||
"snapshot": "legacy",
|
||||
"files": [
|
||||
@@ -302,6 +302,305 @@
|
||||
"mengsan/assets/portraits/wriggler-forest-portrait.png",
|
||||
"mengsan/assets/portraits/wriggler-original.webp",
|
||||
"mengsan/assets/portraits/zhangfei-portrait.jpg",
|
||||
"mengsan/assets/relics/akabeko.png",
|
||||
"mengsan/assets/relics/alchemical_coffer.png",
|
||||
"mengsan/assets/relics/amethyst_aubergine.png",
|
||||
"mengsan/assets/relics/anchor.png",
|
||||
"mengsan/assets/relics/arcane_scroll.png",
|
||||
"mengsan/assets/relics/archaic_tooth.png",
|
||||
"mengsan/assets/relics/art_of_war.png",
|
||||
"mengsan/assets/relics/astrolabe.png",
|
||||
"mengsan/assets/relics/bag_of_marbles.png",
|
||||
"mengsan/assets/relics/bag_of_preparation.png",
|
||||
"mengsan/assets/relics/beating_remnant.png",
|
||||
"mengsan/assets/relics/beautiful_bracelet.png",
|
||||
"mengsan/assets/relics/bellows.png",
|
||||
"mengsan/assets/relics/belt_buckle.png",
|
||||
"mengsan/assets/relics/big_hat.png",
|
||||
"mengsan/assets/relics/big_mushroom.png",
|
||||
"mengsan/assets/relics/biiig_hug.png",
|
||||
"mengsan/assets/relics/bing_bong.png",
|
||||
"mengsan/assets/relics/black_blood.png",
|
||||
"mengsan/assets/relics/black_star.png",
|
||||
"mengsan/assets/relics/blessed_antler.png",
|
||||
"mengsan/assets/relics/blood_soaked_rose.png",
|
||||
"mengsan/assets/relics/blood_vial.png",
|
||||
"mengsan/assets/relics/bone_flute.png",
|
||||
"mengsan/assets/relics/bone_tea.png",
|
||||
"mengsan/assets/relics/book_of_five_rings.png",
|
||||
"mengsan/assets/relics/book_repair_knife.png",
|
||||
"mengsan/assets/relics/bookmark.png",
|
||||
"mengsan/assets/relics/booming_conch.png",
|
||||
"mengsan/assets/relics/bound_phylactery.png",
|
||||
"mengsan/assets/relics/bowler_hat.png",
|
||||
"mengsan/assets/relics/bread.png",
|
||||
"mengsan/assets/relics/brilliant_scarf.png",
|
||||
"mengsan/assets/relics/brimstone.png",
|
||||
"mengsan/assets/relics/bronze_scales.png",
|
||||
"mengsan/assets/relics/burning_blood.png",
|
||||
"mengsan/assets/relics/burning_sticks.png",
|
||||
"mengsan/assets/relics/byrdpip.png",
|
||||
"mengsan/assets/relics/calling_bell.png",
|
||||
"mengsan/assets/relics/candelabra.png",
|
||||
"mengsan/assets/relics/captains_wheel.png",
|
||||
"mengsan/assets/relics/cauldron.png",
|
||||
"mengsan/assets/relics/centennial_puzzle.png",
|
||||
"mengsan/assets/relics/chandelier.png",
|
||||
"mengsan/assets/relics/charons_ashes.png",
|
||||
"mengsan/assets/relics/chemical_x.png",
|
||||
"mengsan/assets/relics/choices_paradox.png",
|
||||
"mengsan/assets/relics/chosen_cheese.png",
|
||||
"mengsan/assets/relics/circlet.png",
|
||||
"mengsan/assets/relics/claws.png",
|
||||
"mengsan/assets/relics/cloak_clasp.png",
|
||||
"mengsan/assets/relics/cracked_core.png",
|
||||
"mengsan/assets/relics/crossbow.png",
|
||||
"mengsan/assets/relics/cursed_pearl.png",
|
||||
"mengsan/assets/relics/darkstone_periapt.png",
|
||||
"mengsan/assets/relics/data_disk.png",
|
||||
"mengsan/assets/relics/daughter_of_the_wind.png",
|
||||
"mengsan/assets/relics/delicate_frond.png",
|
||||
"mengsan/assets/relics/demon_tongue.png",
|
||||
"mengsan/assets/relics/diamond_diadem.png",
|
||||
"mengsan/assets/relics/dingy_rug.png",
|
||||
"mengsan/assets/relics/distinguished_cape.png",
|
||||
"mengsan/assets/relics/divine_destiny.png",
|
||||
"mengsan/assets/relics/divine_right.png",
|
||||
"mengsan/assets/relics/dollys_mirror.png",
|
||||
"mengsan/assets/relics/dowsing_rod.png",
|
||||
"mengsan/assets/relics/dragon_fruit.png",
|
||||
"mengsan/assets/relics/dream_catcher.png",
|
||||
"mengsan/assets/relics/driftwood.png",
|
||||
"mengsan/assets/relics/dusty_tome.png",
|
||||
"mengsan/assets/relics/ectoplasm.png",
|
||||
"mengsan/assets/relics/electric_shrymp.png",
|
||||
"mengsan/assets/relics/ember_tea.png",
|
||||
"mengsan/assets/relics/emotion_chip.png",
|
||||
"mengsan/assets/relics/empty_cage.png",
|
||||
"mengsan/assets/relics/eternal_feather.png",
|
||||
"mengsan/assets/relics/fake_anchor.png",
|
||||
"mengsan/assets/relics/fake_blood_vial.png",
|
||||
"mengsan/assets/relics/fake_happy_flower.png",
|
||||
"mengsan/assets/relics/fake_lees_waffle.png",
|
||||
"mengsan/assets/relics/fake_mango.png",
|
||||
"mengsan/assets/relics/fake_merchants_rug.png",
|
||||
"mengsan/assets/relics/fake_orichalcum.png",
|
||||
"mengsan/assets/relics/fake_snecko_eye.png",
|
||||
"mengsan/assets/relics/fake_strike_dummy.png",
|
||||
"mengsan/assets/relics/fake_venerable_tea_set.png",
|
||||
"mengsan/assets/relics/fencing_manual.png",
|
||||
"mengsan/assets/relics/festive_popper.png",
|
||||
"mengsan/assets/relics/fiddle.png",
|
||||
"mengsan/assets/relics/fishing_rod.png",
|
||||
"mengsan/assets/relics/forgotten_soul.png",
|
||||
"mengsan/assets/relics/fragrant_mushroom.png",
|
||||
"mengsan/assets/relics/fresnel_lens.png",
|
||||
"mengsan/assets/relics/frozen_egg.png",
|
||||
"mengsan/assets/relics/funerary_mask.png",
|
||||
"mengsan/assets/relics/fur_coat.png",
|
||||
"mengsan/assets/relics/galactic_dust.png",
|
||||
"mengsan/assets/relics/gambling_chip.png",
|
||||
"mengsan/assets/relics/game_piece.png",
|
||||
"mengsan/assets/relics/ghost_seed.png",
|
||||
"mengsan/assets/relics/girya.png",
|
||||
"mengsan/assets/relics/glass_eye.png",
|
||||
"mengsan/assets/relics/glitter.png",
|
||||
"mengsan/assets/relics/gnarled_hammer.png",
|
||||
"mengsan/assets/relics/gold_plated_cables.png",
|
||||
"mengsan/assets/relics/golden_compass.png",
|
||||
"mengsan/assets/relics/golden_pearl.png",
|
||||
"mengsan/assets/relics/gorget.png",
|
||||
"mengsan/assets/relics/gremlin_horn.png",
|
||||
"mengsan/assets/relics/hand_drill.png",
|
||||
"mengsan/assets/relics/happy_flower.png",
|
||||
"mengsan/assets/relics/hefty_tablet.png",
|
||||
"mengsan/assets/relics/helical_dart.png",
|
||||
"mengsan/assets/relics/history_course.png",
|
||||
"mengsan/assets/relics/horn_cleat.png",
|
||||
"mengsan/assets/relics/ice_cream.png",
|
||||
"mengsan/assets/relics/infused_core.png",
|
||||
"mengsan/assets/relics/intimidating_helmet.png",
|
||||
"mengsan/assets/relics/iron_club.png",
|
||||
"mengsan/assets/relics/ivory_tile.png",
|
||||
"mengsan/assets/relics/jeweled_mask.png",
|
||||
"mengsan/assets/relics/jewelry_box.png",
|
||||
"mengsan/assets/relics/joss_paper.png",
|
||||
"mengsan/assets/relics/juzu_bracelet.png",
|
||||
"mengsan/assets/relics/kaleidoscope.png",
|
||||
"mengsan/assets/relics/kifuda.png",
|
||||
"mengsan/assets/relics/kunai.png",
|
||||
"mengsan/assets/relics/kusarigama.png",
|
||||
"mengsan/assets/relics/lantern.png",
|
||||
"mengsan/assets/relics/large_capsule.png",
|
||||
"mengsan/assets/relics/lasting_candy.png",
|
||||
"mengsan/assets/relics/lava_lamp.png",
|
||||
"mengsan/assets/relics/lava_rock.png",
|
||||
"mengsan/assets/relics/lead_paperweight.png",
|
||||
"mengsan/assets/relics/leafy_poultice.png",
|
||||
"mengsan/assets/relics/lees_waffle.png",
|
||||
"mengsan/assets/relics/letter_opener.png",
|
||||
"mengsan/assets/relics/lizard_tail.png",
|
||||
"mengsan/assets/relics/looming_fruit.png",
|
||||
"mengsan/assets/relics/lords_parasol.png",
|
||||
"mengsan/assets/relics/lost_coffer.png",
|
||||
"mengsan/assets/relics/lost_wisp.png",
|
||||
"mengsan/assets/relics/lucky_fysh.png",
|
||||
"mengsan/assets/relics/lunar_pastry.png",
|
||||
"mengsan/assets/relics/mango.png",
|
||||
"mengsan/assets/relics/manifest.json",
|
||||
"mengsan/assets/relics/massive_scroll.png",
|
||||
"mengsan/assets/relics/maw_bank.png",
|
||||
"mengsan/assets/relics/meal_ticket.png",
|
||||
"mengsan/assets/relics/meat_cleaver.png",
|
||||
"mengsan/assets/relics/meat_on_the_bone.png",
|
||||
"mengsan/assets/relics/membership_card.png",
|
||||
"mengsan/assets/relics/mercury_hourglass.png",
|
||||
"mengsan/assets/relics/metronome.png",
|
||||
"mengsan/assets/relics/mini_regent.png",
|
||||
"mengsan/assets/relics/miniature_cannon.png",
|
||||
"mengsan/assets/relics/miniature_tent.png",
|
||||
"mengsan/assets/relics/molten_egg.png",
|
||||
"mengsan/assets/relics/mr_struggles.png",
|
||||
"mengsan/assets/relics/mummified_hand.png",
|
||||
"mengsan/assets/relics/music_box.png",
|
||||
"mengsan/assets/relics/mystic_lighter.png",
|
||||
"mengsan/assets/relics/neows_bones.png",
|
||||
"mengsan/assets/relics/neows_sacrifice.png",
|
||||
"mengsan/assets/relics/neows_talisman.png",
|
||||
"mengsan/assets/relics/neows_torment.png",
|
||||
"mengsan/assets/relics/new_leaf.png",
|
||||
"mengsan/assets/relics/ninja_scroll.png",
|
||||
"mengsan/assets/relics/nunchaku.png",
|
||||
"mengsan/assets/relics/nutritious_oyster.png",
|
||||
"mengsan/assets/relics/nutritious_soup.png",
|
||||
"mengsan/assets/relics/oddly_smooth_stone.png",
|
||||
"mengsan/assets/relics/old_coin.png",
|
||||
"mengsan/assets/relics/orange_dough.png",
|
||||
"mengsan/assets/relics/orichalcum.png",
|
||||
"mengsan/assets/relics/ornamental_fan.png",
|
||||
"mengsan/assets/relics/orrery.png",
|
||||
"mengsan/assets/relics/paels_blood.png",
|
||||
"mengsan/assets/relics/paels_claw.png",
|
||||
"mengsan/assets/relics/paels_eye.png",
|
||||
"mengsan/assets/relics/paels_flesh.png",
|
||||
"mengsan/assets/relics/paels_growth.png",
|
||||
"mengsan/assets/relics/paels_horn.png",
|
||||
"mengsan/assets/relics/paels_legion.png",
|
||||
"mengsan/assets/relics/paels_tears.png",
|
||||
"mengsan/assets/relics/paels_tooth.png",
|
||||
"mengsan/assets/relics/paels_wing.png",
|
||||
"mengsan/assets/relics/pandoras_box.png",
|
||||
"mengsan/assets/relics/pantograph.png",
|
||||
"mengsan/assets/relics/paper_krane.png",
|
||||
"mengsan/assets/relics/paper_phrog.png",
|
||||
"mengsan/assets/relics/parrying_shield.png",
|
||||
"mengsan/assets/relics/pear.png",
|
||||
"mengsan/assets/relics/pen_nib.png",
|
||||
"mengsan/assets/relics/pendulum.png",
|
||||
"mengsan/assets/relics/permafrost.png",
|
||||
"mengsan/assets/relics/petrified_toad.png",
|
||||
"mengsan/assets/relics/phial_holster.png",
|
||||
"mengsan/assets/relics/philosophers_stone.png",
|
||||
"mengsan/assets/relics/phylactery_unbound.png",
|
||||
"mengsan/assets/relics/planisphere.png",
|
||||
"mengsan/assets/relics/pocketwatch.png",
|
||||
"mengsan/assets/relics/pollinous_core.png",
|
||||
"mengsan/assets/relics/pomander.png",
|
||||
"mengsan/assets/relics/potion_belt.png",
|
||||
"mengsan/assets/relics/power_cell.png",
|
||||
"mengsan/assets/relics/prayer_wheel.png",
|
||||
"mengsan/assets/relics/precarious_shears.png",
|
||||
"mengsan/assets/relics/precise_scissors.png",
|
||||
"mengsan/assets/relics/preserved_fog.png",
|
||||
"mengsan/assets/relics/prismatic_gem.png",
|
||||
"mengsan/assets/relics/pumpkin_candle.png",
|
||||
"mengsan/assets/relics/punch_dagger.png",
|
||||
"mengsan/assets/relics/radiant_pearl.png",
|
||||
"mengsan/assets/relics/rainbow_ring.png",
|
||||
"mengsan/assets/relics/razor_tooth.png",
|
||||
"mengsan/assets/relics/red_mask.png",
|
||||
"mengsan/assets/relics/red_skull.png",
|
||||
"mengsan/assets/relics/regal_pillow.png",
|
||||
"mengsan/assets/relics/regalite.png",
|
||||
"mengsan/assets/relics/reptile_trinket.png",
|
||||
"mengsan/assets/relics/ring_of_the_drake.png",
|
||||
"mengsan/assets/relics/ring_of_the_snake.png",
|
||||
"mengsan/assets/relics/ringing_triangle.png",
|
||||
"mengsan/assets/relics/ripple_basin.png",
|
||||
"mengsan/assets/relics/royal_poison.png",
|
||||
"mengsan/assets/relics/royal_stamp.png",
|
||||
"mengsan/assets/relics/ruined_helmet.png",
|
||||
"mengsan/assets/relics/runic_capacitor.png",
|
||||
"mengsan/assets/relics/runic_pyramid.png",
|
||||
"mengsan/assets/relics/sai.png",
|
||||
"mengsan/assets/relics/sand_castle.png",
|
||||
"mengsan/assets/relics/screaming_flagon.png",
|
||||
"mengsan/assets/relics/scroll_boxes.png",
|
||||
"mengsan/assets/relics/sea_glass.png",
|
||||
"mengsan/assets/relics/seal_of_gold.png",
|
||||
"mengsan/assets/relics/self_forming_clay.png",
|
||||
"mengsan/assets/relics/sere_talon.png",
|
||||
"mengsan/assets/relics/shovel.png",
|
||||
"mengsan/assets/relics/shuriken.png",
|
||||
"mengsan/assets/relics/signet_ring.png",
|
||||
"mengsan/assets/relics/silken_tress.png",
|
||||
"mengsan/assets/relics/silver_crucible.png",
|
||||
"mengsan/assets/relics/sling_of_courage.png",
|
||||
"mengsan/assets/relics/small_capsule.png",
|
||||
"mengsan/assets/relics/snecko_eye.png",
|
||||
"mengsan/assets/relics/snecko_skull.png",
|
||||
"mengsan/assets/relics/sozu.png",
|
||||
"mengsan/assets/relics/sparkling_rouge.png",
|
||||
"mengsan/assets/relics/spiked_gauntlets.png",
|
||||
"mengsan/assets/relics/stone_calendar.png",
|
||||
"mengsan/assets/relics/stone_cracker.png",
|
||||
"mengsan/assets/relics/stone_humidifier.png",
|
||||
"mengsan/assets/relics/storybook.png",
|
||||
"mengsan/assets/relics/strawberry.png",
|
||||
"mengsan/assets/relics/strike_dummy.png",
|
||||
"mengsan/assets/relics/sturdy_clamp.png",
|
||||
"mengsan/assets/relics/sword_of_jade.png",
|
||||
"mengsan/assets/relics/sword_of_stone.png",
|
||||
"mengsan/assets/relics/symbiotic_virus.png",
|
||||
"mengsan/assets/relics/tanxs_whistle.png",
|
||||
"mengsan/assets/relics/tea_of_discourtesy.png",
|
||||
"mengsan/assets/relics/the_abacus.png",
|
||||
"mengsan/assets/relics/the_boot.png",
|
||||
"mengsan/assets/relics/the_courier.png",
|
||||
"mengsan/assets/relics/throwing_axe.png",
|
||||
"mengsan/assets/relics/tingsha.png",
|
||||
"mengsan/assets/relics/tiny_mailbox.png",
|
||||
"mengsan/assets/relics/toasty_mittens.png",
|
||||
"mengsan/assets/relics/toolbox.png",
|
||||
"mengsan/assets/relics/touch_of_orobas.png",
|
||||
"mengsan/assets/relics/tough_bandages.png",
|
||||
"mengsan/assets/relics/toxic_egg.png",
|
||||
"mengsan/assets/relics/toy_box.png",
|
||||
"mengsan/assets/relics/tri_boomerang.png",
|
||||
"mengsan/assets/relics/tungsten_rod.png",
|
||||
"mengsan/assets/relics/tuning_fork.png",
|
||||
"mengsan/assets/relics/twisted_funnel.png",
|
||||
"mengsan/assets/relics/unceasing_top.png",
|
||||
"mengsan/assets/relics/undying_sigil.png",
|
||||
"mengsan/assets/relics/unsettling_lamp.png",
|
||||
"mengsan/assets/relics/vajra.png",
|
||||
"mengsan/assets/relics/vambrace.png",
|
||||
"mengsan/assets/relics/velvet_choker.png",
|
||||
"mengsan/assets/relics/venerable_tea_set.png",
|
||||
"mengsan/assets/relics/very_hot_cocoa.png",
|
||||
"mengsan/assets/relics/vexing_puzzlebox.png",
|
||||
"mengsan/assets/relics/vitruvian_minion.png",
|
||||
"mengsan/assets/relics/war_hammer.png",
|
||||
"mengsan/assets/relics/war_paint.png",
|
||||
"mengsan/assets/relics/whetstone.png",
|
||||
"mengsan/assets/relics/whispering_earring.png",
|
||||
"mengsan/assets/relics/white_beast_statue.png",
|
||||
"mengsan/assets/relics/white_star.png",
|
||||
"mengsan/assets/relics/wing_charm.png",
|
||||
"mengsan/assets/relics/winged_boots.png",
|
||||
"mengsan/assets/relics/wongo_customer_appreciation_badge.png",
|
||||
"mengsan/assets/relics/wongos_mystery_ticket.png",
|
||||
"mengsan/assets/relics/yummy_cookie.png",
|
||||
"mengsan/assets/sushuang-zhangshiping.png",
|
||||
"mengsan/battle/battle-director.js",
|
||||
"mengsan/battle/battle-flow.js",
|
||||
@@ -453,9 +752,14 @@
|
||||
"mengsan/progression/story.js",
|
||||
"mengsan/register.js",
|
||||
"mengsan/relics/battle.js",
|
||||
"mengsan/relics/catalog.js",
|
||||
"mengsan/relics/combat.js",
|
||||
"mengsan/relics/definitions.js",
|
||||
"mengsan/relics/hooks.js",
|
||||
"mengsan/relics/progression.js",
|
||||
"mengsan/relics/random.js",
|
||||
"mengsan/relics/rooms.js",
|
||||
"mengsan/relics/rules.js",
|
||||
"mengsan/runtime/battle-director.js",
|
||||
"mengsan/runtime/battle-flow.js",
|
||||
"mengsan/runtime/battle-loop.js",
|
||||
@@ -499,6 +803,7 @@
|
||||
"mengsan/ui/map/index.js",
|
||||
"mengsan/ui/map/renderer.js",
|
||||
"mengsan/ui/navigation.js",
|
||||
"mengsan/ui/relic-icon.js",
|
||||
"mengsan/ui/relics.js",
|
||||
"mengsan/ui/reward-package.js",
|
||||
"mengsan/ui/reward-ui.js",
|
||||
|
||||
+11
-3
@@ -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 = () => {
|
||||
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { recordNormalBattleVictory } from "../progression/encounter-progress.js"
|
||||
import { stripBattleProgress } from "../progression/map-checkpoint.js";
|
||||
import { grantGold } from "../relics/progression.js";
|
||||
import { heldRelics } from "../relics/definitions.js";
|
||||
import { getDiagnostics } from "../../diagnostics/index.js";
|
||||
import { prepareRewardPackage, applyRewardPackage }
|
||||
from "../progression/reward-package.js";
|
||||
|
||||
@@ -16,6 +17,11 @@ export function createBattleSettlement({
|
||||
enterNextAct, createRun, now = Date.now, logRewardPackage = () => {}, resolveRelicChoices = async () => {},
|
||||
}) {
|
||||
let item = null, committed = null, commitPromise = null, choicePromise = null, choiceSignature = 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];
|
||||
@@ -29,6 +35,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");
|
||||
}
|
||||
}
|
||||
@@ -78,6 +89,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";
|
||||
}
|
||||
@@ -152,6 +167,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);
|
||||
}
|
||||
@@ -160,8 +176,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);
|
||||
@@ -191,8 +210,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;
|
||||
|
||||
+50
-9
@@ -64,7 +64,7 @@ import { STRANGLER_CHARACTER, isStrangler, rollStranglerHp, recordStranglerActio
|
||||
import { JAXFRUIT_CHARACTER, isJaxfruit, rollJaxfruitHp, recordJaxfruitAction } from "./monsters/jaxfruit-intent.js";
|
||||
import { CRAWLER_CHARACTER, isCrawler, rollCrawlerHp, recordCrawlerAction } from "./monsters/crawler-intent.js";
|
||||
import { INKLET_CHARACTER, isInklet, rollInkletHp, recordInkletAction, capInkletHpLoss } from "./monsters/inklet-intent.js";
|
||||
import { mountEnemyIntent } from "./ui/intent-display.js";
|
||||
import { mountEnemyIntent, refreshEnemyIntents } from "./ui/intent-display.js";
|
||||
import { createFogmogBattle } from "./monsters/fogmog-battle.js";
|
||||
import { createPhrogBattle } from "./monsters/phrog-battle.js";
|
||||
import { PHROG_CHARACTER, WRIGGLER_CHARACTER, INFESTED_COUNT, isPhrogActor, isWriggler, rollPhrogHp, rollWrigglerHp, recordPhrogAction, initializePhrog, skipSpawnedWriggler } from "./monsters/phrog-intent.js";
|
||||
@@ -96,6 +96,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";
|
||||
@@ -143,7 +144,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";
|
||||
@@ -663,7 +683,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
});
|
||||
for (const skill of [
|
||||
"mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_ic_bridge_shuying", "mengsan_shared_bridge_shuying",
|
||||
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
|
||||
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_intent_refresh_shuying",
|
||||
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
|
||||
"mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying",
|
||||
"mengsan_fogmog_action_shuying", "mengsan_mawler_action_shuying",
|
||||
@@ -756,8 +776,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;
|
||||
@@ -803,7 +824,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));
|
||||
@@ -826,10 +850,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();
|
||||
}
|
||||
@@ -840,14 +866,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({}, () => {
|
||||
@@ -1756,6 +1788,15 @@ const createMode = () => {
|
||||
else player.removeSkill("mengsan_vulnerable_shuying");
|
||||
},
|
||||
},
|
||||
mengsan_intent_refresh_shuying: {
|
||||
trigger: { global: ["useCardAfter", "useSkillAfter", "respondAfter", "changeHpAfter", "dieAfter", "phaseAfter"] },
|
||||
forced: true, silent: true, popup: false, priority: -1000,
|
||||
filter() { return Boolean(activeBattle?.session.active); },
|
||||
async content() {
|
||||
const session = activeBattle?.session;
|
||||
if (session?.active) refreshEnemyIntents(session);
|
||||
},
|
||||
},
|
||||
mengsan_scenario_shuying: {
|
||||
trigger: { global: ["changeHpAfter", "gainAfter", "loseAfter", "dieAfter", "turnOverAfter", "linkAfter", "phaseAfter"] },
|
||||
forced: true, silent: true, popup: false, priority: -100,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+43
-30
@@ -132,15 +132,26 @@ export function openCardLibrary(run, options = {}) {
|
||||
if (typeof window !== "undefined") window.removeEventListener?.("resize", fitCards);
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
list.replaceChildren(); detailPanel.replaceChildren(); body.replaceChildren();
|
||||
dialog.replaceChildren();
|
||||
catalogRecords = null; descriptionCache = new WeakMap();
|
||||
selectedButton = null; current = [];
|
||||
virtualGrid = null; sizeObserver = null;
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
const onClose = options.onClose;
|
||||
options = {}; run = null;
|
||||
onClose?.(close);
|
||||
};
|
||||
button("关闭", header, close);
|
||||
let view = !options.sections && options.view === "catalog" ? "catalog" : "deck";
|
||||
const navigation = element("nav", "ms-deck-tabs", null, dialog);
|
||||
navigation.setAttribute("aria-label", "卡牌列表视图");
|
||||
navigation.hidden = Boolean(options.sections);
|
||||
const deckTab = button("持有牌", navigation, () => { view = "deck"; render(); });
|
||||
const catalogTab = button("图鉴", navigation, () => { view = "catalog"; render(); });
|
||||
deckTab.hidden = catalogTab.hidden = Boolean(options.sections);
|
||||
let sectionIndex = 0;
|
||||
const sectionTabs = options.sections ? options.sections().map((section, index) =>
|
||||
button(section.title, navigation, () => { sectionIndex = index; render(); })) : [];
|
||||
const filters = element("div", "ms-catalog-filters", null, dialog);
|
||||
function field(label) {
|
||||
const wrapper = element("label", "ms-catalog-field", null, filters);
|
||||
@@ -179,13 +190,13 @@ export function openCardLibrary(run, options = {}) {
|
||||
summary.setAttribute("aria-live", "polite");
|
||||
const body = element("div", "ms-deck-body", null, dialog);
|
||||
const footer = element("p", "ms-deck-footer", "点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序", dialog);
|
||||
const current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
|
||||
let detail = false, selectedButton = null, scrollTop = 0;
|
||||
const list = element("div", options.sections ? "ms-deck-grid" : "ms-deck-grid ms-library-grid", null, body);
|
||||
let current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
|
||||
let detail = false, selectedButton = null, scrollTop = 0, orderedView = false;
|
||||
const list = element("div", "ms-deck-grid ms-library-grid", null, body);
|
||||
const detailPanel = element("section", "ms-deck-detail", null, body);
|
||||
detailPanel.hidden = true;
|
||||
let catalogRecords = null, descriptionCache = new WeakMap();
|
||||
if (!options.sections) virtualGrid = createVirtualCardGrid(body, list, createTile);
|
||||
virtualGrid = createVirtualCardGrid(body, list, createTile);
|
||||
function back() {
|
||||
detail = false; detailPanel.hidden = true; list.hidden = false;
|
||||
detailPanel.replaceChildren(); body.scrollTop = scrollTop; fitCards();
|
||||
@@ -214,12 +225,13 @@ export function openCardLibrary(run, options = {}) {
|
||||
body.scrollTop = 0; backButton.focus();
|
||||
}
|
||||
function fitCards() {
|
||||
if (options.sections || closed || !body.clientHeight) return;
|
||||
if (closed || !body.clientHeight) return;
|
||||
const padding = getComputedStyle(body);
|
||||
const available = body.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
|
||||
const height = Math.max(64, Math.min(218, Math.floor((available - 8) / 2)));
|
||||
const rows = available >= 264 ? 2 : 1;
|
||||
const height = Math.max(128, Math.min(218, Math.floor((available - (rows - 1) * 8) / rows)));
|
||||
dialog.style.setProperty("--ms-card-height", String(height) + "px");
|
||||
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - 80) * 5 / 7)))) + "px");
|
||||
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - (orderedView ? 104 : 80)) * 5 / 7)))) + "px");
|
||||
virtualGrid?.layout(height);
|
||||
}
|
||||
function createTile(record, index) {
|
||||
@@ -240,7 +252,6 @@ export function openCardLibrary(run, options = {}) {
|
||||
clearTimeout(searchTimer);
|
||||
detail = false; list.hidden = false; detailPanel.hidden = true;
|
||||
detailPanel.replaceChildren();
|
||||
if (!virtualGrid) list.replaceChildren();
|
||||
body.scrollTop = 0;
|
||||
selectedButton = null;
|
||||
const catalog = !options.sections && view === "catalog";
|
||||
@@ -248,8 +259,22 @@ export function openCardLibrary(run, options = {}) {
|
||||
deckTab.setAttribute("aria-pressed", String(!catalog));
|
||||
catalogTab.setAttribute("aria-pressed", String(catalog));
|
||||
list.classList.toggle("ms-catalog-grid", catalog);
|
||||
let records = [];
|
||||
if (catalog) {
|
||||
let records = [], emptyMessage = "此处暂无卡牌。";
|
||||
orderedView = false;
|
||||
if (options.sections) {
|
||||
const sections = options.sections();
|
||||
const section = sections[sectionIndex];
|
||||
orderedView = Boolean(section?.ordered);
|
||||
if (section?.ordered) emptyMessage = "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。";
|
||||
sectionTabs.forEach((tab, index) => {
|
||||
tab.textContent = `${sections[index].title} · ${sections[index].cards.length}`;
|
||||
tab.setAttribute("aria-pressed", String(index === sectionIndex));
|
||||
});
|
||||
records = (section?.cards || []).map(card => ({ card, ordered: section.ordered }));
|
||||
summary.textContent = section?.ordered
|
||||
? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。"
|
||||
: `${section?.title || "牌堆"} · 共 ${records.length} 张`;
|
||||
} else if (catalog) {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
catalogRecords ||= getCardCatalog().map(card => {
|
||||
const details = describeCatalogCard(card);
|
||||
@@ -273,24 +298,11 @@ export function openCardLibrary(run, options = {}) {
|
||||
}
|
||||
footer.textContent = catalog ? "图鉴仅供查阅,不会获得卡牌或改变征程牌组 · 点击卡牌查看完整介绍" :
|
||||
"点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序";
|
||||
if (virtualGrid) {
|
||||
virtualGrid.setRecords(catalog ? records : current.map(card => ({ card })));
|
||||
if (!(catalog ? records : current).length) {
|
||||
element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : "此处暂无卡牌。", list);
|
||||
}
|
||||
fitCards();
|
||||
return;
|
||||
}
|
||||
const sections = catalog ? [{ title: "", cards: records.map(record => record.card) }] :
|
||||
options.sections ? options.sections() : [{ title: "", cards: current }];
|
||||
for (const section of sections) {
|
||||
const cards = section.cards;
|
||||
if (section.title) element("h3", "ms-deck-section", `${section.title} · ${cards.length} 张`, list);
|
||||
if (!cards.length) element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list);
|
||||
cards.forEach((card, index) => {
|
||||
list.appendChild(createTile({ card, ordered: section.ordered }, index));
|
||||
});
|
||||
virtualGrid.setRecords(catalog || options.sections ? records : current.map(card => ({ card })));
|
||||
if (!(catalog || options.sections ? records : current).length) {
|
||||
element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : emptyMessage, list);
|
||||
}
|
||||
fitCards();
|
||||
}
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); });
|
||||
dialog.addEventListener("close", close);
|
||||
@@ -300,10 +312,10 @@ export function openCardLibrary(run, options = {}) {
|
||||
document.body.appendChild(dialog);
|
||||
try { dialog.showModal(); } catch (error) { close(); throw error; }
|
||||
fitCards();
|
||||
if (!options.sections && typeof ResizeObserver !== "undefined") {
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
sizeObserver = new ResizeObserver(fitCards);
|
||||
sizeObserver.observe(body);
|
||||
} else if (!options.sections && typeof window !== "undefined") {
|
||||
} else if (typeof window !== "undefined") {
|
||||
window.addEventListener?.("resize", fitCards);
|
||||
}
|
||||
header.querySelector("button").focus();
|
||||
@@ -327,6 +339,7 @@ export function mountBattlePiles(session, battle) {
|
||||
closeDialog?.();
|
||||
closeDialog = openCardLibrary(null, {
|
||||
title: "战斗牌堆", eyebrow: "梦三 · 个人牌堆",
|
||||
onClose(handle) { if (closeDialog === handle) closeDialog = null; },
|
||||
sections: () => [
|
||||
{ title: "剩余牌堆", cards: battle.drawPile, ordered: true },
|
||||
{ title: "弃牌堆", cards: battle.discardPile, ordered: false },
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function mountHandUI(player, session, env) {
|
||||
let disposed = false;
|
||||
let observer, resizeObserver, pointer = null, nativeInput = false;
|
||||
let finishStyleLoad = null, refreshTimer = null;
|
||||
let refreshFrame = null;
|
||||
const touchScrollClasses = new Map();
|
||||
let layoutEnabled = false, warned = false;
|
||||
const log = (message, error) => {
|
||||
@@ -72,6 +73,15 @@ export async function mountHandUI(player, session, env) {
|
||||
}
|
||||
overlays.sync(adapter.cards());
|
||||
}
|
||||
function scheduleRefresh() {
|
||||
if (disposed || document.hidden || refreshFrame !== null) return;
|
||||
const draw = () => {
|
||||
refreshFrame = null;
|
||||
if (!document.hidden) refresh();
|
||||
};
|
||||
refreshFrame = typeof window.requestAnimationFrame === "function"
|
||||
? window.requestAnimationFrame(draw) : window.setTimeout(draw, 16);
|
||||
}
|
||||
function listen(target, type, listener, options) {
|
||||
target.addEventListener(type, listener, options);
|
||||
restores.push(() => target.removeEventListener(type, listener, options));
|
||||
@@ -120,6 +130,11 @@ export async function mountHandUI(player, session, env) {
|
||||
if (disposed || !nativeInput) return;
|
||||
nativeInput = false;
|
||||
window.clearTimeout(refreshTimer);
|
||||
if (refreshFrame !== null) {
|
||||
if (typeof window.requestAnimationFrame === "function") window.cancelAnimationFrame(refreshFrame);
|
||||
else window.clearTimeout(refreshFrame);
|
||||
refreshFrame = null;
|
||||
}
|
||||
refreshTimer = window.setTimeout(refresh, 0);
|
||||
}
|
||||
function restoreTouchScroll() {
|
||||
@@ -154,6 +169,11 @@ export async function mountHandUI(player, session, env) {
|
||||
disposed = true;
|
||||
finishStyleLoad?.();
|
||||
window.clearTimeout(refreshTimer);
|
||||
if (refreshFrame !== null) {
|
||||
if (typeof window.requestAnimationFrame === "function") window.cancelAnimationFrame(refreshFrame);
|
||||
else window.clearTimeout(refreshFrame);
|
||||
refreshFrame = null;
|
||||
}
|
||||
restoreTouchScroll();
|
||||
observer?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
@@ -201,7 +221,7 @@ export async function mountHandUI(player, session, env) {
|
||||
listen(container, "touchend", () => restoreTouchScroll(), true);
|
||||
listen(container, "touchcancel", () => restoreTouchScroll(), true);
|
||||
}
|
||||
observer = new window.MutationObserver(refresh);
|
||||
observer = new window.MutationObserver(scheduleRefresh);
|
||||
for (const zone of adapter.zones) {
|
||||
observer.observe(zone, { childList: true, subtree: true,
|
||||
attributes: true, attributeFilter: ["class"] });
|
||||
@@ -211,7 +231,7 @@ export async function mountHandUI(player, session, env) {
|
||||
observer.observe(adapter.root.parentNode, { childList: true });
|
||||
}
|
||||
if (window.ResizeObserver && adapter.root) {
|
||||
resizeObserver = new window.ResizeObserver(refresh);
|
||||
resizeObserver = new window.ResizeObserver(scheduleRefresh);
|
||||
resizeObserver.observe(adapter.root);
|
||||
}
|
||||
if (window.PointerEvent && adapter.root) {
|
||||
@@ -221,6 +241,7 @@ export async function mountHandUI(player, session, env) {
|
||||
listen(window, "pointercancel", endInput, true);
|
||||
listen(window, "blur", () => endInput());
|
||||
}
|
||||
listen(document, "visibilitychange", scheduleRefresh);
|
||||
refresh();
|
||||
diagnose(`已挂载手牌 UI,独立排列=${layoutEnabled}`);
|
||||
return { refresh, dispose };
|
||||
|
||||
@@ -2,6 +2,56 @@
|
||||
import { attackHitCount, previewAttackDamage } from "../battle/intent-damage.js";
|
||||
import { isDeathBlowIntent } from "../battle/death-blow.js";
|
||||
import { isStunIntent } from "../battle/stun-intent.js";
|
||||
import { getDiagnostics } from "../../diagnostics/index.js";
|
||||
|
||||
const intentPollers = new WeakMap();
|
||||
const intentFields = ["damage", "hits", "intentType", "summon", "slimed", "infection",
|
||||
"wound", "ringing", "plow", "dazed", "debuff", "stacks", "block", "strength", "sleep", "shrink"];
|
||||
|
||||
export function refreshEnemyIntents(session) {
|
||||
intentPollers.get(session)?.schedule();
|
||||
}
|
||||
|
||||
function observeIntents(session, document, update) {
|
||||
let group = intentPollers.get(session);
|
||||
if (!group) {
|
||||
const callbacks = new Set();
|
||||
const host = document.defaultView || globalThis;
|
||||
let frame = null;
|
||||
const tick = () => {
|
||||
if (document.hidden || session.active === false) return;
|
||||
for (const callback of callbacks) {
|
||||
try { callback(); }
|
||||
catch (error) {
|
||||
getDiagnostics().scope("mengsan.intent").error("refresh.failed", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frame !== null || document.hidden) return;
|
||||
const draw = () => { frame = null; tick(); };
|
||||
frame = host.requestAnimationFrame ? host.requestAnimationFrame(draw) : host.setTimeout(draw, 16);
|
||||
};
|
||||
const timer = host.setInterval(tick, 500);
|
||||
timer?.unref?.();
|
||||
document.addEventListener?.("visibilitychange", schedule);
|
||||
group = { callbacks, schedule, dispose() {
|
||||
host.clearInterval(timer);
|
||||
if (frame !== null) {
|
||||
if (host.requestAnimationFrame) host.cancelAnimationFrame(frame);
|
||||
else host.clearTimeout(frame);
|
||||
}
|
||||
document.removeEventListener?.("visibilitychange", schedule);
|
||||
intentPollers.delete(session);
|
||||
} };
|
||||
intentPollers.set(session, group);
|
||||
}
|
||||
group.callbacks.add(update);
|
||||
return () => {
|
||||
group.callbacks.delete(update);
|
||||
if (!group.callbacks.size) group.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
export function attackIconForDamage(damage) {
|
||||
if (!Number.isInteger(damage) || damage < 0) {
|
||||
@@ -60,9 +110,9 @@ export function mountEnemyIntent(player, session, assetBase, document,
|
||||
badge.append(damageLine, debuffLine, blockLine, buffLine);
|
||||
player.appendChild(badge);
|
||||
let currentIntent = null;
|
||||
let timer;
|
||||
let previous = null, unsubscribe = () => {};
|
||||
session.ownResource(badge, () => {
|
||||
clearInterval(timer);
|
||||
unsubscribe(); previous = null; currentIntent = null;
|
||||
badge.remove();
|
||||
});
|
||||
const refresh = () => {
|
||||
@@ -180,9 +230,23 @@ export function mountEnemyIntent(player, session, assetBase, document,
|
||||
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
|
||||
badge.hidden = false;
|
||||
};
|
||||
timer = setInterval(refresh, 100);
|
||||
const poll = () => {
|
||||
const values = intentFields.map(key => currentIntent?.[key]);
|
||||
values.push(player.maxHp);
|
||||
if (currentIntent?.damage != null) {
|
||||
const target = getTarget();
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
for (const current of targets.length ? targets : [null]) {
|
||||
values.push(previewAttackDamage(currentIntent, player, current));
|
||||
}
|
||||
}
|
||||
if (previous && previous.length === values.length && values.every((value, index) => Object.is(value, previous[index]))) return;
|
||||
refresh(); previous = values;
|
||||
};
|
||||
unsubscribe = observeIntents(session, document, poll);
|
||||
return intent => {
|
||||
currentIntent = intent;
|
||||
refresh();
|
||||
previous = null;
|
||||
poll();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getRelic } from "../../relics/definitions.js";
|
||||
import { createRelicIcon } from "../relic-icon.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 = {
|
||||
@@ -187,6 +188,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; }
|
||||
});
|
||||
@@ -258,7 +261,9 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
|
||||
createText("mengsan-map-menu-action-name-shuying", "卡牌库", copy);
|
||||
createText("mengsan-map-menu-action-description-shuying", `共 ${run.player.deck.length} 张 · 查看花色、点数、属性和卡牌详情`, copy);
|
||||
library.appendChild(copy);
|
||||
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run); });
|
||||
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run, {
|
||||
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
|
||||
}); });
|
||||
parent.appendChild(library);
|
||||
const appendNames = (title, values, emptyText) => {
|
||||
createText("mengsan-map-inventory-section-title-shuying", title, parent);
|
||||
@@ -308,7 +313,10 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
|
||||
if (back) back.disabled = busy;
|
||||
}), ".mengsan-bonds-shuying button") });
|
||||
createMenuAction({ icon: "book", name: "图鉴", description: "卡牌图鉴 · 查看全部梦三卡牌、效果与费用",
|
||||
onClick: () => { closeCardLibrary = openCardLibrary(run, { view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴" }); }
|
||||
onClick: () => { closeCardLibrary = openCardLibrary(run, {
|
||||
view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴",
|
||||
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
|
||||
}); }
|
||||
}).setAttribute("aria-haspopup", "dialog");
|
||||
createMenuAction({ icon: "record", name: "征程记录", description: `已通过 ${run.statistics?.completedNodes || 0} 个节点`, onClick: () => showMenuPage("征程记录", "本次征程的阶段统计", parent => {
|
||||
const list = ui.create.div(".mengsan-map-record-list-shuying", parent);
|
||||
@@ -326,6 +334,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: "回到行军图" }]);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: "回到当前界面" }]);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 保留完整滚动高度,只挂载可见行与上下各两行;滚动时不重建仍可见的卡牌。
|
||||
export function createVirtualCardGrid(viewport, grid, createCard) {
|
||||
const mounted = new Map();
|
||||
const cached = new Map();
|
||||
const maxCached = 24;
|
||||
let records = [], columns = 1, height = 218, gap = 8, padding = 5, viewportHeight = 0;
|
||||
let frame = 0, disposed = false;
|
||||
function draw() {
|
||||
@@ -14,11 +16,14 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
|
||||
for (const [index, node] of mounted) {
|
||||
if ((index < first || index >= end) && node !== document.activeElement) {
|
||||
node.remove(); mounted.delete(index);
|
||||
cached.set(index, node);
|
||||
while (cached.size > maxCached) cached.delete(cached.keys().next().value);
|
||||
}
|
||||
}
|
||||
for (let index = first; index < end; index++) {
|
||||
if (mounted.has(index)) continue;
|
||||
const node = createCard(records[index], index);
|
||||
const node = cached.get(index) || createCard(records[index], index);
|
||||
cached.delete(index);
|
||||
node.dataset.cardIndex = String(index);
|
||||
node.style.gridRow = String(Math.floor(index / columns) + 1);
|
||||
node.style.gridColumn = String(index % columns + 1);
|
||||
@@ -75,7 +80,7 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
|
||||
return {
|
||||
setRecords(value) {
|
||||
// 调用方更新筛选栏后再统一量尺寸,避免用旧高度先生成一批预览。
|
||||
records = value; mounted.clear(); grid.replaceChildren();
|
||||
records = value; mounted.clear(); cached.clear(); grid.replaceChildren();
|
||||
},
|
||||
layout,
|
||||
focus,
|
||||
@@ -84,7 +89,8 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
|
||||
disposed = true; cancelAnimationFrame(frame);
|
||||
viewport.removeEventListener("scroll", schedule);
|
||||
grid.removeEventListener("keydown", keydown);
|
||||
mounted.clear(); records = [];
|
||||
mounted.clear(); cached.clear(); records = [];
|
||||
grid.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 MiB After Width: | Height: | Size: 191 KiB |
@@ -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",
|
||||
|
||||
@@ -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 是否一致。");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user