Author SHA1 Message Date
github-actions[bot] 1a7736274f Prepare manifest for v2.0.2.3-rc.1 2026-10-05 09:42:32 +00:00
Diuse 3ac3b5e52a fixed # 修正已知BUG 2026-10-05 17:41:18 +08:00
Diuse cab413af50 feat # 梦三首次开测 2026-10-05 15:27:45 +08:00
Diuse f7f1766194 fixed # 更新系统改为并发下载 2026-10-05 14:59:30 +08:00
16 changed files with 2274 additions and 1723 deletions
+312 -312
View File
File diff suppressed because it is too large Load Diff
+1077 -1077
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.0.1.1-rc.1", "version": "2.0.2.3-rc.1",
"module_id": "mengsan", "module_id": "mengsan",
"snapshot": "legacy", "snapshot": "legacy",
"files": [ "files": [
+103
View File
@@ -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 共用。 */ /* 天书对局羁绊入口与概览:不依赖十周年 UI 节点,原版和扩展 UI 共用。 */
.shuYing-bond-overview-button { .shuYing-bond-overview-button {
all: unset !important; all: unset !important;
+1 -1
View File
@@ -5,7 +5,7 @@ import initShuYingMenu from "./menu.js";
export const type = "extension"; export const type = "extension";
export let shuYing = new Object(); export let shuYing = new Object();
const shuYingLocalVersion = "2.0.2.1"; const shuYingLocalVersion = "2.0.2.3-rc.1";
shuYing.name = "术樱包"; shuYing.name = "术樱包";
shuYing.editable = false; shuYing.editable = false;
+44 -4
View File
@@ -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 => { return new Promise(resolve => {
const items = options.map(option => typeof option == "string" ? { key: option, name: option } : option) const items = options.map(option => typeof option == "string" ? { key: option, name: option } : option)
.filter(option => option?.key); .filter(option => option?.key);
@@ -240,18 +241,26 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
if (parent) parent.appendChild(node); if (parent) parent.appendChild(node);
return node; return node;
}; };
const previousFocus = document.activeElement;
const mask = createNode("div", "shuYing-option-dialog-mask", null, document.body); const mask = createNode("div", "shuYing-option-dialog-mask", null, document.body);
mask.dataset.variant = variant;
const dialog = createNode("div", "shuYing-option-dialog", null, mask); 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); createNode("div", "shuYing-option-dialog-title", title, dialog);
if (intro) createNode("div", "shuYing-option-dialog-intro", intro, dialog); if (intro) createNode("div", "shuYing-option-dialog-intro", intro, dialog);
const optionArea = createNode("div", "shuYing-option-dialog-options", null, dialog); const optionArea = createNode("div", "shuYing-option-dialog-options", null, dialog);
const controls = createNode("div", "shuYing-option-dialog-controls", null, dialog); const controls = createNode("div", "shuYing-option-dialog-controls", null, dialog);
let closed = false; let closed = false;
let onKeydown = null;
let selectedKey = defaultKey && items.some(item => item.key == defaultKey) ? defaultKey : items[0].key; let selectedKey = defaultKey && items.some(item => item.key == defaultKey) ? defaultKey : items[0].key;
const finish = key => { const finish = key => {
if (closed) return; if (closed) return;
closed = true; closed = true;
if (onKeydown) document.removeEventListener("keydown", onKeydown, true);
mask.remove(); mask.remove();
if (cancelable && previousFocus?.isConnected) previousFocus.focus?.({ preventScroll: true });
resolve(key); resolve(key);
}; };
const refreshSelection = () => { const refreshSelection = () => {
@@ -260,6 +269,7 @@ export default function initFunction(lib, game, ui, get, ai, _status, shuYing =
card.classList.toggle("selected", selected); card.classList.toggle("selected", selected);
card.setAttribute("aria-pressed", String(selected)); card.setAttribute("aria-pressed", String(selected));
}); });
confirm.disabled = currentKey != null && selectedKey == currentKey;
}; };
items.forEach((option, index) => { items.forEach((option, index) => {
const card = createNode("button", "shuYing-option-dialog-card", null, optionArea); 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(); 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.type = "button";
confirm.addEventListener("click", event => { confirm.addEventListener("click", event => {
event.stopPropagation(); 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()); 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(); refreshSelection();
const initial = Array.from(optionArea.querySelectorAll(".shuYing-option-dialog-card")).find(node => node.dataset.key == selectedKey) const initial = Array.from(optionArea.querySelectorAll(".shuYing-option-dialog-card")).find(node => node.dataset.key == selectedKey)
|| optionArea.querySelector(".shuYing-option-dialog-card"); || optionArea.querySelector(".shuYing-option-dialog-card");
+1 -1
View File
@@ -9,7 +9,7 @@
- 批量优化只处理 `mengsan_*.png` 运行时卡牌,参考原件如 `wound-original.png` 不参与。 - 批量优化只处理 `mengsan_*.png` 运行时卡牌,参考原件如 `wound-original.png` 不参与。
- 画风:泛黄宣纸、黑褐水墨、飞白与淡赭色、中国古代人物或兵器;根据卡牌效果独立安排竖幅构图。拆包图只作题材参考,不修改拆包原件。 - 画风:泛黄宣纸、黑褐水墨、飞白与淡赭色、中国古代人物或兵器;根据卡牌效果独立安排竖幅构图。拆包图只作题材参考,不修改拆包原件。
- 立绘不绘制名称、费用、花色、强化角标或边框。左上花色点数、左侧中段费用、右下名称由游戏原生节点和梦三样式叠加;左侧信息栏和右下名称需留空间。 - 立绘不绘制名称、费用、花色、强化角标或边框。左上花色点数、左侧中段费用、右下名称由游戏原生节点和梦三样式叠加;左侧信息栏和右下名称需留空间。
- 卡牌定义使用 `fullimage: true` 与明确的 `image` 路径,不使用横图 `fullskin`。`mengsan_ic_`、`mengsan_colorless_`、`mengsan_event_`、`mengsan_status_`、`mengsan_curse_` 已按前缀接入 `ui/hand/style.css` 的本地完整卡面选择器;其他命名的新增卡牌须单独接入,长牌名同时加入长名称窄屏规则。保持 `cover`,不使用 `100% 100%` 拉伸。 - 卡牌定义使用 `fullimage: true` 与明确的 `image` 路径,不使用横图 `fullskin`。手牌叠层按实际注册定义的 `fullimage: true` 及 `ext:术樱包/mengsan/assets/cards/牌名.png` 自动设置 `data-mengsan-local-art="full"`;四字及以上的翻译名称自动标记为长名,常规和窄屏样式统一,不再维护逐牌 CSS 名单。保持 `cover`,不使用 `100% 100%` 拉伸。
- 费用和强化角标继续使用现有 `data-*` 与伪元素,避免向卡牌子节点写入额外文字而触发十周年 UI 扫描。 - 费用和强化角标继续使用现有 `data-*` 与伪元素,避免向卡牌子节点写入额外文字而触发十周年 UI 扫描。
- 不改变卡牌费用、效果、强化、奖励与存档数据;不修改全局标准/军争牌或十周年 UI。 - 不改变卡牌费用、效果、强化、奖励与存档数据;不修改全局标准/军争牌或十周年 UI。
- 接入后检查完整立绘、花色/费用/名称、扇形叠牌、悬停放大和窄屏。文件检查、隔离行为测试及预览不代表实机验收。 - 接入后检查完整立绘、花色/费用/名称、扇形叠牌、悬停放大和窄屏。文件检查、隔离行为测试及预览不代表实机验收。
+3 -1
View File
@@ -7,7 +7,9 @@ import { createHandInput } from "./input.js";
export async function mountHandUI(player, session, env) { export async function mountHandUI(player, session, env) {
const { ui, document, window, cardCost, styleURL } = env; const { ui, document, window, cardCost, styleURL } = env;
const adapter = createHandAdapter(player, ui); const adapter = createHandAdapter(player, ui);
const overlays = createHandOverlays(document, card => cardCost(card, player)); const overlays = createHandOverlays(document, card => cardCost(card, player), {
definitions: env.lib?.card, translations: env.lib?.translate,
});
const fan = createHandFan(adapter, env); const fan = createHandFan(adapter, env);
let input = null; let input = null;
const restores = []; const restores = [];
+20 -1
View File
@@ -2,13 +2,30 @@ import { cardUpgradeLevel } from "../../cards/upgrades.js";
import { isXCostCard } from "../../battle/combat-rules.js"; import { isXCostCard } from "../../battle/combat-rules.js";
import { applyCardRarity } from "../../cards/rarity.js"; import { applyCardRarity } from "../../cards/rarity.js";
export function createHandOverlays(document, cardCost) { export function createHandOverlays(document, cardCost, { definitions, translations } = {}) {
const entries = new Map(); const entries = new Map();
const setData = (node, key, value) => { const setData = (node, key, value) => {
if (node.dataset[key] !== value) node.dataset[key] = value; if (node.dataset[key] !== value) node.dataset[key] = value;
}; };
function refresh(card) { function refresh(card) {
if (!card.name) return; 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); let entry = entries.get(card);
if (!entry) { if (!entry) {
const cost = document.createElement("span"); const cost = document.createElement("span");
@@ -58,6 +75,8 @@ export function createHandOverlays(document, cardCost) {
entry.upgrade.remove(); entry.upgrade.remove();
entry.border?.remove(); entry.border?.remove();
entry.rarity.remove(); entry.rarity.remove();
delete card.dataset.mengsanLocalArt;
delete card.dataset.mengsanLongName;
delete card.dataset.mengsanRarity; delete card.dataset.mengsanRarity;
delete card.dataset.mengsanRarityLabel; delete card.dataset.mengsanRarityLabel;
for (const key of ["accent", "dark", "ink", "metal"]) card.style.removeProperty(`--ms-rarity-${key}`); for (const key of ["accent", "dark", "ink", "metal"]) card.style.removeProperty(`--ms-rarity-${key}`);
+55 -138
View File
@@ -187,12 +187,12 @@ body.mengsan-hand-ui-shuying :is(
} }
} }
/* 梦三本地竖幅立绘:刘备及四类共享牌统一使用完整卡面;只协调梦三手牌里的原生显示节点。 */ /* 按实际卡牌定义标记本地完整立绘,不再维护牌名清单;只协调梦三手牌原生节点。 */
.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) { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) {
background-size: cover !important; background-size: cover !important;
background-position: center !important; background-position: center !important;
} }
.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) { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
display: block !important; display: block !important;
top: auto !important; top: auto !important;
bottom: 8px !important; bottom: 8px !important;
@@ -214,14 +214,14 @@ body.mengsan-hand-ui-shuying :is(
z-index: 7 !important; z-index: 7 !important;
pointer-events: none; pointer-events: none;
} }
.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 { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) br {
display: none; display: none;
} }
/* 十周年同时保留竖排名称与 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 { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip):has(> .top-name:not(:empty)) > .name {
display: none !important; display: none !important;
} }
.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) { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) {
top: 6px !important; top: 6px !important;
left: 6px !important; left: 6px !important;
right: auto !important; right: auto !important;
@@ -241,17 +241,17 @@ body.mengsan-hand-ui-shuying :is(
pointer-events: none; pointer-events: none;
} }
.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[data-mengsan-local-art="full"] > .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 { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"] > .info:empty {
display: none !important; display: none !important;
} }
.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) { .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) {
color: #a52219 !important; color: #a52219 !important;
} }
/* 强化卡牌的完整卡面样式优先级更高,窄屏同样为角标留出空隙。 */ /* 强化卡牌的完整卡面样式优先级更高,窄屏同样为角标留出空隙。 */
@media (max-width: 640px), (max-height: 500px) { @media (max-width: 640px), (max-height: 500px) {
.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 { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"]:not(.infohidden):not(.infoflip):has(> .mengsan-hand-upgrade-shuying) > .suit-num {
top: 4px !important; top: 4px !important;
padding: 0 2px !important; padding: 0 2px !important;
font-size: 18px !important; font-size: 18px !important;
@@ -259,17 +259,58 @@ body.mengsan-hand-ui-shuying :is(
} }
} }
/* 长牌名完整显示,不挤出卡面;只作用于梦三手牌。 */ /* 本地完整卡面不绘制空标题或重复的花色信息条。 */
.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) { .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) {
font-size: 12px !important; font-size: 12px !important;
letter-spacing: 0 !important; letter-spacing: 0 !important;
white-space: nowrap !important; white-space: nowrap !important;
} }
@media (max-width: 640px), (max-height: 500px) { @media (max-width: 640px), (max-height: 500px) {
.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) { .mengsan-hand-zone-shuying > .card[data-mengsan-local-art="full"][data-mengsan-long-name="true"]:not(.infohidden):not(.infoflip) > :is(.name, .top-name) {
font-size: 10px !important; font-size: 10px !important;
padding: 2px 4px !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. */ /* Infection only; keep cost/name above it and leave native card::after alone. */
@@ -332,130 +373,6 @@ 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,也不改变尺寸、命中区或立绘。 */ /* 稀有度是独立装饰层,不占用原生 card::after,也不改变尺寸、命中区或立绘。 */
.mengsan-hand-rarity-shuying { display: none; pointer-events: none; } .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 { .mengsan-hand-zone-shuying > .card[data-mengsan-rarity]:not([data-mengsan-rarity="pending"]):not(.infohidden):not(.infoflip) > .mengsan-hand-rarity-shuying {
+12 -1
View File
@@ -1,5 +1,5 @@
import { openCardLibrary } from "../card-library.js"; import { openCardLibrary } from "../card-library.js";
import { askMenu, openModeSelection } from "../navigation.js"; import { askMenu, openModeSelection, openNonameMenu } from "../navigation.js";
import { ui, get } from "../../../../../noname.js"; import { ui, get } from "../../../../../noname.js";
import config from "../../config.js"; import config from "../../config.js";
import { getRelic } from "../../relics/definitions.js"; import { getRelic } from "../../relics/definitions.js";
@@ -317,6 +317,17 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
}); });
}) }); }) });
createMenuAction({ icon: "settings", name: "设置与说明", description: "显示、音效与玩法说明 · 开发中", onClick: () => showMenuPage("设置与说明", "个性化与帮助", parent => renderPlaceholder(parent, "后续将在这里提供显示、音效和操作设置。")) }); 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);
await askMenu("暂时无法显示菜单", error.message || "请稍后重试。", [{ id: "close", name: "回到行军图" }]);
}
},
}).setAttribute("aria-haspopup", "dialog");
} }
function handleEscape(event) { function handleEscape(event) {
+67 -4
View File
@@ -1,7 +1,65 @@
import { lib, game, ui } from "../../../../noname.js"; import { lib, game, ui, _status } from "../../../../noname.js";
import { save } from "../../../../noname/util/config.js"; import { save } from "../../../../noname/util/config.js";
let selectingMode = false; 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) { 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. // Reuse the registered engine splash, not reload-to-the-current-mode or a fake mode list.
export const openModeSelection = async () => { export const openModeSelection = async () => {
@@ -96,11 +154,14 @@ export const mountMenu = (overlay, options = {}) => {
if (opened) return; if (opened) return;
opened = true; opened = true;
options.onOpen?.(); options.onOpen?.();
let action = null;
try { try {
const action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [ action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [
{ id: "resume", name: "继续当前界面" }, { id: "resume", name: "继续当前界面" },
{ id: "noname", name: "显示 Noname 菜单" },
{ id: "exit", name: "返回模式选择" }, { id: "exit", name: "返回模式选择" },
]); ]);
if (action === "noname") { await openNonameMenu(); return; }
if (action !== "exit") return; if (action !== "exit") return;
const confirmed = await askMenu("返回模式选择?", "保留上次已完成节点的存档。本次尚未完成的内容不会提交。", [ const confirmed = await askMenu("返回模式选择?", "保留上次已完成节点的存档。本次尚未完成的内容不会提交。", [
{ id: "cancel", name: "留在梦三" }, { id: "cancel", name: "留在梦三" },
@@ -108,8 +169,10 @@ export const mountMenu = (overlay, options = {}) => {
]); ]);
if (confirmed === "confirm") await openModeSelection(); if (confirmed === "confirm") await openModeSelection();
} catch (error) { } catch (error) {
console.error("梦三返回模式选择失败:", error); console.error("梦三菜单操作失败:", error);
await askMenu("暂时无法返回", "没有清除征程。请关闭提示后重试。", [{ id: "close", name: "回到当前界面" }]); await askMenu(action === "noname" ? "暂时无法显示菜单" : "暂时无法返回",
action === "noname" ? error.message || "请关闭提示后重试。" : "没有清除征程。请关闭提示后重试。",
[{ id: "close", name: "回到当前界面" }]);
} finally { } finally {
opened = false; opened = false;
options.onClose?.(); options.onClose?.();
+4
View File
@@ -1,3 +1,7 @@
/* 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 { .mengsan-overlay-shuying {
position: fixed; position: fixed;
inset: 0; inset: 0;
+122 -33
View File
@@ -206,22 +206,38 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
nopointer: true, nopointer: true,
}; };
const channelText = channel => `更新通道:${channel == "preview" ? "测试版" : "正式版"}(点击切换)`; const channelText = channel => `更新通道:<b>${channel == "preview" ? "测试版" : "正式版"}</b>(点击选择)`;
menu.update_channel = { menu.update_channel = {
name: channelText(updater.getUpdateChannel()), name: channelText(updater.getUpdateChannel()),
clear: true, clear: true,
intro: "切换后立即从当前下载源的对应标签下载并校验文件,完成后需重启游戏。测试版可能回退到较早的内容。", intro: "打开选择窗口,确认后下载所选通道的更新,完成后需重启游戏。",
async onclick() { async onclick() {
if (!shuYing.m_bIsDownload) { if (!shuYing.m_bIsDownload) {
alert("有其他文件正在下载,请稍后再试吧。"); alert("正在处理其他操作,请稍后再试。");
return; return;
} }
const current = updater.getUpdateChannel(); const current = updater.getUpdateChannel();
const next = current == "preview" ? "stable" : "preview";
shuYing.m_bIsDownload = false; shuYing.m_bIsDownload = false;
this.innerHTML = `正在切换到${next == "preview" ? "测试版" : "正式版"}…`;
try { 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, { const version = await updater.checkVersion(shuYing, {
channel: next, channel: next,
switchChannel: true, switchChannel: true,
@@ -235,29 +251,53 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
const text = channelText(updater.getUpdateChannel()); const text = channelText(updater.getUpdateChannel());
menu.update_channel.name = text; menu.update_channel.name = text;
this.innerHTML = text; this.innerHTML = text;
shuYing.m_bIsDownload = true;
} }
}, },
}; };
const sourceText = source => `下载源:${source == "github" ? "GitHub" : "Gitea"}(点击切换)`; const sourceText = source => `下载源:<b>${source == "github" ? "GitHub" : "Gitea"}</b>(点击选择)`;
menu.update_source = { menu.update_source = {
name: sourceText(updater.getUpdateSource()), name: sourceText(updater.getUpdateSource()),
clear: true, clear: true,
intro: "选择从 GitHub 或 Gitea 获取标签与文件;切换后刷新在线版本,实际下载由版本检测触发。", intro: "打开下载源选择窗口;确认后刷新在线版本,不会立即下载文件。",
onclick() { async onclick() {
if (!shuYing.m_bIsDownload) { if (!shuYing.m_bIsDownload) {
alert("有其他文件正在下载,请稍后再试吧。"); alert("正在处理其他操作,请稍后再试。");
return; return;
} }
const next = updater.getUpdateSource() == "github" ? "gitea" : "github"; const current = updater.getUpdateSource();
game.saveConfig("shuYing_update_source", next); shuYing.m_bIsDownload = false;
const text = sourceText(next); try {
menu.update_source.name = text; const next = await shuYing.chooseSingleOptionDialog({
this.innerHTML = text; title: "选择下载源",
game.saveConfig("shuYing_online_version", null); intro: "确认后保存下载源并刷新在线版本,不会立即下载文件。需要更新时,请点击“版本检测”。",
shuYing.updateOnlineVersionMenu(null, "检测中..."); defaultKey: current, currentKey: current,
shuYing.getVersion(); 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;
}
}, },
}; };
@@ -273,21 +313,25 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
} }
const logs = [ const logs = [
"-----< 2.0.1.1 改动 >-----", "-----< 2.0.2.3 改动 >-----",
"新增“旧轮次”选项,可在过关后按照旧规则将行动权交给玩家方1号位上家的Boss", "新增正式版、测试版更新通道切换。",
"新增“羁绊技能倾向”选项,已激活羁绊的相关技能将按羁绊等级获得温和的出现概率提升", "支持选择 Gitea 或 GitHub 下载源。",
"羁绊技能倾向权重现可在天书配置中按等级独立调整", "更新系统采用并发下载:安卓最多 4 个、PC 最多 8 个,大文件受内存限制。",
"新增“虚拟偶像参与奖励”选项,开启后虚拟偶像可以选择过关技能并激活、升级羁绊效果", "新增梦三模式安装入口",
"-----< 2.0.1.1 修复 >-----", "-----< 2.0.2.3 修复 >-----",
"修复多次触发青龙偃月刀追加【杀】并击杀Boss后,过关奖励结束时无法正确跳过当前角色剩余回合的问题", "修复天书乱斗跨关后,按轮数冷却的技能未正确重置,以及旧关卡历史影响新关卡计数的问题。",
"修复旧轮次在新Boss召唤前提前判断座位,导致行动权交接条件无法正常触发的问题", "修复【清剿】旧关卡的延迟弃牌结算可能弃掉新关卡奖励的问题。",
"修复当前行动角色为虚拟偶像时,旧轮次无法将行动权交给玩家方1号位上家Boss的问题", "修复天书乱斗应战时的座位重叠、动态 Boss 控制切换、代理头像显示及胜负判断问题。",
"修复“南蛮入侵”和“群雄逐鹿”羁绊虽然对友方无效,但AI仍会将友方计入用牌收益而拒绝使用相关牌的问题", "修正“神武再世”三级羁绊的体力流失统计,按目标本回合累计流失的体力判定。",
"修复切换下载源后,旧版本查询结果覆盖新结果的问题。",
"核心文件修复增加大小和 SHA-256 校验。",
]; ];
const keywordColors = { const keywordColors = {
BOSS: "#ff6b6b", BOSS: "#ff6b6b",
PVE: "#ff6b6b", PVE: "#ff6b6b",
PVP: "#ff6b6b", PVP: "#ff6b6b",
正式版: "#ff6b6b",
测试版: "#ff99cc",
天书乱斗: "#66ccff", 天书乱斗: "#66ccff",
羁绊: "#d77cff", 羁绊: "#d77cff",
动态难度: "#d77cff", 动态难度: "#d77cff",
@@ -339,18 +383,63 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
}, },
}; };
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);
status = "unknown";
}
if (request == mengsanStatusRequest) renderMengsanStatus(status);
};
menu.mengsanModule = { menu.mengsanModule = {
name: "梦三模式(按需安装/卸载)", name: `<span id="${mengsanLabelId}" style="white-space:normal;">${mengsanLabelHTML("checking")}</span>`,
clear: true, clear: true,
onclick() { async onclick() {
if (shuYing.m_bIsDownload) { if (!shuYing.m_bIsDownload) {
shuYing.m_bIsDownload = false;
updater.manageMengsanModule(shuYing);
} else {
alert("有其他文件正在下载,请稍后再试吧。"); 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 = { menu.repairBug = {
name: "本地资源修复", name: "本地资源修复",
+440 -149
View File
@@ -5,6 +5,158 @@ const githubRepositoryApi = "https://api.github.com/repos/Shu-Ying/noname-shuyin
const githubRawBase = "https://raw.githubusercontent.com/Shu-Ying/noname-shuying"; const githubRawBase = "https://raw.githubusercontent.com/Shu-Ying/noname-shuying";
const coreTempDir = "extension/术樱包/.update_tmp"; const coreTempDir = "extension/术樱包/.update_tmp";
let fileMutationUncertain = false; 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) { function parseVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(version); const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(version);
@@ -135,10 +287,10 @@ function getLogText(shuYing) {
async function writeLog(shuYing) { async function writeLog(shuYing) {
if (!shuYing || typeof game.writeFile != "function") return; if (!shuYing || typeof game.writeFile != "function") return;
await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt"); await writeFile(getLogText(shuYing), "extension/术樱包", "log.txt", { diagnostic: true });
} }
async function addLog(shuYing, message) { function addLog(shuYing, message) {
if (!shuYing) return; if (!shuYing) return;
if (!Array.isArray(shuYing._updateLogs)) { if (!Array.isArray(shuYing._updateLogs)) {
@@ -147,13 +299,27 @@ async function addLog(shuYing, message) {
const time = new Date().toLocaleString(); const time = new Date().toLocaleString();
shuYing._updateLogs.push(`[${time}] ${message}`); shuYing._updateLogs.push(`[${time}] ${message}`);
shuYing._updateLogDirty = true;
if (!shuYing._updateLogTimer && !shuYing._updateLogDisabled) {
shuYing._updateLogTimer = setTimeout(() => { void flushLog(shuYing); }, 1000);
}
}
try { async function flushLog(shuYing) {
await writeLog(shuYing); clearTimeout(shuYing._updateLogTimer);
} shuYing._updateLogTimer = null;
catch (error) { 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;
console.error("写入术樱包日志失败:", error); console.error("写入术樱包日志失败:", error);
} });
shuYing._updateLogWrite = operation;
await operation;
if (shuYing._updateLogWrite == operation) shuYing._updateLogWrite = null;
} }
function escapeHTML(text) { function escapeHTML(text) {
@@ -175,17 +341,26 @@ function formatBytes(bytes) {
function setProgress(shuYing, options = {}) { function setProgress(shuYing, options = {}) {
if (!shuYing?.text) return; 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 current = Math.max(0, Number(options.current) || 0);
const total = Math.max(0, Number(options.total) || 0); const total = Math.max(0, Number(options.total) || 0);
const percent = total > 0 ? Math.min(100, Math.round(current / total * 100)) : 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 title = options.title || "资源更新"; const title = options.title || "资源更新";
const status = options.status || "准备中"; const status = options.status || "准备中";
const file = options.file || ""; 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 = ` 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;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: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;">${current}/${total}</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> </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;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%;"> <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%;">
@@ -211,27 +386,120 @@ function splitPath(path) {
}; };
} }
async function fetchFile(path, tag, source) { async function waitForRetry(delay, signal) {
const response = await fetch(getRemoteUrl(tag, path, source)); if (signal?.aborted) throw signal.reason || new Error("下载已停止");
if (!response.ok) throw new Error(`${path} 下载失败:${response.status}`); await new Promise((resolve, reject) => {
const cancel = () => {
return await response.arrayBuffer(); 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 writeTargetFile(data, target) { 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) {
if (typeof game.writeFile != "function") { if (typeof game.writeFile != "function") {
throw new Error("当前环境不支持写入文件"); throw new Error("当前环境不支持写入文件");
} }
const targetInfo = splitPath(target); const targetInfo = splitPath(target);
await writeFile(data, targetInfo.dir, targetInfo.name); const metadata = await writeFile(data, targetInfo.dir, targetInfo.name);
if (verifiedInfo) rememberVerified(target, verifiedInfo, metadata);
} }
async function downloadFile(path, target, manifest) { async function downloadFile(path, target, manifest, options = {}) {
const data = await fetchFile(path, manifest.tag_name, manifest.source); const data = await fetchFile(path, manifest.tag_name, manifest.source,
{ ...options, size: manifest.files[path]?.size });
if (manifest?.files?.[path]) { if (manifest?.files?.[path]) {
await verifyFile(path, data, manifest); await verifyFile(path, data, manifest);
} }
if (options.signal?.aborted) throw options.signal.reason || new Error("下载已停止");
await writeTargetFile(data, target); await writeTargetFile(data, target);
return data; return data;
} }
@@ -471,6 +739,34 @@ 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) { async function safeCheckFile(path) {
// An unreadable file must never be treated as a missing file during replacement. // An unreadable file must never be treated as a missing file during replacement.
return await checkFile(path); return await checkFile(path);
@@ -536,7 +832,7 @@ function hasCordovaFiles() {
return !!lib.device && typeof window.resolveLocalFileSystemURL == "function"; return !!lib.device && typeof window.resolveLocalFileSystemURL == "function";
} }
function cordovaEntry(path, create = false) { function cordovaEntry(path, create = false, mutation = create) {
return fileOperation(`打开文件:${path}`, (resolve, reject) => { return fileOperation(`打开文件:${path}`, (resolve, reject) => {
const root = localStorage.getItem("noname_inited"); const root = localStorage.getItem("noname_inited");
if (!root) throw new Error("无法确定游戏文件目录"); if (!root) throw new Error("无法确定游戏文件目录");
@@ -544,7 +840,7 @@ function cordovaEntry(path, create = false) {
try { entry.getFile(path, { create }, resolve, reject); } try { entry.getFile(path, { create }, resolve, reject); }
catch (error) { reject(error); } catch (error) { reject(error); }
}, reject); }, reject);
}, create); }, mutation);
} }
function readFile(path) { function readFile(path) {
@@ -565,12 +861,13 @@ function readFile(path) {
}); });
} }
async function writeFile(data, path, name) { async function writeFile(data, path, name, options = {}) {
const target = `${path.replace(/\/$/, "")}/${name}`; const target = `${path.replace(/\/$/, "")}/${name}`;
assertLocalPath(target); assertLocalPath(target);
invalidateVerifiedFile(target);
await fileOperation(`写入文件:${target}`, (resolve, reject) => { await fileOperation(`写入文件:${target}`, (resolve, reject) => {
if (hasCordovaFiles()) { if (hasCordovaFiles()) {
cordovaEntry(target, true).then(entry => entry.createWriter(writer => { cordovaEntry(target, true, !options.diagnostic).then(entry => entry.createWriter(writer => {
let truncated = false; let truncated = false;
writer.onerror = () => reject(writer.error || new Error("写入失败")); writer.onerror = () => reject(writer.error || new Error("写入失败"));
writer.onwriteend = () => { writer.onwriteend = () => {
@@ -606,7 +903,9 @@ async function writeFile(data, path, name) {
if (error) reject(error); if (error) reject(error);
else resolve(); else resolve();
}); });
}, true); }, !options.diagnostic);
if (options.diagnostic) return null;
const before = await fileMetadata(target);
const actual = toUint8Array(await readFile(target)); const actual = toUint8Array(await readFile(target));
const expected = toUint8Array(data); const expected = toUint8Array(data);
let equal = actual.length == expected.length; let equal = actual.length == expected.length;
@@ -614,11 +913,14 @@ async function writeFile(data, path, name) {
if (!equal) { if (!equal) {
throw new Error(`文件落盘校验失败:${target}`); throw new Error(`文件落盘校验失败:${target}`);
} }
const after = await fileMetadata(target);
return sameMetadata(before, after) ? after : null;
} }
async function removeFile(path) { async function removeFile(path) {
assertMutationReady(); assertMutationReady();
assertLocalPath(path); assertLocalPath(path);
invalidateVerifiedFile(path);
const exists = await checkFile(path); const exists = await checkFile(path);
if (exists == -1) return; if (exists == -1) return;
if (exists != 1) throw new Error(`待删除路径不是文件:${path}`); if (exists != 1) throw new Error(`待删除路径不是文件:${path}`);
@@ -706,21 +1008,24 @@ async function downloadCoreFiles(shuYing, manifest) {
await updateAllFiles(shuYing, manifest, files); await updateAllFiles(shuYing, manifest, files);
} }
async function getChangedFiles(manifest) { async function getChangedFiles(manifest, shuYing) {
if (typeof game.readFile != "function") { if (typeof game.readFile != "function") throw new Error("当前环境不支持校验本地文件");
throw new Error("当前环境不支持校验本地文件"); const files = getManifestFileKeys(manifest);
} let checked = 0;
const changed = []; const started = Date.now();
for (const file of getManifestFileKeys(manifest)) { const results = await runFilePool(files, async file => {
try { let changed = null;
const data = await readFile(`extension/术樱包/${file}`); try { await verifyLocalFile(file, manifest, shuYing); }
await verifyFile(file, data, manifest); catch (error) { changed = file; }
} checked++;
catch (error) { if (shuYing) setProgress(shuYing, {
changed.push(file); title: "检查更新", status: fullVerification ? "完整校验资源" : "检查资源变化",
} file, current: checked, total: files.length, force: checked == files.length,
} });
return changed; 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);
} }
function makeLegacyModuleManifest(manifest, moduleId) { function makeLegacyModuleManifest(manifest, moduleId) {
@@ -791,7 +1096,33 @@ function getMengsanRecord() {
async function saveMengsanRecord(record) { async function saveMengsanRecord(record) {
const version = record.status == "disabled" ? "disabled" : record.version || "legacy"; const version = record.status == "disabled" ? "disabled" : record.version || "legacy";
// Both keys commit together; saveConfig/putDB request success is too early. 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.
lib.status.reload++; lib.status.reload++;
try { try {
if (lib.db) { if (lib.db) {
@@ -802,10 +1133,9 @@ async function saveMengsanRecord(record) {
transaction = lib.db.transaction(["config"], "readwrite"); transaction = lib.db.transaction(["config"], "readwrite");
transaction.oncomplete = () => resolve(); transaction.oncomplete = () => resolve();
transaction.onerror = event => { requestError = event.target?.error; }; 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"); const store = transaction.objectStore("config");
store.put(record, mengsanRecordKey); for (const [key, value] of Object.entries(values)) store.put(value, key);
store.put(version, mengsanVersionKey);
} }
catch (error) { catch (error) {
try { transaction?.abort(); } catch {} try { transaction?.abort(); } catch {}
@@ -817,12 +1147,10 @@ async function saveMengsanRecord(record) {
const key = `${lib.configprefix}config`; const key = `${lib.configprefix}config`;
const config = JSON.parse(localStorage.getItem(key) || "{}"); const config = JSON.parse(localStorage.getItem(key) || "{}");
if (!config || typeof config != "object" || Array.isArray(config)) throw new Error("本地配置格式无效"); if (!config || typeof config != "object" || Array.isArray(config)) throw new Error("本地配置格式无效");
config[mengsanRecordKey] = record; Object.assign(config, values);
config[mengsanVersionKey] = version;
localStorage.setItem(key, JSON.stringify(config)); localStorage.setItem(key, JSON.stringify(config));
} }
lib.config[mengsanRecordKey] = record; Object.assign(lib.config, values);
lib.config[mengsanVersionKey] = version;
} }
finally { game.reload2(); } finally { game.reload2(); }
} }
@@ -885,9 +1213,8 @@ async function installMengsanFiles(shuYing, manifest, files, previousManifest =
try { try {
await updateAllFiles(shuYing, manifest, files, operationRoot); await updateAllFiles(shuYing, manifest, files, operationRoot);
filesApplied = true; filesApplied = true;
for (const file of currentFiles) { const invalid = await getChangedFiles(manifest, shuYing);
await verifyFile(file, await readFile(`extension/术樱包/${file}`), manifest); if (invalid.length) throw new Error(`梦三模块校验失败:${invalid.slice(0, 5).join("、")}`);
}
await saveMengsanRecord({ status: "installed", version: manifest.version, await saveMengsanRecord({ status: "installed", version: manifest.version,
source: manifest.source, files: currentFiles }); source: manifest.source, files: currentFiles });
} }
@@ -978,21 +1305,23 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
let retainRecovery = false; let retainRecovery = false;
try { try {
ensureProgressNode(shuYing); ensureProgressNode(shuYing);
await invalidateBeforeChanges([...files, ...obsoleteFiles]);
await createDir(`${newRoot}/`); await createDir(`${newRoot}/`);
await createDir(`${oldRoot}/`); await createDir(`${oldRoot}/`);
await createFolders(`${newRoot}/`, files); await createFolders(`${newRoot}/`, files);
await createFolders(`${oldRoot}/`, [...files, ...obsoleteFiles]); await createFolders(`${oldRoot}/`, [...files, ...obsoleteFiles]);
for (let i = 0; i < files.length; i++) { const progress = downloadProgress(shuYing, files, manifest);
const file = files[i]; await runFilePool(files, async (file, index, signal) => {
setProgress(shuYing, {
title: "版本更新", status: "下载并校验", file,
current: i, total: files.length * 2,
});
staged.add(file); staged.add(file);
await downloadFile(file, `${newRoot}/${file}`, manifest); await downloadFile(file, `${newRoot}/${file}`, manifest, {
} signal, shuYing, onProgress: (loaded, delta) => progress.receive(file, loaded, delta),
});
progress.complete(file);
}, { weight: file => manifest.files[file].size * 4 });
progress.finish();
const installStarted = Date.now();
await createFolders("extension/术樱包/", files); await createFolders("extension/术樱包/", files);
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
@@ -1007,11 +1336,14 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
} }
setProgress(shuYing, { setProgress(shuYing, {
title: "版本更新", status: "写入中", file, title: "安装更新", status: "写入并检查", file,
current: files.length + i, total: files.length * 2, current: i, total: files.length, force: i == 0,
}); });
const prepared = await readFile(`${newRoot}/${file}`);
// Recheck staged bytes before publishing a verification receipt.
await verifyFile(file, prepared, manifest);
installed.push(file); installed.push(file);
await writeTargetFile(await readFile(`${newRoot}/${file}`), target); await writeTargetFile(prepared, target, manifest.files[file]);
} }
for (const file of obsoleteFiles) { for (const file of obsoleteFiles) {
const target = `extension/术樱包/${file}`; const target = `extension/术樱包/${file}`;
@@ -1025,6 +1357,8 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
} }
await removeManifestOldPaths(shuYing, manifest); await removeManifestOldPaths(shuYing, manifest);
filesInstalled = true; 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) { catch (error) {
const rollbackFiles = filesInstalled || error.operationUncertain ? [] const rollbackFiles = filesInstalled || error.operationUncertain ? []
@@ -1079,44 +1413,10 @@ async function updateAllFiles(shuYing, manifest, files, operationRoot = newOpera
} }
async function downloadList(shuYing, files, manifest) { async function downloadList(shuYing, files, manifest) {
let finished = 0; // Repair also stages downloads before sequential replacement and rollback.
const total = files.length; const changed = await getChangedFiles(manifest, shuYing);
const allowed = new Set(files);
for (const file of files) { await updateAllFiles(shuYing, manifest, changed.filter(file => allowed.has(file)));
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(), async function getOnlineVersion(channel = getUpdateChannel(),
@@ -1127,7 +1427,8 @@ async function getOnlineVersion(channel = getUpdateChannel(),
async function checkVersion(shuYing, options = {}) { async function checkVersion(shuYing, options = {}) {
try { try {
shuYing._updateLogs = []; beginUpdate(shuYing);
ensureProgressNode(shuYing);
await addLog(shuYing, "版本检查开始"); await addLog(shuYing, "版本检查开始");
const onlineVersion = await getOnlineVersion(options.channel, options.source); const onlineVersion = await getOnlineVersion(options.channel, options.source);
const localVersion = lib.config.shuYing_local_version || "0.0.0.0"; const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
@@ -1161,7 +1462,7 @@ async function checkVersion(shuYing, options = {}) {
); );
} }
const changed = await getChangedFiles(manifest); const changed = await getChangedFiles(manifest, shuYing);
const obsolete = await getObsoleteFiles(manifest); const obsolete = await getObsoleteFiles(manifest);
let mengsanManifest = null; let mengsanManifest = null;
let mengsanChanged = []; let mengsanChanged = [];
@@ -1180,7 +1481,7 @@ async function checkVersion(shuYing, options = {}) {
getManifestFileKeys(previousMengsan) getManifestFileKeys(previousMengsan)
); );
} }
mengsanChanged = await getChangedFiles(mengsanManifest); mengsanChanged = await getChangedFiles(mengsanManifest, shuYing);
mengsanObsolete = await getObsoleteFiles(mengsanManifest); mengsanObsolete = await getObsoleteFiles(mengsanManifest);
} }
catch (error) { catch (error) {
@@ -1234,8 +1535,11 @@ async function checkVersion(shuYing, options = {}) {
return null; return null;
} }
finally { finally {
removeProgressNode(shuYing); try { await finishUpdate(shuYing); }
setBusy(shuYing, false); finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
} }
} }
@@ -1247,7 +1551,7 @@ async function repairMissingFiles(shuYing) {
} }
try { try {
shuYing._updateLogs = []; beginUpdate(shuYing, true);
await addLog(shuYing, "查漏补缺开始"); await addLog(shuYing, "查漏补缺开始");
ensureProgressNode(shuYing); ensureProgressNode(shuYing);
setProgress(shuYing, { title: "查漏补缺", status: "获取校验清单", current: 0, total: 1 }); setProgress(shuYing, { title: "查漏补缺", status: "获取校验清单", current: 0, total: 1 });
@@ -1296,9 +1600,8 @@ async function repairMissingFiles(shuYing) {
await createFolders("extension/术樱包/", files, shuYing); await createFolders("extension/术樱包/", files, shuYing);
await downloadList(shuYing, files, manifest); await downloadList(shuYing, files, manifest);
await removeManifestOldPaths(shuYing, manifest);
if (mengsanManifest) { if (mengsanManifest) {
const moduleChanged = await getChangedFiles(mengsanManifest); const moduleChanged = await getChangedFiles(mengsanManifest, shuYing);
await installMengsanFiles(shuYing, mengsanManifest, moduleChanged, previousMengsan); await installMengsanFiles(shuYing, mengsanManifest, moduleChanged, previousMengsan);
} }
@@ -1312,15 +1615,18 @@ async function repairMissingFiles(shuYing) {
alert("查漏补缺失败,请查看 extension/术樱包/log.txt"); alert("查漏补缺失败,请查看 extension/术樱包/log.txt");
} }
finally { finally {
removeProgressNode(shuYing); try { await finishUpdate(shuYing); }
setBusy(shuYing, false); finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
} }
} }
async function manageMengsanModule(shuYing) { async function manageMengsanModule(shuYing) {
setBusy(shuYing, true); setBusy(shuYing, true);
try { try {
shuYing._updateLogs = []; beginUpdate(shuYing);
assertMutationReady(); assertMutationReady();
const record = getMengsanRecord(); const record = getMengsanRecord();
const localVersion = lib.config.shuYing_local_version || "0.0.0.0"; const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
@@ -1341,6 +1647,7 @@ async function manageMengsanModule(shuYing) {
source: record?.source || getUpdateSource(), files, source: record?.source || getUpdateSource(), files,
}; };
await saveMengsanRecord(pending); await saveMengsanRecord(pending);
await invalidateBeforeChanges(files);
ensureProgressNode(shuYing); ensureProgressNode(shuYing);
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
@@ -1382,59 +1689,42 @@ async function manageMengsanModule(shuYing) {
alert(`${hint}\n${getErrorMessage(error)}\n请查看 extension/术樱包/log.txt。`); alert(`${hint}\n${getErrorMessage(error)}\n请查看 extension/术樱包/log.txt。`);
} }
finally { finally {
removeProgressNode(shuYing); try { await finishUpdate(shuYing); }
setBusy(shuYing, false); finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
} }
} }
async function repairCoreFiles(shuYing) { 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 { 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 downloadCoreFiles(shuYing, manifest);
await addLog(shuYing, "核心文件修复完成");
alert("下载完成,重启生效"); alert("下载完成,重启生效");
} }
catch (error) { catch (error) {
console.error(error); console.error(error);
await addLog(shuYing, `核心文件修复失败:${getErrorMessage(error)}`);
alert("核心文件下载或校验失败,请检查服务器文件与 manifest.json 是否一致。"); alert("核心文件下载或校验失败,请检查服务器文件与 manifest.json 是否一致。");
} }
finally { finally {
removeProgressNode(shuYing); try { await finishUpdate(shuYing); }
setBusy(shuYing, false); finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
} }
} }
@@ -1446,4 +1736,5 @@ export default {
repairMissingFiles, repairMissingFiles,
repairCoreFiles, repairCoreFiles,
manageMengsanModule, manageMengsanModule,
getMengsanModuleStatus,
}; };
+12
View File
@@ -1,3 +1,15 @@
"-----< 2.0.2.3 改动 >-----",
"新增正式版、测试版更新通道切换。",
"支持选择 Gitea 或 GitHub 下载源。",
"更新系统采用并发下载:安卓最多 4 个、PC 最多 8 个,大文件受内存限制。",
"新增梦三模式安装入口",
"-----< 2.0.2.3 修复 >-----",
"修复天书乱斗跨关后,按轮数冷却的技能未正确重置,以及旧关卡历史影响新关卡计数的问题。",
"修复【清剿】旧关卡的延迟弃牌结算可能弃掉新关卡奖励的问题。",
"修复天书乱斗应战时的座位重叠、动态 Boss 控制切换、代理头像显示及胜负判断问题。",
"修正“神武再世”三级羁绊的体力流失统计,按目标本回合累计流失的体力判定。",
"修复切换下载源后,旧版本查询结果覆盖新结果的问题。",
"核心文件修复增加大小和 SHA-256 校验。",
"-----< 2.0.1.1 改动 >-----", "-----< 2.0.1.1 改动 >-----",
"新增“旧轮次”选项,可在过关后按照旧规则将行动权交给玩家方1号位上家的Boss", "新增“旧轮次”选项,可在过关后按照旧规则将行动权交给玩家方1号位上家的Boss",
"新增“羁绊技能倾向”选项,已激活羁绊的相关技能将按羁绊等级获得温和的出现概率提升", "新增“羁绊技能倾向”选项,已激活羁绊的相关技能将按羁绊等级获得温和的出现概率提升",