Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b424c7ca68 | ||
|
|
0205bbebc6 | ||
|
|
63dc8c66f9 | ||
|
|
cba7b5fbcc | ||
|
|
3a3d547ccd | ||
|
|
036f929862 | ||
|
|
3ac3b5e52a | ||
|
|
cab413af50 | ||
|
|
f7f1766194 | ||
|
|
a195977dca | ||
|
|
1e94a43dbc | ||
|
|
94a2a6724b | ||
|
|
c448da39c2 |
@@ -20,7 +20,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Build manifest and ZIP
|
||||
- name: Verify committed manifests and build ZIP
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = $env:RELEASE_TAG
|
||||
@@ -58,11 +58,14 @@ jobs:
|
||||
throw "extension.js 的版本号必须为 $version"
|
||||
}
|
||||
|
||||
python (Join-Path $stage 'tools/generate_manifest.py') `
|
||||
# The updater reads raw tag paths, not release attachments.
|
||||
# Never generate new manifests after the release tag already exists.
|
||||
python (Join-Path $stage 'tools/verify_manifest.py') `
|
||||
--root $stage `
|
||||
--version $version `
|
||||
--out dist/manifest.json
|
||||
if ($LASTEXITCODE -ne 0) { throw '生成清单失败' }
|
||||
--version $version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '标签内清单缺失或过期:请先生成并提交清单,再创建新版本标签'
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $stage 'dist/manifest.json'
|
||||
$manifest = Get-Content $manifestPath -Raw |
|
||||
@@ -98,6 +101,10 @@ jobs:
|
||||
)
|
||||
for name in component["files"]:
|
||||
archive.write(root / name, folder + name)
|
||||
for key in ("manifest", "legacy_manifest"):
|
||||
receipt = descriptor.get(key)
|
||||
if receipt:
|
||||
archive.write(root / receipt, folder + receipt)
|
||||
archive.write(root / "dist/manifest.json", folder + "manifest.json")
|
||||
'@ | python - $stage $package
|
||||
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
|
||||
|
||||
@@ -76,6 +76,10 @@ jobs:
|
||||
--out dist/manifest.json
|
||||
if ($LASTEXITCODE -ne 0) { throw '生成清单失败' }
|
||||
|
||||
python (Join-Path $source 'tools/verify_manifest.py') `
|
||||
--root $source --version $version
|
||||
if ($LASTEXITCODE -ne 0) { throw '发布前清单校验失败' }
|
||||
|
||||
$manifestPath = Join-Path $source 'dist/manifest.json'
|
||||
$manifest = Get-Content $manifestPath -Raw |
|
||||
ConvertFrom-Json
|
||||
@@ -128,6 +132,10 @@ jobs:
|
||||
Expand-Archive -LiteralPath $snapshot `
|
||||
-DestinationPath $stage
|
||||
|
||||
python (Join-Path $stage 'tools/verify_manifest.py') `
|
||||
--root $stage --version $version
|
||||
if ($LASTEXITCODE -ne 0) { throw '标签源码与清单不一致,停止发布' }
|
||||
|
||||
$manifestPath = Join-Path $stage 'dist/manifest.json'
|
||||
$manifest = Get-Content $manifestPath -Raw |
|
||||
ConvertFrom-Json
|
||||
@@ -158,6 +166,10 @@ jobs:
|
||||
)
|
||||
for name in component["files"]:
|
||||
archive.write(root / name, folder + name)
|
||||
for key in ("manifest", "legacy_manifest"):
|
||||
receipt = descriptor.get(key)
|
||||
if receipt:
|
||||
archive.write(root / receipt, folder + receipt)
|
||||
archive.write(root / "dist/manifest.json", folder + "manifest.json")
|
||||
'@ | python - $stage $package
|
||||
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
|
||||
|
||||
@@ -15,4 +15,28 @@ tools/*.mjs
|
||||
/Slay the Spire 2
|
||||
|
||||
*.mjs
|
||||
*.cjs
|
||||
*.cjs
|
||||
|
||||
/.update_tmp/
|
||||
/.planning/
|
||||
/mengsan/logs/
|
||||
/logs/
|
||||
*.log
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
!.env.template
|
||||
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 术樱包自动诊断日志
|
||||
|
||||
扩展加载后自动记录本扩展的关键业务步骤,发生错误时自动生成独立报告。
|
||||
无需开启控制台或手动开始录制;不上传任何数据。
|
||||
|
||||
## 文件位置
|
||||
|
||||
PC 和安卓客户端均写入游戏目录下的 `extension/术樱包/logs/`。
|
||||
|
||||
- `runtime-*.jsonl`:按行保存时间、模块、步骤、关联 ID 和上下文。
|
||||
- `report-*.json`:错误报告,包含首次/最近异常、错误栈、cause、内部异常、
|
||||
运行版本、错误前操作、当前结算阶段和存档状态。
|
||||
|
||||
同一异常在存档层、结算层等多个入口被观察时,共用报告编号并更新修订号;
|
||||
occurrences 表示被捕获的次数,不表示奖励发放次数。最近报告不会因普通日志
|
||||
轮转被删除。保存回执绑定对应修订和真实通道,旧版本写入成功不会误标新版本。
|
||||
|
||||
扩展设置中的“诊断日志位置”显示实际位置和最近报告保存状态。
|
||||
只需要保留相应报告和附近的 runtime 文件;如何发送由用户自行处理。
|
||||
|
||||
## 采集范围
|
||||
|
||||
- 主动记录术樱包加载、更新、梦三战斗、结算、存档、手牌、地图及菜单错误。
|
||||
- 全局错误仅接收来源文件或异常栈属于本扩展的情况,包含编码后的中文路径。
|
||||
- 不因为当前开启梦三就收集所有错误,也不根据错误消息包含“术樱包”归因。
|
||||
- 原引擎错误处理仍会执行;不替换引擎事件、Promise 或 console 的原型。
|
||||
|
||||
## 存档诊断
|
||||
|
||||
报告分别记录 storageStatus、memoryPublished 和 commitReceipt:
|
||||
|
||||
- storageStatus=committed 表示存档事务完成,或 localStorage 写入已返回。
|
||||
- memoryPublished=true 表示运行内存已发布成功存档。
|
||||
- commitReceipt=true 表示结算协调器收到了提交成功结果。
|
||||
|
||||
资源清理、技能释放或地图展示随后失败时,不能据此判断存档失败。
|
||||
重试沿用现有去重和奖励提交规则,日志不参与存档事务,也不触发自动重试。
|
||||
|
||||
## 容量与兼容
|
||||
|
||||
默认每秒批量保存普通日志;错误报告立即排入写入队列。运行日志按约
|
||||
256 KiB 分段;内存保留最近约 512 KiB / 400 条记录。日志目录自动清理
|
||||
系统自己生成的旧文件,保留总量约 5 MiB、最多 64 个文件。报告包含截断和
|
||||
丢弃计数;“完整诊断”指受限的错误链与现场记录,不是无限保留整个游戏过程。
|
||||
|
||||
待写队列默认最多 12 项、合计 2 MiB,优先保存错误报告;另有最多一项正在写入。
|
||||
错误报告复用已脱敏的记录文本,按字节预算一次裁剪,保留首次和最近异常。
|
||||
|
||||
PC 使用异步文件操作,安卓使用 Cordova 目录和 FileWriter 回调,写入前截断旧
|
||||
内容,使用 UTF-8 Blob。单次保存操作设置 5 秒观察超时;超时后停止使用该
|
||||
文件通道,避免迟到写入与后续写入重叠。写入异常不会中断游戏业务。
|
||||
|
||||
原生文件不可用时,降级到独立的 `shuying-diagnostics` IndexedDB;它不与
|
||||
游戏存档共用数据库。若两种持久化方式都不可用,仅保留本次运行的内存
|
||||
记录,菜单会明确显示未持久化,不能宣称文件已生成。
|
||||
|
||||
切入后台或页面隐藏时尝试保存缓冲;被系统强制杀死或断电时,最后尚未完成的
|
||||
异步写入可能丢失。结算内部等待超过 15 秒会记录观察日志,选择奖励、地图节点
|
||||
和剧情等待不会被当作超时错误;观察器不会结束业务事件。
|
||||
|
||||
字段过滤会隐藏凭据字段、常见 URL 凭据参数和 Bearer 内容。报告不会复制完整
|
||||
玩家对象、DOM、引擎状态或整个存档。日志文件不进入发布清单和 Git。
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
byteLength, cleanText, extensionError, extensionSource, serializeError, snapshot,
|
||||
} from "./serialize.js";
|
||||
import { createDiagnosticStorage, isDiagnosticFile, LOG_DIRECTORY } from "./storage.js";
|
||||
|
||||
const noop = () => null;
|
||||
const inactive = Object.freeze({
|
||||
info: noop, warn: noop, error: noop, scope: () => inactive,
|
||||
registerContext: () => noop, flush: async () => {},
|
||||
step: async (_event, action) => action(),
|
||||
status: () => ({ enabled: false }), dispose: noop,
|
||||
});
|
||||
let current = inactive;
|
||||
|
||||
export function getDiagnostics() { return current; }
|
||||
|
||||
export function createDiagnostics(options = {}) {
|
||||
const env = options.env || {};
|
||||
const now = options.now || Date.now;
|
||||
const monotonic = () => {
|
||||
try { return env.performance?.now?.() ?? now(); } catch { return now(); }
|
||||
};
|
||||
const launchId = `${now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const maxMemoryBytes = options.maxMemoryBytes ?? 512 * 1024;
|
||||
const maxJournalBytes = options.maxJournalBytes ?? 256 * 1024;
|
||||
const maxReportBytes = options.maxReportBytes ?? 512 * 1024;
|
||||
const maxDiskBytes = options.maxDiskBytes ?? 5 * 1024 * 1024;
|
||||
const maxFiles = options.maxFiles ?? 64;
|
||||
const maxPending = options.maxPending ?? 12;
|
||||
const maxPendingBytes = options.maxPendingBytes ?? 2 * 1024 * 1024;
|
||||
const flushMs = options.flushMs ?? 1000;
|
||||
const recent = [], contexts = new Map(), pending = new Map();
|
||||
const errors = new WeakMap(), fingerprints = new Map(), listeners = [];
|
||||
let sequence = 0, reportSequence = 0, segment = 0, memoryBytes = 0;
|
||||
let journal = [], journalBytes = 0, revision = 0, queuedRevision = -1;
|
||||
let flushTimer = null, worker = null, stopped = false, unavailable = false;
|
||||
let latestReport = null, droppedRecords = 0, droppedWrites = 0;
|
||||
let inventory = null, inventoryKind = null;
|
||||
let pendingBytes = 0;
|
||||
const metadata = snapshot({
|
||||
extension: "术樱包", extensionVersion: options.version || "unknown",
|
||||
engineVersion: env.lib?.version || "unknown", launchId,
|
||||
platform: env.lib?.node ? "pc" : env.lib?.device ? "android" : "browser",
|
||||
userAgent: env.window?.navigator?.userAgent || "unknown",
|
||||
modules: { mengsan: env.lib?.config?.shuYing_mengsan_installed_version || null },
|
||||
});
|
||||
const roots = [];
|
||||
try {
|
||||
const root = new URL("../", import.meta.url);
|
||||
if (["file:", "http:", "https:"].includes(root.protocol)) roots.push(root.href);
|
||||
} catch {}
|
||||
if (options.extensionRoot) {
|
||||
try {
|
||||
roots.push(new URL(options.extensionRoot.replace(/\/?$/, "/"),
|
||||
env.document?.baseURI || env.window?.location?.href).href);
|
||||
} catch {}
|
||||
}
|
||||
const storage = options.storage || createDiagnosticStorage(env, {
|
||||
timeoutMs: options.storageTimeoutMs,
|
||||
onFailure(error, phase) {
|
||||
record("WARN", "diagnostics.storage.failure", { phase }, error, "diagnostics");
|
||||
},
|
||||
});
|
||||
const storageStatus = () => storage.status?.() || { kind: "file", location: LOG_DIRECTORY };
|
||||
const runtimeName = () => `runtime-${launchId}-${segment}.jsonl`;
|
||||
|
||||
function contextFor(extra) {
|
||||
const values = {};
|
||||
for (const [name, provider] of contexts) {
|
||||
try { values[name] = snapshot(provider()); }
|
||||
catch { values[name] = { diagnosticContextUnavailable: true }; }
|
||||
}
|
||||
return { ...values, ...snapshot(extra || {}) };
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (flushTimer || stopped || unavailable) return;
|
||||
flushTimer = setTimeout(() => { flushTimer = null; void flush(); }, flushMs);
|
||||
flushTimer?.unref?.();
|
||||
}
|
||||
|
||||
function record(level, event, extra, error, module = "extension") {
|
||||
if (stopped) return null;
|
||||
try {
|
||||
const entry = {
|
||||
schemaVersion: 1, sequence: ++sequence, time: new Date(now()).toISOString(),
|
||||
launchId, level, module: cleanText(module, 128), event: cleanText(event, 256),
|
||||
context: contextFor(extra),
|
||||
};
|
||||
if (error !== undefined) entry.error = serializeError(error);
|
||||
const item = { entry, bytes: 0, text: "" };
|
||||
recent.push(item);
|
||||
if (level === "ERROR") createReport(entry, error);
|
||||
const text = JSON.stringify(entry);
|
||||
const bytes = byteLength(text) + 1;
|
||||
item.text = text;
|
||||
delete item.entry;
|
||||
item.bytes = bytes; memoryBytes += bytes;
|
||||
if (journalBytes + bytes > maxJournalBytes && journal.length) {
|
||||
enqueue(runtimeName(), journal.join(""));
|
||||
segment++; journal = []; journalBytes = 0; queuedRevision = -1;
|
||||
}
|
||||
journal.push(text + "\n"); journalBytes += bytes; revision++;
|
||||
while (recent.length > 400 || memoryBytes > maxMemoryBytes) {
|
||||
memoryBytes -= recent.shift().bytes; droppedRecords++;
|
||||
}
|
||||
schedule();
|
||||
if (level === "ERROR") void flush();
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createReport(entry, rawError) {
|
||||
let known;
|
||||
if (rawError && typeof rawError === "object") known = errors.get(rawError);
|
||||
const fingerprint = `${entry.module}:${entry.event}:${entry.error?.name}:${entry.error?.message}`;
|
||||
if (!known) {
|
||||
const prior = fingerprints.get(fingerprint);
|
||||
if (prior && now() - prior.time < 5000) known = prior;
|
||||
}
|
||||
if (!known) known = { id: `${launchId}-${++reportSequence}`, count: 0,
|
||||
first: entry.time, time: now(), firstFailure: entry };
|
||||
known.count++; known.time = now();
|
||||
if (rawError && typeof rawError === "object") errors.set(rawError, known);
|
||||
fingerprints.set(fingerprint, known);
|
||||
while (fingerprints.size > 64) fingerprints.delete(fingerprints.keys().next().value);
|
||||
entry.reportId = known.id;
|
||||
const file = `report-${known.id}.json`;
|
||||
const report = {
|
||||
schemaVersion: 1, reportId: known.id, firstSeen: known.first,
|
||||
lastSeen: entry.time, occurrences: known.count, environment: metadata,
|
||||
storageAtCapture: snapshot(storageStatus()), firstFailure: known.firstFailure,
|
||||
failure: entry,
|
||||
droppedRecords, droppedWrites,
|
||||
};
|
||||
const records = recent.map(item => {
|
||||
const text = item.text || JSON.stringify(item.entry);
|
||||
return { text, bytes: item.text ? item.bytes - 1 : byteLength(text) };
|
||||
});
|
||||
let start = 0;
|
||||
let bytes = byteLength(JSON.stringify(report)) + 14
|
||||
+ records.reduce((total, item) => total + item.bytes + 1, 0);
|
||||
while (bytes + 32 > maxReportBytes && start < records.length - 1) {
|
||||
bytes -= records[start++].bytes + 1;
|
||||
}
|
||||
report.droppedRecords += start;
|
||||
const text = JSON.stringify(report).slice(0, -1) + ',"records":['
|
||||
+ records.slice(start).map(item => item.text).join(",") + "]}";
|
||||
latestReport = { id: known.id, file, revision: known.count,
|
||||
persisted: false, event: entry.event };
|
||||
enqueue(file, text, true, known.count);
|
||||
}
|
||||
|
||||
function enqueue(name, text, priority = false, reportRevision = null) {
|
||||
if (unavailable || stopped) return;
|
||||
const bytes = byteLength(text);
|
||||
const previous = pending.get(name);
|
||||
if (previous) { pendingBytes -= previous.bytes; pending.delete(name); }
|
||||
if (bytes > maxPendingBytes) { droppedWrites++; return; }
|
||||
while (pending.size >= maxPending || pendingBytes + bytes > maxPendingBytes) {
|
||||
const victim = [...pending].find(([, item]) => !item.priority)?.[0]
|
||||
|| (priority ? pending.keys().next().value : null);
|
||||
if (!victim) { droppedWrites++; return; }
|
||||
pendingBytes -= pending.get(victim).bytes;
|
||||
pending.delete(victim); droppedWrites++;
|
||||
}
|
||||
pending.set(name, { text, bytes, priority, reportRevision });
|
||||
pendingBytes += bytes;
|
||||
startWorker();
|
||||
}
|
||||
|
||||
async function prune(protect, text) {
|
||||
const kind = storageStatus().kind;
|
||||
if (!inventory || inventoryKind !== kind) {
|
||||
inventory = new Map((await storage.list()).filter(file =>
|
||||
isDiagnosticFile(file.name)).map(file => [file.name, file]));
|
||||
inventoryKind = kind;
|
||||
}
|
||||
inventory.set(protect, { name: protect, bytes: byteLength(text), time: now() });
|
||||
const files = [...inventory.values()];
|
||||
files.sort((a, b) => (a.time || 0) - (b.time || 0));
|
||||
let bytes = files.reduce((sum, file) => sum + (file.bytes || 0), 0);
|
||||
let count = files.length;
|
||||
for (const file of files) {
|
||||
if (bytes <= maxDiskBytes && count <= maxFiles) break;
|
||||
if (file.name === protect || file.name === runtimeName()
|
||||
|| file.name === latestReport?.file) continue;
|
||||
await storage.remove(file.name);
|
||||
inventory.delete(file.name);
|
||||
bytes -= file.bytes || 0; count--;
|
||||
}
|
||||
}
|
||||
|
||||
function startWorker() {
|
||||
if (worker || !pending.size) return;
|
||||
worker = Promise.resolve().then(async () => {
|
||||
while (pending.size && !unavailable) {
|
||||
const name = [...pending].find(([, item]) => item.priority)?.[0]
|
||||
|| pending.keys().next().value;
|
||||
const item = pending.get(name);
|
||||
pending.delete(name);
|
||||
pendingBytes -= item.bytes;
|
||||
try {
|
||||
await storage.write(name, item.text);
|
||||
if (latestReport?.file === name && latestReport.revision === item.reportRevision) {
|
||||
latestReport.persisted = true;
|
||||
latestReport.savedStorage = snapshot(storageStatus());
|
||||
}
|
||||
try { await prune(name, item.text); } catch { inventory = null; }
|
||||
} catch {
|
||||
unavailable = true; pending.clear(); pendingBytes = 0;
|
||||
}
|
||||
}
|
||||
}).catch(() => { unavailable = true; pending.clear(); pendingBytes = 0; }).finally(() => {
|
||||
worker = null;
|
||||
if (pending.size && !unavailable) startWorker();
|
||||
});
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
try {
|
||||
clearTimeout(flushTimer); flushTimer = null;
|
||||
if (journal.length && queuedRevision !== revision) {
|
||||
queuedRevision = revision;
|
||||
enqueue(runtimeName(), journal.join(""));
|
||||
}
|
||||
while (worker) await worker;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function scope(module, provider = () => ({})) {
|
||||
const details = extra => {
|
||||
try {
|
||||
const context = typeof provider === "function" ? provider() : provider;
|
||||
return { ...snapshot(context), ...snapshot(extra || {}) };
|
||||
} catch { return { diagnosticContextUnavailable: true }; }
|
||||
};
|
||||
return Object.freeze({
|
||||
info: (event, extra) => record("INFO", event, details(extra), undefined, module),
|
||||
warn: (event, extra, error) => record("WARN", event, details(extra), error, module),
|
||||
error: (event, error, extra) => record("ERROR", event, details(extra), error, module),
|
||||
async step(event, action, extra = {}, options = {}) {
|
||||
const started = monotonic();
|
||||
record("INFO", `${event}.start`, details(extra), undefined, module);
|
||||
let timer;
|
||||
if (options.observeWait !== false) {
|
||||
timer = setTimeout(() => {
|
||||
if (env.document?.hidden) return;
|
||||
record("WARN", `${event}.waiting`, details({ ...extra,
|
||||
elapsedMs: monotonic() - started }), undefined, module);
|
||||
}, options.waitMs ?? 15000);
|
||||
timer?.unref?.();
|
||||
}
|
||||
try {
|
||||
const result = await action();
|
||||
record("INFO", `${event}.complete`, details({ ...extra,
|
||||
elapsedMs: monotonic() - started }), undefined, module);
|
||||
return result;
|
||||
} catch (error) {
|
||||
record("ERROR", `${event}.failed`, details({ ...extra,
|
||||
elapsedMs: monotonic() - started }), error, module);
|
||||
throw error;
|
||||
} finally { clearTimeout(timer); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listen(target, event, handler, capture = false) {
|
||||
if (typeof target?.addEventListener !== "function") return;
|
||||
target.addEventListener(event, handler, capture);
|
||||
listeners.push(() => target.removeEventListener(event, handler, capture));
|
||||
}
|
||||
const globalLogger = scope("extension.global");
|
||||
const globalSeen = new WeakMap();
|
||||
function captureGlobal(error, source, extra = {}) {
|
||||
if (!extensionSource(source, roots) && !extensionError(error, roots)) return;
|
||||
if (error && typeof error === "object") {
|
||||
const last = globalSeen.get(error);
|
||||
if (last !== undefined && now() - last < 20) return;
|
||||
globalSeen.set(error, now());
|
||||
}
|
||||
globalLogger.error("uncaught", error, { source, ...extra });
|
||||
}
|
||||
listen(env.window, "error", event => {
|
||||
try {
|
||||
if (extensionSource(event.filename, roots) || extensionError(event.error, roots)) {
|
||||
captureGlobal(event.error || event.message, event.filename,
|
||||
{ line: event.lineno, column: event.colno });
|
||||
} else {
|
||||
const source = event.target?.src || event.target?.href;
|
||||
if (extensionSource(source, roots)) globalLogger.error("resource.load",
|
||||
new Error("术樱包资源加载失败"), { source });
|
||||
}
|
||||
} catch {}
|
||||
}, true);
|
||||
listen(env.window, "unhandledrejection", event => {
|
||||
try {
|
||||
if (extensionError(event.reason, roots)) captureGlobal(event.reason, "",
|
||||
{ origin: "unhandledrejection" });
|
||||
} catch {}
|
||||
});
|
||||
if (env.window) {
|
||||
const previous = env.window.onerror;
|
||||
const chained = function(message, source, line, column, error) {
|
||||
try { captureGlobal(error || message, source, { line, column }); } catch {}
|
||||
return typeof previous === "function" ? previous.apply(this, arguments) : undefined;
|
||||
};
|
||||
env.window.onerror = chained;
|
||||
listeners.push(() => {
|
||||
if (env.window.onerror === chained) env.window.onerror = previous;
|
||||
});
|
||||
}
|
||||
listen(env.document, "visibilitychange", () => {
|
||||
if (env.document.hidden) void flush();
|
||||
});
|
||||
listen(env.window, "pagehide", () => { void flush(); });
|
||||
const api = Object.freeze({
|
||||
...scope("extension"), scope,
|
||||
registerContext(name, provider) {
|
||||
contexts.set(name, provider);
|
||||
return () => contexts.delete(name);
|
||||
},
|
||||
flush,
|
||||
status: () => snapshot({ enabled: true, ...storageStatus(),
|
||||
unavailable, latestReport, droppedRecords, droppedWrites,
|
||||
pendingFiles: pending.size, pendingBytes,
|
||||
runtimeFile: runtimeName(), launchId }),
|
||||
dispose() {
|
||||
void flush(); stopped = true; clearTimeout(flushTimer);
|
||||
for (const remove of listeners) remove();
|
||||
if (current === api) current = inactive;
|
||||
},
|
||||
});
|
||||
api.info("diagnostics.started", { environment: metadata });
|
||||
return api;
|
||||
}
|
||||
|
||||
export function configureDiagnostics({ lib, game, window, document, version, extensionRoot }) {
|
||||
if (current !== inactive) return current;
|
||||
try {
|
||||
current = createDiagnostics({ version, extensionRoot,
|
||||
env: { lib, game, window, document, performance: window?.performance,
|
||||
localStorage: window?.localStorage, indexedDB: window?.indexedDB } });
|
||||
} catch {}
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
const secretKey = /password|passwd|secret|token|cookie|authorization|api[_-]?key|credential/i;
|
||||
|
||||
export function cleanText(value, limit = 8192) {
|
||||
let text;
|
||||
try { text = String(value); } catch { return "[unreadable]"; }
|
||||
const truncated = text.length > limit;
|
||||
text = text.slice(0, limit);
|
||||
text = text.replace(/([?&](?:token|key|api[_-]?key|access_token|password|secret|auth)[a-z0-9_-]*=)[^&#\s]*/gi,
|
||||
"$1[redacted]");
|
||||
text = text.replace(/((?:Bearer|Basic)\s+)[\w.+\/=-]+/gi, "$1[redacted]");
|
||||
text = text.replace(/\b(?:https?|ftp):\/\/[^\s/?#]*/gi, (url, offset, source) => {
|
||||
const schemeEnd = url.indexOf("://") + 3;
|
||||
const at = url.lastIndexOf("@");
|
||||
if (at >= schemeEnd) return url.slice(0, schemeEnd) + "[redacted]@" + url.slice(at + 1);
|
||||
if (truncated && offset + url.length === source.length
|
||||
&& url.slice(schemeEnd).includes(":")) return url.slice(0, schemeEnd) + "[redacted]";
|
||||
return url;
|
||||
});
|
||||
return !truncated && text.length <= limit ? text : text.slice(0, limit) + "[truncated]";
|
||||
}
|
||||
|
||||
function read(object, key) {
|
||||
try { return object[key]; } catch { return "[unreadable]"; }
|
||||
}
|
||||
|
||||
export function snapshot(value, depth = 0, seen = new WeakSet()) {
|
||||
if (value === null || value === undefined) return value ?? null;
|
||||
if (typeof value === "string") return cleanText(value, 2048);
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
|
||||
if (typeof value !== "object") return cleanText(value, 256);
|
||||
if (seen.has(value)) return "[circular]";
|
||||
if (depth >= 5) return "[depth limit]";
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
const result = value.slice(0, 32).map(item => snapshot(item, depth + 1, seen));
|
||||
if (value.length > 32) result.push(`[${value.length - 32} more items]`);
|
||||
return result;
|
||||
}
|
||||
let keys;
|
||||
try { keys = Object.keys(value); } catch { return "[unreadable]"; }
|
||||
const result = {};
|
||||
for (const key of keys.slice(0, 40)) {
|
||||
if (key === "__proto__" || key === "constructor") continue;
|
||||
result[key] = secretKey.test(key) ? "[redacted]"
|
||||
: snapshot(read(value, key), depth + 1, seen);
|
||||
}
|
||||
if (keys.length > 40) result.truncatedKeys = keys.length - 40;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeError(error, depth = 0, seen = new WeakSet(),
|
||||
budget = { nodes: 0, characters: 32768 }) {
|
||||
if (++budget.nodes > 32 || budget.characters <= 0) {
|
||||
return { name: "TruncatedError", message: "[error size limit]" };
|
||||
}
|
||||
const text = (value, limit = 8192) => {
|
||||
const result = cleanText(value, Math.max(0, Math.min(limit, budget.characters)));
|
||||
budget.characters -= result.length;
|
||||
return result;
|
||||
};
|
||||
if (!error || typeof error !== "object") {
|
||||
return { name: "ThrownValue", message: text(error) };
|
||||
}
|
||||
if (seen.has(error)) return { name: "CircularError", message: "[circular]" };
|
||||
if (depth >= 5) return { name: "TruncatedError", message: "[depth limit]" };
|
||||
seen.add(error);
|
||||
const result = {
|
||||
name: text(read(error, "name") || "Error", 128),
|
||||
message: text(read(error, "message") || error),
|
||||
stack: text(read(error, "stack") || "", 16384),
|
||||
};
|
||||
for (const key of ["code", "operationUncertain"]) {
|
||||
const value = read(error, key);
|
||||
if (value !== undefined) result[key] = snapshot(value);
|
||||
}
|
||||
const cause = read(error, "cause");
|
||||
if (cause !== undefined) result.cause = serializeError(cause, depth + 1, seen, budget);
|
||||
const errors = read(error, "errors");
|
||||
if (Array.isArray(errors)) {
|
||||
result.errors = errors.slice(0, 16).map(item =>
|
||||
serializeError(item, depth + 1, seen, budget));
|
||||
if (errors.length > 16) result.truncatedErrors = errors.length - 16;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decode(value) {
|
||||
let text = cleanText(value, 32768).replace(/\\/g, "/");
|
||||
for (let count = 0; count < 2; count++) {
|
||||
try { text = decodeURIComponent(text); } catch { break; }
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function extensionSource(value, roots = []) {
|
||||
if (typeof value !== "string" || !value) return false;
|
||||
const source = cleanText(value, 32768).replace(/\\/g, "/").trim();
|
||||
const url = source.match(/(?:file|https?):\/\/[^\n]+/i)?.[0];
|
||||
const native = source.match(/[a-z]:\/[^\n]+/i)?.[0];
|
||||
const candidate = decode((url || native || source)
|
||||
.replace(/\)\s*$/, "").split(/[?#]/, 1)[0]);
|
||||
if (roots.some(root => {
|
||||
const base = decode(root).split(/[?#]/, 1)[0];
|
||||
return base && (candidate.startsWith(base)
|
||||
|| (base.startsWith("file:") && candidate.startsWith(base.replace(/^file:\/+/, ""))));
|
||||
})) return true;
|
||||
if (/^(?:\.?\/)?extension\/术樱包\//.test(candidate)) return true;
|
||||
return !roots.length && /\/extension\/术樱包\//.test(candidate);
|
||||
}
|
||||
|
||||
export function extensionError(error, roots = [], depth = 0) {
|
||||
if (!error || typeof error !== "object" || depth >= 5) return false;
|
||||
const stack = read(error, "stack");
|
||||
if (typeof stack === "string" && stack.split("\n").slice(1).some(line =>
|
||||
/(?:\bat\s|@)/.test(line) && extensionSource(line, roots))) return true;
|
||||
if (extensionSource(read(error, "fileName"), roots)) return true;
|
||||
if (extensionError(read(error, "cause"), roots, depth + 1)) return true;
|
||||
const errors = read(error, "errors");
|
||||
return Array.isArray(errors) && errors.slice(0, 16).some(item =>
|
||||
extensionError(item, roots, depth + 1));
|
||||
}
|
||||
|
||||
export function byteLength(text) {
|
||||
if (typeof TextEncoder === "function") return new TextEncoder().encode(text).length;
|
||||
return new Blob([text]).size;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { byteLength } from "./serialize.js";
|
||||
|
||||
export const LOG_DIRECTORY = "extension/术樱包/logs";
|
||||
const ownedName = /^(?:runtime|report)-[a-zA-Z0-9-]+\.(?:jsonl|json)$/;
|
||||
|
||||
export function isDiagnosticFile(name) {
|
||||
return typeof name === "string" && ownedName.test(name);
|
||||
}
|
||||
|
||||
function checkedName(name) {
|
||||
if (!isDiagnosticFile(name)) throw new Error("Invalid diagnostic filename");
|
||||
return name;
|
||||
}
|
||||
|
||||
function callback(start) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try { start(resolve, reject); } catch (error) { reject(error); }
|
||||
});
|
||||
}
|
||||
|
||||
function nodeStorage(env) {
|
||||
const fs = env.lib?.node?.fs;
|
||||
const root = env.window?.__dirname;
|
||||
if (!fs || typeof root !== "string" || !root) return null;
|
||||
const directory = `${root.replace(/[\\/]$/, "")}/${LOG_DIRECTORY}`;
|
||||
const invoke = (method, ...args) => callback((resolve, reject) => {
|
||||
fs[method](...args, (error, value) => error ? reject(error) : resolve(value));
|
||||
});
|
||||
const ready = () => invoke("mkdir", directory, { recursive: true });
|
||||
return {
|
||||
kind: "file", location: directory,
|
||||
async write(name, text) {
|
||||
await ready();
|
||||
await invoke("writeFile", `${directory}/${checkedName(name)}`, text, "utf8");
|
||||
},
|
||||
async list() {
|
||||
await ready();
|
||||
const names = (await invoke("readdir", directory)).filter(isDiagnosticFile);
|
||||
const entries = [];
|
||||
for (const name of names) {
|
||||
const stat = await invoke("lstat", `${directory}/${name}`);
|
||||
if (stat.isFile()) entries.push({ name, bytes: stat.size, time: stat.mtimeMs });
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
remove: name => invoke("unlink", `${directory}/${checkedName(name)}`),
|
||||
};
|
||||
}
|
||||
|
||||
function cordovaStorage(env) {
|
||||
const host = env.window;
|
||||
if (typeof host?.resolveLocalFileSystemURL !== "function") return null;
|
||||
const root = env.localStorage?.getItem("noname_inited");
|
||||
if (!root) return null;
|
||||
let directoryPromise;
|
||||
const directory = () => {
|
||||
if (!directoryPromise) directoryPromise = callback((resolve, reject) => {
|
||||
host.resolveLocalFileSystemURL(root, resolve, reject);
|
||||
}).then(async entry => {
|
||||
for (const part of LOG_DIRECTORY.split("/")) {
|
||||
entry = await callback((resolve, reject) =>
|
||||
entry.getDirectory(part, { create: true }, resolve, reject));
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return directoryPromise;
|
||||
};
|
||||
const file = async name => {
|
||||
const entry = await directory();
|
||||
return callback((resolve, reject) =>
|
||||
entry.getFile(checkedName(name), { create: true }, resolve, reject));
|
||||
};
|
||||
return {
|
||||
kind: "file", location: `${root.replace(/\/$/, "")}/${LOG_DIRECTORY}`,
|
||||
async write(name, text) {
|
||||
const entry = await file(name);
|
||||
await callback((resolve, reject) => entry.createWriter(writer => {
|
||||
let truncated = false;
|
||||
writer.onerror = () => reject(writer.error || new Error("Diagnostic write failed"));
|
||||
writer.onwriteend = () => {
|
||||
if (writer.error) { reject(writer.error); return; }
|
||||
if (truncated) { resolve(); return; }
|
||||
truncated = true;
|
||||
try { writer.write(new Blob([text], { type: "text/plain;charset=utf-8" })); }
|
||||
catch (error) { reject(error); }
|
||||
};
|
||||
try { writer.truncate(0); } catch (error) { reject(error); }
|
||||
}, reject));
|
||||
},
|
||||
async list() {
|
||||
const entry = await directory();
|
||||
const reader = entry.createReader();
|
||||
const entries = [];
|
||||
while (true) {
|
||||
const batch = await callback((resolve, reject) =>
|
||||
reader.readEntries(resolve, reject));
|
||||
if (!batch.length) break;
|
||||
for (const item of batch) {
|
||||
if (!item.isFile || !isDiagnosticFile(item.name)) continue;
|
||||
const metadata = await callback((resolve, reject) =>
|
||||
item.getMetadata(resolve, reject));
|
||||
entries.push({ name: item.name, bytes: metadata.size,
|
||||
time: new Date(metadata.modificationTime).getTime() });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
async remove(name) {
|
||||
const entry = await file(name);
|
||||
return callback((resolve, reject) => entry.remove(resolve, reject));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function databaseStorage(env) {
|
||||
const indexedDB = env.indexedDB;
|
||||
if (!indexedDB) return null;
|
||||
let database;
|
||||
const open = () => database ||= callback((resolve, reject) => {
|
||||
const request = indexedDB.open("shuying-diagnostics", 1);
|
||||
let failed = false;
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains("files")) db.createObjectStore("files", { keyPath: "name" });
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
if (failed) { request.result.close(); return; }
|
||||
request.result.onversionchange = () => request.result.close();
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = () => { failed = true; reject(request.error); };
|
||||
request.onblocked = () => { failed = true; reject(new Error("Diagnostic database blocked")); };
|
||||
});
|
||||
const transaction = async action => {
|
||||
const db = await open();
|
||||
return callback((resolve, reject) => {
|
||||
const tx = db.transaction(["files"], "readwrite");
|
||||
let value, failure;
|
||||
tx.oncomplete = () => resolve(value);
|
||||
tx.onerror = event => { failure = event.target?.error; };
|
||||
tx.onabort = () => reject(tx.error || failure || new Error("Diagnostic transaction aborted"));
|
||||
try { action(tx.objectStore("files"), result => { value = result; }); }
|
||||
catch (error) { try { tx.abort(); } catch {} reject(error); }
|
||||
});
|
||||
};
|
||||
return {
|
||||
kind: "indexeddb", location: "本机独立诊断数据库",
|
||||
write(name, text) {
|
||||
return transaction(store => store.put({ name: checkedName(name), text,
|
||||
bytes: byteLength(text), time: Date.now() }));
|
||||
},
|
||||
list() {
|
||||
return transaction((store, done) => {
|
||||
const entries = [];
|
||||
const cursor = store.openCursor();
|
||||
cursor.onsuccess = () => {
|
||||
const item = cursor.result;
|
||||
if (!item) { done(entries); return; }
|
||||
if (isDiagnosticFile(item.value.name)) entries.push({
|
||||
name: item.value.name, bytes: item.value.bytes, time: item.value.time,
|
||||
});
|
||||
item.continue();
|
||||
};
|
||||
});
|
||||
},
|
||||
remove: name => transaction(store => store.delete(checkedName(name))),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiagnosticStorage(env, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 5000;
|
||||
const onFailure = options.onFailure || (() => {});
|
||||
const candidates = [];
|
||||
try {
|
||||
const native = nodeStorage(env) || cordovaStorage(env);
|
||||
if (native) candidates.push(native);
|
||||
} catch (error) { onFailure(error, "file.init"); }
|
||||
try {
|
||||
const database = databaseStorage(env);
|
||||
if (database) candidates.push(database);
|
||||
} catch (error) { onFailure(error, "database.init"); }
|
||||
let index = 0;
|
||||
const timed = action => new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Diagnostic storage timed out")), timeoutMs);
|
||||
Promise.resolve().then(action).then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
const execute = async (method, ...args) => {
|
||||
while (index < candidates.length) {
|
||||
const backend = candidates[index];
|
||||
try { return await timed(() => backend[method](...args)); }
|
||||
catch (error) {
|
||||
try { onFailure(error, `${backend.kind}.${method}`); } catch {}
|
||||
if (method !== "write") throw error;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
throw new Error("Diagnostic persistence unavailable; records kept in memory");
|
||||
};
|
||||
return {
|
||||
write: (name, text) => execute("write", name, text),
|
||||
list: () => execute("list"),
|
||||
remove: name => execute("remove", name),
|
||||
status: () => ({ kind: candidates[index]?.kind || "memory",
|
||||
location: candidates[index]?.location || "仅当前运行内存,未能持久化" }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"version": "2.0.2.3-rc.3",
|
||||
"module_id": "mengsan",
|
||||
"snapshot": "legacy",
|
||||
"files": [
|
||||
"mengsan/assets/card-overlays/infection-sprite.webp",
|
||||
"mengsan/assets/cards/mengsan_baozou.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_alchemize.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_anointed.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_automation.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_beacon_of_hope.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_beat_down.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_believe_in_you.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_bolas.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_calamity.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_catastrophe.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_coordinate.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_dark_shackles.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_discovery.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_dramatic_entrance.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_entropy.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_equilibrium.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_eternal_armor.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_fasten.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_finesse.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_fisticuffs.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_flash_of_steel.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_gang_up.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_gold_axe.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_hand_of_greed.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_hidden_gem.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_huddle_up.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_impatience.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_intercept.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_jack_of_all_trades.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_jackpot.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_knockdown.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_lift.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_master_of_strategy.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_mayhem.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_mimic.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_mind_blast.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_nostalgia.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_omnislice.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_panache.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_panic_button.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_prep_time.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_production.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_prolong.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_prowess.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_purity.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_rally.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_rend.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_restlessness.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_rolling_boulder.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_salvo.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_scrawl.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_secret_technique.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_secret_weapon.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_seeker_strike.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_shockwave.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_splash.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_stratagem.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_tag_team.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_the_ball.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_the_bomb.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_the_gambit.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_thinking_ahead.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_thrumming_hatchet.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_ultimate_defend.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_ultimate_strike.png",
|
||||
"mengsan/assets/cards/mengsan_colorless_volley.png",
|
||||
"mengsan/assets/cards/mengsan_curse_ascenders_bane.png",
|
||||
"mengsan/assets/cards/mengsan_curse_bad_luck.png",
|
||||
"mengsan/assets/cards/mengsan_curse_clumsy.png",
|
||||
"mengsan/assets/cards/mengsan_curse_curse_of_the_bell.png",
|
||||
"mengsan/assets/cards/mengsan_curse_debt.png",
|
||||
"mengsan/assets/cards/mengsan_curse_decay.png",
|
||||
"mengsan/assets/cards/mengsan_curse_doubt.png",
|
||||
"mengsan/assets/cards/mengsan_curse_enthralled.png",
|
||||
"mengsan/assets/cards/mengsan_curse_folly.png",
|
||||
"mengsan/assets/cards/mengsan_curse_greed.png",
|
||||
"mengsan/assets/cards/mengsan_curse_guilty.png",
|
||||
"mengsan/assets/cards/mengsan_curse_injury.png",
|
||||
"mengsan/assets/cards/mengsan_curse_normality.png",
|
||||
"mengsan/assets/cards/mengsan_curse_poor_sleep.png",
|
||||
"mengsan/assets/cards/mengsan_curse_regret.png",
|
||||
"mengsan/assets/cards/mengsan_curse_shame.png",
|
||||
"mengsan/assets/cards/mengsan_curse_spore_mind.png",
|
||||
"mengsan/assets/cards/mengsan_curse_writhe.png",
|
||||
"mengsan/assets/cards/mengsan_dazed_shuying.png",
|
||||
"mengsan/assets/cards/mengsan_event_abundance.png",
|
||||
"mengsan/assets/cards/mengsan_event_apotheosis.png",
|
||||
"mengsan/assets/cards/mengsan_event_apparition.png",
|
||||
"mengsan/assets/cards/mengsan_event_brightest_flame.png",
|
||||
"mengsan/assets/cards/mengsan_event_byrd_swoop.png",
|
||||
"mengsan/assets/cards/mengsan_event_enlightenment.png",
|
||||
"mengsan/assets/cards/mengsan_event_exterminate.png",
|
||||
"mengsan/assets/cards/mengsan_event_feeding_frenzy.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_chaos.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_choking.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_curious.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_energized.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_expertise.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_improvement.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_sapping.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_violence.png",
|
||||
"mengsan/assets/cards/mengsan_event_mad_science_wisdom.png",
|
||||
"mengsan/assets/cards/mengsan_event_maul.png",
|
||||
"mengsan/assets/cards/mengsan_event_metamorphosis.png",
|
||||
"mengsan/assets/cards/mengsan_event_neows_fury.png",
|
||||
"mengsan/assets/cards/mengsan_event_peck.png",
|
||||
"mengsan/assets/cards/mengsan_event_relax.png",
|
||||
"mengsan/assets/cards/mengsan_event_squash.png",
|
||||
"mengsan/assets/cards/mengsan_event_toric_toughness.png",
|
||||
"mengsan/assets/cards/mengsan_event_whistle.png",
|
||||
"mengsan/assets/cards/mengsan_event_wish.png",
|
||||
"mengsan/assets/cards/mengsan_fangxue.png",
|
||||
"mengsan/assets/cards/mengsan_fangyu.png",
|
||||
"mengsan/assets/cards/mengsan_feijianhuixuanbiao.png",
|
||||
"mengsan/assets/cards/mengsan_fennu.png",
|
||||
"mengsan/assets/cards/mengsan_ic_ashen_strike.png",
|
||||
"mengsan/assets/cards/mengsan_ic_barricade.png",
|
||||
"mengsan/assets/cards/mengsan_ic_battle_ready.png",
|
||||
"mengsan/assets/cards/mengsan_ic_battle_trance.png",
|
||||
"mengsan/assets/cards/mengsan_ic_blaze.png",
|
||||
"mengsan/assets/cards/mengsan_ic_bludgeon.png",
|
||||
"mengsan/assets/cards/mengsan_ic_bodyguard.png",
|
||||
"mengsan/assets/cards/mengsan_ic_boulder.png",
|
||||
"mengsan/assets/cards/mengsan_ic_brand.png",
|
||||
"mengsan/assets/cards/mengsan_ic_break.png",
|
||||
"mengsan/assets/cards/mengsan_ic_bully.png",
|
||||
"mengsan/assets/cards/mengsan_ic_burning_pact.png",
|
||||
"mengsan/assets/cards/mengsan_ic_clash.png",
|
||||
"mengsan/assets/cards/mengsan_ic_colossus.png",
|
||||
"mengsan/assets/cards/mengsan_ic_conflagration.png",
|
||||
"mengsan/assets/cards/mengsan_ic_corruption.png",
|
||||
"mengsan/assets/cards/mengsan_ic_crimson_mantle.png",
|
||||
"mengsan/assets/cards/mengsan_ic_cruelty.png",
|
||||
"mengsan/assets/cards/mengsan_ic_dark_embrace.png",
|
||||
"mengsan/assets/cards/mengsan_ic_demon_form.png",
|
||||
"mengsan/assets/cards/mengsan_ic_demonic_shield.png",
|
||||
"mengsan/assets/cards/mengsan_ic_dismantle.png",
|
||||
"mengsan/assets/cards/mengsan_ic_dominate.png",
|
||||
"mengsan/assets/cards/mengsan_ic_drum_of_battle.png",
|
||||
"mengsan/assets/cards/mengsan_ic_dual_wield.png",
|
||||
"mengsan/assets/cards/mengsan_ic_eager.png",
|
||||
"mengsan/assets/cards/mengsan_ic_entrench.png",
|
||||
"mengsan/assets/cards/mengsan_ic_evil_eye.png",
|
||||
"mengsan/assets/cards/mengsan_ic_feed.png",
|
||||
"mengsan/assets/cards/mengsan_ic_feel_no_pain.png",
|
||||
"mengsan/assets/cards/mengsan_ic_ferocity.png",
|
||||
"mengsan/assets/cards/mengsan_ic_fiend_fire.png",
|
||||
"mengsan/assets/cards/mengsan_ic_fight_me.png",
|
||||
"mengsan/assets/cards/mengsan_ic_flame_barrier.png",
|
||||
"mengsan/assets/cards/mengsan_ic_forgotten_ritual.png",
|
||||
"mengsan/assets/cards/mengsan_ic_grapple.png",
|
||||
"mengsan/assets/cards/mengsan_ic_hellraiser.png",
|
||||
"mengsan/assets/cards/mengsan_ic_hemokinesis.png",
|
||||
"mengsan/assets/cards/mengsan_ic_howl_from_beyond.png",
|
||||
"mengsan/assets/cards/mengsan_ic_impervious.png",
|
||||
"mengsan/assets/cards/mengsan_ic_infernal_blade.png",
|
||||
"mengsan/assets/cards/mengsan_ic_inferno.png",
|
||||
"mengsan/assets/cards/mengsan_ic_inflame.png",
|
||||
"mengsan/assets/cards/mengsan_ic_juggernaut.png",
|
||||
"mengsan/assets/cards/mengsan_ic_juggle.png",
|
||||
"mengsan/assets/cards/mengsan_ic_midnight.png",
|
||||
"mengsan/assets/cards/mengsan_ic_not_yet.png",
|
||||
"mengsan/assets/cards/mengsan_ic_offering.png",
|
||||
"mengsan/assets/cards/mengsan_ic_pacts_end.png",
|
||||
"mengsan/assets/cards/mengsan_ic_pillage.png",
|
||||
"mengsan/assets/cards/mengsan_ic_primal_force.png",
|
||||
"mengsan/assets/cards/mengsan_ic_pyre.png",
|
||||
"mengsan/assets/cards/mengsan_ic_rage.png",
|
||||
"mengsan/assets/cards/mengsan_ic_rally.png",
|
||||
"mengsan/assets/cards/mengsan_ic_relentless_assault.png",
|
||||
"mengsan/assets/cards/mengsan_ic_rupture.png",
|
||||
"mengsan/assets/cards/mengsan_ic_second_wind.png",
|
||||
"mengsan/assets/cards/mengsan_ic_stampede.png",
|
||||
"mengsan/assets/cards/mengsan_ic_stoke.png",
|
||||
"mengsan/assets/cards/mengsan_ic_stomp.png",
|
||||
"mengsan/assets/cards/mengsan_ic_stone_armor.png",
|
||||
"mengsan/assets/cards/mengsan_ic_taunt.png",
|
||||
"mengsan/assets/cards/mengsan_ic_tear_asunder.png",
|
||||
"mengsan/assets/cards/mengsan_ic_thrash.png",
|
||||
"mengsan/assets/cards/mengsan_ic_twin_strike.png",
|
||||
"mengsan/assets/cards/mengsan_ic_unleash.png",
|
||||
"mengsan/assets/cards/mengsan_ic_unshakable.png",
|
||||
"mengsan/assets/cards/mengsan_ic_uppercut.png",
|
||||
"mengsan/assets/cards/mengsan_infection_shuying.png",
|
||||
"mengsan/assets/cards/mengsan_jianbingdaji.png",
|
||||
"mengsan/assets/cards/mengsan_jianyi.png",
|
||||
"mengsan/assets/cards/mengsan_pomie.png",
|
||||
"mengsan/assets/cards/mengsan_qiling.png",
|
||||
"mengsan/assets/cards/mengsan_quanshenzhuangji.png",
|
||||
"mengsan/assets/cards/mengsan_rongrongzhiquan.png",
|
||||
"mengsan/assets/cards/mengsan_shandianpili.png",
|
||||
"mengsan/assets/cards/mengsan_shuangchongdaji.png",
|
||||
"mengsan/assets/cards/mengsan_slimed_shuying.png",
|
||||
"mengsan/assets/cards/mengsan_songjianwushi.png",
|
||||
"mengsan/assets/cards/mengsan_status_beckon.png",
|
||||
"mengsan/assets/cards/mengsan_status_burn.png",
|
||||
"mengsan/assets/cards/mengsan_status_debris.png",
|
||||
"mengsan/assets/cards/mengsan_status_deprecated_card.png",
|
||||
"mengsan/assets/cards/mengsan_status_frantic_escape.png",
|
||||
"mengsan/assets/cards/mengsan_status_soot.png",
|
||||
"mengsan/assets/cards/mengsan_status_toxic.png",
|
||||
"mengsan/assets/cards/mengsan_status_void.png",
|
||||
"mengsan/assets/cards/mengsan_status_wither.png",
|
||||
"mengsan/assets/cards/mengsan_tiezhanbo.png",
|
||||
"mengsan/assets/cards/mengsan_touchui.png",
|
||||
"mengsan/assets/cards/mengsan_tupo.png",
|
||||
"mengsan/assets/cards/mengsan_wanmeidaji.png",
|
||||
"mengsan/assets/cards/mengsan_wound_shuying.png",
|
||||
"mengsan/assets/cards/mengsan_wuzhuang.png",
|
||||
"mengsan/assets/cards/mengsan_xuanfengzhan.png",
|
||||
"mengsan/assets/cards/mengsan_xueqiang.png",
|
||||
"mengsan/assets/cards/mengsan_yuanhen.png",
|
||||
"mengsan/assets/cards/mengsan_yubeidaji.png",
|
||||
"mengsan/assets/cards/mengsan_yujin.png",
|
||||
"mengsan/assets/cards/mengsan_zhanli.png",
|
||||
"mengsan/assets/cards/mengsan_zhongsha.png",
|
||||
"mengsan/assets/cards/wound-original.png",
|
||||
"mengsan/assets/flyconid-portrait.png",
|
||||
"mengsan/assets/intent/Intent_attack_1.png",
|
||||
"mengsan/assets/intent/Intent_attack_1.webp",
|
||||
"mengsan/assets/intent/Intent_attack_2.png",
|
||||
"mengsan/assets/intent/Intent_attack_2.webp",
|
||||
"mengsan/assets/intent/Intent_attack_3.png",
|
||||
"mengsan/assets/intent/Intent_attack_3.webp",
|
||||
"mengsan/assets/intent/Intent_attack_4.png",
|
||||
"mengsan/assets/intent/Intent_attack_4.webp",
|
||||
"mengsan/assets/intent/Intent_attack_5.png",
|
||||
"mengsan/assets/intent/Intent_attack_5.webp",
|
||||
"mengsan/assets/intent/Intent_buff.png",
|
||||
"mengsan/assets/intent/Intent_card_debuff.png",
|
||||
"mengsan/assets/intent/Intent_death_blow.png",
|
||||
"mengsan/assets/intent/Intent_debuff.png",
|
||||
"mengsan/assets/intent/Intent_debuff.webp",
|
||||
"mengsan/assets/intent/Intent_defend.png",
|
||||
"mengsan/assets/intent/Intent_escape.png",
|
||||
"mengsan/assets/intent/Intent_heal.png",
|
||||
"mengsan/assets/intent/Intent_sleep.png",
|
||||
"mengsan/assets/intent/Intent_status_card.png",
|
||||
"mengsan/assets/intent/Intent_stun.png",
|
||||
"mengsan/assets/intent/Intent_summon.png",
|
||||
"mengsan/assets/intent/Intent_unknown.png",
|
||||
"mengsan/assets/portraits/bygone-effigy-forest-portrait.png",
|
||||
"mengsan/assets/portraits/bygone-effigy-original.png",
|
||||
"mengsan/assets/portraits/byrdonis-forest-portrait.png",
|
||||
"mengsan/assets/portraits/byrdonis-original.png",
|
||||
"mengsan/assets/portraits/ceremonial-beast-forest-portrait.png",
|
||||
"mengsan/assets/portraits/ceremonial-beast-original.png",
|
||||
"mengsan/assets/portraits/cubex-construct-forest-portrait.png",
|
||||
"mengsan/assets/portraits/cubex-construct-original.webp",
|
||||
"mengsan/assets/portraits/eye-with-teeth-forest-portrait.png",
|
||||
"mengsan/assets/portraits/eye-with-teeth.webp",
|
||||
"mengsan/assets/portraits/flyconid-portrait.png",
|
||||
"mengsan/assets/portraits/fogmog-and-eye-forest-portrait.png",
|
||||
"mengsan/assets/portraits/fogmog-forest-portrait.png",
|
||||
"mengsan/assets/portraits/fogmog.webp",
|
||||
"mengsan/assets/portraits/fuzzy-wurm-crawler-forest-portrait.png",
|
||||
"mengsan/assets/portraits/fuzzy-wurm-crawler-original.webp",
|
||||
"mengsan/assets/portraits/guanyu-portrait.jpg",
|
||||
"mengsan/assets/portraits/inklet-forest-portrait.png",
|
||||
"mengsan/assets/portraits/inklet-original.png",
|
||||
"mengsan/assets/portraits/kin-follower-forest-portrait.png",
|
||||
"mengsan/assets/portraits/kin-follower-original.webp",
|
||||
"mengsan/assets/portraits/kin-priest-forest-portrait.png",
|
||||
"mengsan/assets/portraits/kin-priest-original.png",
|
||||
"mengsan/assets/portraits/leaf-slime-m-forest-portrait.png",
|
||||
"mengsan/assets/portraits/leaf-slime-m-original.png",
|
||||
"mengsan/assets/portraits/leaf-slime-s-forest-portrait.png",
|
||||
"mengsan/assets/portraits/leaf-slime-s-original.png",
|
||||
"mengsan/assets/portraits/liubei-portrait.jpg",
|
||||
"mengsan/assets/portraits/mawler-forest-portrait.png",
|
||||
"mengsan/assets/portraits/mawler-original.webp",
|
||||
"mengsan/assets/portraits/nibbit-forest-portrait.png",
|
||||
"mengsan/assets/portraits/nibbit-original.webp",
|
||||
"mengsan/assets/portraits/phrog-parasite-forest-portrait.png",
|
||||
"mengsan/assets/portraits/phrog-parasite-original.png",
|
||||
"mengsan/assets/portraits/raider-assassin-portrait.png",
|
||||
"mengsan/assets/portraits/raider-axe-portrait.png",
|
||||
"mengsan/assets/portraits/raider-brute-portrait.png",
|
||||
"mengsan/assets/portraits/raider-crossbow-portrait.png",
|
||||
"mengsan/assets/portraits/raider-tracker-portrait.png",
|
||||
"mengsan/assets/portraits/shrinker-beetle-forest-portrait.png",
|
||||
"mengsan/assets/portraits/shrinker-beetle-original.png",
|
||||
"mengsan/assets/portraits/slithering-strangler-forest-portrait.png",
|
||||
"mengsan/assets/portraits/slithering-strangler-original.png",
|
||||
"mengsan/assets/portraits/snapping-jaxfruit-forest-portrait.png",
|
||||
"mengsan/assets/portraits/snapping-jaxfruit-original.png",
|
||||
"mengsan/assets/portraits/sushuang-zhangshiping.png",
|
||||
"mengsan/assets/portraits/twig-slime-m-forest-portrait.png",
|
||||
"mengsan/assets/portraits/twig-slime-m-original.png",
|
||||
"mengsan/assets/portraits/twig-slime-s-forest-portrait.png",
|
||||
"mengsan/assets/portraits/twig-slime-s-original.png",
|
||||
"mengsan/assets/portraits/vantom-forest-portrait.png",
|
||||
"mengsan/assets/portraits/vantom-original.png",
|
||||
"mengsan/assets/portraits/vine-shambler-forest-portrait.png",
|
||||
"mengsan/assets/portraits/vine-shambler-original-thumbnail.webp",
|
||||
"mengsan/assets/portraits/wriggler-forest-portrait.png",
|
||||
"mengsan/assets/portraits/wriggler-original.webp",
|
||||
"mengsan/assets/portraits/zhangfei-portrait.jpg",
|
||||
"mengsan/assets/sushuang-zhangshiping.png",
|
||||
"mengsan/battle/battle-director.js",
|
||||
"mengsan/battle/battle-flow.js",
|
||||
"mengsan/battle/battle-loop.js",
|
||||
"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",
|
||||
"mengsan/battle/intent-damage.js",
|
||||
"mengsan/battle/intent-effects.js",
|
||||
"mengsan/battle/skill-teardown.js",
|
||||
"mengsan/battle/stun-intent.js",
|
||||
"mengsan/bonds/battle.js",
|
||||
"mengsan/bonds/definitions.js",
|
||||
"mengsan/bonds/intents.js",
|
||||
"mengsan/bonds/state.js",
|
||||
"mengsan/cards/affixes.js",
|
||||
"mengsan/cards/battle-card-copy.js",
|
||||
"mengsan/cards/battle-card-reclaim.js",
|
||||
"mengsan/cards/battle-energy.js",
|
||||
"mengsan/cards/block-draw.js",
|
||||
"mengsan/cards/card-data.js",
|
||||
"mengsan/cards/card-definitions.js",
|
||||
"mengsan/cards/card-pools.js",
|
||||
"mengsan/cards/classification-types.js",
|
||||
"mengsan/cards/description.js",
|
||||
"mengsan/cards/hand-exhaust.js",
|
||||
"mengsan/cards/hand-upgrade.js",
|
||||
"mengsan/cards/havoc-play.js",
|
||||
"mengsan/cards/infection-card.js",
|
||||
"mengsan/cards/intrinsic-affixes.js",
|
||||
"mengsan/cards/ironclad-advanced-data.js",
|
||||
"mengsan/cards/ironclad-data.js",
|
||||
"mengsan/cards/ironclad-hooks.js",
|
||||
"mengsan/cards/ironclad.js",
|
||||
"mengsan/cards/life-loss-block.js",
|
||||
"mengsan/cards/mode-cards.js",
|
||||
"mengsan/cards/packs/colorless/data.js",
|
||||
"mengsan/cards/packs/colorless/index.js",
|
||||
"mengsan/cards/packs/colorless/rewards.js",
|
||||
"mengsan/cards/packs/curse/data.js",
|
||||
"mengsan/cards/packs/data.js",
|
||||
"mengsan/cards/packs/event/data.js",
|
||||
"mengsan/cards/packs/index.js",
|
||||
"mengsan/cards/packs/liubei/advanced-data.js",
|
||||
"mengsan/cards/packs/liubei/advanced.js",
|
||||
"mengsan/cards/packs/liubei/data.js",
|
||||
"mengsan/cards/packs/liubei/index.js",
|
||||
"mengsan/cards/packs/liubei/legacy.js",
|
||||
"mengsan/cards/packs/liubei/metadata.js",
|
||||
"mengsan/cards/packs/liubei/names.js",
|
||||
"mengsan/cards/packs/liubei/rewards.js",
|
||||
"mengsan/cards/packs/liubei/runtime.js",
|
||||
"mengsan/cards/packs/liubei/traits.js",
|
||||
"mengsan/cards/packs/rewards.js",
|
||||
"mengsan/cards/packs/shared/data.js",
|
||||
"mengsan/cards/packs/shared/runtime.js",
|
||||
"mengsan/cards/packs/status/data.js",
|
||||
"mengsan/cards/personal-piles.js",
|
||||
"mengsan/cards/rampage.js",
|
||||
"mengsan/cards/random-hand-exhaust.js",
|
||||
"mengsan/cards/rarity-data.js",
|
||||
"mengsan/cards/rarity.js",
|
||||
"mengsan/cards/retired-cards.js",
|
||||
"mengsan/cards/shared-battle-end.js",
|
||||
"mengsan/cards/shared-hooks.js",
|
||||
"mengsan/cards/slimed-card.js",
|
||||
"mengsan/cards/spite.js",
|
||||
"mengsan/cards/starting-deck.js",
|
||||
"mengsan/cards/status-cards.js",
|
||||
"mengsan/cards/strike-counter.js",
|
||||
"mengsan/cards/temporary-strength.js",
|
||||
"mengsan/cards/upgrades.js",
|
||||
"mengsan/cards/whirlwind.js",
|
||||
"mengsan/cards/wither-card.js",
|
||||
"mengsan/cards/wound-card.js",
|
||||
"mengsan/config.js",
|
||||
"mengsan/content/acts/act1/encounters.js",
|
||||
"mengsan/content/acts/act1/index.js",
|
||||
"mengsan/content/acts/act1/inklet-group.js",
|
||||
"mengsan/content/acts/act1/kin-group.js",
|
||||
"mengsan/content/acts/act1/map.js",
|
||||
"mengsan/content/acts/act1/nibbit-pair.js",
|
||||
"mengsan/content/acts/act1/raider-trio.js",
|
||||
"mengsan/content/acts/act1/rewards.js",
|
||||
"mengsan/content/acts/act1/stories/forest-ambush.js",
|
||||
"mengsan/content/acts/act1/stories/liubei-opening.js",
|
||||
"mengsan/content/acts/act2/index.js",
|
||||
"mengsan/content/acts/act2/map.js",
|
||||
"mengsan/content/acts/act3/index.js",
|
||||
"mengsan/content/acts/act3/map.js",
|
||||
"mengsan/content/affixes.js",
|
||||
"mengsan/content/card-definitions.js",
|
||||
"mengsan/content/card-library.js",
|
||||
"mengsan/content/encounters.js",
|
||||
"mengsan/content/mode-cards.js",
|
||||
"mengsan/content/registry.js",
|
||||
"mengsan/content/scenario-characters.js",
|
||||
"mengsan/content/shared/index.js",
|
||||
"mengsan/content/shared/rewards.js",
|
||||
"mengsan/docs/battle-config.md",
|
||||
"mengsan/docs/combat-rules.md",
|
||||
"mengsan/docs/liubei-opening.md",
|
||||
"mengsan/map/index.js",
|
||||
"mengsan/map/renderer.js",
|
||||
"mengsan/mode.js",
|
||||
"mengsan/monsters/actions.js",
|
||||
"mengsan/monsters/artifact-status.js",
|
||||
"mengsan/monsters/beast-intent.js",
|
||||
"mengsan/monsters/byrdonis-intent.js",
|
||||
"mengsan/monsters/crawler-intent.js",
|
||||
"mengsan/monsters/cubex-intent.js",
|
||||
"mengsan/monsters/effigy-intent.js",
|
||||
"mengsan/monsters/effigy-slow.js",
|
||||
"mengsan/monsters/flyconid-intent.js",
|
||||
"mengsan/monsters/fogmog-battle.js",
|
||||
"mengsan/monsters/fogmog-intent.js",
|
||||
"mengsan/monsters/inklet-intent.js",
|
||||
"mengsan/monsters/jaxfruit-intent.js",
|
||||
"mengsan/monsters/kin-intent.js",
|
||||
"mengsan/monsters/leafmedium-intent.js",
|
||||
"mengsan/monsters/leafslime-goop.js",
|
||||
"mengsan/monsters/leafslime-intent.js",
|
||||
"mengsan/monsters/mawler-intent.js",
|
||||
"mengsan/monsters/monster.js",
|
||||
"mengsan/monsters/nibbit-intent.js",
|
||||
"mengsan/monsters/phrog-battle.js",
|
||||
"mengsan/monsters/phrog-intent.js",
|
||||
"mengsan/monsters/raider-intent.js",
|
||||
"mengsan/monsters/shrinker-intent.js",
|
||||
"mengsan/monsters/shrinker-status.js",
|
||||
"mengsan/monsters/strangler-constrict.js",
|
||||
"mengsan/monsters/strangler-intent.js",
|
||||
"mengsan/monsters/twigmedium-intent.js",
|
||||
"mengsan/monsters/twigslime-intent.js",
|
||||
"mengsan/monsters/vantom-intent.js",
|
||||
"mengsan/monsters/vine-intent.js",
|
||||
"mengsan/monsters/vine-tangled.js",
|
||||
"mengsan/progression/dialogue-choice.js",
|
||||
"mengsan/progression/encounter-progress.js",
|
||||
"mengsan/progression/map-checkpoint.js",
|
||||
"mengsan/progression/mode-storage.js",
|
||||
"mengsan/progression/reward-package.js",
|
||||
"mengsan/progression/reward.js",
|
||||
"mengsan/progression/state.js",
|
||||
"mengsan/progression/story.js",
|
||||
"mengsan/register.js",
|
||||
"mengsan/relics/battle.js",
|
||||
"mengsan/relics/combat.js",
|
||||
"mengsan/relics/definitions.js",
|
||||
"mengsan/relics/rooms.js",
|
||||
"mengsan/runtime/battle-director.js",
|
||||
"mengsan/runtime/battle-flow.js",
|
||||
"mengsan/runtime/battle-loop.js",
|
||||
"mengsan/runtime/battle-resources.js",
|
||||
"mengsan/runtime/battle-session.js",
|
||||
"mengsan/runtime/battle-settlement.js",
|
||||
"mengsan/runtime/card-data.js",
|
||||
"mengsan/runtime/card-library.js",
|
||||
"mengsan/runtime/combat-rules.js",
|
||||
"mengsan/runtime/dialogue-choice.js",
|
||||
"mengsan/runtime/dialogue.js",
|
||||
"mengsan/runtime/engine-session.js",
|
||||
"mengsan/runtime/flow-ui.js",
|
||||
"mengsan/runtime/flyconid-intent.js",
|
||||
"mengsan/runtime/gm-manager.js",
|
||||
"mengsan/runtime/intent-display.js",
|
||||
"mengsan/runtime/mode-storage.js",
|
||||
"mengsan/runtime/monster.js",
|
||||
"mengsan/runtime/navigation.js",
|
||||
"mengsan/runtime/personal-piles.js",
|
||||
"mengsan/runtime/reward-ui.js",
|
||||
"mengsan/runtime/reward.js",
|
||||
"mengsan/runtime/skill-teardown.js",
|
||||
"mengsan/runtime/story.js",
|
||||
"mengsan/state.js",
|
||||
"mengsan/style.css",
|
||||
"mengsan/ui/bonds.js",
|
||||
"mengsan/ui/card-library.js",
|
||||
"mengsan/ui/dialogue.js",
|
||||
"mengsan/ui/energy-display.js",
|
||||
"mengsan/ui/flow-ui.js",
|
||||
"mengsan/ui/gm-manager.js",
|
||||
"mengsan/ui/hand/adapter.js",
|
||||
"mengsan/ui/hand/diagnostics.js",
|
||||
"mengsan/ui/hand/fan.js",
|
||||
"mengsan/ui/hand/index.js",
|
||||
"mengsan/ui/hand/input.js",
|
||||
"mengsan/ui/hand/overlays.js",
|
||||
"mengsan/ui/hand/style.css",
|
||||
"mengsan/ui/intent-display.js",
|
||||
"mengsan/ui/map/index.js",
|
||||
"mengsan/ui/map/renderer.js",
|
||||
"mengsan/ui/navigation.js",
|
||||
"mengsan/ui/relics.js",
|
||||
"mengsan/ui/reward-package.js",
|
||||
"mengsan/ui/reward-ui.js",
|
||||
"mengsan/ui/style.css",
|
||||
"mengsan/ui/virtual-card-grid.js"
|
||||
]
|
||||
}
|
||||
@@ -1096,6 +1096,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 更新设置:沿用选择窗口材质,两个选项与取消/确认按钮适配窄屏。 */
|
||||
.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;
|
||||
|
||||
@@ -2,10 +2,11 @@ import { lib, game, ui, get, ai, _status } from "../../noname.js";
|
||||
import updater from "./update.js"
|
||||
import func from "./function.js"
|
||||
import initShuYingMenu from "./menu.js";
|
||||
import { configureDiagnostics } from "./diagnostics/index.js";
|
||||
|
||||
export const type = "extension";
|
||||
export let shuYing = new Object();
|
||||
const shuYingLocalVersion = "2.0.1.1-rc.1";
|
||||
const shuYingLocalVersion = "2.0.2.3-rc.3";
|
||||
|
||||
shuYing.name = "术樱包";
|
||||
shuYing.editable = false;
|
||||
@@ -16,6 +17,11 @@ shuYing.text = {}; //中上方文本
|
||||
shuYing.text_style = {}; //中上方文本样式
|
||||
shuYing.moduleCache = {}; //动态加载模块缓存,避免同一文件重复import
|
||||
shuYing.voices = {}; //语音台词表,voices.js缺失时保持为空
|
||||
shuYing.diagnostics = configureDiagnostics({
|
||||
lib, game, window, document, version: shuYingLocalVersion,
|
||||
extensionRoot: shuYing.url,
|
||||
});
|
||||
const diagnostics = shuYing.diagnostics.scope("extension");
|
||||
|
||||
//按需加载扩展内部模块。optional为true时,文件缺失只关闭对应功能,不中断整个扩展入口。
|
||||
shuYing.loadModule = async (path, label, optional = true) => {
|
||||
@@ -24,6 +30,7 @@ shuYing.loadModule = async (path, label, optional = true) => {
|
||||
.then(module => module.default ?? module)
|
||||
.catch(error => {
|
||||
const message = `术樱包:${label || path}加载失败`;
|
||||
diagnostics.error("module.load.failed", error, { path, label, optional });
|
||||
if (!optional) throw error;
|
||||
console.warn(message, error);
|
||||
return null;
|
||||
@@ -125,6 +132,7 @@ shuYing.getVersion = (callback) => {
|
||||
.catch(error => {
|
||||
if (requestId != shuYing.versionRequestId) return null;
|
||||
console.error("术樱包获取在线版本失败:", error);
|
||||
diagnostics.error("version.lookup.failed", error);
|
||||
shuYing.updateOnlineVersionMenu(null, "获取失败");
|
||||
return null;
|
||||
});
|
||||
@@ -245,17 +253,20 @@ shuYing.initCharacter = async () => {
|
||||
}
|
||||
|
||||
//初始化游戏时
|
||||
shuYing.precontent = async () => {
|
||||
shuYing.precontent = () => diagnostics.step("precontent", async () => {
|
||||
await shuYing.loadVoices();
|
||||
shuYing.initCSS();
|
||||
shuYing.initFunction();
|
||||
shuYing.initConfig();
|
||||
const mengsanState = lib.config.shuYing_mengsan_installed_version;
|
||||
let hasMengsan = !!mengsanState && mengsanState != "disabled";
|
||||
if (!mengsanState && typeof game.checkFile != "function") {
|
||||
const mengsanInstallation = lib.config.shuYing_mengsan_installation;
|
||||
let hasMengsan = mengsanInstallation
|
||||
? mengsanInstallation.status == "installed"
|
||||
: !!mengsanState && mengsanState != "disabled";
|
||||
if (!mengsanInstallation && !mengsanState && typeof game.checkFile != "function") {
|
||||
hasMengsan = true;
|
||||
}
|
||||
else if (!mengsanState && typeof game.checkFile == "function") {
|
||||
else if (!mengsanInstallation && !mengsanState && typeof game.checkFile == "function") {
|
||||
hasMengsan = await new Promise(resolve => {
|
||||
game.checkFile("extension/术樱包/mengsan/register.js",
|
||||
result => resolve(result == 1), () => resolve(false));
|
||||
@@ -270,7 +281,7 @@ shuYing.precontent = async () => {
|
||||
initShuYingMenu({ lib, game, ui, shuYing, updater });
|
||||
shuYing.getVersion();
|
||||
await shuYing.initCharacter();
|
||||
};
|
||||
});
|
||||
|
||||
shuYing.config = () => {
|
||||
|
||||
|
||||
@@ -225,7 +225,8 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
|
||||
};
|
||||
|
||||
// 创建可复用的对局单选界面;单击选中卡片,确认后返回对应键值。
|
||||
shuYing.chooseSingleOptionDialog = ({ title = "请选择", intro = "", options = [], defaultKey = null } = {}) => {
|
||||
shuYing.chooseSingleOptionDialog = ({ title = "请选择", intro = "", options = [], defaultKey = null,
|
||||
currentKey = null, cancelable = false, variant = "default", confirmText = "确定" } = {}) => {
|
||||
return new Promise(resolve => {
|
||||
const items = options.map(option => typeof option == "string" ? { key: option, name: option } : option)
|
||||
.filter(option => option?.key);
|
||||
@@ -240,18 +241,26 @@ 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 = () => {
|
||||
@@ -260,6 +269,7 @@ 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);
|
||||
@@ -276,14 +286,44 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
|
||||
refreshSelection();
|
||||
});
|
||||
});
|
||||
const confirm = createNode("button", "shuYing-option-dialog-confirm", "确定", controls);
|
||||
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);
|
||||
confirm.type = "button";
|
||||
confirm.addEventListener("click", event => {
|
||||
event.stopPropagation();
|
||||
finish(selectedKey);
|
||||
if (!confirm.disabled) finish(selectedKey);
|
||||
});
|
||||
mask.addEventListener("click", event => {
|
||||
event.stopPropagation();
|
||||
if (cancelable && event.target == mask) finish(null);
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
`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/` | 战斗会话、回合、结算、费用与通用战斗效果 |
|
||||
|
||||
|
After Width: | Height: | Size: 661 KiB |
@@ -0,0 +1,57 @@
|
||||
# 梦三卡牌立绘规范
|
||||
|
||||
2026-10-03:铁甲战士94张已全部接入:新增67、沿用27、暂缓0;另有衍生巨石。68张新增完整5:7水墨立绘使用 `mengsan_ic_*.png`。刘备专属牌组与空无色牌组已分目录和牌组类,详情见 [制作清单](../../../../../sts2-card-capture-20261003/generation/铁甲战士制作清单.md)、[全部立绘预览](../../../../../sts2-card-capture-20261003/generation/铁甲战士立绘预览.md) 及 [牌组结构](../../cards/packs/README.md)。尚未进行实机验收。
|
||||
|
||||
用户于 2026-10-02 指定以截图 `C:\Users\Diuse\Downloads\Ai\49.png` 中“防御”为同系列基准。
|
||||
|
||||
- 后续新增及转换的梦三卡牌使用约 5:7 的完整竖幅立绘;手牌常规尺寸 112×156,窄屏 82×114。禁止直接使用横向拆包图作为运行时卡面,也不要拉伸横图。
|
||||
- 运行时 PNG 限制在 480×672 像素以内、单张不超过 300 KiB;5:7 立绘统一缩小到 480×672,现有小图不放大,其他旧比例等比例缩小。生成的大原图必须先优化再接入,不裁切、不拉伸;保留透明度、文件名和引用。使用 `node extension/术樱包/mengsan/tools/optimize-card-art.cjs --all` 批量处理,或将 `--all` 换成单张实际卡牌 PNG 的绝对路径;需要 sharp,可由 `MENGSAN_SHARP_MODULE` 指定模块位置。共享牌的 `record-art.py` 已在写入流程中调用优化器。
|
||||
- 批量优化只处理 `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%` 拉伸。
|
||||
- 费用和强化角标继续使用现有 `data-*` 与伪元素,避免向卡牌子节点写入额外文字而触发十周年 UI 扫描。
|
||||
- 不改变卡牌费用、效果、强化、奖励与存档数据;不修改全局标准/军争牌或十周年 UI。
|
||||
- 接入后检查完整立绘、花色/费用/名称、扇形叠牌、悬停放大和窄屏。文件检查、隔离行为测试及预览不代表实机验收。
|
||||
|
||||
现有本地卡面检查:愤怒、飞剑回旋镖、剑柄打击、全身撞击、熔融之拳及晕眩统一竖幅;防御与重杀原有竖幅保留。
|
||||
|
||||
## 卡牌描述(2026-10-02)
|
||||
|
||||
后续卡牌只描述当前费用与当前效果,不写是否可升级/强化、次数或上限,也不写升级前后对比或升级后变化。普通牌显示普通数值,已强化的牌直接显示其当前数值。此规范同步适用于悬停介绍、静态翻译、奖励摘要、牌库详情及磨砺选择说明;保留强化机制、操作按钮和紧凑 +X 角标。
|
||||
|
||||
闪电霹雳已按本规范接入竖幅水墨立绘。
|
||||
|
||||
双重打击:对应拆包 twin_strike.png 的双道斩击主题,使用同规格竖幅水墨立绘。1费,两次独立伤害;描述只显示当前牌数值。
|
||||
|
||||
铁斩波:对应拆包 iron_wave.png 的钢刃与防护波主题;竖幅水墨立绘。1费,先获得格挡再攻击,沿用护甲、脆弱、力量与易伤规则;描述只显示当前牌数值。
|
||||
|
||||
头槌:对应拆包 headbutt.png 的额头撞击主题;竖幅水墨立绘。1费,先攻击再选择一张个人弃牌置于个人抽牌堆顶部。移动原实体牌,保留ID、花色、强化与词缀,不新增永久牌。
|
||||
|
||||
突破:对应拆包 breakthrough.png 的突破包围与群体横扫主题;5:7竖幅宣纸水墨立绘。1费,先通过原生 loseHp 失去1点生命,再对所有存活敌方造成9点伤害。失去生命不抵扣格挡;等待正常濒死/救援结算,若角色死亡或战斗结束则停止攻击。描述只显示当前牌费用与效果。
|
||||
|
||||
预备打击:对应 setup_strike.png 的挥拳与蓄势主题;5:7竖幅宣纸水墨立绘。1费,先造成7点伤害,再在当前完整回合内获得2点临时力量。按网页主表而非未正式发布的更新历史实现;描述只显示当前效果。临时力量可叠加,独立于永久力量,当前完整回合结束/死亡/战斗退出时清理。
|
||||
|
||||
完美打击:对应 perfected_strike.png 的精准挥刀主题;全幅5:7竖幅宣纸水墨立绘。2费,基础6点伤害,本场战斗每张当前名称含“打击”的牌增加2点伤害。统计手牌、个人抽牌堆、个人弃牌堆、消耗牌及本人打出区的真实牌,去重并包含此牌自身;生成/复制/变形即时生效,不读取永久牌组、不统计公共或敌方牌。描述只显示当前牌费用与效果。
|
||||
|
||||
余烬:对应 cinder.png 的灼热弯刀与燃尽主题;5:7完整竖幅宣纸水墨立绘。2费,先造成18点伤害,再从当时自己的剩余手牌中随机消耗一张。遵循网页0.101.0后的手牌规则,不消耗抽牌堆顶部。通过原生失牌事件移入私有消耗区,不进入弃牌堆/洗牌,不更改永久牌组;没有手牌时仍造成伤害。描述只显示当前费用与效果。
|
||||
|
||||
放血:对应 bloodletting.png 的气血换取力量主题;完整5:7竖幅宣纸水墨立绘。0费,先通过原生 loseHp 失去3点生命,再获得2点当前费用,不增加费用上限。等待濒死救援;死亡、战斗结束或切换后不再获得费用。属于技能牌,缠身不额外增加攻击费用;描述只显示当前费用与效果。
|
||||
|
||||
坚毅:对应 true_grit.png 的顶住巨岩、防御与坚忍主题;完整5:7竖幅宣纸水墨立绘。1费,先获得7点格挡,再从当时自己的剩余手牌中随机消耗一张;当前选择模式通过原生选牌事件选择自己的手牌。移动原实体牌到私有消耗区,保留ID、花色、当前状态和词缀,不改永久牌组;无手牌时仍获得格挡。等待格挡事件,防止战斗结束、换场、死亡及陈旧选牌继续失牌;沿用脆弱的格挡衰减。描述只显示当前费用与效果。
|
||||
|
||||
破灭:对应 HAVOC/havoc.png 的宽刃旋斩与黑披风主题;完整5:7竖幅宣纸水墨立绘。普通1费,当前另一状态0费;免费使用自己的抽牌堆顶部实体牌,再仅消耗该原牌。费用豁免绑定原生选择/使用父链和实体身份,不改卡牌费用或词缀,愤怒生成的副本正常进入自己的弃牌堆,不跟随原牌消耗(对应Wiki0.104.0修复)。原生可用性和目标规则保持有效,无法使用的堆顶牌仍消耗;目标由原生使用流程选择。空抽牌堆不洗牌、不触发抽牌不足失败。保留当前数值、ID、花色、词缀和永久牌组,防止换场/死亡期间陈旧使用。描述只显示当前费用和效果。
|
||||
|
||||
耸肩无视:对应 SHRUG_IT_OFF/shrug_it_off.png 的耸肩摊手、厚重肩甲弹开攻击主题;完整5:7竖幅宣纸水墨立绘。1费,先通过原生 changeHujia 获得8点格挡,再通过原生 draw 抽1张牌;当前牌直接显示其实际格挡值。沿用脆弱对格挡的衰减与个人牌堆抽牌/洗牌/不足失败流程;等待格挡完全结算,战斗结束、死亡、换场或个人牌堆被替换后不再抽牌,不触碰公共和其他角色牌堆,不修改永久牌组;无消耗关键词。描述只显示当前费用与效果。
|
||||
|
||||
武装:对应 ARMAMENTS/armaments.png 的系紧金属护臂、加固防护主题;完整5:7竖幅宣纸水墨立绘。1费,先获得5点格挡,再选择强化一张当时的手牌;当前全体状态强化所有当时的手牌。仅对现有强化规则允许的牌生效,已满级、状态牌、永恒词缀、消耗牌及本次使用的实体牌不参与;没有符合条件的手牌仍获得格挡。等待原生格挡及选牌事件,战斗结束、换场、死亡、牌堆替换或选择期间失去该牌后不提交陈旧选择。复制真实牌元数据,保留ID、花色、点数、词缀并刷新费用与+X角标;不写永久牌组,退出战斗恢复临时强化。描述只显示当前费用和效果。运行时PNG缩小并量化,拆包原件保留。
|
||||
|
||||
战栗:对应 TREMBLE/tremble.png 的蜷缩颤抖、恐惧易伤主题;完整5:7竖幅黑褐宣纸水墨立绘,不使用横向裁切,不烘焙文字或边框。1费,对一名存活敌人施加3层易伤;当前强化状态施加4层。沿用人工制品抵消、攻击伤害增加50%向上取整与自身回合末减少1层规则;技能牌不造成直接伤害,不承受缠结的攻击牌额外费用。使用后原生消耗,仅退出本场战斗;未使用而弃置仍进入弃牌堆,永久牌组保留。战斗结束、自己或目标死亡及非敌方目标不施加。描述只显示当前费用及当前效果,无强化资格或未来变化说明。运行时PNG保持比例缩小至480×672并量化压缩,拆包参考原件不改动。
|
||||
|
||||
血墙:对应 BLOOD_WALL/blood_wall.png 的赤红屏障抵挡攻击主题;完整5:7竖幅黑褐宣纸水墨与暗朱红立绘,不裁切横图,不烘焙文字或边框。2费,先失去2点生命,再获得16点格挡;当前强化状态获得20点。等待原生loseHp及濒死救援结算;存活且仍在同一场战斗和同一会话中的使用者才继续获得格挡。战斗结束、死亡、换场、会话替换或角色离场后不继续。生命流失不是攻击伤害,不被护甲抵消,也不受力量、虚弱或易伤修正;格挡沿用原生changeHujia及脆弱规则,无护甲上限。技能牌不承受缠结攻击牌额外费用,无固有消耗,不改永久牌组。描述只显示当前费用和效果,不列强化资格或预告。运行时PNG保持比例缩小至480×672并量化压缩,拆包参考原件不改动。
|
||||
|
||||
欺凌:对应 BULLY/bully.png 的强者压制、乘虚进攻主题;完整5:7黑褐宣纸水墨竖幅,不裁切横图,不烘焙名称费用边框。0费,对一名存活敌人造成4点基础伤害,目标每有1层当前易伤额外增加2点;当前强化状态每层增加3点。每次结算读取实际目标的当前层数,仅发起一次原生攻击,不消耗易伤、不读取使用者或其他目标层数,不使用回合剩余数量代替层数。基础数值进入既有力量、临时力量、易伤50%及其他攻击修正链,易伤增幅不由牌效果重复乘算。非法负数、非整数、非有限层数或伤害溢出拒绝发起伤害。战斗结束、角色死亡、自身或友方目标不结算;不限制距离,次数不限,无固有消耗。作为攻击牌,缠结时按既有规则增加1费用并显示当前费用,响应仍免费。当前描述不列强化资格或未来效果。运行时PNG保持比例缩小至480×672并量化压缩,拆包参考原件不改动。
|
||||
|
||||
旋风斩:WHIRLWIND/whirlwind.png 的环形刀风连击主题,完整5:7宣纸水墨竖幅,无烘焙文字费用边框。X费,对全部敌人造成5点伤害X次,当前强化状态为8点。原生useCard支付时消耗剩余费用并固定X,一条用牌只支付、提取一次,次数不随后续加费变化;0费可使用但没有伤害。缠结先扣1附加费用再以剩余费用作X;不足附加费用不可用。破灭授权免费使用时固定现有费用作X但不扣费;响应不收费。次数仅绑定最近原生用牌、使用者、原战斗和原会话,嵌套其他牌不得借用X。逐轮重新取存活敌人,死亡或离场目标跳过,新生成敌人从下一轮参与;死亡、结束、换场、会话替换或使用者离场停止。每次等待原生伤害并沿用力量、临时力量、易伤等,不合并伤害。图鉴新增X费筛选,手牌用空文本data叠层显示X。永久牌数据不写次数,不固有消耗。描述仅当前效果,运行时480×672量化压缩,拆包原件保留。
|
||||
怨恨:SPITE/spite.png 的金甲头盔、含毒巨刃与负伤反击主题;完整5:7宣纸水墨竖幅,无烘焙名称费用边框。0费,对一名存活敌人造成5点伤害;本回合失去过生命时攻击2次,当前强化状态攻击3次,每次仍为5点。读取引擎当前完整回合的changeHp历史,仅实际changedHp<0且属于本场根事件、本人时触发,护甲完全抵消或回复不触发,流失生命后再回复仍有效,不读取伤害请求量或永久状态。使用开始时固定次数,攻击期间新失血影响下一次用牌;逐次等待原生伤害并沿用力量、临时力量、易伤等修正。原战斗/会话/根事件及角色成员和存活敌对关系每次复核,死亡、结束、换场、离场后停止。无固有消耗,不写永久牌组;攻击牌沿用缠结增加1费用,响应免费。当前描述不列强化资格或预告。运行时480×672量化压缩,拆包参考原件保留。
|
||||
暴走:RAMPAGE/rampage.png 的碧甲狂兽、猩红螺旋眼与逐击增强主题;完整5:7黑褐宣纸水墨竖幅,无烘焙费用名称边框。按网页正式主表1费/9伤害/每次本场加5,当前强化状态每次加9;不采用带未正式发布标记的10伤害更新。先发起一次原生攻击,结算后仅增加原实体牌的下一次基础伤害,普通连续9/14/19;力量、临时力量、易伤等沿原生链修正,不把最终伤害当作累积量。累积以实体对象绑定战斗/会话,独立暴走及同ID的另一个实体不共享,弃牌、回收、重抽和本场强化保留;永久牌组和实体牌元数据不写累积。战斗牌堆快照只携带当前会话的纯数字展示数据,详情显示当前实际伤害;退出会话清除,延迟旧清理不覆盖新会话。绑定最近原生useCard及唯一同名原牌,一次用牌只结算一次,陈旧场次/会话/根事件、非敌方目标、死亡或原牌离开本人的牌堆后不继续。伤害前检查安全整数溢出,无固有消耗;沿用缠结增加1费用,响应免费。当前描述不列强化资格或未来变化。运行时480×672量化压缩,拆包参考原件保留。
|
||||
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 278 KiB |