diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 8e8f3a9..ca03790 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" \ No newline at end of file + Write-Host "发布完成:$tag" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dcab3a..4896f9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 '打包失败' } diff --git a/.gitignore b/.gitignore index 839bf71..eadf95c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/extension.js b/extension.js index 5fe1e15..0cca5ce 100644 --- a/extension.js +++ b/extension.js @@ -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(); diff --git a/mengsan/assets/intent/Intent_buff.png b/mengsan/assets/intent/Intent_buff.png new file mode 100644 index 0000000..921ff37 Binary files /dev/null and b/mengsan/assets/intent/Intent_buff.png differ diff --git a/mengsan/assets/intent/Intent_card_debuff.png b/mengsan/assets/intent/Intent_card_debuff.png new file mode 100644 index 0000000..c0b21f7 Binary files /dev/null and b/mengsan/assets/intent/Intent_card_debuff.png differ diff --git a/mengsan/assets/intent/Intent_death_blow.png b/mengsan/assets/intent/Intent_death_blow.png new file mode 100644 index 0000000..b1b6736 Binary files /dev/null and b/mengsan/assets/intent/Intent_death_blow.png differ diff --git a/mengsan/assets/intent/Intent_defend.png b/mengsan/assets/intent/Intent_defend.png new file mode 100644 index 0000000..e4ee37c Binary files /dev/null and b/mengsan/assets/intent/Intent_defend.png differ diff --git a/mengsan/assets/intent/Intent_escape.png b/mengsan/assets/intent/Intent_escape.png new file mode 100644 index 0000000..1865075 Binary files /dev/null and b/mengsan/assets/intent/Intent_escape.png differ diff --git a/mengsan/assets/intent/Intent_heal.png b/mengsan/assets/intent/Intent_heal.png new file mode 100644 index 0000000..ece0f92 Binary files /dev/null and b/mengsan/assets/intent/Intent_heal.png differ diff --git a/mengsan/assets/intent/Intent_sleep.png b/mengsan/assets/intent/Intent_sleep.png new file mode 100644 index 0000000..79bfec3 Binary files /dev/null and b/mengsan/assets/intent/Intent_sleep.png differ diff --git a/mengsan/assets/intent/Intent_status_card.png b/mengsan/assets/intent/Intent_status_card.png new file mode 100644 index 0000000..24fdaeb Binary files /dev/null and b/mengsan/assets/intent/Intent_status_card.png differ diff --git a/mengsan/assets/intent/Intent_stun.png b/mengsan/assets/intent/Intent_stun.png new file mode 100644 index 0000000..be2ae32 Binary files /dev/null and b/mengsan/assets/intent/Intent_stun.png differ diff --git a/mengsan/assets/intent/Intent_summon.png b/mengsan/assets/intent/Intent_summon.png new file mode 100644 index 0000000..d77d780 Binary files /dev/null and b/mengsan/assets/intent/Intent_summon.png differ diff --git a/mengsan/assets/intent/Intent_unknown.png b/mengsan/assets/intent/Intent_unknown.png new file mode 100644 index 0000000..49e3231 Binary files /dev/null and b/mengsan/assets/intent/Intent_unknown.png differ diff --git a/mengsan/config.js b/mengsan/config.js index 45ae2e1..8c2bdb8 100644 --- a/mengsan/config.js +++ b/mengsan/config.js @@ -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", diff --git a/mengsan/content/acts/act1/map.js b/mengsan/content/acts/act1/map.js index 9650b20..3382917 100644 --- a/mengsan/content/acts/act1/map.js +++ b/mengsan/content/acts/act1/map.js @@ -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, diff --git a/mengsan/content/acts/act1/rewards.js b/mengsan/content/acts/act1/rewards.js index 0374d05..d16bdaf 100644 --- a/mengsan/content/acts/act1/rewards.js +++ b/mengsan/content/acts/act1/rewards.js @@ -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", diff --git a/mengsan/map/renderer.js b/mengsan/map/renderer.js index afcfcf7..3c61682 100644 --- a/mengsan/map/renderer.js +++ b/mengsan/map/renderer.js @@ -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"); diff --git a/mengsan/mode.js b/mengsan/mode.js index e62e2af..7aad497 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -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; }, diff --git a/mengsan/runtime/death-blow.js b/mengsan/runtime/death-blow.js new file mode 100644 index 0000000..dc96331 --- /dev/null +++ b/mengsan/runtime/death-blow.js @@ -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; +} diff --git a/mengsan/runtime/intent-damage.js b/mengsan/runtime/intent-damage.js new file mode 100644 index 0000000..039cd6b --- /dev/null +++ b/mengsan/runtime/intent-damage.js @@ -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; +} diff --git a/mengsan/runtime/intent-display.js b/mengsan/runtime/intent-display.js index 403bd8f..9006752 100644 --- a/mengsan/runtime/intent-display.js +++ b/mengsan/runtime/intent-display.js @@ -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(); + }; } diff --git a/mengsan/runtime/stun-intent.js b/mengsan/runtime/stun-intent.js new file mode 100644 index 0000000..9e11fbf --- /dev/null +++ b/mengsan/runtime/stun-intent.js @@ -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); +} diff --git a/mengsan/state.js b/mengsan/state.js index 13199c1..aae143c 100644 --- a/mengsan/state.js +++ b/mengsan/state.js @@ -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)}`; diff --git a/mengsan/style.css b/mengsan/style.css index df2fb27..03ec96d 100644 --- a/mengsan/style.css +++ b/mengsan/style.css @@ -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; diff --git a/menu.js b/menu.js index 2669a7a..d8fe896 100644 --- a/menu.js +++ b/menu.js @@ -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, diff --git a/tools/UPDATE.md b/tools/UPDATE.md new file mode 100644 index 0000000..a363c00 --- /dev/null +++ b/tools/UPDATE.md @@ -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` 明确声明。 + +新增模块时,清单生成器可通过配置建立独立清单;客户端的安装菜单、启动 +加载和卸载入口仍需为该模块添加对应管理逻辑。 diff --git a/tools/generate_manifest.py b/tools/generate_manifest.py index 8bcc58c..26a4525 100644 --- a/tools/generate_manifest.py +++ b/tools/generate_manifest.py @@ -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__": diff --git a/tools/legacy/mengsan-files.json b/tools/legacy/mengsan-files.json new file mode 100644 index 0000000..1e06fa1 --- /dev/null +++ b/tools/legacy/mengsan-files.json @@ -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" +] \ No newline at end of file diff --git a/tools/manifest_config.json b/tools/manifest_config.json index fa39bb8..98a3b22 100644 --- a/tools/manifest_config.json +++ b/tools/manifest_config.json @@ -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" + } + } } diff --git a/update.js b/update.js index 818d703..f871df9 100644 --- a/update.js +++ b/update.js @@ -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, };