fixed # 修复手机端地图滑动异常的问题

fixed # 重构更新框架,支持选择是否下载梦三模式
fixed # 补充部分意图的资源图片与逻辑
This commit is contained in:
2026-09-29 17:44:21 +08:00
parent c9c09582c1
commit dd562ae627
32 changed files with 1156 additions and 135 deletions
+16 -6
View File
@@ -92,10 +92,13 @@ jobs:
) as archive:
for name in manifest["files"]:
archive.write(root / name, folder + name)
archive.write(
root / "dist/manifest.json",
folder + "manifest.json"
)
for descriptor in manifest.get("modules", {}).values():
component = json.loads(
(root / descriptor["manifest"]).read_text(encoding="utf-8")
)
for name in component["files"]:
archive.write(root / name, folder + name)
archive.write(root / "dist/manifest.json", folder + "manifest.json")
'@ | python - $stage $package
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
@@ -135,7 +138,14 @@ jobs:
-Body $body
$assetApi = "$api/$($release.id)/assets"
foreach ($file in @($manifestPath, $package)) {
$moduleManifests = @(
Get-ChildItem (Join-Path $stage 'dist/modules') `
-Filter '*.json' -ErrorAction SilentlyContinue
)
$releaseFiles = @($manifestPath)
$releaseFiles += $moduleManifests
$releaseFiles += $package
foreach ($file in $releaseFiles) {
curl.exe --fail --silent --show-error `
-H "Authorization: token $env:GITEA_TOKEN" `
-F "attachment=@$file" `
@@ -151,4 +161,4 @@ jobs:
-ContentType 'application/json' `
-Body '{"draft":false}' | Out-Null
Write-Host "发布完成:$tag"
Write-Host "发布完成:$tag"
+16 -4
View File
@@ -90,10 +90,19 @@ jobs:
Copy-Item -LiteralPath $manifestPath `
-Destination (Join-Path $env:GITHUB_WORKSPACE `
'dist/manifest.json') -Force
$moduleManifestDir = Join-Path $source 'dist/modules'
if (Test-Path $moduleManifestDir) {
New-Item -ItemType Directory -Force `
(Join-Path $env:GITHUB_WORKSPACE 'dist/modules') | Out-Null
Copy-Item -Path (Join-Path $moduleManifestDir '*.json') `
-Destination (Join-Path $env:GITHUB_WORKSPACE 'dist/modules') `
-Force
}
git config user.name 'github-actions[bot]'
git config user.email `
'41898282+github-actions[bot]@users.noreply.github.com'
git add dist/manifest.json
git add dist/modules
if ($LASTEXITCODE -ne 0) { throw '暂存清单失败' }
git commit -m "Prepare manifest for $tag"
if ($LASTEXITCODE -ne 0) { throw '提交清单失败' }
@@ -143,10 +152,13 @@ jobs:
) as archive:
for name in manifest["files"]:
archive.write(root / name, folder + name)
archive.write(
root / "dist/manifest.json",
folder + "manifest.json"
)
for descriptor in manifest.get("modules", {}).values():
component = json.loads(
(root / descriptor["manifest"]).read_text(encoding="utf-8")
)
for name in component["files"]:
archive.write(root / name, folder + name)
archive.write(root / "dist/manifest.json", folder + "manifest.json")
'@ | python - $stage $package
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
+7
View File
@@ -1,6 +1,13 @@
log.txt
findings.md
progress.md
task_plan.md
dist/manifest.json
tools/__pycache__/
tools/test_update.mjs
tools/test_intent.mjs
/mengsan/.planning/
/mengsan/verification
+16 -3
View File
@@ -250,9 +250,22 @@ shuYing.precontent = async () => {
shuYing.initCSS();
shuYing.initFunction();
shuYing.initConfig();
const registerMengsan = await shuYing.loadModule("./mengsan/register.js", "梦三模式", true);
if (typeof registerMengsan == "function") {
registerMengsan();
const mengsanState = lib.config.shuYing_mengsan_installed_version;
let hasMengsan = !!mengsanState && mengsanState != "disabled";
if (!mengsanState && typeof game.checkFile != "function") {
hasMengsan = true;
}
else if (!mengsanState && typeof game.checkFile == "function") {
hasMengsan = await new Promise(resolve => {
game.checkFile("extension/术樱包/mengsan/register.js",
result => resolve(result == 1), () => resolve(false));
});
}
if (hasMengsan) {
const registerMengsan = await shuYing.loadModule(
"./mengsan/register.js", "梦三模式", true
);
if (typeof registerMengsan == "function") registerMengsan();
}
initShuYingMenu({ lib, game, ui, shuYing, updater });
shuYing.getVersion();
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+2 -1
View File
@@ -6,7 +6,7 @@ const mengsanConfig = {
saveKey: "mengsan_run_shuying",
profileKey: "mengsan_profile_shuying",
schemaVersion: 1,
mapLayoutVersion: 7,
mapLayoutVersion: 8,
routeCount: 4,
characters: ["mengsan_liubei_shuying"],
acts: contentRegistry.acts,
@@ -16,6 +16,7 @@ const mengsanConfig = {
elite: "精英",
event: "事件",
story: "剧情",
chest: "宝箱",
rest: "休息",
shop: "商店",
boss: "Boss",
+10 -2
View File
@@ -1,9 +1,17 @@
const act1Map = {
id: "act1",
name: "第一关·初梦",
floorNodes: [1, 4, 4, 4, 1],
minFloorNodes: [1, 3, 3, 3, 1],
floorNodes: [
1, 4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 1,
],
minFloorNodes: [
1, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 1,
],
nodeWeights: { battle: 65, event: 15, rest: 10, shop: 10 },
requiredFloors: { 2: "battle", 10: "chest", 16: "rest" },
chestRewardPool: "act1.pool.chest",
fixedNodes: [
{
floor: 0,
+5
View File
@@ -1,4 +1,9 @@
export const rewardPools = {
"act1.pool.chest": [
"shared.reward.item.handCharm",
"shared.reward.maxHp",
"shared.reward.support.scout",
],
"act1.pool.story.scoutGift": [
"shared.reward.support.scout",
"shared.reward.card.sha",
+8
View File
@@ -10,6 +10,8 @@ const iconPaths = {
elite: "M16 3l10 5v9c0 6-10 12-10 12S6 23 6 17V8zM10 13l6 4 6-4M16 17v7M11 5l5-3 5 3",
event: "M10 10c0-9 15-8 15 0 0 5-9 5-9 10M16 25v1",
story: "M8 5h18v20H10c-5 0-5-6 0-6h16M8 5c-5 0-5 6 0 6h3V5M12 10h10M12 14h8",
chest: "M4 13h24v15H4zM2 8h28v6H2zM16 8v20" +
"M9 8c-4-3-1-7 3-5l4 5M23 8c4-3 1-7-3-5l-4 5",
rest: "M3 26L16 5l13 21zM10 26l6-11 6 11M16 5V2M7 28h19",
shop: "M4 12l3-7h18l3 7M4 12v4h24v-4M7 16v12h18V16M12 28v-8h8v8M3 28h26M11 6v6M21 6v6",
boss: "M6 28V5M6 6c8-8 12 6 22-1v16c-10 7-14-7-22 1M11 10l11 7M22 10l-11 7M3 28h9",
@@ -60,6 +62,7 @@ const nodeColor = {
elite: "#7e3f79",
event: "#477aa6",
story: "#8c6a34",
chest: "#a27b35",
rest: "#3f8560",
shop: "#a27b35",
boss: "#a62727",
@@ -353,6 +356,7 @@ export const showMap = run => new Promise(resolve => {
viewport.addEventListener("pointerdown", event => {
if (!event.isPrimary || event.button !== 0) return;
suppressClick = false;
if (event.pointerType !== "mouse") return;
drag = { id: event.pointerId, y: event.clientY, top: viewport.scrollTop, moved: false };
});
viewport.addEventListener("pointermove", event => {
@@ -386,6 +390,10 @@ export const showMap = run => new Promise(resolve => {
}
}, true);
const map = ui.create.div(".mengsan-map-shuying", viewport);
const floorCount = Math.max(...run.map.nodes.map(node =>
Math.floor(node.floor))) + 1;
map.style.setProperty("--mengsan-map-min-height",
`${floorCount * 100}px`);
const landscape = document.createElementNS("http://www.w3.org/2000/svg", "svg");
landscape.classList.add("mengsan-ink-landscape-shuying");
landscape.setAttribute("viewBox", "0 0 800 1200");
+144 -13
View File
@@ -4,6 +4,21 @@ import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport }
import { installPersonalPiles } from "./runtime/personal-piles.js";
import { createMonster } from "./runtime/monster.js";
import { mountEnemyIntent } from "./runtime/intent-display.js";
import { attackHitCount, outgoingAttackDamage } from "./runtime/intent-damage.js";
import {
beginDeathBlow,
finishDeathBlow,
hasPendingDeathBlow,
isDeathBlowIntent,
} from "./runtime/death-blow.js";
import {
STUN_INTENT,
applyStun,
clearStun,
finishStunnedTurn,
isStunned,
skipStunnedAction,
} from "./runtime/stun-intent.js";
import { selectFlyconidMove, recordFlyconidAction } from "./runtime/flyconid-intent.js";
import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./runtime/combat-rules.js";
import { mountBattlePiles } from "./runtime/card-library.js";
@@ -172,6 +187,13 @@ const finishUtilityNode = async (run, node) => {
]);
applyReward(run, choice);
}
else if (node.type == "chest") {
await chooseReward(run, {
rewardPool: config.acts[run.actIndex].chestRewardPool ||
"shared.pool.boss.premium",
rewardTitle: "宝箱奖励(三选一)",
});
}
else if (node.type == "shop") {
const price = 20;
const choice = await chooseButtons("商店 Demo", [
@@ -186,12 +208,18 @@ const finishUtilityNode = async (run, node) => {
}
}
else {
const choice = await chooseButtons("随机事件", [
{ id: "branch", name: "调查异象", description: "在当前路线后插入一个额外剧情战斗节点" },
const choices = [
{ id: "gold", name: "收下钱袋", description: "获得 15 金币" },
]);
];
if (run.actIndex !== 0) {
choices.unshift({
id: "branch", name: "调查异象",
description: "在当前路线后插入一个额外剧情战斗节点",
});
}
const choice = await chooseButtons("随机事件", choices);
if (choice == "branch") insertStoryNode(run, node.id);
else {
else if (choice == "gold") {
run.player.gold += 15;
run.statistics.goldEarned += 15;
}
@@ -286,10 +314,17 @@ const executeFlyconidIntent = async player => {
game.log(player, "消耗1费用发动", move.name);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
if (isDeathBlowIntent(move)) {
await beginDeathBlow(player, target, move);
return;
}
if (move.damage != null) {
const hit = target.damage(move.damage, player);
hit.mengsanAttack_shuying = true;
await hit;
for (let index = 0; index < attackHitCount(move); index++) {
if (!target.isAlive() || !player.isAlive()) break;
const hit = target.damage(outgoingAttackDamage(move, player), player);
hit.mengsanAttack_shuying = true;
await hit;
}
}
if (target.isAlive() && player.isAlive()) applyFlyconidDebuff(target, move);
};
@@ -316,7 +351,8 @@ const createScenario = (plan, current) => {
installMonsterPile(player, monster, current);
if (monster) {
current.energyUI.set(player, mountEnergy(player, current.session));
current.intentUI.set(player, mountEnemyIntent(player, current.session, STYLE_PATH, document));
current.intentUI.set(player, mountEnemyIntent(player,
current.session, STYLE_PATH, document, () => game.me));
}
for (const participant of [...game.players, ...game.dead]) participant.setSeatNum(Number(participant.dataset.position) + 1);
await equipBattleUnit(player, spec, current.resources);
@@ -398,7 +434,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
const monsters = plan.units.map((spec, index) => initBattleUnit(participants[index], spec));
plan.units.forEach((spec, index) => installMonsterPile(participants[index], monsters[index], currentBattle));
participants.forEach((player, index) => {
if (monsters[index]) currentBattle.intentUI.set(player, mountEnemyIntent(player, session, STYLE_PATH, document));
if (monsters[index]) currentBattle.intentUI.set(player,
mountEnemyIntent(player, session, STYLE_PATH, document,
() => game.me));
});
activeBattle.director = createScenario(plan, currentBattle);
activeBattle.director.bind("player", me);
@@ -418,7 +456,15 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
run.player.permanentSkills.forEach(skill => {
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill);
});
for (const skill of ["mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying", "mengsan_flyconid_action_shuying"]) {
for (const skill of [
"mengsan_draw_shuying", "mengsan_card_use_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
"mengsan_flyconid_action_shuying",
"mengsan_death_blow_finish_shuying",
"mengsan_stun_skip_shuying", "mengsan_stun_recover_shuying",
"mengsan_stun_clear_shuying",
]) {
if (!lib.skill.global.includes(skill)) {
game.addGlobalSkill(skill);
session.ownResource({}, () => game.removeGlobalSkill(skill));
@@ -552,7 +598,10 @@ const routeRun = async (run, selected = null, supplied = false) => {
const node = supplied ? selected : await showMap(run);
supplied = false;
if (!node) { await openModeSelection(); return; }
if (["event", "rest", "shop"].includes(node.type)) { await finishUtilityNode(run, node); continue; }
if (["event", "rest", "shop", "chest"].includes(node.type)) {
await finishUtilityNode(run, node);
continue;
}
if (node.type === "story" && config.nodeContents?.[node.contentId]?.kind === "dialogue") {
if (await finishStoryNode(run, node)) continue;
return;
@@ -650,6 +699,42 @@ const createMode = identityMode => {
update(intent);
return true;
},
mengsanStunEnemy_shuying(player, options = {}) {
const update = activeBattle?.intentUI.get(player);
if (!activeBattle?.session.active || !update ||
!player?.isAlive() ||
player.storage?.mengsanCamp_shuying !== "enemy") {
return false;
}
if (isStunned(player)) return false;
const { resume = "advance", recover } = options;
const flyconid = isFlyconid(player);
const intent = options.intent ?? (flyconid ?
player.storage.mengsanFlyconidIntent_shuying : null);
const restore = recover || (flyconid ?
(choice, original) => {
const state =
player.storage.mengsanFlyconidState_shuying || {};
player.storage.mengsanFlyconidState_shuying =
recordFlyconidAction(state, null, false);
const next = choice === "retry" ? original : null;
player.storage.mengsanFlyconidIntent_shuying = next;
return next;
} : null);
const onRecover = async (choice, original) => {
const next = await restore(choice, original);
update(next === undefined ?
(choice === "retry" ? original : null) : next);
};
if (typeof restore !== "function") {
throw new TypeError("此怪物需要击晕恢复回调");
}
if (!applyStun(player, intent, { resume, recover: onRecover })) {
return false;
}
update(STUN_INTENT);
return true;
},
async mengsanPersistRun_shuying(run) {
await saveRun(run);
},
@@ -720,14 +805,60 @@ const createMode = identityMode => {
trigger: { player: "phaseDrawBegin2" },
forced: true, silent: true, popup: false,
filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); },
async content(event, trigger, player) { trigger.num = player.storage.mengsanMonster_shuying.draw; },
async content(event, trigger, player) {
trigger.num = isStunned(player) ? 0 :
player.storage.mengsanMonster_shuying.draw;
},
},
mengsan_flyconid_action_shuying: {
trigger: { global: "phaseUseBefore" },
forced: true, silent: true, popup: false, priority: 100,
filter(event) { return Boolean(activeBattle?.session.active && isFlyconid(event.player) && event.player.storage.mengsanFlyconidIntent_shuying); },
filter(event) { return Boolean(activeBattle?.session.active &&
!isStunned(event.player) && isFlyconid(event.player) &&
event.player.storage.mengsanFlyconidIntent_shuying); },
async content(event, trigger) { await executeFlyconidIntent(trigger.player); },
},
mengsan_stun_skip_shuying: {
trigger: { global: "phaseUseBefore" },
forced: true, silent: true, popup: false, priority: 200,
filter(event) {
return Boolean(activeBattle?.session.active &&
isStunned(event.player));
},
async content(event, trigger) {
skipStunnedAction(trigger.player, trigger);
},
},
mengsan_stun_recover_shuying: {
trigger: { global: "phaseAfter" },
forced: true, silent: true, popup: false, priority: 200,
filter(event) {
return Boolean(activeBattle?.session.active &&
isStunned(event.player));
},
async content(event, trigger) {
await finishStunnedTurn(trigger.player);
},
},
mengsan_stun_clear_shuying: {
trigger: { global: "dieAfter" },
forced: true, silent: true, popup: false,
filter(event) { return isStunned(event.player); },
async content(event, trigger) {
clearStun(trigger.player);
},
},
mengsan_death_blow_finish_shuying: {
trigger: { global: "phaseUseAfter" },
forced: true, silent: true, popup: false,
filter(event) {
return Boolean(activeBattle?.session.active &&
hasPendingDeathBlow(event.player));
},
async content(event, trigger) {
await finishDeathBlow(trigger.player);
},
},
mengsan_frail_shuying: {
mark: true, marktext: "脆",
onremove(player) { delete player.storage.mengsanFrail_shuying; },
+38
View File
@@ -0,0 +1,38 @@
import {
attackHitCount,
outgoingAttackDamage,
} from "./intent-damage.js";
const pendingDeathBlows = new WeakSet();
export function isDeathBlowIntent(intent) {
return intent?.intentType === "death_blow";
}
export function hasPendingDeathBlow(source) {
return pendingDeathBlows.has(source);
}
export async function beginDeathBlow(source, target, intent) {
if (!isDeathBlowIntent(intent)) {
throw new TypeError("濒死一击需要 death_blow 意图类型");
}
if (attackHitCount(intent) !== 1) {
throw new RangeError("濒死一击只能造成一次伤害");
}
const damage = outgoingAttackDamage(intent, source);
if (!source?.isAlive() || !target?.isAlive()) return false;
const hit = target.damage(damage, source);
hit.mengsanAttack_shuying = true;
await hit;
if (source.isAlive()) pendingDeathBlows.add(source);
return true;
}
export async function finishDeathBlow(source) {
if (!hasPendingDeathBlow(source)) return false;
pendingDeathBlows.delete(source);
if (source.isAlive()) await source.die();
return true;
}
+28
View File
@@ -0,0 +1,28 @@
export function attackHitCount(intent) {
const hits = intent.hits ?? 1;
if (!Number.isInteger(hits) || hits < 1) {
throw new RangeError("攻击段数必须是正整数");
}
return hits;
}
export function outgoingAttackDamage(intent, source) {
if (!Number.isInteger(intent.damage) || intent.damage < 0) {
throw new RangeError("预测伤害必须是非负整数");
}
const storedStrength = source?.storage?.mengsanStrength_shuying;
const strength = Number.isInteger(storedStrength) ? storedStrength : 0;
let damage = Math.max(0, intent.damage + strength);
if (source?.storage?.mengsanWeak_shuying > 0) {
damage = Math.floor(damage * 0.75);
}
return damage;
}
export function previewAttackDamage(intent, source, target) {
let damage = outgoingAttackDamage(intent, source);
if (target?.storage?.mengsanVulnerable_shuying > 0) {
damage = Math.ceil(damage * 1.5);
}
return damage;
}
+85 -24
View File
@@ -1,6 +1,12 @@
// 只呈现已经选定的预测技能;不从手牌或原生技能猜测意图。
import { attackHitCount, previewAttackDamage } from "./intent-damage.js";
import { isDeathBlowIntent } from "./death-blow.js";
import { isStunIntent } from "./stun-intent.js";
export function attackIconForDamage(damage) {
if (!Number.isInteger(damage) || damage < 0) throw new RangeError("预测伤害必须是非负整数");
if (!Number.isInteger(damage) || damage < 0) {
throw new RangeError("预测伤害必须是非负整数");
}
if (damage < 5) return 1;
if (damage < 10) return 2;
if (damage < 20) return 3;
@@ -8,56 +14,111 @@ export function attackIconForDamage(damage) {
return 5;
}
export function mountEnemyIntent(player, session, assetBase, document) {
export function mountEnemyIntent(player, session, assetBase, document,
getTarget = () => null) {
const badge = document.createElement("div");
badge.className = "mengsan-intent-shuying";
badge.hidden = true;
badge.setAttribute("aria-live", "polite");
const name = document.createElement("span");
name.className = "mengsan-intent-name-shuying";
badge.setAttribute("aria-atomic", "true");
const damageLine = document.createElement("span");
damageLine.className = "mengsan-intent-damage-shuying";
const amount = document.createElement("span");
amount.className = "mengsan-intent-amount-shuying";
const icon = document.createElement("img");
icon.className = "mengsan-intent-icon-shuying";
icon.alt = "预计伤害";
icon.alt = "Attack";
icon.decoding = "async";
damageLine.append(amount, icon);
damageLine.append(icon, amount);
const debuffLine = document.createElement("span");
debuffLine.className = "mengsan-intent-debuff-shuying";
const debuffIcon = document.createElement("img");
debuffIcon.className = "mengsan-intent-icon-shuying";
debuffIcon.alt = "预计施加负面状态";
debuffIcon.alt = "Debuff";
debuffIcon.decoding = "async";
debuffIcon.src = `${assetBase}/assets/intent/Intent_debuff.webp`;
const debuffAmount = document.createElement("span");
debuffLine.append(debuffIcon, debuffAmount);
badge.append(name, damageLine, debuffLine);
badge.append(damageLine, debuffLine);
player.appendChild(badge);
session.ownResource(badge, () => badge.remove());
return intent => {
let currentIntent = null;
let timer;
session.ownResource(badge, () => {
clearInterval(timer);
badge.remove();
});
const refresh = () => {
const intent = currentIntent;
if (!intent) {
badge.hidden = true;
badge.removeAttribute("title");
return;
}
if (typeof intent.name !== "string" || !intent.name.trim()) throw new TypeError("预测技能必须有名称");
name.textContent = intent.name;
const hasDamage = intent.damage != null;
damageLine.hidden = !hasDamage;
const hasDebuff = typeof intent.debuff === "string" && Number.isInteger(intent.stacks) && intent.stacks > 0;
debuffLine.hidden = !hasDebuff;
if (hasDebuff) debuffAmount.textContent = `${intent.debuff}×${intent.stacks}`;
if (hasDamage) {
const tier = attackIconForDamage(intent.damage);
amount.textContent = String(intent.damage);
const src = `${assetBase}/assets/intent/Intent_attack_${tier}.webp`;
if (isStunIntent(intent)) {
damageLine.hidden = false;
debuffLine.hidden = true;
amount.textContent = "";
const src = `${assetBase}/assets/intent/Intent_stun.png`;
if (icon.getAttribute("src") !== src) icon.src = src;
badge.title = `${intent.name}:预计造成 ${intent.damage} 点伤害(非最终伤害)${hasDebuff ? `,施加${intent.stacks}层${intent.debuff}` : ""}`;
icon.alt = "Stunned";
if (badge.getAttribute("aria-label") !== "Stunned") {
badge.setAttribute("aria-label", "Stunned");
}
badge.hidden = false;
return;
}
const hasDamage = intent.damage != null;
const deathBlow = isDeathBlowIntent(intent);
if (deathBlow && !hasDamage) {
throw new TypeError("濒死一击必须有伤害数值");
}
damageLine.hidden = !hasDamage;
const hasDebuff = typeof intent.debuff === "string" &&
Number.isInteger(intent.stacks) && intent.stacks > 0;
debuffLine.hidden = !hasDebuff;
if (hasDebuff) {
const label = intent.id === "frail" ? "Frail" :
intent.id === "vulnerable" ? "Vulnerable" : "Debuff";
const debuffText = `${label} ×${intent.stacks}`;
if (debuffAmount.textContent !== debuffText) {
debuffAmount.textContent = debuffText;
}
}
if (hasDamage) {
const hits = attackHitCount(intent);
if (deathBlow && hits !== 1) {
throw new RangeError("濒死一击只能造成一次伤害");
}
const damage = previewAttackDamage(intent, player, getTarget());
const tier = deathBlow ? null :
attackIconForDamage(damage * hits);
const damageText = hits > 1 ? `${damage}×${hits}` :
String(damage);
if (amount.textContent !== damageText) {
amount.textContent = damageText;
}
const src = deathBlow ?
`${assetBase}/assets/intent/Intent_death_blow.png` :
`${assetBase}/assets/intent/Intent_attack_${tier}.webp`;
if (icon.getAttribute("src") !== src) icon.src = src;
icon.alt = deathBlow ? "Death Blow" : "Attack";
const label = `${deathBlow ? "Death Blow" : "Attack"}: ` +
`${damage} damage${hits > 1 ?
` × ${hits} hits` : ""}${hasDebuff ?
`, ${debuffAmount.textContent}` : ""}`;
if (badge.getAttribute("aria-label") !== label) {
badge.setAttribute("aria-label", label);
}
} else {
badge.title = hasDebuff ? `${intent.name}:施加${intent.stacks}层${intent.debuff}` : intent.name;
const label = debuffAmount.textContent;
if (badge.getAttribute("aria-label") !== label) {
badge.setAttribute("aria-label", label);
}
}
badge.hidden = false;
};
timer = setInterval(refresh, 100);
return intent => {
currentIntent = intent;
refresh();
};
}
+45
View File
@@ -0,0 +1,45 @@
const stunned = new WeakMap();
export const STUN_INTENT = Object.freeze({ intentType: "stun" });
export function isStunIntent(intent) {
return intent?.intentType === "stun";
}
export function isStunned(player) {
return stunned.has(player);
}
export function applyStun(player, intent, options = {}) {
if (stunned.has(player)) return false;
const { resume = "advance", recover } = options;
if (resume !== "advance" && resume !== "retry") {
throw new RangeError("击晕恢复方式无效");
}
if (typeof recover !== "function") {
throw new TypeError("击晕需要恢复回调");
}
if (!player?.isAlive()) return false;
stunned.set(player, { intent, resume, recover, skipped: false });
return true;
}
export function skipStunnedAction(player, phaseUse) {
const state = stunned.get(player);
if (!state || state.skipped) return false;
phaseUse.cancel();
state.skipped = true;
return true;
}
export async function finishStunnedTurn(player) {
const state = stunned.get(player);
if (!state?.skipped) return false;
stunned.delete(player);
if (player.isAlive()) await state.recover(state.resume, state.intent);
return true;
}
export function clearStun(player) {
return stunned.delete(player);
}
+3
View File
@@ -110,6 +110,9 @@ export const generateActMap = (run, actIndex) => {
let type;
if (floor == lastFloor) type = "boss";
else if (fixedNode?.type) type = fixedNode.type;
else if (act.requiredFloors?.[floor + 1]) {
type = act.requiredFloors[floor + 1];
}
else if (floor == 0) type = "battle";
else type = weightedType(run, act.nodeWeights);
const id = `${act.id}_f${floor}_n${index}_${Math.floor(nextRandom(run) * 1e6)}`;
+4 -8
View File
@@ -188,7 +188,7 @@
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: none;
touch-action: pan-y;
cursor: grab;
user-select: none;
scrollbar-width: none;
@@ -198,7 +198,8 @@
position: relative;
display: block !important;
width: min(100%, 860px);
height: max(1080px, 160vh) !important;
height: max(1080px, 160vh,
var(--mengsan-map-min-height, 1080px)) !important;
margin: 0 auto;
overflow: hidden;
isolation: isolate;
@@ -520,7 +521,6 @@
.mengsan-map-viewport-shuying::-webkit-scrollbar { display: none; width: 0; height: 0; }
.mengsan-map-viewport-shuying.dragging, .mengsan-map-viewport-shuying.dragging * { cursor: grabbing !important; }
.mengsan-map-shuying .mengsan-node-shuying { touch-action: none; }
/* 梦三行军图视觉系统:深墨舞台、宣纸路线、朱砂交互、铜金层级 */
.mengsan-map-overlay-shuying {
--ms-ink-950: #111713;
@@ -1301,7 +1301,7 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
text-align: center;
pointer-events: none;
}
/* The selected skill appears below an enemy's frame; number precedes the weapon icon. */
/* The selected intent appears below an enemy's frame. */
.player > .mengsan-intent-shuying {
position: absolute !important;
bottom: -31px !important;
@@ -1327,10 +1327,6 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
.player > .mengsan-intent-shuying[hidden],
.mengsan-intent-damage-shuying[hidden],
.mengsan-intent-debuff-shuying[hidden] { display: none !important; }
.mengsan-intent-name-shuying {
overflow: hidden;
text-overflow: ellipsis;
}
.mengsan-intent-damage-shuying {
display: inline-flex;
flex: none;
+13
View File
@@ -339,6 +339,19 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
},
};
menu.mengsanModule = {
name: "梦三模式(按需安装/卸载)",
clear: true,
onclick() {
if (shuYing.m_bIsDownload) {
shuYing.m_bIsDownload = false;
updater.manageMengsanModule(shuYing);
} else {
alert("有其他文件正在下载,请稍后再试吧。");
}
},
};
menu.repairBug = {
name: "本地资源修复",
clear: true,
+39
View File
@@ -0,0 +1,39 @@
# 更新清单与可选模块
## 梦三模式
梦三仍与主包保存在同一仓库,但作为独立下载模块发布。基础清单
`dist/manifest.json` 不包含 `mengsan/` 文件;模块清单
`dist/modules/mengsan.json` 单独记录梦三文件、大小和 SHA-256。
用户通过扩展菜单的“梦三模式(按需安装/卸载)”下载或卸载。主包版本
更新只检查主包文件,以及本地已安装模块的文件;“查漏补缺”会检查主包
资源和已安装模块,不会自动下载未安装的梦三模式。
## 发布流程
1. 在 `tools/manifest_config.json` 的 `core` 中维护主包必须文件。
2. 在 `modules` 中维护可选模块的目录、入口和显示名。
3. 发布工作流为每个模块生成独立清单,并将清单提交到发布标签。
4. 完整 ZIP 安装包仍包含所有模块,供离线或整包安装使用。
客户端从标签的 raw 文件路径读取清单。因此 Gitea 发布必须使用已经包含
`dist/manifest.json` 和 `dist/modules/*.json` 的同一标签;当前 Gitea 工作流
会生成并上传附件,但不会改写触发它的标签。
发布时生成器会把 `tools/legacy/mengsan-files.json`、上一次完整清单和当前模块
文件合并,随新标签发布迁移路径清单。它用于接管旧安装中的梦三文件并清理
不再属于新版本的文件。该文件由迁移时仓库内的梦三路径生成,作为冻结的旧版
所有权记录,兼容旧更新器已经推进主包版本号、但尚未更新梦三模块的用户。
## 旧文件与目录
升级时比较旧版和新版的文件路径清单,删除旧清单有、新清单没有的模块文件;
新增或损坏文件会按 SHA-256 下载和修复。模块卸载只删除模块清单声明的文件。
不根据“目录不在新清单中”递归删除目录,避免误删用户自行放入目录的文件。
确需删除整个旧目录时,先确认其不包含用户数据,再通过主包清单的
`removeDirectories` 明确声明。
新增模块时,清单生成器可通过配置建立独立清单;客户端的安装菜单、启动
加载和卸载入口仍需为该模块添加对应管理逻辑。
+200 -49
View File
@@ -68,15 +68,36 @@ def load_config(root: Path, config_path: str) -> dict:
"core": [],
"remove": [],
"removeDirectories": [],
"modules": {},
}
with path.open("r", encoding="utf-8") as file:
config = json.load(file)
modules = {}
for name, item in config.get("modules", {}).items():
legacy_files = item.get("legacy_files", [])
legacy_source = item.get("legacy_files_from")
if legacy_source:
legacy_path = Path(legacy_source)
if not legacy_path.is_absolute():
legacy_path = root / legacy_path
with legacy_path.open("r", encoding="utf-8") as file:
legacy_files = json.load(file)
if not isinstance(legacy_files, list):
raise ValueError(f"legacy file list must be an array: {name}")
modules[name] = {
"root": normalize_path(item["root"]),
"entry": normalize_path(item["entry"]),
"title": item.get("title", name),
"legacy_files": [normalize_path(path) for path in legacy_files],
}
return {
"core": [normalize_path(item) for item in config.get("core", [])],
"remove": [normalize_path(item) for item in config.get("remove", [])],
"removeDirectories": [normalize_path(item) for item in config.get("removeDirectories", [])],
"modules": modules,
}
@@ -117,67 +138,168 @@ def set_nested_file(tree: dict, relative_path: str, info: dict) -> None:
}
def build_manifest(root: Path, version: str, config: dict) -> dict:
flat_files = {}
tree = {
"type": "directory",
"name": root.name,
"children": {},
}
core_files = config["core"]
core_file_set = set(core_files)
remove_files = config["remove"]
remove_directories = config["removeDirectories"]
validate_config_files(root, core_files, "core")
validate_relative_paths(remove_files, "remove")
validate_relative_paths(remove_directories, "removeDirectories")
def build_manifest(root: Path, version: str, config: dict,
previous_manifest=None) -> tuple[dict, dict, dict]:
module_configs = config.get("modules", {})
module_roots = {name: item["root"] for name, item in module_configs.items()}
validate_relative_paths(list(module_roots.values()), "module roots")
if len(set(module_roots.values())) != len(module_roots):
raise ValueError("module roots must be unique")
for name, item in module_configs.items():
for other_name, other_root in module_roots.items():
if name != other_name and (
item["root"] == other_root
or item["root"].startswith(other_root + "/")
or other_root.startswith(item["root"] + "/")
):
raise ValueError("module roots cannot overlap")
def owned_by_module(path: str) -> bool:
return any(path == module_root or path.startswith(module_root + "/")
for module_root in module_roots.values())
config["remove"] = [file for file in config["remove"]
if not owned_by_module(file)]
config["removeDirectories"] = [
directory for directory in config["removeDirectories"]
if not any(directory == module_root
or directory.startswith(module_root + "/")
or module_root.startswith(directory + "/")
for module_root in module_roots.values())
]
all_files = {}
for path in scan_files(root):
relative_path = str(path.relative_to(root)).replace("\\", "/")
info = {
all_files[relative_path] = {
"size": path.stat().st_size,
"sha256": sha256_file(path),
}
flat_files[relative_path] = info
set_nested_file(tree["children"], relative_path, info)
def make_manifest(file_map: dict, core_files: list[str], removals: list[str],
remove_directories: list[str], module_id: str = "") -> dict:
tree = {
"type": "directory",
"name": root.name,
"children": {},
}
for relative_path, info in file_map.items():
set_nested_file(tree["children"], relative_path, info)
remove_file_conflicts = [file for file in remove_files if file in flat_files]
if remove_file_conflicts:
raise ValueError(f"remove still contains current files: {', '.join(remove_file_conflicts)}")
core_file_set = set(core_files)
validate_config_files(root, core_files, "core")
missing_core = [file for file in core_files if file not in file_map]
if missing_core:
raise ValueError(
"core contains files outside this manifest: "
+ ", ".join(missing_core)
)
validate_relative_paths(removals, "remove")
validate_relative_paths(remove_directories, "removeDirectories")
conflicts = [file for file in removals if file in file_map]
if conflicts:
raise ValueError(f"remove still contains current files: {', '.join(conflicts)}")
directory_conflicts = [
directory for directory in remove_directories
if any(file == directory or file.startswith(directory + "/")
for file in file_map)
]
if directory_conflicts:
raise ValueError(
"removeDirectories still contain current files: "
+ ", ".join(directory_conflicts)
)
remove_directory_conflicts = [
directory for directory in remove_directories
if any(file == directory or file.startswith(f"{directory}/") for file in flat_files)
]
if remove_directory_conflicts:
raise ValueError(
"removeDirectories still contain current files: "
+ ", ".join(remove_directory_conflicts)
)
folder_digest = hashlib.sha256()
for relative_path in sorted(file_map):
info = file_map[relative_path]
folder_digest.update(relative_path.encode("utf-8"))
folder_digest.update(str(info["size"]).encode("ascii"))
folder_digest.update(info["sha256"].encode("ascii"))
folder_digest = hashlib.sha256()
for relative_path in sorted(flat_files):
info = flat_files[relative_path]
folder_digest.update(relative_path.encode("utf-8"))
folder_digest.update(str(info["size"]).encode("ascii"))
folder_digest.update(info["sha256"].encode("ascii"))
result = {
"version": version,
"generated_at": datetime.now(timezone.utc).isoformat(),
"algorithm": "sha256",
"update_channels": True,
"file_count": len(file_map),
"size_bytes": sum(info["size"] for info in file_map.values()),
"folder_sha256": folder_digest.hexdigest(),
"core": core_files,
"assets": [file for file in sorted(file_map) if file not in core_file_set],
"remove": removals,
"removeDirectories": remove_directories,
"files": file_map,
"tree": tree,
}
if module_id:
result["module_id"] = module_id
return result
return {
"version": version,
"generated_at": datetime.now(timezone.utc).isoformat(),
"algorithm": "sha256",
"update_channels": True,
"file_count": len(flat_files),
"folder_sha256": folder_digest.hexdigest(),
"core": core_files,
"assets": [file for file in sorted(flat_files) if file not in core_file_set],
"remove": remove_files,
"removeDirectories": remove_directories,
"files": flat_files,
"tree": tree,
module_files = {name: {} for name in module_configs}
base_files = {}
for path, info in all_files.items():
owner = next((name for name, module_root in module_roots.items()
if path == module_root or path.startswith(module_root + "/")), None)
if owner:
module_files[owner][path] = info
else:
base_files[path] = info
base_manifest = make_manifest(
base_files, config["core"], config["remove"], config["removeDirectories"]
)
base_manifest["modules"] = {
name: {
"title": item["title"],
"entry": item["entry"],
"manifest": f"dist/modules/{name}.json",
"file_count": len(module_files[name]),
"size_bytes": sum(info["size"] for info in module_files[name].values()),
}
for name, item in module_configs.items()
}
manifests = {}
legacy_manifests = {}
for name, item in module_configs.items():
files = module_files[name]
if item["entry"] not in files:
raise FileNotFoundError(f"module entry is missing: {item['entry']}")
manifests[name] = make_manifest(
files, [item["entry"]], [], [], module_id=name
)
manifests[name]["assets"] = [
file for file in sorted(files) if file != item["entry"]
]
if not item["entry"].startswith(item["root"] + "/"):
raise ValueError(f"module entry is outside its root: {item['entry']}")
previous_files = previous_manifest.get("files", {}) \
if isinstance(previous_manifest, dict) else {}
legacy_files = set(item["legacy_files"])
legacy_files.update(module_files[name])
legacy_files.update(
file for file in previous_files
if isinstance(file, str) and file.startswith(item["root"] + "/")
)
legacy_files = sorted(legacy_files)
if legacy_files:
validate_relative_paths(list(legacy_files), "legacy module files")
if any(not file.startswith(item["root"] + "/")
for file in legacy_files):
raise ValueError(f"legacy files escape module root: {name}")
legacy_manifests[name] = {
"version": version,
"module_id": name,
"snapshot": "legacy",
"files": legacy_files,
}
base_manifest["modules"][name]["legacy_manifest"] = (
f"dist/modules/{name}.legacy.json"
)
return base_manifest, manifests, legacy_manifests
def main() -> None:
parser = argparse.ArgumentParser(description="Generate shuying manifest.json")
@@ -194,10 +316,37 @@ def main() -> None:
raise ValueError("Version cannot be empty")
output = (root / args.out).resolve()
previous_manifest = None
if output.is_file():
with output.open("r", encoding="utf-8") as file:
previous_manifest = json.load(file)
config = preserve_removals(load_config(root, args.config), output)
manifest = build_manifest(root, version, config)
manifest, modules, legacy_modules = build_manifest(
root, version, config, previous_manifest
)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
module_output = output.parent / "modules"
for name, module_manifest in modules.items():
module_path = module_output / f"{name}.json"
module_path.parent.mkdir(parents=True, exist_ok=True)
module_path.write_text(
json.dumps(module_manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
for name, module_manifest in legacy_modules.items():
module_path = module_output / f"{name}.legacy.json"
module_path.parent.mkdir(parents=True, exist_ok=True)
module_path.write_text(
json.dumps(module_manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if module_output.is_dir():
expected = {f"{name}.json" for name in modules}
expected.update(f"{name}.legacy.json" for name in legacy_modules)
for stale in module_output.glob("*.json"):
if stale.name not in expected:
stale.unlink()
print(f"version: {version}")
print(f"file_count: {manifest['file_count']}")
@@ -207,6 +356,8 @@ def main() -> None:
print(f"remove_directory_count: {len(manifest['removeDirectories'])}")
print(f"folder_sha256: {manifest['folder_sha256']}")
print(f"generated: {output}")
for name, module_manifest in modules.items():
print(f"module_{name}_file_count: {module_manifest['file_count']}")
if __name__ == "__main__":
+61
View File
@@ -0,0 +1,61 @@
[
"mengsan/assets/flyconid-portrait.png",
"mengsan/assets/intent/Intent_attack_1.webp",
"mengsan/assets/intent/Intent_attack_2.webp",
"mengsan/assets/intent/Intent_attack_3.webp",
"mengsan/assets/intent/Intent_attack_4.webp",
"mengsan/assets/intent/Intent_attack_5.webp",
"mengsan/assets/intent/Intent_debuff.webp",
"mengsan/assets/sushuang-zhangshiping.png",
"mengsan/config.js",
"mengsan/content/acts/act1/index.js",
"mengsan/content/acts/act1/map.js",
"mengsan/content/acts/act1/rewards.js",
"mengsan/content/acts/act1/stories/forest-ambush.js",
"mengsan/content/acts/act1/stories/liubei-opening.js",
"mengsan/content/acts/act2/index.js",
"mengsan/content/acts/act2/map.js",
"mengsan/content/acts/act3/index.js",
"mengsan/content/acts/act3/map.js",
"mengsan/content/affixes.js",
"mengsan/content/card-definitions.js",
"mengsan/content/card-library.js",
"mengsan/content/mode-cards.js",
"mengsan/content/registry.js",
"mengsan/content/scenario-characters.js",
"mengsan/content/shared/index.js",
"mengsan/content/shared/rewards.js",
"mengsan/docs/battle-config.md",
"mengsan/docs/combat-rules.md",
"mengsan/docs/liubei-opening.md",
"mengsan/map/index.js",
"mengsan/map/renderer.js",
"mengsan/mode.js",
"mengsan/register.js",
"mengsan/runtime/battle-director.js",
"mengsan/runtime/battle-flow.js",
"mengsan/runtime/battle-loop.js",
"mengsan/runtime/battle-resources.js",
"mengsan/runtime/battle-session.js",
"mengsan/runtime/battle-settlement.js",
"mengsan/runtime/card-data.js",
"mengsan/runtime/card-library.js",
"mengsan/runtime/combat-rules.js",
"mengsan/runtime/dialogue-choice.js",
"mengsan/runtime/dialogue.js",
"mengsan/runtime/engine-session.js",
"mengsan/runtime/flow-ui.js",
"mengsan/runtime/flyconid-intent.js",
"mengsan/runtime/gm-manager.js",
"mengsan/runtime/intent-display.js",
"mengsan/runtime/mode-storage.js",
"mengsan/runtime/monster.js",
"mengsan/runtime/navigation.js",
"mengsan/runtime/personal-piles.js",
"mengsan/runtime/reward-ui.js",
"mengsan/runtime/reward.js",
"mengsan/runtime/skill-teardown.js",
"mengsan/runtime/story.js",
"mengsan/state.js",
"mengsan/style.css"
]
+9 -5
View File
@@ -4,10 +4,6 @@
"menu.js",
"function.js",
"update.js",
"mengsan/mode.js",
"mengsan/config.js",
"mengsan/state.js",
"mengsan/style.css",
"tianshu/extension.js",
"tianshu/config.js",
"pve/boss/index.js",
@@ -79,5 +75,13 @@
"pve/boss/sets/tianshu/voices.js"
],
"remove": [],
"removeDirectories": []
"removeDirectories": [],
"modules": {
"mengsan": {
"root": "mengsan",
"entry": "mengsan/register.js",
"title": "梦三模式",
"legacy_files_from": "tools/legacy/mengsan-files.json"
}
}
}
+407 -20
View File
@@ -165,6 +165,12 @@ function escapeHTML(text) {
}[char]));
}
function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0);
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function setProgress(shuYing, options = {}) {
if (!shuYing?.text) return;
@@ -238,14 +244,26 @@ async function getManifestFromTag(tag, requireChannels = true,
if (requireChannels && manifest?.update_channels !== true) {
throw new Error("目标标签尚未包含新版更新器,请发布支持通道切换的版本");
}
validateManifest(manifest, tag.version);
manifest.tag_name = tag.name;
manifest.source = source;
return manifest;
}
function validateManifest(manifest, version, moduleId = "") {
if (!manifest || manifest.algorithm != "sha256"
|| !manifest.files || Array.isArray(manifest.files)
|| typeof manifest.files != "object"
|| manifest.version != tag.version) {
|| manifest.version != version
|| (moduleId && manifest.module_id != moduleId)) {
throw new Error("校验清单格式错误");
}
const files = getManifestFileKeys(manifest);
if (moduleId && files.some(file => !file.startsWith(`${moduleId}/`))) {
throw new Error(`校验清单包含越界的 ${moduleId} 模块文件`);
}
if (!files.length || files.some(file => {
const info = manifest.files[file];
return !info || !Number.isSafeInteger(info.size) || info.size < 0
@@ -257,7 +275,27 @@ async function getManifestFromTag(tag, requireChannels = true,
getManifestAssetFiles(manifest, coreFiles);
getManifestRemoveFiles(manifest);
getManifestRemoveDirectories(manifest);
return manifest;
}
async function getModuleManifest(tag, rootManifest, moduleId,
source = rootManifest?.source || getUpdateSource()) {
const descriptor = rootManifest?.modules?.[moduleId];
if (!descriptor || !isSafeManifestPath(descriptor.manifest)) {
throw new Error(`更新清单未提供 ${moduleId} 模块`);
}
if (typeof descriptor.entry != "string"
|| !isSafeManifestPath(descriptor.entry)
|| !descriptor.entry.startsWith(`${moduleId}/`)) {
throw new Error(`${moduleId} 模块入口配置无效`);
}
const response = await fetch(getRemoteUrl(tag.name, descriptor.manifest, source));
if (!response.ok) throw new Error(`${moduleId} 模块清单请求失败:${response.status}`);
const manifest = await response.json();
validateManifest(manifest, tag.version, moduleId);
if (!manifest.files[descriptor.entry]) {
throw new Error(`${moduleId} 模块清单缺少入口文件`);
}
manifest.tag_name = tag.name;
manifest.source = source;
return manifest;
@@ -609,6 +647,89 @@ async function getChangedFiles(manifest) {
return changed;
}
function makeLegacyModuleManifest(manifest, moduleId) {
const prefix = `${moduleId}/`;
const files = Object.fromEntries(Object.entries(manifest.files || {})
.filter(([file]) => file.startsWith(prefix)));
if (!Object.keys(files).length) return null;
const entry = `${moduleId}/register.js`;
return {
...manifest,
module_id: moduleId,
files,
core: files[entry] ? [entry] : [],
assets: Object.keys(files).filter(file => file != entry),
remove: [],
removeDirectories: [],
};
}
async function getInstalledModuleManifest(moduleId, version, source) {
const tag = { name: `v${version}`, version };
const oldRoot = await getManifestFromTag(tag, false, source);
if (oldRoot.modules?.[moduleId]) {
return await getModuleManifest(tag, oldRoot, moduleId, source);
}
return makeLegacyModuleManifest(oldRoot, moduleId);
}
async function getLegacyModuleManifest(moduleId, rootManifest) {
const path = rootManifest?.modules?.[moduleId]?.legacy_manifest;
if (!path) return null;
if (!isSafeManifestPath(path)) {
throw new Error(`${moduleId} 迁移清单路径无效`);
}
const response = await fetch(getRemoteUrl(
rootManifest.tag_name, path, rootManifest.source
));
if (!response.ok) throw new Error(`${moduleId} 迁移清单请求失败:${response.status}`);
const snapshot = await response.json();
if (snapshot.version != rootManifest.version
|| snapshot.module_id != moduleId
|| snapshot.snapshot != "legacy"
|| !Array.isArray(snapshot.files)) {
throw new Error(`${moduleId} 迁移清单格式错误`);
}
const files = uniqueManifestList(snapshot.files);
if (files.some(file => !file.startsWith(`${moduleId}/`))) {
throw new Error(`${moduleId} 迁移清单包含越界文件`);
}
return { files: Object.fromEntries(files.map(file => [file, {}])) };
}
async function isMengsanInstalled(localVersion, previousManifest,
currentManifest) {
const saved = lib.config.shuYing_mengsan_installed_version;
if (saved == "disabled") return false;
if (saved) return true;
const entryPath = "extension/术樱包/mengsan/register.js";
if (typeof game.checkFile == "function"
&& await safeCheckFile(entryPath) == 1) {
return true;
}
if (typeof game.checkFile != "function" && typeof game.readFile == "function") {
try {
await readFile(entryPath);
return true;
}
catch (error) { }
}
const oldManifest = previousManifest || (parseVersion(localVersion)
? await getManifestFromTag({
name: `v${localVersion}`, version: localVersion,
}, false, currentManifest.source).catch(() => null)
: null);
return !!oldManifest?.files?.["mengsan/register.js"];
}
function mergeRemovedFiles(manifest, oldFiles, retainedFiles = []) {
const current = new Set(getManifestFileKeys(manifest));
const retained = new Set(retainedFiles);
manifest.remove = [...new Set([...(manifest.remove || []),
...oldFiles.filter(file => !current.has(file) && !retained.has(file))])];
getManifestRemoveFiles(manifest);
}
async function getObsoleteFiles(manifest) {
const existing = [];
for (const file of getManifestRemoveFiles(manifest)) {
@@ -635,13 +756,15 @@ async function updateAllFiles(shuYing, manifest, files) {
const oldRoot = `${coreTempDir}/old`;
const backedUp = new Set();
const installed = [];
const obsoleteBackups = [];
const obsoleteFiles = getManifestRemoveFiles(manifest);
let filesInstalled = false;
try {
ensureProgressNode(shuYing);
await createDir(`${newRoot}/`);
await createDir(`${oldRoot}/`);
await createFolders(`${newRoot}/`, files);
await createFolders(`${oldRoot}/`, files);
await createFolders(`${oldRoot}/`, [...files, ...obsoleteFiles]);
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -672,11 +795,25 @@ async function updateAllFiles(shuYing, manifest, files) {
installed.push(file);
await writeTargetFile(await readFile(`${newRoot}/${file}`), target);
}
filesInstalled = true;
for (const file of obsoleteFiles) {
const target = `extension/术樱包/${file}`;
try {
const original = await readFile(target);
await writeTargetFile(original, `${oldRoot}/${file}`);
backedUp.add(file);
obsoleteBackups.push(file);
}
catch (error) {
if (await safeCheckFile(target) == 1) throw error;
}
}
await removeManifestOldPaths(shuYing, manifest);
filesInstalled = true;
}
catch (error) {
for (const file of filesInstalled ? [] : installed.reverse()) {
const rollbackFiles = filesInstalled ? []
: [...installed, ...obsoleteBackups].reverse();
for (const file of rollbackFiles) {
const target = `extension/术樱包/${file}`;
try {
if (backedUp.has(file)) {
@@ -693,7 +830,7 @@ async function updateAllFiles(shuYing, manifest, files) {
throw error;
}
finally {
for (const file of files) {
for (const file of new Set([...files, ...backedUp])) {
await removeFile(`${newRoot}/${file}`);
if (backedUp.has(file)) await removeFile(`${oldRoot}/${file}`);
}
@@ -758,41 +895,124 @@ async function checkVersion(shuYing, options = {}) {
&& compareVersions(onlineVersion.online_version, localVersion) < 0) {
throw new Error("目标更新源的版本落后于本地版本,请稍后重试");
}
if (options.switchChannel && parseVersion(localVersion)
&& localVersion != manifest.version) {
const previous = await getManifestFromTag({
name: `v${localVersion}`,
version: localVersion,
}, false, manifest.source);
const targetFiles = new Set(getManifestFileKeys(manifest));
const removed = getManifestFileKeys(previous)
.filter(file => !targetFiles.has(file));
manifest.remove = [...new Set([...(manifest.remove || []), ...removed])];
getManifestRemoveFiles(manifest);
let previousManifest = null;
if (parseVersion(localVersion) && localVersion != manifest.version) {
previousManifest = await getManifestFromTag({
name: `v${localVersion}`, version: localVersion,
}, false, manifest.source).catch(error => {
console.warn("读取本地旧版清单失败,跳过旧文件差集清理:", error);
return null;
});
}
const installedMengsan = await isMengsanInstalled(
localVersion, previousManifest, manifest
);
if (installedMengsan
&& !lib.config.shuYing_mengsan_installed_version
&& parseVersion(localVersion)) {
game.saveConfig("shuYing_mengsan_installed_version",
localVersion == manifest.version ? "legacy" : localVersion);
}
if (previousManifest) {
const retainedModuleFiles = manifest.modules?.mengsan
? getManifestFileKeys(previousManifest)
.filter(file => file.startsWith("mengsan/"))
: [];
mergeRemovedFiles(
manifest,
getManifestFileKeys(previousManifest),
retainedModuleFiles
);
}
const changed = await getChangedFiles(manifest);
const obsolete = await getObsoleteFiles(manifest);
await addLog(shuYing, `更新源:${manifest.source};标签:${manifest.tag_name};待更新文件:${changed.length};待删除文件:${obsolete.length}`);
let mengsanManifest = null;
let mengsanChanged = [];
let mengsanObsolete = [];
let mengsanUpdateError = null;
let previousMengsan = null;
if (installedMengsan && manifest.modules?.mengsan) {
try {
mengsanManifest = await getModuleManifest({
name: manifest.tag_name, version: manifest.version,
}, manifest, "mengsan");
const installedModuleVersion =
lib.config.shuYing_mengsan_installed_version;
if (installedModuleVersion
&& installedModuleVersion != "disabled"
&& parseVersion(installedModuleVersion)
&& installedModuleVersion != manifest.version) {
previousMengsan = await getInstalledModuleManifest(
"mengsan", installedModuleVersion, manifest.source
);
}
else if (previousManifest && previousManifest.modules?.mengsan) {
previousMengsan = await getModuleManifest({
name: `v${localVersion}`, version: localVersion,
}, previousManifest, "mengsan", manifest.source);
}
else if (previousManifest) {
previousMengsan = makeLegacyModuleManifest(
previousManifest, "mengsan"
);
}
if (!previousMengsan
&& (!installedModuleVersion || installedModuleVersion == "legacy")) {
previousMengsan = await getLegacyModuleManifest(
"mengsan", manifest
);
}
if (previousMengsan) {
mergeRemovedFiles(
mengsanManifest,
getManifestFileKeys(previousMengsan)
);
}
mengsanChanged = await getChangedFiles(mengsanManifest);
mengsanObsolete = await getObsoleteFiles(mengsanManifest);
}
catch (error) {
mengsanUpdateError = error;
await addLog(shuYing, `梦三模块更新暂不可用:${getErrorMessage(error)}`);
}
}
const allChangedCount = changed.length + mengsanChanged.length;
const allObsoleteCount = obsolete.length + mengsanObsolete.length;
await addLog(shuYing, `更新源:${manifest.source};标签:${manifest.tag_name};待更新文件:${allChangedCount};待删除文件:${allObsoleteCount}`);
if (localVersion == onlineVersion.online_version
&& !changed.length && !obsolete.length) {
&& !allChangedCount && !allObsoleteCount && !mengsanUpdateError) {
game.saveConfig("shuYing_online_version", onlineVersion.online_version);
alert("本地版本为最新版");
return onlineVersion.online_version;
}
const message = localVersion == onlineVersion.online_version
? `当前版本有 ${changed.length} 个文件需修复、${obsolete.length} 个旧文件需删除,是否继续?`
? `当前版本有 ${allChangedCount} 个文件需修复、${allObsoleteCount} 个旧文件需删除,是否继续?`
: `检测到最新版本为:${onlineVersion.online_version}, 本地版本为:${localVersion},是否更新?`;
if (!options.switchChannel && !confirm(message)) {
return null;
}
await updateAllFiles(shuYing, manifest, changed);
if (mengsanManifest && !mengsanUpdateError) {
try {
await updateAllFiles(shuYing, mengsanManifest, mengsanChanged);
game.saveConfig("shuYing_mengsan_installed_version", manifest.version);
}
catch (error) {
mengsanUpdateError = error;
await addLog(shuYing, `梦三模块更新失败:${getErrorMessage(error)}`);
}
}
game.saveConfig("shuYing_local_version", onlineVersion.online_version);
game.saveConfig("shuYing_online_version", onlineVersion.online_version);
await addLog(shuYing, "版本更新完成");
alert("下载完成,重启生效");
alert(mengsanUpdateError
? "主包更新完成;梦三模块未完成更新,可稍后重试。"
: "下载完成,重启生效");
return onlineVersion.online_version;
}
catch (error) {
@@ -821,6 +1041,69 @@ async function repairMissingFiles(shuYing) {
setProgress(shuYing, { title: "查漏补缺", status: "获取校验清单", current: 0, total: 1 });
const manifest = await getManifest();
const files = getManifestDownloadFiles(manifest);
const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
let previousManifest = null;
if (parseVersion(localVersion) && localVersion != manifest.version) {
previousManifest = await getManifestFromTag({
name: `v${localVersion}`, version: localVersion,
}, false, manifest.source).catch(() => null);
if (previousManifest) {
const retainedModuleFiles = manifest.modules?.mengsan
? getManifestFileKeys(previousManifest)
.filter(file => file.startsWith("mengsan/"))
: [];
mergeRemovedFiles(
manifest,
getManifestFileKeys(previousManifest),
retainedModuleFiles
);
}
}
const installedMengsan = await isMengsanInstalled(
localVersion, previousManifest, manifest
);
let mengsanManifest = null;
let previousMengsan = null;
if (installedMengsan && manifest.modules?.mengsan) {
mengsanManifest = await getModuleManifest({
name: manifest.tag_name, version: manifest.version,
}, manifest, "mengsan");
const installedModuleVersion =
lib.config.shuYing_mengsan_installed_version;
if (installedModuleVersion == "legacy") {
previousMengsan = await getLegacyModuleManifest(
"mengsan", manifest
);
}
else if (installedModuleVersion
&& parseVersion(installedModuleVersion)
&& installedModuleVersion != manifest.version) {
previousMengsan = await getInstalledModuleManifest(
"mengsan", installedModuleVersion, manifest.source
);
}
else if (previousManifest?.modules?.mengsan) {
previousMengsan = await getModuleManifest({
name: `v${localVersion}`, version: localVersion,
}, previousManifest, "mengsan", manifest.source);
}
else if (previousManifest) {
previousMengsan = makeLegacyModuleManifest(
previousManifest, "mengsan"
);
}
else if (!installedModuleVersion) {
previousMengsan = await getLegacyModuleManifest(
"mengsan", manifest
);
}
if (previousMengsan) {
mergeRemovedFiles(
mengsanManifest,
getManifestFileKeys(previousMengsan)
);
}
}
await addLog(shuYing, `待检查文件数:${files.length}`);
await addLog(shuYing, `校验清单版本:${manifest.version || "未知"};清单文件数:${Object.keys(manifest.files || {}).length}`);
@@ -830,6 +1113,11 @@ async function repairMissingFiles(shuYing) {
await downloadList(shuYing, files, manifest);
await removeManifestOldPaths(shuYing, manifest);
if (mengsanManifest) {
const moduleChanged = await getChangedFiles(mengsanManifest);
await updateAllFiles(shuYing, mengsanManifest, moduleChanged);
game.saveConfig("shuYing_mengsan_installed_version", manifest.version);
}
setProgress(shuYing, { title: "查漏补缺", status: "完成", current: files.length, total: files.length });
await addLog(shuYing, "查漏补缺完成");
@@ -846,6 +1134,104 @@ async function repairMissingFiles(shuYing) {
}
}
async function manageMengsanModule(shuYing) {
setBusy(shuYing, true);
try {
shuYing._updateLogs = [];
const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
const rootManifest = await getManifest();
const installed = await isMengsanInstalled(
localVersion, null, rootManifest
);
if (installed) {
if (!confirm("确认卸载梦三模式?只删除梦三模块清单拥有的文件。")) return;
let moduleManifest;
const moduleVersion = lib.config.shuYing_mengsan_installed_version;
if (moduleVersion == "legacy" || !moduleVersion) {
const legacyManifest = await getLegacyModuleManifest(
"mengsan", rootManifest
).catch(() => null);
const currentManifest = await getModuleManifest({
name: rootManifest.tag_name, version: rootManifest.version,
}, rootManifest, "mengsan");
let localManifest = null;
if (parseVersion(localVersion)
&& localVersion != rootManifest.version) {
localManifest = await getInstalledModuleManifest(
"mengsan", localVersion, rootManifest.source
).catch(() => null);
}
moduleManifest = {
files: Object.fromEntries([...new Set([
...getManifestFileKeys(legacyManifest || { files: {} }),
...getManifestFileKeys(localManifest || { files: {} }),
...getManifestFileKeys(currentManifest),
])].map(file => [file, {}])),
};
}
else if (parseVersion(moduleVersion || "")) {
moduleManifest = await getInstalledModuleManifest(
"mengsan", moduleVersion, rootManifest.source
).catch(async error => {
await addLog(shuYing, `读取已安装模块清单失败:${getErrorMessage(error)}`);
return null;
});
}
if (!moduleManifest && parseVersion(localVersion)) {
moduleManifest = await getInstalledModuleManifest(
"mengsan", localVersion, rootManifest.source
).catch(() => null);
}
if (!moduleManifest) {
moduleManifest = await getModuleManifest({
name: rootManifest.tag_name, version: rootManifest.version,
}, rootManifest, "mengsan");
}
const files = getManifestFileKeys(moduleManifest);
if (typeof game.removeFile != "function") {
throw new Error("当前环境不支持安全卸载文件");
}
for (const file of files) {
await addLog(shuYing, `卸载梦三文件:${file}`);
await removeFile(`extension/术樱包/${file}`);
if (typeof game.checkFile == "function"
&& await safeCheckFile(`extension/术樱包/${file}`) == 1) {
throw new Error(`梦三文件删除失败:${file}`);
}
}
game.saveConfig("shuYing_mengsan_installed_version", "disabled");
alert("梦三模式文件已卸载,请重启游戏生效。");
return;
}
const moduleInfo = rootManifest.modules?.mengsan;
const sizeText = moduleInfo?.size_bytes
? `,约 ${formatBytes(moduleInfo.size_bytes)}` : "";
if (!confirm(`梦三模式尚未安装,是否下载并校验全部梦三资源${sizeText}?`)) return;
const moduleManifest = await getModuleManifest({
name: rootManifest.tag_name, version: rootManifest.version,
}, rootManifest, "mengsan");
const files = getManifestFileKeys(moduleManifest);
ensureProgressNode(shuYing);
setProgress(shuYing, {
title: "安装梦三模式", status: "准备下载", current: 0,
total: files.length * 2,
});
await updateAllFiles(shuYing, moduleManifest, files);
game.saveConfig("shuYing_mengsan_installed_version", rootManifest.version);
alert("梦三模式下载完成,请重启游戏生效。");
}
catch (error) {
console.error(error);
await addLog(shuYing, `梦三模块操作失败:${getErrorMessage(error)}`);
alert("梦三模式操作失败,请检查网络或 extension/术樱包/log.txt。");
}
finally {
removeProgressNode(shuYing);
setBusy(shuYing, false);
}
}
async function repairCoreFiles(shuYing) {
let shouldDownload = false;
let manifest;
@@ -904,4 +1290,5 @@ export default {
checkVersion,
repairMissingFiles,
repairCoreFiles,
manageMengsanModule,
};