Author SHA1 Message Date
github-actions[bot] fc8b0d99b4 Prepare manifest for v2.0.2.1 2026-10-04 20:26:20 +00:00
Diuse bb46c05cf6 fixed # 更新资源图片大小 2026-10-05 04:22:45 +08:00
Diuse d47ddf9ad4 fixed # 更新版本号 2026-10-04 19:34:19 +08:00
Diuse 9e1732d2f8 fixed # 补全部分卡牌
fixed # 修复部分已知BUG
2026-10-04 19:32:38 +08:00
Diuse d0e0792538 fixed # 补全梦三怪物、卡牌
fixed # 修复天书乱斗中,过关技能不会重置次数的问题
2026-10-03 04:42:19 +08:00
37 changed files with 574 additions and 2303 deletions
+4 -2
View File
@@ -17,22 +17,24 @@ tools/*.mjs
*.mjs
*.cjs
# 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
-62
View File
@@ -1,62 +0,0 @@
# 术樱包自动诊断日志
扩展加载后自动记录本扩展的关键业务步骤,发生错误时自动生成独立报告。
无需开启控制台或手动开始录制;不上传任何数据。
## 文件位置
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。
-348
View File
@@ -1,348 +0,0 @@
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;
}
-127
View File
@@ -1,127 +0,0 @@
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;
}
-206
View File
@@ -1,206 +0,0 @@
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 || "仅当前运行内存,未能持久化" }),
};
}
+35 -70
View File
@@ -1,19 +1,16 @@
{
"version": "2.0.2.3-rc.3",
"generated_at": "2026-10-08T07:44:37.994065+00:00",
"version": "2.0.2.1",
"generated_at": "2026-10-04T20:26:18.324852+00:00",
"algorithm": "sha256",
"update_channels": true,
"file_count": 281,
"size_bytes": 47954184,
"folder_sha256": "89d5f54fc020dcd9f00aec51b02dd6f53c7d3183e22c3a171d96670df96c521f",
"file_count": 278,
"size_bytes": 55472293,
"folder_sha256": "874bf6414e4d4f761bc9ccb25a6a8f46e2efdf35609887ea856b9b68b3c8c9a3",
"core": [
"extension.js",
"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",
@@ -398,33 +395,21 @@
"size": 43,
"sha256": "d7f3da55f8b2e8bf77ace03bd4911497a4abb9e1f942555f7685337801278205"
},
"diagnostics/index.js": {
"size": 14514,
"sha256": "8a5c2d8f19e6f1f1612dd635ddd32088697d1cc9001d37521369558783576d51"
},
"diagnostics/serialize.js": {
"size": 5480,
"sha256": "1fec2ed225e0551eecfb143cfbed106e2f2c3eaa914f134fd507da95d6f0ecb4"
},
"diagnostics/storage.js": {
"size": 7841,
"sha256": "8ed0c7f1b1f7213ca68d718961b48f943e99247d27f73f7555e3e790f085de92"
},
"extension.css": {
"size": 55501,
"sha256": "0907c22921f33d3180378dfcec4cdea793227d325c281d5f3a3f698c83b42b85"
"size": 51738,
"sha256": "356aa9473ab72ad43850c13451fd15a25c6ce2d93678cd0dcba5568d10443b40"
},
"extension.js": {
"size": 15923,
"sha256": "a44544c728a2137ac8bcb45702a38b45cddc6265e004665b29c77b67737bda38"
"size": 15459,
"sha256": "9656c118dfc744e89fef826d75fc1d58515507021ad1e710d7fd6d479cc631ee"
},
"function.js": {
"size": 36025,
"sha256": "39a02025adc2459c93c4f5539c7c3a9b70c0d344284d43252af8be52e8565c33"
"size": 33746,
"sha256": "df1aa3102a214986723af365da1029485680519d355a1ff88973eb36f8a6f909"
},
"menu.js": {
"size": 30028,
"sha256": "6efbc24c6cdfb51922e069f13fa31d0c5000244ab5d4553d244aa371487c3e8f"
"size": 24566,
"sha256": "d6f1fb89707de3558ca9a00814a1d79f12f5799cd85328e764e02a66e4ffba5e"
},
"pve/audio/dead/十二生肖/chenlong.mp3": {
"size": 68757,
@@ -1215,8 +1200,8 @@
"sha256": "6787f2d322dcb02edc41bb282388623ac6d1fe9bd5966ba6fe017a7cb2bbe929"
},
"pve/images/青青子衿/simayi_boss_shuying.jpg": {
"size": 195590,
"sha256": "26933ced3153897bb9cc7f25f2a8098e0d376235c538946e1a22e6b91b4d1ddc"
"size": 7770307,
"sha256": "4752a33861a9ed55633de356e60819a73918cc99c82f3c0544a0befc29515473"
},
"pve/images/青青子衿/yuanshu_boss_shuying.jpg": {
"size": 2155807,
@@ -1315,12 +1300,12 @@
"sha256": "41914d7d542ddfb93c89aefffc245869d63a9fb2f52293df8e6667009832afd8"
},
"update.js": {
"size": 75926,
"sha256": "5b62cf2771f188e7fe5ed2c31095afd55494883117e50555530997a2d060c783"
"size": 60005,
"sha256": "f809d6e3ea5114f78a0773314c23b4120ccbd763b1f2f0f1f400753287af76f7"
},
"update.txt": {
"size": 4787,
"sha256": "cba4f2eb6c04eee700423c0336311a86019666232763100d0a33cca5fe09c824"
"size": 3903,
"sha256": "8ef4a71189bdfc4068e0ebab27dad13f7551354f72a0c6ed51d77b62c8c06fb2"
},
"voices.js": {
"size": 1711,
@@ -1578,45 +1563,25 @@
}
}
},
"diagnostics": {
"type": "directory",
"children": {
"index.js": {
"type": "file",
"size": 14514,
"sha256": "8a5c2d8f19e6f1f1612dd635ddd32088697d1cc9001d37521369558783576d51"
},
"serialize.js": {
"type": "file",
"size": 5480,
"sha256": "1fec2ed225e0551eecfb143cfbed106e2f2c3eaa914f134fd507da95d6f0ecb4"
},
"storage.js": {
"type": "file",
"size": 7841,
"sha256": "8ed0c7f1b1f7213ca68d718961b48f943e99247d27f73f7555e3e790f085de92"
}
}
},
"extension.css": {
"type": "file",
"size": 55501,
"sha256": "0907c22921f33d3180378dfcec4cdea793227d325c281d5f3a3f698c83b42b85"
"size": 51738,
"sha256": "356aa9473ab72ad43850c13451fd15a25c6ce2d93678cd0dcba5568d10443b40"
},
"extension.js": {
"type": "file",
"size": 15923,
"sha256": "a44544c728a2137ac8bcb45702a38b45cddc6265e004665b29c77b67737bda38"
"size": 15459,
"sha256": "9656c118dfc744e89fef826d75fc1d58515507021ad1e710d7fd6d479cc631ee"
},
"function.js": {
"type": "file",
"size": 36025,
"sha256": "39a02025adc2459c93c4f5539c7c3a9b70c0d344284d43252af8be52e8565c33"
"size": 33746,
"sha256": "df1aa3102a214986723af365da1029485680519d355a1ff88973eb36f8a6f909"
},
"menu.js": {
"type": "file",
"size": 30028,
"sha256": "6efbc24c6cdfb51922e069f13fa31d0c5000244ab5d4553d244aa371487c3e8f"
"size": 24566,
"sha256": "d6f1fb89707de3558ca9a00814a1d79f12f5799cd85328e764e02a66e4ffba5e"
},
"pve": {
"type": "directory",
@@ -2714,8 +2679,8 @@
},
"simayi_boss_shuying.jpg": {
"type": "file",
"size": 195590,
"sha256": "26933ced3153897bb9cc7f25f2a8098e0d376235c538946e1a22e6b91b4d1ddc"
"size": 7770307,
"sha256": "4752a33861a9ed55633de356e60819a73918cc99c82f3c0544a0befc29515473"
},
"yuanshu_boss_shuying.jpg": {
"type": "file",
@@ -2860,13 +2825,13 @@
},
"update.js": {
"type": "file",
"size": 75926,
"sha256": "5b62cf2771f188e7fe5ed2c31095afd55494883117e50555530997a2d060c783"
"size": 60005,
"sha256": "f809d6e3ea5114f78a0773314c23b4120ccbd763b1f2f0f1f400753287af76f7"
},
"update.txt": {
"type": "file",
"size": 4787,
"sha256": "cba4f2eb6c04eee700423c0336311a86019666232763100d0a33cca5fe09c824"
"size": 3903,
"sha256": "8ef4a71189bdfc4068e0ebab27dad13f7551354f72a0c6ed51d77b62c8c06fb2"
},
"voices.js": {
"type": "file",
@@ -3010,8 +2975,8 @@
"title": "梦三模式",
"entry": "mengsan/register.js",
"manifest": "dist/modules/mengsan.json",
"file_count": 458,
"size_bytes": 145119394,
"file_count": 457,
"size_bytes": 145118687,
"legacy_manifest": "dist/modules/mengsan.legacy.json"
}
}
+77 -87
View File
@@ -1,11 +1,11 @@
{
"version": "2.0.2.3-rc.3",
"generated_at": "2026-10-08T07:44:37.994893+00:00",
"version": "2.0.2.1",
"generated_at": "2026-10-04T20:26:18.326606+00:00",
"algorithm": "sha256",
"update_channels": true,
"file_count": 458,
"size_bytes": 145119394,
"folder_sha256": "0882a36243881a5b8796b0e2e007ace2677c4bd16ea002668144e6d2859f4668",
"file_count": 457,
"size_bytes": 145118687,
"folder_sha256": "066cda4d0a38b376251ebf0f57074236c8aa3a9f64f0fc47e54ad021d6a5ed60",
"core": [
"mengsan/register.js"
],
@@ -308,7 +308,6 @@
"mengsan/battle/battle-resources.js",
"mengsan/battle/battle-session.js",
"mengsan/battle/battle-settlement.js",
"mengsan/battle/camps.js",
"mengsan/battle/combat-rules.js",
"mengsan/battle/death-blow.js",
"mengsan/battle/engine-session.js",
@@ -1644,28 +1643,24 @@
"sha256": "ba3adfa4793a1ef3e2da9959a39fad62789fcd6f41b84e9e342c81ff88edbbff"
},
"mengsan/battle/battle-flow.js": {
"size": 7771,
"sha256": "eac0461db750ddd40c37b9a7b3ab27d95cbfd59686a98eb5ccf7d8c9666d5a14"
"size": 5801,
"sha256": "9ceffe9539c1d303f75eb23e4512554c174e602f85f53e9f9a37e2fb1afe7bb1"
},
"mengsan/battle/battle-loop.js": {
"size": 3565,
"sha256": "0af53c3d317fe9816594c2786bf5a1bfa960babf0c135b9b4d524794730efbcf"
},
"mengsan/battle/battle-resources.js": {
"size": 3178,
"sha256": "07281284f270454586369b21c3de080084d6701c43b2998682b00fdfb20d8d88"
"size": 3040,
"sha256": "6e29789023111c4f7b91a11d7169069cc6b9d0087a3451d526233fcf03f389a2"
},
"mengsan/battle/battle-session.js": {
"size": 6214,
"sha256": "00c128d1ccf6c2492c5a83dea2e24ff75233e706374803525e35700ed8162bff"
"size": 5455,
"sha256": "5e50205af67054761876aa37fbd02ebefc3225f8d1a90afcd708c215ae8e5518"
},
"mengsan/battle/battle-settlement.js": {
"size": 13209,
"sha256": "a9f1dbd5df8f039f18a22398427e336467aa0588599accff72a84ccf20644f2e"
},
"mengsan/battle/camps.js": {
"size": 2386,
"sha256": "51ee6d2aa78261bd60b2ffa2662b7a37e62b516c379b8cedd46ecf0466630278"
"size": 11994,
"sha256": "be8ef59f8dba0c9cd1f3032ea1de1615058476d1a591c1858b4fa180a13ee578"
},
"mengsan/battle/combat-rules.js": {
"size": 5966,
@@ -1684,8 +1679,8 @@
"sha256": "c0efe2d26f5ee76f090c5ef04b8607def99ad1a3315dedd5c2b8e42aff218a7d"
},
"mengsan/battle/intent-effects.js": {
"size": 2311,
"sha256": "5fc1c57b88c996b9ba1ff27d220749001acecea028950479c5fc4bf26b854fe9"
"size": 2362,
"sha256": "dd4209231232acde790a127db4fe2ba004a6c380ea44a542f882256c36d2b13f"
},
"mengsan/battle/skill-teardown.js": {
"size": 6702,
@@ -2032,8 +2027,8 @@
"sha256": "867ac35043e5ae50a20349f944f2514c5e6201b71f2f92ca9fd28a0a26fe11f0"
},
"mengsan/mode.js": {
"size": 115328,
"sha256": "aebdcb7805aa86fdf92b53a9f6bdba3e97d699c5e79258c0fe8a16333a6dd1e0"
"size": 113891,
"sha256": "a4f7498ef506f894890fbcbd3058fab0c13876657d3d207b3e99be9829702577"
},
"mengsan/monsters/actions.js": {
"size": 33807,
@@ -2176,8 +2171,8 @@
"sha256": "c0b5fa06fda99cf4d06570cb7e95fafc141b355055d20b0bd8f7ff91f1c1afa7"
},
"mengsan/progression/mode-storage.js": {
"size": 5463,
"sha256": "202bcf2de55a4115c3a3fcecb6171b57dc3a3cb053f452b0f9e99db0e7fe899e"
"size": 3740,
"sha256": "7e266df0de15e4f2252600f10a21a09c01949680c1aaaaf5453591b9df3573d4"
},
"mengsan/progression/reward-package.js": {
"size": 1527,
@@ -2216,12 +2211,12 @@
"sha256": "331664f916965f8d72ac9db50a59c5ff4f18f7a95c6beb5eeb9e44d247ffd053"
},
"mengsan/ui/bonds.js": {
"size": 4641,
"sha256": "5ae360d8209181554dac328bdde84d93f6f051654052ab6cf39ddf94f60f1f81"
"size": 4415,
"sha256": "275713f94c9ede664537104a1eb483ac198b477ecc3e5cca52bc51208b3b274d"
},
"mengsan/ui/card-library.js": {
"size": 21251,
"sha256": "fb7c94751c3bbb8f0beff046fd829bd73e2c8e75504a7dacf743f75351ec552c"
"size": 20528,
"sha256": "b58a96859599082e83da6e8c9026619392cf9a72ebe7223d86e078de3ea14d56"
},
"mengsan/ui/dialogue.js": {
"size": 7649,
@@ -2244,44 +2239,44 @@
"sha256": "91adc86b2810e27bd09d07297ac427cbbc151936f4dd8736ab1413b4eab78a0c"
},
"mengsan/ui/hand/diagnostics.js": {
"size": 1077,
"sha256": "1d46f60103de7721cda2b533d18345531d052b38e39b0f7ec678c8ea92c7345f"
"size": 723,
"sha256": "f41c12348a508a7a1505fbc311b9c4b3cf85be038cc93e467a5f094bf1e5dde3"
},
"mengsan/ui/hand/fan.js": {
"size": 4355,
"sha256": "55e1e01dda7b4231c8e1a2fadf6ccbee39b5e98c543bc55500c6493cf6cd51b1"
},
"mengsan/ui/hand/index.js": {
"size": 9872,
"sha256": "797214e4d04e2efc2be5627691bf1c685778547fa08df8477c61c76779af3745"
"size": 8916,
"sha256": "4c423b3a2690f35112d1b111e940fc25ba8d760e64380a43e37f3373ff3df72a"
},
"mengsan/ui/hand/input.js": {
"size": 12278,
"sha256": "b04df574792e6e4be7cce4e0d8bd682efeb091b9b752b40a0b110575442adfbb"
},
"mengsan/ui/hand/overlays.js": {
"size": 3970,
"sha256": "9e489fc939f97a21266764f1beccdda1cc3ac9df22f72c633647d7481fa4e662"
"size": 3060,
"sha256": "8c9d53b0fb1856378ca72d3a7cc3de814f0668914e7cb89bce697cabc22a7fb4"
},
"mengsan/ui/hand/style.css": {
"size": 15492,
"sha256": "12618f7fd4acd54db197f1ca82dceed87b75c4b6003f02fcaa9054862718e692"
"size": 35504,
"sha256": "29b0cbad19570fda49a86f1e65476df9863f3d22a38927e009659e5cbfdcef89"
},
"mengsan/ui/intent-display.js": {
"size": 13291,
"sha256": "cd0fb73b7c42e3fa72474ab73870a13270abc2f390bfb7f0c96032041eff5ac2"
"size": 10556,
"sha256": "feb7fdb208d4ee540a0fe66b329402c8587f22dcdb8de2e1eb455b7d6af9a185"
},
"mengsan/ui/map/index.js": {
"size": 61,
"sha256": "6fc56c6c084508a39d801521a103f8c39eea90eb895f47a78924997241d9f932"
},
"mengsan/ui/map/renderer.js": {
"size": 26125,
"sha256": "c4ecb528fbb5dc09535c3a4ad7a859cc4bace9dccfbaafec015627cd5d700c6c"
"size": 24919,
"sha256": "89cec835a8479b0fb4ba455179edc2200c9ef7d0d864fc9702921732d0074db6"
},
"mengsan/ui/navigation.js": {
"size": 8631,
"sha256": "83ea39ff385347ea0f769a7bc0ed829ac25caf6167a4d4b669ee72b682e9b990"
"size": 5154,
"sha256": "213d24bfee22e7c6a57312198e21a4877c43b6a6b42e238efa5107f1b08092b8"
},
"mengsan/ui/relics.js": {
"size": 3371,
@@ -2296,12 +2291,12 @@
"sha256": "70a1bd26b1ec730bc7cdf04bddf160f2e3ae8daadd54c4c38c5263549aa88dee"
},
"mengsan/ui/style.css": {
"size": 77648,
"sha256": "46e38bb6c818d3fae8c0b61e5e2cc7c5c29db69cb72daa244f1a2944db8ebfb8"
"size": 77404,
"sha256": "7f05fd95ed996f70411a02893cbfd6f1b25e4cd426acc58858800703d52ad946"
},
"mengsan/ui/virtual-card-grid.js": {
"size": 4820,
"sha256": "0933ec3eea0169b34a6e7b4c20e9f75b0645fa787ea70b7ec7d4469213f94ae2"
"size": 4509,
"sha256": "0d8aa28041246a20895a289ab360e982044b2028f6c03090a0039ce85f7e2977"
}
},
"tree": {
@@ -3806,8 +3801,8 @@
},
"battle-flow.js": {
"type": "file",
"size": 7771,
"sha256": "eac0461db750ddd40c37b9a7b3ab27d95cbfd59686a98eb5ccf7d8c9666d5a14"
"size": 5801,
"sha256": "9ceffe9539c1d303f75eb23e4512554c174e602f85f53e9f9a37e2fb1afe7bb1"
},
"battle-loop.js": {
"type": "file",
@@ -3816,23 +3811,18 @@
},
"battle-resources.js": {
"type": "file",
"size": 3178,
"sha256": "07281284f270454586369b21c3de080084d6701c43b2998682b00fdfb20d8d88"
"size": 3040,
"sha256": "6e29789023111c4f7b91a11d7169069cc6b9d0087a3451d526233fcf03f389a2"
},
"battle-session.js": {
"type": "file",
"size": 6214,
"sha256": "00c128d1ccf6c2492c5a83dea2e24ff75233e706374803525e35700ed8162bff"
"size": 5455,
"sha256": "5e50205af67054761876aa37fbd02ebefc3225f8d1a90afcd708c215ae8e5518"
},
"battle-settlement.js": {
"type": "file",
"size": 13209,
"sha256": "a9f1dbd5df8f039f18a22398427e336467aa0588599accff72a84ccf20644f2e"
},
"camps.js": {
"type": "file",
"size": 2386,
"sha256": "51ee6d2aa78261bd60b2ffa2662b7a37e62b516c379b8cedd46ecf0466630278"
"size": 11994,
"sha256": "be8ef59f8dba0c9cd1f3032ea1de1615058476d1a591c1858b4fa180a13ee578"
},
"combat-rules.js": {
"type": "file",
@@ -3856,8 +3846,8 @@
},
"intent-effects.js": {
"type": "file",
"size": 2311,
"sha256": "5fc1c57b88c996b9ba1ff27d220749001acecea028950479c5fc4bf26b854fe9"
"size": 2362,
"sha256": "dd4209231232acde790a127db4fe2ba004a6c380ea44a542f882256c36d2b13f"
},
"skill-teardown.js": {
"type": "file",
@@ -4373,8 +4363,8 @@
},
"mode.js": {
"type": "file",
"size": 115328,
"sha256": "aebdcb7805aa86fdf92b53a9f6bdba3e97d699c5e79258c0fe8a16333a6dd1e0"
"size": 113891,
"sha256": "a4f7498ef506f894890fbcbd3058fab0c13876657d3d207b3e99be9829702577"
},
"monsters": {
"type": "directory",
@@ -4561,8 +4551,8 @@
},
"mode-storage.js": {
"type": "file",
"size": 5463,
"sha256": "202bcf2de55a4115c3a3fcecb6171b57dc3a3cb053f452b0f9e99db0e7fe899e"
"size": 3740,
"sha256": "7e266df0de15e4f2252600f10a21a09c01949680c1aaaaf5453591b9df3573d4"
},
"reward-package.js": {
"type": "file",
@@ -4621,13 +4611,13 @@
"children": {
"bonds.js": {
"type": "file",
"size": 4641,
"sha256": "5ae360d8209181554dac328bdde84d93f6f051654052ab6cf39ddf94f60f1f81"
"size": 4415,
"sha256": "275713f94c9ede664537104a1eb483ac198b477ecc3e5cca52bc51208b3b274d"
},
"card-library.js": {
"type": "file",
"size": 21251,
"sha256": "fb7c94751c3bbb8f0beff046fd829bd73e2c8e75504a7dacf743f75351ec552c"
"size": 20528,
"sha256": "b58a96859599082e83da6e8c9026619392cf9a72ebe7223d86e078de3ea14d56"
},
"dialogue.js": {
"type": "file",
@@ -4659,8 +4649,8 @@
},
"diagnostics.js": {
"type": "file",
"size": 1077,
"sha256": "1d46f60103de7721cda2b533d18345531d052b38e39b0f7ec678c8ea92c7345f"
"size": 723,
"sha256": "f41c12348a508a7a1505fbc311b9c4b3cf85be038cc93e467a5f094bf1e5dde3"
},
"fan.js": {
"type": "file",
@@ -4669,8 +4659,8 @@
},
"index.js": {
"type": "file",
"size": 9872,
"sha256": "797214e4d04e2efc2be5627691bf1c685778547fa08df8477c61c76779af3745"
"size": 8916,
"sha256": "4c423b3a2690f35112d1b111e940fc25ba8d760e64380a43e37f3373ff3df72a"
},
"input.js": {
"type": "file",
@@ -4679,20 +4669,20 @@
},
"overlays.js": {
"type": "file",
"size": 3970,
"sha256": "9e489fc939f97a21266764f1beccdda1cc3ac9df22f72c633647d7481fa4e662"
"size": 3060,
"sha256": "8c9d53b0fb1856378ca72d3a7cc3de814f0668914e7cb89bce697cabc22a7fb4"
},
"style.css": {
"type": "file",
"size": 15492,
"sha256": "12618f7fd4acd54db197f1ca82dceed87b75c4b6003f02fcaa9054862718e692"
"size": 35504,
"sha256": "29b0cbad19570fda49a86f1e65476df9863f3d22a38927e009659e5cbfdcef89"
}
}
},
"intent-display.js": {
"type": "file",
"size": 13291,
"sha256": "cd0fb73b7c42e3fa72474ab73870a13270abc2f390bfb7f0c96032041eff5ac2"
"size": 10556,
"sha256": "feb7fdb208d4ee540a0fe66b329402c8587f22dcdb8de2e1eb455b7d6af9a185"
},
"map": {
"type": "directory",
@@ -4704,15 +4694,15 @@
},
"renderer.js": {
"type": "file",
"size": 26125,
"sha256": "c4ecb528fbb5dc09535c3a4ad7a859cc4bace9dccfbaafec015627cd5d700c6c"
"size": 24919,
"sha256": "89cec835a8479b0fb4ba455179edc2200c9ef7d0d864fc9702921732d0074db6"
}
}
},
"navigation.js": {
"type": "file",
"size": 8631,
"sha256": "83ea39ff385347ea0f769a7bc0ed829ac25caf6167a4d4b669ee72b682e9b990"
"size": 5154,
"sha256": "213d24bfee22e7c6a57312198e21a4877c43b6a6b42e238efa5107f1b08092b8"
},
"relics.js": {
"type": "file",
@@ -4731,13 +4721,13 @@
},
"style.css": {
"type": "file",
"size": 77648,
"sha256": "46e38bb6c818d3fae8c0b61e5e2cc7c5c29db69cb72daa244f1a2944db8ebfb8"
"size": 77404,
"sha256": "7f05fd95ed996f70411a02893cbfd6f1b25e4cd426acc58858800703d52ad946"
},
"virtual-card-grid.js": {
"type": "file",
"size": 4820,
"sha256": "0933ec3eea0169b34a6e7b4c20e9f75b0645fa787ea70b7ec7d4469213f94ae2"
"size": 4509,
"sha256": "0d8aa28041246a20895a289ab360e982044b2028f6c03090a0039ce85f7e2977"
}
}
}
+1 -2
View File
@@ -1,5 +1,5 @@
{
"version": "2.0.2.3-rc.3",
"version": "2.0.2.1",
"module_id": "mengsan",
"snapshot": "legacy",
"files": [
@@ -309,7 +309,6 @@
"mengsan/battle/battle-resources.js",
"mengsan/battle/battle-session.js",
"mengsan/battle/battle-settlement.js",
"mengsan/battle/camps.js",
"mengsan/battle/combat-rules.js",
"mengsan/battle/death-blow.js",
"mengsan/battle/engine-session.js",
-103
View File
@@ -1096,109 +1096,6 @@
}
}
/* 更新设置:沿用选择窗口材质,两个选项与取消/确认按钮适配窄屏。 */
.shuYing-option-dialog-mask[data-variant="settings"] {
--setting-muted: #d1c9bc;
--setting-focus: #ffe2a0;
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right))
max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)) !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog {
width: min(600px, 100%) !important;
max-height: calc(100vh - 32px) !important;
max-height: calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))) !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-title,
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-intro,
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-controls {
flex: none !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-intro,
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-tag,
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-description {
color: var(--setting-muted) !important;
font-size: 14px !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-options {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
min-height: 0 !important;
gap: 12px !important;
padding: 18px !important;
overflow-x: hidden !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-card {
min-height: 170px !important;
padding: 38px 18px 18px !important;
transform: none !important;
touch-action: manipulation;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-order {
display: none !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-name {
margin-top: 0 !important;
font-size: 25px !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-description {
margin-top: 12px !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-controls {
gap: 12px !important;
padding: 0 18px 18px !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-confirm {
flex: 1 !important;
min-width: 0 !important;
min-height: 48px !important;
height: auto !important;
padding: 10px 8px !important;
font-size: 16px !important;
line-height: 1.4 !important;
touch-action: manipulation;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-cancel {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(232, 190, 99, 0.4) !important;
color: var(--setting-muted) !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] button:focus-visible {
outline: 2px solid var(--setting-focus) !important;
outline-offset: 2px !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] button:active:not(:disabled) {
opacity: 0.85 !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-confirm:disabled {
opacity: 0.5 !important;
cursor: default !important;
box-shadow: none !important;
}
@media screen and (max-width: 520px) {
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-options {
grid-template-columns: minmax(0, 1fr) !important;
padding: 12px !important;
}
.shuYing-option-dialog-mask[data-variant="settings"] .shuYing-option-dialog-card {
min-height: 140px !important;
}
}
/* 天书对局羁绊入口与概览:不依赖十周年 UI 节点,原版和扩展 UI 共用。 */
.shuYing-bond-overview-button {
all: unset !important;
+3 -11
View File
@@ -2,11 +2,10 @@ 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.3";
const shuYingLocalVersion = "2.0.2.1";
shuYing.name = "术樱包";
shuYing.editable = false;
@@ -17,11 +16,6 @@ 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) => {
@@ -30,7 +24,6 @@ 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;
@@ -132,7 +125,6 @@ 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;
});
@@ -253,7 +245,7 @@ shuYing.initCharacter = async () => {
}
//初始化游戏时
shuYing.precontent = () => diagnostics.step("precontent", async () => {
shuYing.precontent = async () => {
await shuYing.loadVoices();
shuYing.initCSS();
shuYing.initFunction();
@@ -281,7 +273,7 @@ shuYing.precontent = () => diagnostics.step("precontent", async () => {
initShuYingMenu({ lib, game, ui, shuYing, updater });
shuYing.getVersion();
await shuYing.initCharacter();
});
};
shuYing.config = () => {
+4 -44
View File
@@ -225,8 +225,7 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
};
// 创建可复用的对局单选界面;单击选中卡片,确认后返回对应键值。
shuYing.chooseSingleOptionDialog = ({ title = "请选择", intro = "", options = [], defaultKey = null,
currentKey = null, cancelable = false, variant = "default", confirmText = "确定" } = {}) => {
shuYing.chooseSingleOptionDialog = ({ title = "请选择", intro = "", options = [], defaultKey = null } = {}) => {
return new Promise(resolve => {
const items = options.map(option => typeof option == "string" ? { key: option, name: option } : option)
.filter(option => option?.key);
@@ -241,26 +240,18 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
if (parent) parent.appendChild(node);
return node;
};
const previousFocus = document.activeElement;
const mask = createNode("div", "shuYing-option-dialog-mask", null, document.body);
mask.dataset.variant = variant;
const dialog = createNode("div", "shuYing-option-dialog", null, mask);
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", title);
createNode("div", "shuYing-option-dialog-title", title, dialog);
if (intro) createNode("div", "shuYing-option-dialog-intro", intro, dialog);
const optionArea = createNode("div", "shuYing-option-dialog-options", null, dialog);
const controls = createNode("div", "shuYing-option-dialog-controls", null, dialog);
let closed = false;
let onKeydown = null;
let selectedKey = defaultKey && items.some(item => item.key == defaultKey) ? defaultKey : items[0].key;
const finish = key => {
if (closed) return;
closed = true;
if (onKeydown) document.removeEventListener("keydown", onKeydown, true);
mask.remove();
if (cancelable && previousFocus?.isConnected) previousFocus.focus?.({ preventScroll: true });
resolve(key);
};
const refreshSelection = () => {
@@ -269,7 +260,6 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
card.classList.toggle("selected", selected);
card.setAttribute("aria-pressed", String(selected));
});
confirm.disabled = currentKey != null && selectedKey == currentKey;
};
items.forEach((option, index) => {
const card = createNode("button", "shuYing-option-dialog-card", null, optionArea);
@@ -286,44 +276,14 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
refreshSelection();
});
});
if (cancelable) {
const cancel = createNode("button", "shuYing-option-dialog-confirm shuYing-option-dialog-cancel", "取消", controls);
cancel.type = "button";
cancel.addEventListener("click", event => { event.stopPropagation(); finish(null); });
}
const confirm = createNode("button", "shuYing-option-dialog-confirm", confirmText, controls);
const confirm = createNode("button", "shuYing-option-dialog-confirm", "确定", controls);
confirm.type = "button";
confirm.addEventListener("click", event => {
event.stopPropagation();
if (!confirm.disabled) finish(selectedKey);
});
mask.addEventListener("click", event => {
event.stopPropagation();
if (cancelable && event.target == mask) finish(null);
finish(selectedKey);
});
mask.addEventListener("click", event => event.stopPropagation());
dialog.addEventListener("click", event => event.stopPropagation());
if (cancelable) {
dialog.addEventListener("keydown", event => event.stopPropagation());
onKeydown = event => {
if (event.key == "Escape") {
event.preventDefault();
event.stopPropagation();
finish(null);
}
else if (event.key == "Tab") {
const buttons = Array.from(dialog.querySelectorAll("button:not(:disabled)"));
const first = buttons[0], last = buttons[buttons.length - 1];
if (!dialog.contains(document.activeElement)
|| event.shiftKey && document.activeElement == first
|| !event.shiftKey && document.activeElement == last) {
event.preventDefault();
(event.shiftKey ? last : first)?.focus();
}
event.stopPropagation();
}
};
document.addEventListener("keydown", onKeydown, true);
}
refreshSelection();
const initial = Array.from(optionArea.querySelectorAll(".shuYing-option-dialog-card")).find(node => node.dataset.key == selectedKey)
|| optionArea.querySelector(".shuYing-option-dialog-card");
-16
View File
@@ -2,22 +2,6 @@
`register.js` 是扩展加载入口,`mode.js` 负责组装模式、注册技能并连接各领域模块,`config.js` 提供模式配置。发布器和外部扩展应继续从 `register.js` 加载,避免依赖内部文件路径。
梦三独立组装模式,不加载或继承身份模式。敌我关系统一由
`battle/camps.js` 读取角色的 `storage.mengsanCamp_shuying`:主角、支援和
羁绊助战为 `ally`,怪物及敌方援军为 `enemy`。`side` 与梦三专属的
`identity` 标记只用于核心及界面兼容,不赋予主公、忠臣或反贼规则。
技能应使用 `isFriendOf` / `isEnemyOf`、`getFriends` / `getEnemies` 或
`get.attitude` 判断敌我。基础态度为同阵营及自身 `+8`、对立阵营 `-8`,
未设置有效阵营的其他对象为 `0`;剧情切换阵营后按新阵营判断。
`get.effect`、`get.effect_use`、`get.damageEffect`、`get.recoverEffect` 和
牌价值、技能效果修正继续使用核心算法,并通过上述态度计算行动收益。
有益效果不一律视为正收益,伤害也不一律视为负收益,仍遵循技能自己的 AI 配置。
梦三不启用主公技,初始技能及动态授予技能均按此限制处理。阵营始终公开,
出牌或技能的 `logAi` / `addExpose` 调用不推断隐藏身份;击杀不附加身份奖惩,
胜负与奖励沿用梦三关卡和结算流程。
| 目录 | 职责 |
| --- | --- |
| `battle/` | 战斗会话、回合、结算、费用与通用战斗效果 |
+1 -1
View File
@@ -9,7 +9,7 @@
- 批量优化只处理 `mengsan_*.png` 运行时卡牌,参考原件如 `wound-original.png` 不参与。
- 画风:泛黄宣纸、黑褐水墨、飞白与淡赭色、中国古代人物或兵器;根据卡牌效果独立安排竖幅构图。拆包图只作题材参考,不修改拆包原件。
- 立绘不绘制名称、费用、花色、强化角标或边框。左上花色点数、左侧中段费用、右下名称由游戏原生节点和梦三样式叠加;左侧信息栏和右下名称需留空间。
- 卡牌定义使用 `fullimage: true` 与明确的 `image` 路径,不使用横图 `fullskin`。手牌叠层按实际注册定义的 `fullimage: true` 及 `ext:术樱包/mengsan/assets/cards/牌名.png` 自动设置 `data-mengsan-local-art="full"`;四字及以上的翻译名称自动标记为长名,常规和窄屏样式统一,不再维护逐牌 CSS 名单。保持 `cover`,不使用 `100% 100%` 拉伸。
- 卡牌定义使用 `fullimage: true` 与明确的 `image` 路径,不使用横图 `fullskin`。`mengsan_ic_`、`mengsan_colorless_`、`mengsan_event_`、`mengsan_status_`、`mengsan_curse_` 已按前缀接入 `ui/hand/style.css` 的本地完整卡面选择器;其他命名的新增卡牌须单独接入,长牌名同时加入长名称窄屏规则。保持 `cover`,不使用 `100% 100%` 拉伸。
- 费用和强化角标继续使用现有 `data-*` 与伪元素,避免向卡牌子节点写入额外文字而触发十周年 UI 扫描。
- 不改变卡牌费用、效果、强化、奖励与存档数据;不修改全局标准/军争牌或十周年 UI。
- 接入后检查完整立绘、花色/费用/名称、扇形叠牌、悬停放大和窄屏。文件检查、隔离行为测试及预览不代表实机验收。
+20 -51
View File
@@ -1,89 +1,67 @@
// 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 () => {}, releaseBattle = () => {}, presentVictory = async () => {}}) {
export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, 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 step("drain", () => active.session.drain());
await active.session.drain();
// Allow mode-start/parent frames and foreign pauses to unwind; never resume them.
await step("quiesce.beforePrepare", quiesce);
if (!job.pending) job.pending = await step("prepare", () => settlement.prepare(job.input));
await quiesce();
if (!job.pending) job.pending = await settlement.prepare(job.input);
if (job.pending.outcome === "victory" && !job.victoryPresented) {
await step("victoryDialogue", () => presentVictory(copy(job.pending)), false);
await presentVictory(copy(job.pending));
job.victoryPresented = true;
}
if (job.pending.state !== "chosen") {
state = "choosing";
if (!job.hasChoice) {
job.choiceId = await step("rewardSelection", () => job.pending.outcome === "defeat" ? chooseDefeat(copy(job.pending.base))
: chooseReward(copy(job.pending.choices), {
job.choiceId = job.pending.outcome === "defeat" ? await chooseDefeat(copy(job.pending.base))
: await 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 step("applyChoice", () => settlement.choose(job.input.run.runId, job.pending.id, job.choiceId));
job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId);
}
if (!job.committed) {
state = "committing";
job.committed = await step("commit", () => settlement.commit(job.input.run.runId, job.pending.id));
job.committed = await settlement.commit(job.input.run.runId, job.pending.id);
}
if (!job.skillsReleased) {
state = "releasingSkills";
await step("releaseSkills", releaseSkills);
await releaseSkills();
job.skillsReleased = true;
await step("quiesce.afterSkills", quiesce);
await quiesce();
}
state = "cleaning";
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;
}
await active.session.dispose(); // Failed cleanup retry does NOT replay a committed reward.
await quiesce();
if (!job.delivered) {
state = "presenting";
if (job.committed.route === "map") {
const mapRun = copy(job.committed.run);
job.nextNode = await step("showMap", () => showMap(mapRun), false);
job.nextNode = await showMap(mapRun);
job.committed.run = mapRun;
}
else if (job.committed.route !== "new") {
await step("showEnding", () => showEnding(job.committed.route, copy(job.committed.run)), false);
await showEnding(job.committed.route, copy(job.committed.run));
}
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.
@@ -98,33 +76,24 @@ 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 step("quiesce.beforeBattle", quiesce);
await 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"; 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;
}
state = "battle"; start();
} catch (cause) { state = "blocked"; error = cause; throw cause; }
},
requestFinish({session, hp, outcome = "victory", defeatedEnemies = 1}) {
if (state !== "battle" || session !== active.session) {
diagnostics.info("finish.ignored", { requestedSessionId: session?.id, outcome });
return false;
}
if (state !== "battle" || session !== active.session) 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; },
+1 -4
View File
@@ -20,13 +20,11 @@ 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.slice().reverse()) {
for (const {object,key,value,descriptor} of fields.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,
@@ -51,7 +49,6 @@ export function createBattleResources(session, {game, ui, _status}) {
for (const key of ["currentPhase","lastPhasedPlayer","roundStart"]) {
if (owned.has(_status[key])) delete _status[key];
}
owned.clear();
});
},
});
+2 -16
View File
@@ -1,5 +1,4 @@
// 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) {
@@ -8,11 +7,6 @@ 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 => {
@@ -26,7 +20,6 @@ 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;
@@ -65,13 +58,9 @@ 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) {
diagnostics.error("event.finish.failed", error, { eventName: event.name });
errors.push(error);
}
try { event.finish(); } catch (error) { errors.push(error); }
}
notify();
return true;
@@ -91,10 +80,7 @@ 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 diagnostics.step("resource.release", () => release(resource), {
resourceType: resource?.constructor?.name,
playerId: resource?.playerid, nodeName: resource?.nodeName,
});
await release(resource);
resources.delete(resource); owners.delete(resource);
}
for (const event of events) owners.delete(event);
+3 -23
View File
@@ -4,7 +4,6 @@ import { applySharedBattleEndCards } from "../cards/shared-battle-end.js";
import { settleBondBattle } from "../bonds/state.js";
import { recordNormalBattleVictory } from "../progression/encounter-progress.js";
import { stripBattleProgress } from "../progression/map-checkpoint.js";
import { getDiagnostics } from "../../diagnostics/index.js";
import { prepareRewardPackage, applyRewardPackage }
from "../progression/reward-package.js";
@@ -15,11 +14,6 @@ export function createBattleSettlement({
enterNextAct, createRun, now = Date.now, logRewardPackage = () => {},
}) {
let item = null, committed = null, commitPromise = null;
const diagnostics = getDiagnostics().scope("mengsan.settlement", () => ({
settlementId: item?.id, runId: item?.base.runId,
nodeId: item?.nodeId, pendingState: item?.state,
route: item?.route, commitReceipt: !!committed,
}));
const readRun = () => store.read()[config.saveKey];
function current(storage, runId) {
const run = storage[config.saveKey];
@@ -33,11 +27,6 @@ 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");
}
}
@@ -86,10 +75,6 @@ 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";
}
@@ -164,7 +149,6 @@ 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);
}
@@ -173,11 +157,8 @@ 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) {
diagnostics.info("commit.duplicate");
return {...copy(committed), duplicate:true};
}
if (commitPromise) { diagnostics.info("commit.inflight"); return commitPromise; }
if (committed) return {...copy(committed), duplicate:true};
if (commitPromise) 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);
@@ -207,9 +188,8 @@ 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};
}, { runId, settlementId: id, nodeId: item.nodeId }).then(result => {
}).then(result => {
committed = copy(result);
diagnostics.info("commit.receipt", { duplicate: result.duplicate, route: result.route });
return copy(committed);
}).finally(() => { commitPromise = null; });
return commitPromise;
-54
View File
@@ -1,54 +0,0 @@
// 阵营是梦三敌我关系的唯一来源;side/identity 仅供核心及界面兼容。
export const battleCamp = player => {
const camp = player?.storage?.mengsanCamp_shuying;
return camp === "ally" || camp === "enemy" ? camp : null;
};
export const campFriend = (from, to) => Boolean(from && to &&
(from === to || (battleCamp(from) !== null && battleCamp(from) === battleCamp(to))));
export const campEnemy = (from, to) => Boolean(from && to && from !== to &&
battleCamp(from) !== null && battleCamp(to) !== null &&
battleCamp(from) !== battleCamp(to));
// get.attitude 会继续处理混乱、技能态度修正;get.effect 等沿用核心收益计算。
export const campAttitude = (from, to) =>
campFriend(from, to) ? 8 : campEnemy(from, to) ? -8 : 0;
export function setBattleCamp(player, camp) {
if (camp !== "ally" && camp !== "enemy") throw new Error("梦三战斗阵营无效:" + camp);
player.storage.mengsanCamp_shuying = camp;
player.side = camp === "enemy";
player.identity = `mengsan_${camp}_shuying`;
player.isZhu = false;
player.setIdentity(player.identity);
player.identityShown = true;
player.ai.shown = 1;
player.classList.add("mengsan-hide-identity-shuying");
}
export function createCampPlayerMethods(game) {
return {
isFriendOf(player) { return campFriend(this, player); },
isEnemyOf(player) { return campEnemy(this, player); },
getEnemies(filter, includeDie) {
return game[includeDie ? "filterPlayer2" : "filterPlayer"](player =>
campEnemy(this, player) && (typeof filter !== "function" || filter(player)));
},
getFriends(filter, includeDie) {
const includeSelf = filter === true;
return game[includeDie ? "filterPlayer2" : "filterPlayer"](player =>
(player !== this || includeSelf) && campFriend(this, player) &&
(typeof filter !== "function" || filter(player)));
},
// 阵营始终公开,不根据行动收益推断隐藏身份或积累暴露值。
logAi() { this.ai.shown = 1; },
addExpose() { return this; },
showIdentity() {
const camp = battleCamp(this);
if (camp) setBattleCamp(this, camp);
},
isZhu2() { return false; },
hasZhuSkill() { return false; },
};
}
+3 -2
View File
@@ -2,10 +2,11 @@ import { applyMengsanDebuff } from "../monsters/artifact-status.js";
import { applyTangled } from "../monsters/vine-tangled.js";
import { attackHitCount, outgoingAttackDamage } from "./intent-damage.js";
import { beginDeathBlow, isDeathBlowIntent } from "./death-blow.js";
import { campEnemy } from "./camps.js";
export const getIntentHostiles = (game, source) =>
game.players.filter(target => target.isAlive() && campEnemy(source, target));
game.players.filter(target => target !== source && target.isAlive() &&
target.storage?.mengsanCamp_shuying !==
source.storage?.mengsanCamp_shuying);
export function createIntentExecutor(game, getActiveBattle) {
const active = source => source.isAlive() &&
+42 -78
View File
@@ -15,7 +15,6 @@ import { ironcladBlocksDraw, ironcladPreservesBlock, afterIroncladDraw, beforeIr
import { afterSharedDraw, beforeSharedBlock, sharedStrengthPenalty } from "./cards/shared-hooks.js";
import { hasCardAffix } from "./cards/intrinsic-affixes.js";
import { createBattleEnergy } from "./cards/battle-energy.js";
import { setBattleCamp, createCampPlayerMethods, campAttitude } from "./battle/camps.js";
import { createRandomHandExhauster } from "./cards/random-hand-exhaust.js";
import { createBattleCardCopier } from "./cards/battle-card-copy.js";
import { createBattleCardReclaimer } from "./cards/battle-card-reclaim.js";
@@ -62,7 +61,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, refreshEnemyIntents } from "./ui/intent-display.js";
import { mountEnemyIntent } 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";
@@ -94,7 +93,6 @@ 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";
@@ -142,26 +140,7 @@ const clearRun = modeStorage.clearRun;
const showMap = run => renderMap(run, { saveRun });
let battleSequence = 0;
let activeBattle = null;
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 reportFlowError = error => { console.error("梦三流程暂停,未自动清档:", error); };
const chooseRun = async savedRun => {
const available = savedRun?.status === "running";
@@ -356,6 +335,15 @@ const shuffleBattlePile = (run, cards) => {
}
};
const setBattleCamp = (player, camp) => {
player.storage.mengsanCamp_shuying = camp;
player.side = camp === "enemy";
player.identity = player === game.me ? "zhu" : camp === "ally" ? "zhong" : "fan";
player.setIdentity(player.identity);
player.identityShown = true;
player.ai.shown = 1;
player.classList.add("mengsan-hide-identity-shuying");
};
const initBattleUnit = (player, spec) => {
// 只在实际入场且未指定生命时,用征程随机源抽取各怪物的普通生命范围;资料列表不消耗随机数。
const monsterSpec = spec.camp === "enemy" && spec.hp == null ?
@@ -624,6 +612,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
log: message => game.log(message),
});
if (!session.active) return;
game.zhu = me;
if (run.player.maxHp == null) {
run.player.maxHp = me.maxHp;
run.player.hp = me.hp;
@@ -648,7 +637,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_intent_refresh_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_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",
@@ -734,13 +723,13 @@ const registerPlayers = (session, players) => {
const index = list.indexOf(player); if (index >= 0) list.splice(index, 1);
}
if (game.me === player) game.me = null;
if (game.zhu === player) game.zhu = null;
player.remove();
});
}
};
const makeFlow = (releaseSkills = async () => {}, releaseBattle = () => {}) => createBattleFlow({
const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
releaseSkills,
releaseBattle,
presentVictory: pending => {
if (pending.bondGrowth) {
const growth = pending.bondGrowth;
@@ -785,10 +774,7 @@ 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){
diagnostics.info("victory.deferred", { pendingUses: current.sharedPendingUses.size });
current.sharedDeferredVictory=true;return false;
}
if(outcome==="victory" && current.sharedPendingUses?.size){current.sharedDeferredVictory=true;return false;}
for (const player of game.dead) {
recordBondDeath(player, _status.mengsanRun_shuying,
message => game.log(message));
@@ -811,12 +797,10 @@ 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();
}
@@ -827,20 +811,14 @@ 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}`);
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;
});
const resources = createBattleResources(session, {game,ui,_status});
let current;
const flow = makeFlow(() => current.releaseSkills?.());
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;
root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager);
const root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager);
current.root = root;
const previousRoot = _status.eventManager.rootEvent;
session.ownResource({}, () => {
@@ -915,10 +893,11 @@ const startJourney = async () => {
await routeRun(run);
};
const createMode = () => {
const nativeChangeHujia = lib.element.Player.prototype.changeHujia;
const nativeChangeHp = lib.element.Player.prototype.changeHp;
const nativeAddSkill = lib.element.Player.prototype.addSkill;
const createMode = identityMode => {
const identityElement = identityMode.element || {};
const identityPlayer = identityElement.player || {};
const nativeChangeHujia = identityPlayer.changeHujia || lib.element.Player.prototype.changeHujia;
const nativeChangeHp = identityPlayer.changeHp || lib.element.Player.prototype.changeHp;
const cardContext = {
game, get, lib, random:nextRandom, status:_status, getBattle:()=>activeBattle,
copyToDiscard: createBattleCardCopier(game, () => activeBattle),
@@ -942,9 +921,10 @@ const createMode = () => {
const modeCards = createMengsanCards({cardPacks,isBattleActive:cardContext.isBattleActive});
return {
...identityMode,
name: MODE_ID,
character: createScenarioCharacters(lib.element.Character),
card: modeCards.card,
character: { ...(identityMode.character || {}), ...createScenarioCharacters(lib.element.Character) },
card: { ...(identityMode.card || {}), ...modeCards.card },
connect: false,
start: [
async () => {
@@ -953,14 +933,9 @@ const createMode = () => {
},
],
element: {
...identityElement,
player: {
...createCampPlayerMethods(game),
addSkill(skill, ...args) {
// 初始武将技能由核心过滤;同时阻止奖励、剧情等动态授予主公技。
if (Array.isArray(skill)) skill = skill.filter(name => !lib.skill[name]?.zhuSkill);
else if (lib.skill[skill]?.zhuSkill) return;
return nativeAddSkill.call(this, skill, ...args);
},
...identityPlayer,
async dieAfter() {
// 原生死亡已成立;在战斗结算关闭会话前落实斩杀的永久收益。
if(this.isDead())await cardPacks.onDeath(_status.event);
@@ -973,7 +948,11 @@ const createMode = () => {
message => game.log(message));
game.checkResult();
},
dieAfter2() {}, // 击杀不额外摸牌或弃牌,奖励由梦三结算流程处理。
dieAfter2() {}, // Identity-mode kill rewards/loyalist penalties do not apply here.
isFriendOf(player) { return this === player || (this.storage.mengsanCamp_shuying === player?.storage?.mengsanCamp_shuying); },
isEnemyOf(player) { return Boolean(player && this.storage.mengsanCamp_shuying !== player.storage?.mengsanCamp_shuying); },
getEnemies(filter, includeDie) { return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => this.isEnemyOf(p) && (!filter || filter(p))); },
getFriends(filter, includeDie) { const self = filter === true; return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => (p !== this || self) && this.isFriendOf(p) && (typeof filter !== "function" || filter(p))); },
changeHp(num, popup) {
const next = nativeChangeHp.call(this, num, popup);
if (activeBattle?.session.active) capInkletHpLoss(this, next);
@@ -1006,12 +985,7 @@ const createMode = () => {
},
},
game: {
getVideoName() {
const me = game.me;
const names = [me?.name1 || me?.name, me?.name2].filter(Boolean);
return [names.map(name => get.translation(name)).join("/") || "梦三",
"梦三" + (_status.mengsanEncounter_shuying?.name ? " · " + _status.mengsanEncounter_shuying.name : "")];
},
...(identityMode.game || {}),
mengsanSetEnemyIntent_shuying(player, intent) {
const update = activeBattle?.intentUI.get(player);
if (!update) return false;
@@ -1265,10 +1239,11 @@ const createMode = () => {
},
},
get: {
rawAttitude: campAttitude,
realAttitude: campAttitude,
...(identityMode.get || {}),
rawAttitude(from, to) { return from === to || from.storage.mengsanCamp_shuying === to.storage.mengsanCamp_shuying ? 8 : -8; },
},
skill: {
...(identityMode.skill || {}),
...cardPacks.skills,
...createRelicSkills(() => activeBattle, () => game.me),
...createRelicCombatSkills(() => activeBattle, () => game.me),
@@ -1742,15 +1717,6 @@ 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,
@@ -1863,10 +1829,7 @@ const createMode = () => {
},
},
translate: {
mengsan_ally_shuying: "友",
mengsan_ally_shuying2: "友方",
mengsan_enemy_shuying: "敌",
mengsan_enemy_shuying2: "敌方",
...(identityMode.translate || {}),
...modeCards.translate,
...scenarioTranslations,
mengsan_draw_shuying: "梦三牌组",
@@ -1949,7 +1912,8 @@ export async function createMengsanMode() {
const style = lib.init.css(`${STYLE_PATH}/ui`, "style", resolve);
style.addEventListener("error", resolve, { once: true });
});
const mode = createMode();
const identityMode = await game.loadModeAsync("identity");
const mode = createMode(identityMode);
mode.splash = "ext:术樱包/pve/images/tianshu.jpg";
return mode;
}
+9 -47
View File
@@ -1,11 +1,8 @@
// 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));
@@ -19,14 +16,10 @@ const copyStorage = () => {
// Keep one mode-level writer; a failed write must not poison later retries.
let modeStorageQueue = Promise.resolve();
const writeModeStorage = (storage, receipt) => {
receipt.phase = "storage.write";
receipt.storageStatus = "pending";
const writeModeStorage = storage => {
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) => {
@@ -35,18 +28,11 @@ const writeModeStorage = (storage, receipt) => {
try {
// game.putDB resolves at request success, which is before commit.
transaction = lib.db.transaction(["data"], "readwrite");
transaction.oncomplete = () => {
receipt.storageStatus = "committed";
diagnostics.info("transaction.committed", receipt);
resolve();
};
transaction.oncomplete = () => resolve();
transaction.onerror = event => {
requestError = event.target?.error;
};
transaction.onabort = () => {
receipt.storageStatus = "aborted";
reject(transaction.error || requestError || new Error("梦三存档事务已中止"));
};
transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三存档事务已中止"));
transaction.objectStore("data").put(storage, MODE_ID);
}
catch (error) {
@@ -59,39 +45,18 @@ const writeModeStorage = (storage, receipt) => {
});
};
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);
const persistModeStorage = (update = () => {}) => {
// 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, receipt);
receipt.phase = "memory.publish";
await writeModeStorage(storage);
lib.storage = storage;
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);
});
}).finally(() => game.reload2());
modeStorageQueue = operation.catch(() => {});
return operation;
};
@@ -102,11 +67,9 @@ 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);
@@ -124,14 +87,13 @@ const clearRun = async () => {
return Object.freeze({
read: copyStorage,
get lastWrite() { return lastWrite ? { ...lastWrite } : null; },
saveRun, clearRun,
async update(mutator, context = {}) {
async update(mutator) {
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);
},
});
-3
View File
@@ -2,7 +2,6 @@ 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);
@@ -34,8 +33,6 @@ 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);
+30 -43
View File
@@ -132,26 +132,15 @@ 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);
@@ -190,13 +179,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);
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 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);
const detailPanel = element("section", "ms-deck-detail", null, body);
detailPanel.hidden = true;
let catalogRecords = null, descriptionCache = new WeakMap();
virtualGrid = createVirtualCardGrid(body, list, createTile);
if (!options.sections) virtualGrid = createVirtualCardGrid(body, list, createTile);
function back() {
detail = false; detailPanel.hidden = true; list.hidden = false;
detailPanel.replaceChildren(); body.scrollTop = scrollTop; fitCards();
@@ -225,13 +214,12 @@ export function openCardLibrary(run, options = {}) {
body.scrollTop = 0; backButton.focus();
}
function fitCards() {
if (closed || !body.clientHeight) return;
if (options.sections || closed || !body.clientHeight) return;
const padding = getComputedStyle(body);
const available = body.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
const rows = available >= 264 ? 2 : 1;
const height = Math.max(128, Math.min(218, Math.floor((available - (rows - 1) * 8) / rows)));
const height = Math.max(64, Math.min(218, Math.floor((available - 8) / 2)));
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 - (orderedView ? 104 : 80)) * 5 / 7)))) + "px");
dialog.style.setProperty("--ms-card-art-width", String(Math.max(8, Math.min(98, Math.floor((height - 80) * 5 / 7)))) + "px");
virtualGrid?.layout(height);
}
function createTile(record, index) {
@@ -252,6 +240,7 @@ 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";
@@ -259,22 +248,8 @@ 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 = [], 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) {
let records = [];
if (catalog) {
const query = search.value.trim().toLocaleLowerCase();
catalogRecords ||= getCardCatalog().map(card => {
const details = describeCatalogCard(card);
@@ -298,11 +273,24 @@ export function openCardLibrary(run, options = {}) {
}
footer.textContent = catalog ? "图鉴仅供查阅,不会获得卡牌或改变征程牌组 · 点击卡牌查看完整介绍" :
"点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序";
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);
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));
});
}
fitCards();
}
dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); });
dialog.addEventListener("close", close);
@@ -312,10 +300,10 @@ export function openCardLibrary(run, options = {}) {
document.body.appendChild(dialog);
try { dialog.showModal(); } catch (error) { close(); throw error; }
fitCards();
if (typeof ResizeObserver !== "undefined") {
if (!options.sections && typeof ResizeObserver !== "undefined") {
sizeObserver = new ResizeObserver(fitCards);
sizeObserver.observe(body);
} else if (typeof window !== "undefined") {
} else if (!options.sections && typeof window !== "undefined") {
window.addEventListener?.("resize", fitCards);
}
header.querySelector("button").focus();
@@ -339,7 +327,6 @@ 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 },
-9
View File
@@ -1,17 +1,8 @@
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}`);
+3 -26
View File
@@ -7,9 +7,7 @@ import { createHandInput } from "./input.js";
export async function mountHandUI(player, session, env) {
const { ui, document, window, cardCost, styleURL } = env;
const adapter = createHandAdapter(player, ui);
const overlays = createHandOverlays(document, card => cardCost(card, player), {
definitions: env.lib?.card, translations: env.lib?.translate,
});
const overlays = createHandOverlays(document, card => cardCost(card, player));
const fan = createHandFan(adapter, env);
let input = null;
const restores = [];
@@ -20,7 +18,6 @@ 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) => {
@@ -73,15 +70,6 @@ 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));
@@ -130,11 +118,6 @@ 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() {
@@ -169,11 +152,6 @@ 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();
@@ -221,7 +199,7 @@ export async function mountHandUI(player, session, env) {
listen(container, "touchend", () => restoreTouchScroll(), true);
listen(container, "touchcancel", () => restoreTouchScroll(), true);
}
observer = new window.MutationObserver(scheduleRefresh);
observer = new window.MutationObserver(refresh);
for (const zone of adapter.zones) {
observer.observe(zone, { childList: true, subtree: true,
attributes: true, attributeFilter: ["class"] });
@@ -231,7 +209,7 @@ export async function mountHandUI(player, session, env) {
observer.observe(adapter.root.parentNode, { childList: true });
}
if (window.ResizeObserver && adapter.root) {
resizeObserver = new window.ResizeObserver(scheduleRefresh);
resizeObserver = new window.ResizeObserver(refresh);
resizeObserver.observe(adapter.root);
}
if (window.PointerEvent && adapter.root) {
@@ -241,7 +219,6 @@ 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 };
+1 -20
View File
@@ -2,30 +2,13 @@ import { cardUpgradeLevel } from "../../cards/upgrades.js";
import { isXCostCard } from "../../battle/combat-rules.js";
import { applyCardRarity } from "../../cards/rarity.js";
export function createHandOverlays(document, cardCost, { definitions, translations } = {}) {
export function createHandOverlays(document, cardCost) {
const entries = new Map();
const setData = (node, key, value) => {
if (node.dataset[key] !== value) node.dataset[key] = value;
};
function refresh(card) {
if (!card.name) return;
// Opt in only the mode's registered complete illustrations; native card skins stay native.
// Re-evaluate after card.init/transformation, without adding child text for Decade UI to scan.
const definition = definitions?.[card.name];
const localArt = definition?.fullimage === true &&
definition.image === `ext:术樱包/mengsan/assets/cards/${card.name}.png`;
if (localArt) {
setData(card, "mengsanLocalArt", "full");
const title = translations?.[card.name];
if (typeof title === "string" && Array.from(title).length >= 4) {
setData(card, "mengsanLongName", "true");
} else {
delete card.dataset.mengsanLongName;
}
} else {
delete card.dataset.mengsanLocalArt;
delete card.dataset.mengsanLongName;
}
let entry = entries.get(card);
if (!entry) {
const cost = document.createElement("span");
@@ -75,8 +58,6 @@ export function createHandOverlays(document, cardCost, { definitions, translatio
entry.upgrade.remove();
entry.border?.remove();
entry.rarity.remove();
delete card.dataset.mengsanLocalArt;
delete card.dataset.mengsanLongName;
delete card.dataset.mengsanRarity;
delete card.dataset.mengsanRarityLabel;
for (const key of ["accent", "dark", "ink", "metal"]) card.style.removeProperty(`--ms-rarity-${key}`);
+138 -55
View File
@@ -187,12 +187,12 @@ body.mengsan-hand-ui-shuying :is(
}
}
/* 按实际卡牌定义标记本地完整立绘,不再维护牌名清单;只协调梦三手牌原生节点。 */
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) {
/* 梦三本地竖幅立绘:刘备及四类共享牌统一使用完整卡面;只协调梦三手牌里的原生显示节点。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):not(.infohidden):not(.infoflip) {
background-size: cover !important;
background-position: center !important;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
display: block !important;
top: auto !important;
bottom: 8px !important;
@@ -214,14 +214,14 @@ body.mengsan-hand-ui-shuying :is(
z-index: 7 !important;
pointer-events: none;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) br {
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) br {
display: none;
}
/* 十周年名称有文字时优先使用;空节点不能遮掉原生名称。 */
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip):has(> .top-name:not(:empty)) > .name {
/* 十周年同时保留竖排名称与 top-name 时,只显示后者。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):not(.infohidden):not(.infoflip):has(> .top-name) > .name {
display: none !important;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
top: 6px !important;
left: 6px !important;
right: auto !important;
@@ -241,17 +241,17 @@ body.mengsan-hand-ui-shuying :is(
pointer-events: none;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"] > .image,
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"] > .info:empty {
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]) > .image,
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]) > .info:empty {
display: none !important;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:is([data-suit="heart"], [data-suit="diamond"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):is([data-suit="heart"], [data-suit="diamond"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
color: #a52219 !important;
}
/* 强化卡牌的完整卡面样式优先级更高,窄屏同样为角标留出空隙。 */
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip):has(> .mengsan-hand-upgrade-shuying) > .suit-num {
.mengsan-hand-zone-shuying > .card:is([data-card-name^="mengsan_ic_"], [data-card-name^="mengsan_colorless_"], [data-card-name^="mengsan_event_"], [data-card-name^="mengsan_status_"], [data-card-name^="mengsan_curse_"], [data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_wuzhuang"], [data-card-name="mengsan_zhanli"], [data-card-name="mengsan_xueqiang"], [data-card-name="mengsan_qiling"], [data-card-name="mengsan_yuanhen"], [data-card-name="mengsan_baozou"], [data-card-name="mengsan_xuanfengzhan"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"], [data-card-name="mengsan_wound_shuying"]):not(.infohidden):not(.infoflip):has(> .mengsan-hand-upgrade-shuying) > .suit-num {
top: 4px !important;
padding: 0 2px !important;
font-size: 18px !important;
@@ -259,58 +259,17 @@ body.mengsan-hand-ui-shuying :is(
}
}
/* 本地完整卡面不绘制空标题或重复的花色信息条。 */
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name):empty {
display: none !important;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:has(> .suit-num:not(:empty)) > .info:not(.gaintag) {
display: none !important;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > .suit-num > .suit {
font-size: inherit !important;
line-height: inherit;
}
/* 保留原生词缀内容,覆盖十周年 fullimage 的字号归零,避开费用和名称。 */
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > .gaintag.info:not(:empty) {
display: block !important;
top: auto !important;
bottom: 36px !important;
left: 31px !important;
right: 5px !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
padding: 2px 3px !important;
box-sizing: border-box;
font-size: 12px !important;
line-height: 14px !important;
letter-spacing: 0 !important;
white-space: normal !important;
overflow-wrap: anywhere;
overflow: visible !important;
background: #231b17e6 !important;
color: #fff1cb !important;
text-shadow: none !important;
border: 1px solid #d8ba83 !important;
border-radius: 3px;
z-index: 6 !important;
pointer-events: none;
}
/* 长牌名由翻译长度自动标记,新增牌不需要再抄写 CSS 名单。 */
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"][data-mengsan-long-name="true"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
/* 长牌名完整显示,不挤出卡面;只作用于梦三手牌。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_ic_ashen_strike"], [data-card-name="mengsan_ic_fight_me"], [data-card-name="mengsan_ic_demonic_shield"], [data-card-name="mengsan_ic_forgotten_ritual"], [data-card-name="mengsan_ic_burning_pact"], [data-card-name="mengsan_ic_second_wind"], [data-card-name="mengsan_ic_pacts_end"], [data-card-name="mengsan_ic_fiend_fire"], [data-card-name="mengsan_ic_impervious"], [data-card-name="mengsan_ic_not_yet"], [data-card-name="mengsan_ic_demon_form"], [data-card-name="mengsan_ic_pyre"], [data-card-name="mengsan_ic_crimson_mantle"], [data-card-name="mengsan_ic_flame_barrier"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 12px !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
}
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"][data-mengsan-long-name="true"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_ic_ashen_strike"], [data-card-name="mengsan_ic_fight_me"], [data-card-name="mengsan_ic_demonic_shield"], [data-card-name="mengsan_ic_forgotten_ritual"], [data-card-name="mengsan_ic_burning_pact"], [data-card-name="mengsan_ic_second_wind"], [data-card-name="mengsan_ic_pacts_end"], [data-card-name="mengsan_ic_fiend_fire"], [data-card-name="mengsan_ic_impervious"], [data-card-name="mengsan_ic_not_yet"], [data-card-name="mengsan_ic_demon_form"], [data-card-name="mengsan_ic_pyre"], [data-card-name="mengsan_ic_crimson_mantle"], [data-card-name="mengsan_ic_flame_barrier"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 10px !important;
padding: 2px 4px !important;
}
.mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > .gaintag.info:not(:empty) {
font-size: 10px !important;
line-height: 12px !important;
}
}
/* Infection only; keep cost/name above it and leave native card::after alone. */
@@ -373,6 +332,130 @@ body.mengsan-hand-ui-shuying :is(
}
}
/* 第二批刘备牌组长牌名,沿用现有手牌字号。 */
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_ic_relentless_assault"], [data-card-name="mengsan_ic_howl_from_beyond"], [data-card-name="mengsan_ic_battle_trance"], [data-card-name="mengsan_ic_infernal_blade"], [data-card-name="mengsan_ic_eager"], [data-card-name="mengsan_ic_feel_no_pain"], [data-card-name="mengsan_ic_stone_armor"], [data-card-name="mengsan_ic_primal_force"], [data-card-name="mengsan_ic_battle_ready"], [data-card-name="mengsan_ic_hellraiser"], [data-card-name="mengsan_ic_dark_embrace"], [data-card-name="mengsan_ic_unshakable"], [data-card-name="mengsan_ic_juggernaut"], [data-card-name="mengsan_ic_rally"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 12px !important; letter-spacing: 0 !important; white-space: nowrap !important;
}
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_ic_relentless_assault"], [data-card-name="mengsan_ic_howl_from_beyond"], [data-card-name="mengsan_ic_battle_trance"], [data-card-name="mengsan_ic_infernal_blade"], [data-card-name="mengsan_ic_eager"], [data-card-name="mengsan_ic_feel_no_pain"], [data-card-name="mengsan_ic_stone_armor"], [data-card-name="mengsan_ic_primal_force"], [data-card-name="mengsan_ic_battle_ready"], [data-card-name="mengsan_ic_hellraiser"], [data-card-name="mengsan_ic_dark_embrace"], [data-card-name="mengsan_ic_unshakable"], [data-card-name="mengsan_ic_juggernaut"], [data-card-name="mengsan_ic_rally"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 10px !important; padding: 2px 4px !important;
}
}
/* 四类共享牌的长名称与刘备牌组采用相同字号,窄屏仍完整显示。 */
.mengsan-hand-zone-shuying > .card:is(
[data-card-name="mengsan_colorless_dramatic_entrance"],
[data-card-name="mengsan_colorless_ultimate_strike"],
[data-card-name="mengsan_colorless_gang_up"],
[data-card-name="mengsan_colorless_seeker_strike"],
[data-card-name="mengsan_colorless_thrumming_hatchet"],
[data-card-name="mengsan_colorless_mind_blast"],
[data-card-name="mengsan_colorless_tag_team"],
[data-card-name="mengsan_colorless_dark_shackles"],
[data-card-name="mengsan_colorless_jack_of_all_trades"],
[data-card-name="mengsan_colorless_thinking_ahead"],
[data-card-name="mengsan_colorless_production"],
[data-card-name="mengsan_colorless_believe_in_you"],
[data-card-name="mengsan_colorless_restlessness"],
[data-card-name="mengsan_colorless_panic_button"],
[data-card-name="mengsan_colorless_ultimate_defend"],
[data-card-name="mengsan_colorless_coordinate"],
[data-card-name="mengsan_colorless_panache"],
[data-card-name="mengsan_colorless_prowess"],
[data-card-name="mengsan_colorless_prep_time"],
[data-card-name="mengsan_colorless_hand_of_greed"],
[data-card-name="mengsan_colorless_the_gambit"],
[data-card-name="mengsan_colorless_secret_technique"],
[data-card-name="mengsan_colorless_secret_weapon"],
[data-card-name="mengsan_colorless_master_of_strategy"],
[data-card-name="mengsan_colorless_alchemize"],
[data-card-name="mengsan_colorless_scrawl"],
[data-card-name="mengsan_colorless_hidden_gem"],
[data-card-name="mengsan_colorless_beacon_of_hope"],
[data-card-name="mengsan_colorless_eternal_armor"],
[data-card-name="mengsan_event_byrd_swoop"],
[data-card-name="mengsan_event_mad_science_sapping"],
[data-card-name="mengsan_event_mad_science_violence"],
[data-card-name="mengsan_event_mad_science_choking"],
[data-card-name="mengsan_event_feeding_frenzy"],
[data-card-name="mengsan_event_mad_science_energized"],
[data-card-name="mengsan_event_mad_science_wisdom"],
[data-card-name="mengsan_event_mad_science_chaos"],
[data-card-name="mengsan_event_toric_toughness"],
[data-card-name="mengsan_event_mad_science_expertise"],
[data-card-name="mengsan_event_mad_science_curious"],
[data-card-name="mengsan_event_mad_science_improvement"],
[data-card-name="mengsan_event_neows_fury"],
[data-card-name="mengsan_event_brightest_flame"],
[data-card-name="mengsan_status_frantic_escape"],
[data-card-name="mengsan_curse_spore_mind"],
[data-card-name="mengsan_curse_ascenders_bane"],
[data-card-name="mengsan_curse_curse_of_the_bell"],
[data-card-name="mengsan_curse_poor_sleep"],
[data-card-name="mengsan_status_deprecated_card"]
):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 12px !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
}
@media (max-width: 640px), (max-height: 500px) {
.mengsan-hand-zone-shuying > .card:is(
[data-card-name="mengsan_colorless_dramatic_entrance"],
[data-card-name="mengsan_colorless_ultimate_strike"],
[data-card-name="mengsan_colorless_gang_up"],
[data-card-name="mengsan_colorless_seeker_strike"],
[data-card-name="mengsan_colorless_thrumming_hatchet"],
[data-card-name="mengsan_colorless_mind_blast"],
[data-card-name="mengsan_colorless_tag_team"],
[data-card-name="mengsan_colorless_dark_shackles"],
[data-card-name="mengsan_colorless_jack_of_all_trades"],
[data-card-name="mengsan_colorless_thinking_ahead"],
[data-card-name="mengsan_colorless_production"],
[data-card-name="mengsan_colorless_believe_in_you"],
[data-card-name="mengsan_colorless_restlessness"],
[data-card-name="mengsan_colorless_panic_button"],
[data-card-name="mengsan_colorless_ultimate_defend"],
[data-card-name="mengsan_colorless_coordinate"],
[data-card-name="mengsan_colorless_panache"],
[data-card-name="mengsan_colorless_prowess"],
[data-card-name="mengsan_colorless_prep_time"],
[data-card-name="mengsan_colorless_hand_of_greed"],
[data-card-name="mengsan_colorless_the_gambit"],
[data-card-name="mengsan_colorless_secret_technique"],
[data-card-name="mengsan_colorless_secret_weapon"],
[data-card-name="mengsan_colorless_master_of_strategy"],
[data-card-name="mengsan_colorless_alchemize"],
[data-card-name="mengsan_colorless_scrawl"],
[data-card-name="mengsan_colorless_hidden_gem"],
[data-card-name="mengsan_colorless_beacon_of_hope"],
[data-card-name="mengsan_colorless_eternal_armor"],
[data-card-name="mengsan_event_byrd_swoop"],
[data-card-name="mengsan_event_mad_science_sapping"],
[data-card-name="mengsan_event_mad_science_violence"],
[data-card-name="mengsan_event_mad_science_choking"],
[data-card-name="mengsan_event_feeding_frenzy"],
[data-card-name="mengsan_event_mad_science_energized"],
[data-card-name="mengsan_event_mad_science_wisdom"],
[data-card-name="mengsan_event_mad_science_chaos"],
[data-card-name="mengsan_event_toric_toughness"],
[data-card-name="mengsan_event_mad_science_expertise"],
[data-card-name="mengsan_event_mad_science_curious"],
[data-card-name="mengsan_event_mad_science_improvement"],
[data-card-name="mengsan_event_neows_fury"],
[data-card-name="mengsan_event_brightest_flame"],
[data-card-name="mengsan_status_frantic_escape"],
[data-card-name="mengsan_curse_spore_mind"],
[data-card-name="mengsan_curse_ascenders_bane"],
[data-card-name="mengsan_curse_curse_of_the_bell"],
[data-card-name="mengsan_curse_poor_sleep"],
[data-card-name="mengsan_status_deprecated_card"]
):not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 10px !important;
padding: 2px 4px !important;
}
}
/* 稀有度是独立装饰层,不占用原生 card::after,也不改变尺寸、命中区或立绘。 */
.mengsan-hand-rarity-shuying { display: none; pointer-events: none; }
.mengsan-hand-zone-shuying > .card[data-mengsan-rarity]:not([data-mengsan-rarity="pending"]):not(.infohidden):not(.infoflip) > .mengsan-hand-rarity-shuying {
+4 -68
View File
@@ -2,56 +2,6 @@
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) {
@@ -110,9 +60,9 @@ export function mountEnemyIntent(player, session, assetBase, document,
badge.append(damageLine, debuffLine, blockLine, buffLine);
player.appendChild(badge);
let currentIntent = null;
let previous = null, unsubscribe = () => {};
let timer;
session.ownResource(badge, () => {
unsubscribe(); previous = null; currentIntent = null;
clearInterval(timer);
badge.remove();
});
const refresh = () => {
@@ -230,23 +180,9 @@ export function mountEnemyIntent(player, session, assetBase, document,
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
badge.hidden = false;
};
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);
timer = setInterval(refresh, 100);
return intent => {
currentIntent = intent;
previous = null;
poll();
refresh();
};
}
+3 -23
View File
@@ -1,11 +1,10 @@
import { openCardLibrary } from "../card-library.js";
import { askMenu, openModeSelection, openNonameMenu } from "../navigation.js";
import { askMenu, openModeSelection } from "../navigation.js";
import { ui, get } from "../../../../../noname.js";
import config from "../../config.js";
import { getRelic } from "../../relics/definitions.js";
import { renderBonds } from "../bonds.js";
import { getSelectableNodes } from "../../progression/state.js";
import { getDiagnostics } from "../../../diagnostics/index.js";
// Original vector pictograms; no external image/font requests.
const iconPaths = {
@@ -187,8 +186,6 @@ 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; }
});
@@ -260,9 +257,7 @@ 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, {
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
}); });
library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run); });
parent.appendChild(library);
const appendNames = (title, values, emptyText) => {
createText("mengsan-map-inventory-section-title-shuying", title, parent);
@@ -311,10 +306,7 @@ 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: "梦三 · 图鉴",
onClose(handle) { if (closeCardLibrary === handle) closeCardLibrary = null; },
}); }
onClick: () => { closeCardLibrary = openCardLibrary(run, { view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴" }); }
}).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);
@@ -325,18 +317,6 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
});
}) });
createMenuAction({ icon: "settings", name: "设置与说明", description: "显示、音效与玩法说明 · 开发中", onClick: () => showMenuPage("设置与说明", "个性化与帮助", parent => renderPlaceholder(parent, "后续将在这里提供显示、音效和操作设置。")) });
createMenuAction({ icon: "settings", name: "显示 Noname 菜单", description: "打开无名杀原生菜单,管理扩展与游戏设置",
onClick: async event => {
event.stopPropagation();
setMenuOpen(false);
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: "回到行军图" }]);
}
},
}).setAttribute("aria-haspopup", "dialog");
}
function handleEscape(event) {
+4 -72
View File
@@ -1,69 +1,7 @@
import { lib, game, ui, _status } from "../../../../noname.js";
import { lib, game, ui } from "../../../../noname.js";
import { save } from "../../../../noname/util/config.js";
import { getDiagnostics } from "../../diagnostics/index.js";
let selectingMode = false;
let nonameMenuTask = null;
// Open the engine's real settings menu without ending the current expedition flow.
export const openNonameMenu = async () => {
if (nonameMenuTask) return nonameMenuTask;
const container = ui.menuContainer;
if (!container?.isConnected || typeof ui.click?.config !== "function"
|| typeof ui.click?.configMenu !== "function") {
throw new Error("Noname 菜单尚未加载,请稍后重试。");
}
nonameMenuTask = new Promise((resolve, reject) => {
const previousFocus = document.activeElement;
const initiallyHidden = container.classList.contains("hidden");
const wasPaused = _status.paused2;
const layerClass = "mengsan-noname-menu-open-shuying";
const hadLayerClass = ui.window.classList.contains(layerClass);
const previousTabindex = container.getAttribute("tabindex");
const overlays = Array.from(ui.window.querySelectorAll(".mengsan-overlay-shuying"))
.map(node => ({ node, inert: node.inert }));
let closed = false;
const finish = error => {
if (closed) return;
closed = true;
observer.disconnect();
if (!hadLayerClass) ui.window.classList.remove(layerClass);
for (const { node, inert } of overlays) node.inert = inert;
if (previousTabindex == null) container.removeAttribute("tabindex");
else container.setAttribute("tabindex", previousTabindex);
if (previousFocus?.isConnected) previousFocus.focus?.({ preventScroll: true });
if (error) reject(error);
else resolve();
};
const observer = new MutationObserver(() => {
if (!container.isConnected || container.classList.contains("hidden")) finish();
});
try {
observer.observe(container, { attributes: true, attributeFilter: ["class"] });
observer.observe(ui.window, { childList: true });
ui.window.classList.add(layerClass);
for (const { node } of overlays) node.inert = true;
if (initiallyHidden) ui.click.config();
if (container.classList.contains("hidden")) throw new Error("Noname 菜单未能打开,请稍后重试。");
container.setAttribute("tabindex", "-1");
container.focus({ preventScroll: true });
} catch (error) {
if (initiallyHidden) {
try {
if (!container.classList.contains("hidden")) ui.click.configMenu();
} catch (closeError) {
getDiagnostics().scope("mengsan.navigation").error("menu.close.failed", closeError);
console.error("Noname 菜单关闭失败:", closeError);
}
if (wasPaused) game.pause2();
else game.resume2();
}
finish(error);
}
});
try { await nonameMenuTask; }
finally { nonameMenuTask = null; }
};
// Reuse the registered engine splash, not reload-to-the-current-mode or a fake mode list.
export const openModeSelection = async () => {
@@ -158,14 +96,11 @@ export const mountMenu = (overlay, options = {}) => {
if (opened) return;
opened = true;
options.onOpen?.();
let action = null;
try {
action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [
const action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [
{ id: "resume", name: "继续当前界面" },
{ id: "noname", name: "显示 Noname 菜单" },
{ id: "exit", name: "返回模式选择" },
]);
if (action === "noname") { await openNonameMenu(); return; }
if (action !== "exit") return;
const confirmed = await askMenu("返回模式选择?", "保留上次已完成节点的存档。本次尚未完成的内容不会提交。", [
{ id: "cancel", name: "留在梦三" },
@@ -173,11 +108,8 @@ 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: "回到当前界面" }]);
console.error("梦三返回模式选择失败:", error);
await askMenu("暂时无法返回", "没有清除征程。请关闭提示后重试。", [{ id: "close", name: "回到当前界面" }]);
} finally {
opened = false;
options.onClose?.();
-4
View File
@@ -1,7 +1,3 @@
/* Only elevate the engine menu and its dropdowns while opened from Mengsan. */
.mengsan-noname-menu-open-shuying > .menu-container { z-index: 120 !important; }
.mengsan-noname-menu-open-shuying > .popup-container { z-index: 121 !important; }
.mengsan-overlay-shuying {
position: fixed;
inset: 0;
+3 -9
View File
@@ -1,8 +1,6 @@
// 保留完整滚动高度,只挂载可见行与上下各两行;滚动时不重建仍可见的卡牌。
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() {
@@ -16,14 +14,11 @@ 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 = cached.get(index) || createCard(records[index], index);
cached.delete(index);
const node = createCard(records[index], index);
node.dataset.cardIndex = String(index);
node.style.gridRow = String(Math.floor(index / columns) + 1);
node.style.gridColumn = String(index % columns + 1);
@@ -80,7 +75,7 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
return {
setRecords(value) {
// 调用方更新筛选栏后再统一量尺寸,避免用旧高度先生成一批预览。
records = value; mounted.clear(); cached.clear(); grid.replaceChildren();
records = value; mounted.clear(); grid.replaceChildren();
},
layout,
focus,
@@ -89,8 +84,7 @@ export function createVirtualCardGrid(viewport, grid, createCard) {
disposed = true; cancelAnimationFrame(frame);
viewport.removeEventListener("scroll", schedule);
grid.removeEventListener("keydown", keydown);
mounted.clear(); cached.clear(); records = [];
grid.replaceChildren();
mounted.clear(); records = [];
},
};
}
+33 -138
View File
@@ -206,38 +206,22 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
nopointer: true,
};
const channelText = channel => `更新通道:<b>${channel == "preview" ? "测试版" : "正式版"}</b>(点击选择)`;
const channelText = channel => `更新通道:${channel == "preview" ? "测试版" : "正式版"}(点击切换)`;
menu.update_channel = {
name: channelText(updater.getUpdateChannel()),
clear: true,
intro: "打开选择窗口,确认后下载所选通道的更新,完成后需重启游戏。",
intro: "切换后立即从当前下载源的对应标签下载并校验文件,完成后需重启游戏。测试版可能回退到较早的内容。",
async onclick() {
if (!shuYing.m_bIsDownload) {
alert("正在处理其他操作,请稍后再试。");
alert("有其他文件正在下载,请稍后再试吧。");
return;
}
const current = updater.getUpdateChannel();
const next = current == "preview" ? "stable" : "preview";
shuYing.m_bIsDownload = false;
this.innerHTML = `正在切换到${next == "preview" ? "测试版" : "正式版"}…`;
try {
const next = await shuYing.chooseSingleOptionDialog({
title: "选择更新通道",
intro: "确认后会下载所选通道的更新,完成后需重启游戏。测试版的版本可能早于当前正式版。",
defaultKey: current, currentKey: current,
cancelable: true, variant: "settings", confirmText: "切换并更新",
options: [
{
key: "stable", name: "正式版", tag: current == "stable" ? "当前使用" : "日常游玩",
description: "接收正式发布的版本,适合日常游玩。"
},
{
key: "preview", name: "测试版", tag: current == "preview" ? "当前使用" : "提前体验",
description: "提前体验新内容,可能包含尚未完善的功能。"
},
],
});
if (!next || next == current) return;
this.innerHTML = `正在切换到${next == "preview" ? "测试版" : "正式版"}…`;
const version = await updater.checkVersion(shuYing, {
channel: next,
switchChannel: true,
@@ -251,53 +235,29 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
const text = channelText(updater.getUpdateChannel());
menu.update_channel.name = text;
this.innerHTML = text;
shuYing.m_bIsDownload = true;
}
},
};
const sourceText = source => `下载源:<b>${source == "github" ? "GitHub" : "Gitea"}</b>(点击选择)`;
const sourceText = source => `下载源:${source == "github" ? "GitHub" : "Gitea"}(点击切换)`;
menu.update_source = {
name: sourceText(updater.getUpdateSource()),
clear: true,
intro: "打开下载源选择窗口;确认后刷新在线版本,不会立即下载文件。",
async onclick() {
intro: "选择从 GitHub 或 Gitea 获取标签与文件;切换后刷新在线版本,实际下载由版本检测触发。",
onclick() {
if (!shuYing.m_bIsDownload) {
alert("正在处理其他操作,请稍后再试。");
alert("有其他文件正在下载,请稍后再试吧。");
return;
}
const current = updater.getUpdateSource();
shuYing.m_bIsDownload = false;
try {
const next = await shuYing.chooseSingleOptionDialog({
title: "选择下载源",
intro: "确认后保存下载源并刷新在线版本,不会立即下载文件。需要更新时,请点击“版本检测”。",
defaultKey: current, currentKey: current,
cancelable: true, variant: "settings", confirmText: "保存下载源",
options: [
{
key: "gitea", name: "Gitea", tag: current == "gitea" ? "当前使用" : "默认下载源",
description: "从 Gitea 获取版本信息和更新文件(国内源,下载速度上限1.4M)"
},
{
key: "github", name: "GitHub", tag: current == "github" ? "当前使用" : "可选下载源",
description: "从 GitHub 获取版本信息和更新文件。"
},
],
});
if (!next || next == current) return;
game.saveConfig("shuYing_update_source", next);
game.saveConfig("shuYing_online_version", null);
shuYing.updateOnlineVersionMenu(null, "检测中...");
void shuYing.getVersion();
}
finally {
const text = sourceText(updater.getUpdateSource());
menu.update_source.name = text;
this.innerHTML = text;
shuYing.m_bIsDownload = true;
}
const next = updater.getUpdateSource() == "github" ? "gitea" : "github";
game.saveConfig("shuYing_update_source", next);
const text = sourceText(next);
menu.update_source.name = text;
this.innerHTML = text;
game.saveConfig("shuYing_online_version", null);
shuYing.updateOnlineVersionMenu(null, "检测中...");
shuYing.getVersion();
},
};
@@ -313,25 +273,21 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
}
const logs = [
"-----< 2.0.2.3 改动 >-----",
"新增正式版、测试版更新通道切换。",
"支持选择 Gitea 或 GitHub 下载源。",
"更新系统采用并发下载:安卓最多 4 个、PC 最多 8 个,大文件受内存限制。",
"新增梦三模式安装入口",
"-----< 2.0.2.3 修复 >-----",
"修复天书乱斗跨关后,按轮数冷却的技能未正确重置,以及旧关卡历史影响新关卡计数的问题。",
"修复【清剿】旧关卡的延迟弃牌结算可能弃掉新关卡奖励的问题。",
"修复天书乱斗应战时的座位重叠、动态 Boss 控制切换、代理头像显示及胜负判断问题。",
"修正“神武再世”三级羁绊的体力流失统计,按目标本回合累计流失的体力判定。",
"修复切换下载源后,旧版本查询结果覆盖新结果的问题。",
"核心文件修复增加大小和 SHA-256 校验。",
"-----< 2.0.1.1 改动 >-----",
"新增“旧轮次”选项,可在过关后按照旧规则将行动权交给玩家方1号位上家的Boss",
"新增“羁绊技能倾向”选项,已激活羁绊的相关技能将按羁绊等级获得温和的出现概率提升",
"羁绊技能倾向权重现可在天书配置中按等级独立调整",
"新增“虚拟偶像参与奖励”选项,开启后虚拟偶像可以选择过关技能并激活、升级羁绊效果",
"-----< 2.0.1.1 修复 >-----",
"修复多次触发青龙偃月刀追加【杀】并击杀Boss后,过关奖励结束时无法正确跳过当前角色剩余回合的问题",
"修复旧轮次在新Boss召唤前提前判断座位,导致行动权交接条件无法正常触发的问题",
"修复当前行动角色为虚拟偶像时,旧轮次无法将行动权交给玩家方1号位上家Boss的问题",
"修复“南蛮入侵”和“群雄逐鹿”羁绊虽然对友方无效,但AI仍会将友方计入用牌收益而拒绝使用相关牌的问题",
];
const keywordColors = {
BOSS: "#ff6b6b",
PVE: "#ff6b6b",
PVP: "#ff6b6b",
正式版: "#ff6b6b",
测试版: "#ff99cc",
天书乱斗: "#66ccff",
羁绊: "#d77cff",
动态难度: "#d77cff",
@@ -383,79 +339,18 @@ 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: ["检测中", "请稍候"],
installed: ["已安装", "点击卸载"],
disabled: ["未安装", "点击安装"],
installing: ["安装未完成", "点击修复"],
repair: ["待修复", "点击修复"],
uninstalling: ["卸载未完成", "继续卸载"],
unknown: ["状态未确认", "点击检查"],
working: ["处理中", "请稍候"],
};
const mengsanLabelHTML = status => {
const [label, action] = mengsanStates[status] || mengsanStates.unknown;
return `梦三模式:<b>${label}</b>(${action})`;
};
let mengsanLabelNode = null;
let mengsanStatusRequest = 0;
let mengsanModuleOperating = false;
const renderMengsanStatus = status => {
const content = mengsanLabelHTML(status);
menu.mengsanModule.name = `<span id="${mengsanLabelId}" style="white-space:normal;">${content}</span>`;
// Keep the existing node so closing the menu during a download still refreshes it.
mengsanLabelNode = document.getElementById(mengsanLabelId) || mengsanLabelNode;
if (mengsanLabelNode) mengsanLabelNode.innerHTML = content;
};
shuYing.updateMengsanModuleMenu = async () => {
const request = ++mengsanStatusRequest;
mengsanLabelNode = document.getElementById(mengsanLabelId) || mengsanLabelNode;
if (mengsanModuleOperating) { renderMengsanStatus("working"); return; }
let status;
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);
};
menu.mengsanModule = {
name: `<span id="${mengsanLabelId}" style="white-space:normal;">${mengsanLabelHTML("checking")}</span>`,
name: "梦三模式(按需安装/卸载)",
clear: true,
async onclick() {
if (!shuYing.m_bIsDownload) {
onclick() {
if (shuYing.m_bIsDownload) {
shuYing.m_bIsDownload = false;
updater.manageMengsanModule(shuYing);
} else {
alert("有其他文件正在下载,请稍后再试吧。");
return;
}
shuYing.m_bIsDownload = false;
mengsanModuleOperating = true;
++mengsanStatusRequest; // Discard any pre-operation status lookup still in flight.
renderMengsanStatus("working");
try { await updater.manageMengsanModule(shuYing); }
finally {
mengsanModuleOperating = false;
await shuYing.updateMengsanModuleMenu();
}
},
};
void shuYing.updateMengsanModuleMenu();
menu.repairBug = {
name: "本地资源修复",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 7.4 MiB

-3
View File
@@ -4,9 +4,6 @@
"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",
+150 -464
View File
@@ -5,158 +5,6 @@ const githubRepositoryApi = "https://api.github.com/repos/Shu-Ying/noname-shuyin
const githubRawBase = "https://raw.githubusercontent.com/Shu-Ying/noname-shuying";
const coreTempDir = "extension/术樱包/.update_tmp";
let fileMutationUncertain = false;
const verifiedCacheKey = "shuYing_update_verified_files";
let verifiedFiles = new Map();
let sessionVerified = new Set();
let verificationContext = null;
let verificationDirty = false;
let fullVerification = false;
function cacheContext() {
if (lib.node?.fs && typeof window.__dirname == "string") return `node:${window.__dirname}`;
if (hasCordovaFiles()) return `cordova:${localStorage.getItem("noname_inited")}`;
return null; // The browser file service exposes no reliable modification timestamp.
}
function beginUpdate(shuYing, full = false) {
fullVerification = full;
verificationContext = cacheContext();
verifiedFiles = new Map();
sessionVerified = new Set();
verificationDirty = false;
const saved = lib.config[verifiedCacheKey];
if (verificationContext && saved?.schema == 1 && saved.context == verificationContext
&& saved.files && typeof saved.files == "object" && !Array.isArray(saved.files)) {
for (const [file, entry] of Object.entries(saved.files)) {
if (isSafeManifestPath(file) && entry && typeof entry.sha256 == "string" && /^[a-f0-9]{64}$/.test(entry.sha256)
&& Number.isSafeInteger(entry.size) && entry.size >= 0 && typeof entry.fingerprint == "string") {
verifiedFiles.set(file, { ...entry });
}
}
}
shuYing._updateLogs = [];
shuYing._updateLogDirty = false;
shuYing._updateLogDisabled = false;
shuYing._progressRendered = 0;
shuYing._updateStarted = Date.now();
shuYing._updateStats = { cached: 0, hashed: 0 };
void shuYing.updateMengsanModuleMenu?.();
}
function relativeTarget(target) {
const prefix = "extension/术樱包/";
if (!target.startsWith(prefix)) return null;
const file = target.slice(prefix.length);
return isSafeManifestPath(file) && !file.startsWith(".update_tmp/") && file != "log.txt" ? file : null;
}
function invalidateVerifiedFile(target) {
const file = relativeTarget(target);
if (!file) return;
if (verifiedFiles.delete(file)) verificationDirty = true;
sessionVerified.delete(file);
}
async function persistVerificationCache() {
if (!verificationContext || !verificationDirty) return;
await saveUpdaterConfig({ [verifiedCacheKey]: {
schema: 1, context: verificationContext, files: Object.fromEntries(verifiedFiles),
} });
verificationDirty = false;
}
async function invalidateBeforeChanges(files) {
for (const file of files) invalidateVerifiedFile(`extension/术樱包/${file}`);
// Commit invalidation before mutation, including same-size writes on coarse timestamps.
await persistVerificationCache();
}
function sameMetadata(left, right) {
return !!left && !!right && left.size == right.size && left.fingerprint == right.fingerprint;
}
function rememberVerified(target, info, metadata) {
const file = relativeTarget(target);
if (!verificationContext || !file || !metadata || metadata.size != info.size) return;
verifiedFiles.set(file, { sha256: info.sha256, size: info.size, fingerprint: metadata.fingerprint });
sessionVerified.add(file);
verificationDirty = true;
}
async function verifyLocalFile(file, manifest, shuYing, full = fullVerification) {
const target = `extension/术樱包/${file}`;
const info = manifest.files[file];
try {
const before = await fileMetadata(target);
const cached = verifiedFiles.get(file);
if ((!full || sessionVerified.has(file)) && before && cached
&& cached.sha256 == info.sha256 && cached.size == info.size && sameMetadata(cached, before)) {
if (shuYing?._updateStats) shuYing._updateStats.cached++;
return;
}
invalidateVerifiedFile(target);
await verifyFile(file, await readFile(target), manifest);
const after = await fileMetadata(target);
if (before && !sameMetadata(before, after)) throw new Error(`校验期间文件发生变化:${file}`);
rememberVerified(target, info, sameMetadata(before, after) ? after : null);
if (shuYing?._updateStats) shuYing._updateStats.hashed++;
}
catch (error) {
invalidateVerifiedFile(target);
throw error;
}
}
async function runFilePool(items, handler, options = {}) {
const concurrency = options.concurrency || (lib.device ? 4 : 8);
const budget = lib.device ? 24 * 1024 * 1024 : 64 * 1024 * 1024;
const controller = new AbortController();
const results = new Array(items.length);
let next = 0, active = 0, reserved = 0, failure = null;
return await new Promise((resolve, reject) => {
const fail = error => {
const cause = operationError(error) || new Error("资源任务失败");
if (!failure) { failure = cause; controller.abort(cause); }
if (cause.operationUncertain) failure.operationUncertain = true;
};
const pump = () => {
while (!failure && active < concurrency && next < items.length) {
// Bound estimated working memory; a file exceeding the budget runs alone.
let weight;
try { weight = Math.min(budget, Math.max(1, options.weight?.(items[next]) || 1)); }
catch (error) { fail(error); break; }
if (active && reserved + weight > budget) break;
const index = next++;
active++;
reserved += weight;
Promise.resolve().then(() => handler(items[index], index, controller.signal))
.then(value => { results[index] = value; })
.catch(fail).finally(() => { active--; reserved -= weight; pump(); });
}
if (!active && (failure || next == items.length)) {
if (failure) {
if (fileMutationUncertain) failure.operationUncertain = true;
reject(failure);
}
else resolve(results);
}
};
pump();
});
}
async function finishUpdate(shuYing) {
try {
await persistVerificationCache();
}
catch (error) { addLog(shuYing, `校验缓存保存失败,下次重新检查:${getErrorMessage(error)}`); }
if (shuYing._updateStarted) {
const stats = shuYing._updateStats;
addLog(shuYing, `本次耗时 ${((Date.now() - shuYing._updateStarted) / 1000).toFixed(1)} 秒;缓存通过 ${stats?.cached || 0} 次,完整校验通过 ${stats?.hashed || 0} 次`);
}
await flushLog(shuYing);
await shuYing.updateMengsanModuleMenu?.();
}
function parseVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(version);
@@ -287,13 +135,11 @@ function getLogText(shuYing) {
async function writeLog(shuYing) {
if (!shuYing || typeof game.writeFile != "function") return;
await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt", { diagnostic: true });
await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt");
}
function addLog(shuYing, message) {
async function addLog(shuYing, message) {
if (!shuYing) return;
try { shuYing.diagnostics?.scope("updater").info("update.message", { message }); }
catch {}
if (!Array.isArray(shuYing._updateLogs)) {
shuYing._updateLogs = [];
@@ -301,36 +147,13 @@ function addLog(shuYing, message) {
const time = new Date().toLocaleString();
shuYing._updateLogs.push(`[${time}] ${message}`);
shuYing._updateLogDirty = true;
if (!shuYing._updateLogTimer && !shuYing._updateLogDisabled) {
shuYing._updateLogTimer = setTimeout(() => { void flushLog(shuYing); }, 1000);
}
}
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;
if (shuYing._updateLogWrite) await shuYing._updateLogWrite;
if (!shuYing._updateLogDirty || shuYing._updateLogDisabled) return;
shuYing._updateLogDirty = false;
const operation = writeLog(shuYing).catch(error => {
shuYing._updateLogDisabled = true;
clearTimeout(shuYing._updateLogTimer);
shuYing._updateLogTimer = null;
await writeLog(shuYing);
}
catch (error) {
console.error("写入术樱包日志失败:", error);
recordUpdateError(shuYing, "legacyLog.write.failed", error);
});
shuYing._updateLogWrite = operation;
await operation;
if (shuYing._updateLogWrite == operation) shuYing._updateLogWrite = null;
}
}
function escapeHTML(text) {
@@ -352,26 +175,17 @@ function formatBytes(bytes) {
function setProgress(shuYing, options = {}) {
if (!shuYing?.text) return;
const now = Date.now();
if (!options.force && now - (shuYing._progressRendered || 0) < 120) return;
shuYing._progressRendered = now;
const current = Math.max(0, Number(options.current) || 0);
const total = Math.max(0, Number(options.total) || 0);
const percent = options.totalBytes > 0
? Math.min(100, Math.round((options.bytes || 0) / options.totalBytes * 100))
: total > 0 ? Math.min(100, Math.round(current / total * 100)) : 0;
const percent = total > 0 ? Math.min(100, Math.round(current / total * 100)) : 0;
const title = options.title || "资源更新";
const status = options.status || "准备中";
const file = options.file || "";
const transfer = options.totalBytes > 0
? `${formatBytes(options.bytes || 0)} / ${formatBytes(options.totalBytes)} · ${formatBytes(options.speed || 0)}/秒`
: `${current}/${total}`;
shuYing.text.innerHTML = `
<div style="position:static;box-sizing:border-box;writing-mode:horizontal-tb;text-orientation:mixed;display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;width:100%;">
<div style="position:static;box-sizing:border-box;font-size:16px;font-weight:700;color:#fff;white-space:nowrap;word-break:keep-all;overflow:hidden;text-overflow:ellipsis;min-width:0;">${escapeHTML(title)}</div>
<div style="position:static;box-sizing:border-box;font-size:12px;color:rgba(245,241,232,0.72);white-space:nowrap;word-break:keep-all;flex:0 0 auto;">${escapeHTML(transfer)}</div>
<div style="position:static;box-sizing:border-box;font-size:12px;color:rgba(245,241,232,0.72);white-space:nowrap;word-break:keep-all;flex:0 0 auto;">${current}/${total}</div>
</div>
<div style="position:static;box-sizing:border-box;display:block;width:100%;height:8px;min-height:8px;border-radius:999px;background:linear-gradient(90deg,#6ee7b7 0%,#60a5fa ${percent}%,rgba(255,255,255,0.14) ${percent}%,rgba(255,255,255,0.14) 100%);overflow:hidden;"></div>
<div style="position:static;box-sizing:border-box;writing-mode:horizontal-tb;text-orientation:mixed;display:flex;justify-content:space-between;gap:10px;font-size:12px;color:rgba(245,241,232,0.82);min-width:0;width:100%;">
@@ -397,120 +211,27 @@ function splitPath(path) {
};
}
async function waitForRetry(delay, signal) {
if (signal?.aborted) throw signal.reason || new Error("下载已停止");
await new Promise((resolve, reject) => {
const cancel = () => {
clearTimeout(timer);
signal.removeEventListener("abort", cancel);
reject(signal.reason || new Error("下载已停止"));
};
const timer = setTimeout(() => { signal?.removeEventListener("abort", cancel); resolve(); }, delay);
signal?.addEventListener("abort", cancel, { once: true });
});
async function fetchFile(path, tag, source) {
const response = await fetch(getRemoteUrl(tag, path, source));
if (!response.ok) throw new Error(`${path} 下载失败:${response.status}`);
return await response.arrayBuffer();
}
async function fetchFile(path, tag, source, options = {}) {
for (let attempt = 0; ; attempt++) {
if (options.signal?.aborted) throw options.signal.reason || new Error("下载已停止");
const controller = new AbortController();
const cancel = () => controller.abort(options.signal.reason);
options.signal?.addEventListener("abort", cancel, { once: true });
let timer, reader, timedOut = false;
const resetTimeout = () => {
clearTimeout(timer);
timer = setTimeout(() => { timedOut = true; controller.abort(new Error(`${path} 下载超时`)); }, 60000);
};
options.onProgress?.(0, 0);
try {
resetTimeout();
const response = await fetch(getRemoteUrl(tag, path, source), { signal: controller.signal });
reader = response.body?.getReader?.();
if (!response.ok) {
const error = new Error(`${path} 下载失败:${response.status}`);
error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status);
throw error;
}
if (!reader) {
const data = await response.arrayBuffer();
if (Number.isSafeInteger(options.size) && data.byteLength > options.size) {
throw new Error(`${path} 下载数据超过清单大小`);
}
options.onProgress?.(data.byteLength, data.byteLength);
return data;
}
const chunks = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.byteLength;
if (Number.isSafeInteger(options.size) && loaded > options.size) {
throw new Error(`${path} 下载数据超过清单大小`);
}
chunks.push(value);
options.onProgress?.(loaded, value.byteLength);
resetTimeout();
}
const data = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; }
return data.buffer;
}
catch (error) {
if (options.signal?.aborted || attempt >= 2
|| !(timedOut || error?.retryable || error instanceof TypeError)) throw error;
await addLog(options.shuYing, `下载重试 ${attempt + 1}/2:${path};${getErrorMessage(error)}`);
}
finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", cancel);
if (reader) await reader.cancel().catch(() => {});
}
await waitForRetry(600 * (attempt + 1), options.signal);
}
}
function downloadProgress(shuYing, files, manifest) {
const received = new Map();
const totalBytes = files.reduce((sum, file) => sum + manifest.files[file].size, 0);
const started = Date.now();
let transferred = 0, completed = 0, bytes = 0;
const render = (file, force = false) => {
setProgress(shuYing, { title: "下载更新", status: `下载并校验(${completed}/${files.length})`,
file, current: completed, total: files.length, bytes, totalBytes,
speed: transferred / Math.max(0.1, (Date.now() - started) / 1000), force });
};
return {
receive(file, loaded, delta) {
const value = Math.min(loaded, manifest.files[file].size);
bytes += value - (received.get(file) || 0);
received.set(file, value);
transferred += delta;
render(file);
},
complete(file) { completed++; render(file, completed == files.length); },
finish() { addLog(shuYing, `下载及准备完成:${files.length} 个文件,${formatBytes(totalBytes)},用时 ${((Date.now() - started) / 1000).toFixed(1)} 秒`); },
};
}
async function writeTargetFile(data, target, verifiedInfo = null) {
async function writeTargetFile(data, target) {
if (typeof game.writeFile != "function") {
throw new Error("当前环境不支持写入文件");
}
const targetInfo = splitPath(target);
const metadata = await writeFile(data, targetInfo.dir, targetInfo.name);
if (verifiedInfo) rememberVerified(target, verifiedInfo, metadata);
await writeFile(data, targetInfo.dir, targetInfo.name);
}
async function downloadFile(path, target, manifest, options = {}) {
const data = await fetchFile(path, manifest.tag_name, manifest.source,
{ ...options, size: manifest.files[path]?.size });
async function downloadFile(path, target, manifest) {
const data = await fetchFile(path, manifest.tag_name, manifest.source);
if (manifest?.files?.[path]) {
await verifyFile(path, data, manifest);
}
if (options.signal?.aborted) throw options.signal.reason || new Error("下载已停止");
await writeTargetFile(data, target);
return data;
}
@@ -750,34 +471,6 @@ function checkFile(path) {
});
}
function fileMetadata(path) {
assertLocalPath(path);
return fileOperation(`读取文件属性:${path}`, (resolve, reject) => {
if (lib.node?.fs && typeof window.__dirname == "string") {
lib.node.fs.stat(lib.node.path.join(window.__dirname, path), (error, stat) => {
if (error?.code == "ENOENT") { resolve(null); return; }
if (error) { reject(error); return; }
if (!stat.isFile() || !Number.isFinite(stat.mtimeMs) || !Number.isFinite(stat.ctimeMs)) {
resolve(null);
return;
}
resolve({ size: stat.size, fingerprint: `node:${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}` });
});
}
else if (hasCordovaFiles()) {
cordovaEntry(path).then(entry => entry.file(file => {
try {
const modified = Number(file.lastModified) || new Date(file.lastModifiedDate).getTime();
resolve(Number.isFinite(modified) && modified > 0
? { size: file.size, fingerprint: `cordova:${modified}` } : null);
}
catch { resolve(null); }
}, reject)).catch(error => error?.code == 1 ? resolve(null) : reject(error));
}
else resolve(null);
});
}
async function safeCheckFile(path) {
// An unreadable file must never be treated as a missing file during replacement.
return await checkFile(path);
@@ -843,7 +536,7 @@ function hasCordovaFiles() {
return !!lib.device && typeof window.resolveLocalFileSystemURL == "function";
}
function cordovaEntry(path, create = false, mutation = create) {
function cordovaEntry(path, create = false) {
return fileOperation(`打开文件:${path}`, (resolve, reject) => {
const root = localStorage.getItem("noname_inited");
if (!root) throw new Error("无法确定游戏文件目录");
@@ -851,7 +544,7 @@ function cordovaEntry(path, create = false, mutation = create) {
try { entry.getFile(path, { create }, resolve, reject); }
catch (error) { reject(error); }
}, reject);
}, mutation);
}, create);
}
function readFile(path) {
@@ -872,13 +565,12 @@ function readFile(path) {
});
}
async function writeFile(data, path, name, options = {}) {
async function writeFile(data, path, name) {
const target = `${path.replace(/\/$/, "")}/${name}`;
assertLocalPath(target);
invalidateVerifiedFile(target);
await fileOperation(`写入文件:${target}`, (resolve, reject) => {
if (hasCordovaFiles()) {
cordovaEntry(target, true, !options.diagnostic).then(entry => entry.createWriter(writer => {
cordovaEntry(target, true).then(entry => entry.createWriter(writer => {
let truncated = false;
writer.onerror = () => reject(writer.error || new Error("写入失败"));
writer.onwriteend = () => {
@@ -914,9 +606,7 @@ async function writeFile(data, path, name, options = {}) {
if (error) reject(error);
else resolve();
});
}, !options.diagnostic);
if (options.diagnostic) return null;
const before = await fileMetadata(target);
}, true);
const actual = toUint8Array(await readFile(target));
const expected = toUint8Array(data);
let equal = actual.length == expected.length;
@@ -924,14 +614,11 @@ async function writeFile(data, path, name, options = {}) {
if (!equal) {
throw new Error(`文件落盘校验失败:${target}`);
}
const after = await fileMetadata(target);
return sameMetadata(before, after) ? after : null;
}
async function removeFile(path) {
assertMutationReady();
assertLocalPath(path);
invalidateVerifiedFile(path);
const exists = await checkFile(path);
if (exists == -1) return;
if (exists != 1) throw new Error(`待删除路径不是文件:${path}`);
@@ -1019,24 +706,21 @@ async function downloadCoreFiles(shuYing, manifest) {
await updateAllFiles(shuYing, manifest, files);
}
async function getChangedFiles(manifest, shuYing) {
if (typeof game.readFile != "function") throw new Error("当前环境不支持校验本地文件");
const files = getManifestFileKeys(manifest);
let checked = 0;
const started = Date.now();
const results = await runFilePool(files, async file => {
let changed = null;
try { await verifyLocalFile(file, manifest, shuYing); }
catch (error) { changed = file; }
checked++;
if (shuYing) setProgress(shuYing, {
title: "检查更新", status: fullVerification ? "完整校验资源" : "检查资源变化",
file, current: checked, total: files.length, force: checked == files.length,
});
return changed;
}, { concurrency: lib.device ? 1 : 2, weight: file => manifest.files[file].size * 3 });
await addLog(shuYing, `资源检查完成:${files.length} 个文件,用时 ${((Date.now() - started) / 1000).toFixed(1)} 秒`);
return results.filter(Boolean);
async function getChangedFiles(manifest) {
if (typeof game.readFile != "function") {
throw new Error("当前环境不支持校验本地文件");
}
const changed = [];
for (const file of getManifestFileKeys(manifest)) {
try {
const data = await readFile(`extension/术樱包/${file}`);
await verifyFile(file, data, manifest);
}
catch (error) {
changed.push(file);
}
}
return changed;
}
function makeLegacyModuleManifest(manifest, moduleId) {
@@ -1107,33 +791,7 @@ function getMengsanRecord() {
async function saveMengsanRecord(record) {
const version = record.status == "disabled" ? "disabled" : record.version || "legacy";
await saveUpdaterConfig({ [mengsanRecordKey]: record, [mengsanVersionKey]: version });
}
async function getMengsanModuleStatus() {
const record = getMengsanRecord();
if (record) return record.status;
const version = lib.config[mengsanVersionKey];
if (version) return version == "disabled" ? "disabled" : "installed";
// Legacy ZIP installations may have no receipt; inspect only the local entry.
const entry = "extension/术樱包/mengsan/register.js";
if (lib.node?.fs || hasCordovaFiles() || typeof game.checkFile == "function") {
const exists = await checkFile(entry);
return exists == 1 ? "installed" : exists == -1 ? "disabled" : "unknown";
}
if (typeof game.readFile == "function") {
try { await readFile(entry); return "installed"; }
catch (error) {
if (error?.code == "ENOENT" || error?.code == 1) return "disabled";
throw error;
}
}
return "unknown";
}
async function saveUpdaterConfig(values) {
// Publish only after both the IndexedDB transaction and its requests commit.
// Both keys commit together; saveConfig/putDB request success is too early.
lib.status.reload++;
try {
if (lib.db) {
@@ -1144,9 +802,10 @@ async function saveUpdaterConfig(values) {
transaction = lib.db.transaction(["config"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onerror = event => { requestError = event.target?.error; };
transaction.onabort = () => reject(transaction.error || requestError || new Error("更新记录未能保存"));
transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三安装记录未能保存"));
const store = transaction.objectStore("config");
for (const [key, value] of Object.entries(values)) store.put(value, key);
store.put(record, mengsanRecordKey);
store.put(version, mengsanVersionKey);
}
catch (error) {
try { transaction?.abort(); } catch {}
@@ -1158,10 +817,12 @@ async function saveUpdaterConfig(values) {
const key = `${lib.configprefix}config`;
const config = JSON.parse(localStorage.getItem(key) || "{}");
if (!config || typeof config != "object" || Array.isArray(config)) throw new Error("本地配置格式无效");
Object.assign(config, values);
config[mengsanRecordKey] = record;
config[mengsanVersionKey] = version;
localStorage.setItem(key, JSON.stringify(config));
}
Object.assign(lib.config, values);
lib.config[mengsanRecordKey] = record;
lib.config[mengsanVersionKey] = version;
}
finally { game.reload2(); }
}
@@ -1224,8 +885,9 @@ async function installMengsanFiles(shuYing, manifest, files, previousManifest =
try {
await updateAllFiles(shuYing, manifest, files, operationRoot);
filesApplied = true;
const invalid = await getChangedFiles(manifest, shuYing);
if (invalid.length) throw new Error(`梦三模块校验失败:${invalid.slice(0, 5).join("、")}`);
for (const file of currentFiles) {
await verifyFile(file, await readFile(`extension/术樱包/${file}`), manifest);
}
await saveMengsanRecord({ status: "installed", version: manifest.version,
source: manifest.source, files: currentFiles });
}
@@ -1237,10 +899,7 @@ async function installMengsanFiles(shuYing, manifest, files, previousManifest =
else if (!previousManifest && !oldFiles.length) recovery = { status: "disabled", files: [], version: "disabled" };
}
try { await saveMengsanRecord(recovery); }
catch (saveError) {
recordUpdateError(shuYing, "installation.receipt.failed", saveError);
console.error("安装中断记录仍保持待处理状态:", saveError);
}
catch (saveError) { console.error("安装中断记录仍保持待处理状态:", saveError); }
throw error;
}
}
@@ -1319,23 +978,21 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
let retainRecovery = false;
try {
ensureProgressNode(shuYing);
await invalidateBeforeChanges([...files, ...obsoleteFiles]);
await createDir(`${newRoot}/`);
await createDir(`${oldRoot}/`);
await createFolders(`${newRoot}/`, files);
await createFolders(`${oldRoot}/`, [...files, ...obsoleteFiles]);
const progress = downloadProgress(shuYing, files, manifest);
await runFilePool(files, async (file, index, signal) => {
staged.add(file);
await downloadFile(file, `${newRoot}/${file}`, manifest, {
signal, shuYing, onProgress: (loaded, delta) => progress.receive(file, loaded, delta),
for (let i = 0; i < files.length; i++) {
const file = files[i];
setProgress(shuYing, {
title: "版本更新", status: "下载并校验", file,
current: i, total: files.length * 2,
});
progress.complete(file);
}, { weight: file => manifest.files[file].size * 4 });
progress.finish();
staged.add(file);
await downloadFile(file, `${newRoot}/${file}`, manifest);
}
const installStarted = Date.now();
await createFolders("extension/术樱包/", files);
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -1350,14 +1007,11 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
}
setProgress(shuYing, {
title: "安装更新", status: "写入并检查", file,
current: i, total: files.length, force: i == 0,
title: "版本更新", status: "写入中", file,
current: files.length + i, total: files.length * 2,
});
const prepared = await readFile(`${newRoot}/${file}`);
// Recheck staged bytes before publishing a verification receipt.
await verifyFile(file, prepared, manifest);
installed.push(file);
await writeTargetFile(prepared, target, manifest.files[file]);
await writeTargetFile(await readFile(`${newRoot}/${file}`), target);
}
for (const file of obsoleteFiles) {
const target = `extension/术樱包/${file}`;
@@ -1371,8 +1025,6 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
}
await removeManifestOldPaths(shuYing, manifest);
filesInstalled = true;
setProgress(shuYing, { title: "安装更新", status: "文件处理完成", current: files.length, total: files.length, force: true });
await addLog(shuYing, `文件替换完成,用时 ${((Date.now() - installStarted) / 1000).toFixed(1)} 秒`);
}
catch (error) {
const rollbackFiles = filesInstalled || error.operationUncertain ? []
@@ -1391,7 +1043,6 @@ 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;
@@ -1406,10 +1057,7 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
error: getErrorMessage(error),
}, null, 2), `${operationRoot}/recovery.json`);
}
catch (saveError) {
recordUpdateError(shuYing, "recovery.receipt.failed", saveError, { operationRoot });
console.error("恢复说明写入失败,旧文件仍保留:", operationRoot, saveError);
}
catch (saveError) { console.error("恢复说明写入失败,旧文件仍保留:", operationRoot, saveError); }
await addLog(shuYing, `回滚未完成,恢复文件已保留在:${operationRoot}`);
}
throw error;
@@ -1423,7 +1071,6 @@ 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.
}
}
@@ -1432,10 +1079,44 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
}
async function downloadList(shuYing, files, manifest) {
// Repair also stages downloads before sequential replacement and rollback.
const changed = await getChangedFiles(manifest, shuYing);
const allowed = new Set(files);
await updateAllFiles(shuYing, manifest, changed.filter(file => allowed.has(file)));
let finished = 0;
const total = files.length;
for (const file of files) {
const target = `extension/术樱包/${file}`;
try {
await addLog(shuYing, `检查:${file}`);
setProgress(shuYing, { title: "查漏补缺", status: "检查本地文件", file: target, current: finished, total });
const exists = typeof game.checkFile == "function" ? await safeCheckFile(target) : 0;
if (exists == 1 && typeof game.readFile == "function") {
try {
await verifyFile(file, await readFile(target), manifest);
finished++;
await addLog(shuYing, `校验通过,跳过:${file}`);
setProgress(shuYing, { title: "查漏补缺", status: "校验通过", file: target, current: finished, total });
continue;
}
catch (error) {
await addLog(shuYing, `本地文件需修复:${file};${getErrorMessage(error)}`);
}
}
await addLog(shuYing, `下载:${file}`);
setProgress(shuYing, { title: "查漏补缺", status: "下载中", file: target, current: finished, total });
await downloadFile(file, target, manifest);
if (manifest?.files?.[file]) {
await addLog(shuYing, `校验通过:${file}`);
}
finished++;
await addLog(shuYing, `完成:${file}`);
setProgress(shuYing, { title: "查漏补缺", status: "已完成", file: target, current: finished, total });
}
catch (error) {
await addLog(shuYing, `失败:${file};${getErrorMessage(error)}`);
throw new Error(`${file} 处理失败:${getErrorMessage(error)}`);
}
}
}
async function getOnlineVersion(channel = getUpdateChannel(),
@@ -1446,8 +1127,7 @@ async function getOnlineVersion(channel = getUpdateChannel(),
async function checkVersion(shuYing, options = {}) {
try {
beginUpdate(shuYing);
ensureProgressNode(shuYing);
shuYing._updateLogs = [];
await addLog(shuYing, "版本检查开始");
const onlineVersion = await getOnlineVersion(options.channel, options.source);
const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
@@ -1481,7 +1161,7 @@ async function checkVersion(shuYing, options = {}) {
);
}
const changed = await getChangedFiles(manifest, shuYing);
const changed = await getChangedFiles(manifest);
const obsolete = await getObsoleteFiles(manifest);
let mengsanManifest = null;
let mengsanChanged = [];
@@ -1500,7 +1180,7 @@ async function checkVersion(shuYing, options = {}) {
getManifestFileKeys(previousMengsan)
);
}
mengsanChanged = await getChangedFiles(mengsanManifest, shuYing);
mengsanChanged = await getChangedFiles(mengsanManifest);
mengsanObsolete = await getObsoleteFiles(mengsanManifest);
}
catch (error) {
@@ -1549,17 +1229,13 @@ async function checkVersion(shuYing, options = {}) {
}
catch (error) {
console.error(error);
recordUpdateError(shuYing, "version.update.failed", error);
await addLog(shuYing, `版本更新失败:${getErrorMessage(error)}`);
alert("版本检测或下载失败,请检查网络或服务器配置。");
return null;
}
finally {
try { await finishUpdate(shuYing); }
finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
}
@@ -1571,7 +1247,7 @@ async function repairMissingFiles(shuYing) {
}
try {
beginUpdate(shuYing, true);
shuYing._updateLogs = [];
await addLog(shuYing, "查漏补缺开始");
ensureProgressNode(shuYing);
setProgress(shuYing, { title: "查漏补缺", status: "获取校验清单", current: 0, total: 1 });
@@ -1620,8 +1296,9 @@ async function repairMissingFiles(shuYing) {
await createFolders("extension/术樱包/", files, shuYing);
await downloadList(shuYing, files, manifest);
await removeManifestOldPaths(shuYing, manifest);
if (mengsanManifest) {
const moduleChanged = await getChangedFiles(mengsanManifest, shuYing);
const moduleChanged = await getChangedFiles(mengsanManifest);
await installMengsanFiles(shuYing, mengsanManifest, moduleChanged, previousMengsan);
}
@@ -1631,23 +1308,19 @@ async function repairMissingFiles(shuYing) {
}
catch (error) {
console.error(error);
recordUpdateError(shuYing, "missing.repair.failed", error);
await addLog(shuYing, `查漏补缺失败:${getErrorMessage(error)}`);
alert("查漏补缺失败,请查看 extension/术樱包/log.txt");
}
finally {
try { await finishUpdate(shuYing); }
finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
}
async function manageMengsanModule(shuYing) {
setBusy(shuYing, true);
try {
beginUpdate(shuYing);
shuYing._updateLogs = [];
assertMutationReady();
const record = getMengsanRecord();
const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
@@ -1668,7 +1341,6 @@ async function manageMengsanModule(shuYing) {
source: record?.source || getUpdateSource(), files,
};
await saveMengsanRecord(pending);
await invalidateBeforeChanges(files);
ensureProgressNode(shuYing);
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -1703,7 +1375,6 @@ 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" ? "卸载未完成,重启后梦三暂停加载,可再次点击此按钮继续卸载。"
@@ -1711,43 +1382,59 @@ async function manageMengsanModule(shuYing) {
alert(`${hint}\n${getErrorMessage(error)}\n请查看 extension/术樱包/log.txt。`);
}
finally {
try { await finishUpdate(shuYing); }
finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
}
async function repairCoreFiles(shuYing) {
let shouldDownload = false;
let manifest;
let coreFiles;
try {
manifest = await getManifest();
coreFiles = getManifestCoreFiles(manifest);
}
catch (error) {
console.error(error);
alert("获取服务器校验清单失败,请检查网络或 manifest.json。");
setBusy(shuYing, false);
return;
}
if (typeof game.readFile != "function") {
shouldDownload = confirm("此端暂不支持本地资源检查。如有问题,点击确定重新下载核心文件。");
}
else {
try {
for (const file of coreFiles) {
await readFile(`extension/术樱包/${file}`);
}
shouldDownload = confirm("本地资源文件检测无误,若有问题点击确定重新下载主要js文件(包含关键js文件, 请勿关机、断网等操作,注意备份!)");
}
catch (error) {
console.error(error);
shouldDownload = confirm("本地资源不完整!点击确认重新获取!");
}
}
if (!shouldDownload) {
setBusy(shuYing, false);
return;
}
try {
beginUpdate(shuYing, true);
ensureProgressNode(shuYing);
await addLog(shuYing, "核心文件修复开始");
const manifest = await getManifest();
const files = getManifestCoreFiles(manifest);
if (!files.length) throw new Error("校验清单缺少核心文件");
const coreManifest = { ...manifest, files: Object.fromEntries(files.map(file => [file, manifest.files[file]])) };
const changed = await getChangedFiles(coreManifest, shuYing);
const message = changed.length ? `有 ${changed.length} 个核心文件需修复,是否重新下载核心文件?`
: "核心文件校验通过,是否仍重新下载核心文件?";
if (!confirm(message)) return;
await downloadCoreFiles(shuYing, manifest);
await addLog(shuYing, "核心文件修复完成");
alert("下载完成,重启生效");
}
catch (error) {
console.error(error);
recordUpdateError(shuYing, "core.repair.failed", error);
await addLog(shuYing, `核心文件修复失败:${getErrorMessage(error)}`);
alert("核心文件下载或校验失败,请检查服务器文件与 manifest.json 是否一致。");
}
finally {
try { await finishUpdate(shuYing); }
finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
}
@@ -1759,5 +1446,4 @@ export default {
repairMissingFiles,
repairCoreFiles,
manageMengsanModule,
getMengsanModuleStatus,
};
-12
View File
@@ -1,15 +1,3 @@
"-----< 2.0.2.3 改动 >-----",
"新增正式版、测试版更新通道切换。",
"支持选择 Gitea 或 GitHub 下载源。",
"更新系统采用并发下载:安卓最多 4 个、PC 最多 8 个,大文件受内存限制。",
"新增梦三模式安装入口",
"-----< 2.0.2.3 修复 >-----",
"修复天书乱斗跨关后,按轮数冷却的技能未正确重置,以及旧关卡历史影响新关卡计数的问题。",
"修复【清剿】旧关卡的延迟弃牌结算可能弃掉新关卡奖励的问题。",
"修复天书乱斗应战时的座位重叠、动态 Boss 控制切换、代理头像显示及胜负判断问题。",
"修正“神武再世”三级羁绊的体力流失统计,按目标本回合累计流失的体力判定。",
"修复切换下载源后,旧版本查询结果覆盖新结果的问题。",
"核心文件修复增加大小和 SHA-256 校验。",
"-----< 2.0.1.1 改动 >-----",
"新增“旧轮次”选项,可在过关后按照旧规则将行动权交给玩家方1号位上家的Boss",
"新增“羁绊技能倾向”选项,已激活羁绊的相关技能将按羁绊等级获得温和的出现概率提升",