fixed # 修复手机端地图滑动异常的问题
fixed # 重构更新框架,支持选择是否下载梦三模式 fixed # 补充部分意图的资源图片与逻辑
@@ -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"
|
||||
|
||||
@@ -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 '打包失败' }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` 明确声明。
|
||||
|
||||
新增模块时,清单生成器可通过配置建立独立清单;客户端的安装菜单、启动
|
||||
加载和卸载入口仍需为该模块添加对应管理逻辑。
|
||||
@@ -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__":
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||