diff --git a/.gitignore b/.gitignore index d687722..4618c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,12 @@ task_plan.md dist/manifest.json tools/__pycache__/ -tools/test_update.mjs -tools/test_intent.mjs +tools/*.mjs /mengsan/.planning/ /mengsan/verification /mengsan/assets/sts2/ -/Slay the Spire 2 \ No newline at end of file +/Slay the Spire 2 + +*.mjs \ No newline at end of file diff --git a/mengsan/battle/battle-flow.js b/mengsan/battle/battle-flow.js index 1233856..15ee6cf 100644 --- a/mengsan/battle/battle-flow.js +++ b/mengsan/battle/battle-flow.js @@ -20,6 +20,7 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding, : await chooseReward(copy(job.pending.choices), { boss: job.pending.boss, fixedRewards: copy(job.pending.fixedRewards || []), + rewardPackage: copy(job.pending.rewardPackage || null), }); if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection"); job.hasChoice = true; diff --git a/mengsan/battle/battle-settlement.js b/mengsan/battle/battle-settlement.js index 4654e5f..72aa279 100644 --- a/mengsan/battle/battle-settlement.js +++ b/mengsan/battle/battle-settlement.js @@ -1,10 +1,15 @@ // Candidate settlement adapter. Dependencies are the current mode/state reward functions. import { applyBattleEndRelics } from "../relics/battle.js"; import { settleBondBattle } from "../bonds/state.js"; +import { prepareRewardPackage, applyRewardPackage } + from "../progression/reward-package.js"; const copy = value => JSON.parse(JSON.stringify(value)); const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]); -export function createBattleSettlement({store, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct, now = Date.now}) { +export function createBattleSettlement({ + store, config, getRandomRewardChoices, applyReward, completeNode, + enterNextAct, now = Date.now, logRewardPackage = () => {}, +}) { const readRun = () => store.read()[config.saveKey]; function current(storage, runId) { const run = storage[config.saveKey]; @@ -49,7 +54,11 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a // Persist the configured amount as a candidate, never as automatic victory income. const choices = outcome === "victory" && !encounter.skipRandomReward ? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : []; + const rewardPackage = outcome === "victory" && encounter.rewardPackage + ? prepareRewardPackage(base, encounter.rewardPackage, + getRandomRewardChoices) : null; run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome, relicRecovery, bondGrowth}; + run.battleFlow.pending.rewardPackage = copy(rewardPackage); return run.battleFlow.pending; }); }, @@ -57,21 +66,29 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a return store.update(storage => { const {item} = pending(storage, runId, id); if (item.state === "chosen") { - if (item.choiceId !== choiceId) throw new Error("Choice already locked"); + const sameChoice = item.rewardPackage + ? item.choiceId?.cardId === choiceId?.cardId && + item.choiceId?.relicId === choiceId?.relicId + : item.choiceId === choiceId; + if (!sameChoice) throw new Error("Choice already locked"); return item; } const result = copy(item.base); let route = "defeat"; if (item.outcome === "victory") { + if (item.rewardPackage) { + applyRewardPackage(result, item.rewardPackage, choiceId, + applyReward); + } const choice = item.choices.find(c => c.id === choiceId); - if (item.choices.length && choiceId !== null) { + if (!item.rewardPackage && item.choices.length && choiceId !== null) { if (!choice) throw new Error("Reward is not a fixed candidate"); if (choice.kind === "gold") { if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward"); result.player.gold += choice.amount; result.statistics.goldEarned += choice.amount; } else applyReward(result, choice.effectId || choice.id); - } else if (choiceId !== null) throw new Error("Unexpected reward selection"); + } else if (!item.rewardPackage && choiceId !== null) throw new Error("Unexpected reward selection"); for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId); if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed"); route = item.boss && !enterNextAct(result) ? "victory" : "map"; @@ -79,6 +96,9 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a // Save the exact result (including random card ID/target) before final commit. item.state = "chosen"; item.choiceId = choiceId; item.result = result; item.route = route; item.completedAt = now(); + if (item.rewardPackage && item.outcome === "victory") { + logRewardPackage(item.rewardPackage, choiceId); + } return item; }); }, diff --git a/mengsan/content/acts/act1/rewards.js b/mengsan/content/acts/act1/rewards.js index 70a39aa..b055d3e 100644 --- a/mengsan/content/acts/act1/rewards.js +++ b/mengsan/content/acts/act1/rewards.js @@ -1,6 +1,7 @@ import { relicRewardIds } from "../../../relics/definitions.js"; export const rewardPools = { + "act1.pool.liubeiOpening.relics": [...relicRewardIds], "act1.pool.chest": [ ...relicRewardIds, "shared.reward.item.handCharm", diff --git a/mengsan/content/acts/act1/stories/liubei-opening.js b/mengsan/content/acts/act1/stories/liubei-opening.js index 948f22e..8c1f192 100644 --- a/mengsan/content/acts/act1/stories/liubei-opening.js +++ b/mengsan/content/acts/act1/stories/liubei-opening.js @@ -46,8 +46,9 @@ export const openingRewards = { export default { id: "act1.battle.liubeiOpening", kind: "battle", name: "刘备初始剧情", mapName: "初识的卢", requiredCharacter: "mengsan_liubei_shuying", - enemies: ["mengsan_soldier_shuying"], gold: 20, + enemies: ["mengsan_soldier_shuying"], gold: 400, skipRandomReward: true, fixedRewards: ["act1.reward.dilu"], openingDialogue, victoryDialogue, + rewardPackage: { gold: 400, relicPool: "act1.pool.liubeiOpening.relics" }, battlePlan: { units: [{ id: "soldier", character: "mengsan_soldier_shuying", camp: "enemy", diff --git a/mengsan/content/registry.js b/mengsan/content/registry.js index 8718c18..0d7cf95 100644 --- a/mengsan/content/registry.js +++ b/mengsan/content/registry.js @@ -66,6 +66,13 @@ Object.entries(nodeContents).forEach(([contentId, content]) => { validateDialogue(content.victoryDialogue, contentId + " 战后"); validateDialogue(content.openingDialogue, contentId + " 开场"); for (const id of content.fixedRewards || []) if (!rewards[id]) throw new Error("固定奖励不存在:" + id); + if (content.rewardPackage) { + const pack = content.rewardPackage; + if (!Number.isSafeInteger(pack.gold) || pack.gold < 0 || + !rewardPools[pack.relicPool]?.every(id => rewards[id].relic)) { + throw new Error(`梦三奖励包配置无效:${contentId}`); + } + } if (content.battlePlan) validateBattlePlan(content.battlePlan); (content.choices || []).forEach(choice => { if (!choice.id) throw new Error(`梦三剧情选项缺少ID:${contentId}`); diff --git a/mengsan/docs/liubei-opening.md b/mengsan/docs/liubei-opening.md index b9b28fb..40c454d 100644 --- a/mengsan/docs/liubei-opening.md +++ b/mengsan/docs/liubei-opening.md @@ -16,6 +16,7 @@ - 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。 - `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。 - `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。 +- `rewardPackage` 额外固定获得400金币、手动强化1张牌、随机遗物三选一。遗物候选复用当前新遗物奖励池,排除已持有项,随机结果在打开界面前保存;不足三件时显示剩余候选。强化只允许选择尚未满级且没有永恒词缀的可强化牌;没有候选时明确提示并跳过该项。两项选择完成后,与金币、的卢及节点完成一起提交,重试或恢复存档不会重复领取。遗物池耗尽时同样提示并跳过遗物选择。 - 固定奖励与选择奖励共用过关奖励界面,以“固定战利品”/“可选战利品”等文字区分;固定奖励显示名称和说明,点击“领取固定奖励”后继续结算,不能放弃。若同时配置两类奖励,固定奖励全部获得,候选奖励仍择一领取或放弃。 - 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。 - 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。 diff --git a/mengsan/docs/ui-subsystem-outline.md b/mengsan/docs/ui-subsystem-outline.md new file mode 100644 index 0000000..0e99c49 --- /dev/null +++ b/mengsan/docs/ui-subsystem-outline.md @@ -0,0 +1,140 @@ +# 梦三手牌 UI 子系统规划大纲 + +## 1. 背景与目标 + +- 当前手牌 UI 与无名杀真实卡牌节点的关系 +- 十周年 UI 不同风格可能造成的布局差异 +- 建立梦三独立控制的手牌显示层 +- 兼容原生 UI 与目标 UI 风格 + +## 2. 范围与非目标 + +### 2.1 本次范围 + +- 手牌布局、卡牌尺寸与间距 +- 梦三费用、强化等卡牌覆盖层 +- 卡牌显示与无名杀游戏状态同步 +- 战斗期间挂载、退出时清理 + +### 2.2 非目标 + +- 不改写无名杀全局卡牌系统 +- 不改变其他模式或扩展的全局样式 +- 不承诺兼容所有未知 UI 扩展 +- 不在验证交互方案前替换真实卡牌对象 + +## 3. 设计原则 + +- 无名杀真实卡牌对象是游戏状态的唯一来源 +- 梦三 UI 只负责显示与输入转接 +- 梦三样式使用独立根节点和命名空间 +- 不依赖十周年 UI 未公开的内部结构作为硬性前提 +- 适配失败时可回退到现有手牌显示 + +## 4. 分层设计 + +### 4.1 引擎卡牌与游戏状态层 + +- 真实卡牌对象的所有权 +- 摸牌、出牌、响应、选目标、排序的现有流程 + +### 4.2 梦三手牌视图层 + +- 卡牌视觉表示 +- 手牌排列与响应式布局 +- 费用、强化等梦三标记 +- 选中、可用、禁用等状态呈现 + +### 4.3 适配与生命周期层 + +- 真实卡牌到梦三视图的对象映射 +- 卡牌进入、离开、更新时的同步 +- 战斗开始时挂载、退出时完整清理 +- 未知或不支持状态的回退行为 + +## 5. 关键技术决策 + +### 5.1 真实卡牌节点与自绘视图的关系 + +- 评估复用真实节点与代理视图两种方案 +- 确认节点移动是否影响引擎及其他 UI 扩展 +- 验证代理视图如何调用引擎既有选择和出牌流程 +- 明确决策门槛与回退方案 + +### 5.2 输入与交互 + +- 点击与拖动 +- 选中、取消选中 +- 目标选择与确认 +- 出牌与响应 +- 自动排序与手动排序 +- 卡牌移出手牌区后的视图回收 + +### 5.3 样式隔离 + +- 梦三根节点和 CSS 类命名规范 +- 避免全局选择器、全局原型覆盖和全局事件劫持 +- 层级、缩放、窗口尺寸和移动端布局策略 + +## 6. 分阶段实施 + +### 阶段一:现有交互链路调查与可行性验证 + +- 记录引擎对真实卡牌节点的依赖 +- 验证核心操作和 UI 扩展的节点行为 +- 产出方案比较与 go/no-go 决策 + +### 阶段二:状态映射与适配接口 + +- 定义卡牌身份映射 +- 定义视图更新与交互回调 +- 明确缺少能力时的降级路径 + +### 阶段三:梦三手牌视图原型 + +- 创建隔离的 UI 根节点 +- 实现布局、卡牌外观和梦三覆盖层 +- 验证视图随真实卡牌状态更新 + +### 阶段四:完整交互接入 + +- 接入选择、出牌、响应、取消与排序 +- 保证卡牌身份和事件目标始终对应 + +### 阶段五:生命周期、回退与兼容验证 + +- 验证进入战斗、正常结束、异常退出时的清理 +- 验证原生 UI 与十周年主要风格 +- 记录未支持的扩展和限制 + +## 7. 兼容性验证矩阵 + +| 环境 | 布局显示 | 点击/拖动 | 出牌/响应 | 选目标/取消 | 排序 | 退出清理 | +|---|---|---|---|---|---|---| +| 无名杀原生 UI | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | +| 十周年 UI:手杀风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | +| 十周年 UI:十周年风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | +| 十周年 UI:新杀风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | + +## 8. 风险与应对 + +- 真实卡牌节点移动导致引擎或其他扩展状态不同步 +- 代理卡牌视图未继承引擎交互语义 +- 不同 UI 修改同一容器或卡牌样式 +- 监听器、观察器或 DOM 节点在退出后残留 +- 引擎升级改变内部行为 + +## 9. 验收标准 + +- 不修改梦三以外的全局 UI 样式或原型 +- 核心卡牌交互在目标环境中完整可用 +- 梦三覆盖层与布局不依赖特定主题的坐标假设 +- 战斗结束或异常退出后无梦三 UI 残留 +- 不支持的环境能安全回退并有明确诊断信息 + +## 10. 待确认事项 + +- 首期支持哪些无名杀版本与 UI 风格 +- 是否要求手动拖动排序 +- 小屏幕和触屏设备的交互要求 +- 不支持某种 UI 时是否提示玩家切回原生手牌显示 diff --git a/mengsan/mode.js b/mengsan/mode.js index aa64b57..27f66ba 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -1,8 +1,12 @@ import { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js"; import { createMengsanCards } from "./cards/mode-cards.js"; import { createRelicBattle, createRelicSkills } from "./relics/battle.js"; -import { grantRelic, getRelic } from "./relics/definitions.js"; +import { grantRelic, getRelic, initializeRelicMaxHp } + from "./relics/definitions.js"; import { mountRelics } from "./ui/relics.js"; +import { createOpeningEffects, createRelicCombatSkills } + from "./relics/combat.js"; +import { applyRoomRelics, restRecovery } from "./relics/rooms.js"; import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./battle/battle-director.js"; import { installPersonalPiles } from "./cards/personal-piles.js"; import { createMonster } from "./monsters/monster.js"; @@ -51,6 +55,7 @@ import { canAcquireCard } from "./cards/card-definitions.js"; import { addCardToDeck, createRandomCardData } from "./cards/card-data.js"; import { hasUpgradeableCard, upgradeRandomCard } from "./cards/upgrades.js"; import { chooseBattleReward } from "./ui/reward-ui.js"; +import { chooseRewardPackage } from "./ui/reward-package.js"; import { applyStoryOutcome, getAvailableStoryChoices } from "./progression/story.js"; import { playDialogue } from "./ui/dialogue.js"; import { chooseButtons } from "./ui/flow-ui.js"; @@ -201,14 +206,19 @@ const finishStoryNode = async (run, node) => { const finishUtilityNode = async (run, node) => { if (node.type == "rest") { const choice = await chooseButtons("休息节点", [ - { id: "heal", name: "休息", description: "回复 8 点生命" }, + { id: "heal", name: "休息", + description: `回复 ${restRecovery(run)} 点生命` }, { id: "upgrade", name: "磨砺", description: hasUpgradeableCard(run.player.deck) ? "随机强化一张尚未满级的可强化牌" : "没有可强化卡牌", disabled: !hasUpgradeableCard(run.player.deck) }, ]); - applyReward(run, choice); + if (choice === "heal") { + const result = applyRoomRelics(run, node, choice); + if (result) game.log(`休息回复${result.recovered}点生命`, + ...result.relics.map(relic => relic.name)); + } else applyReward(run, choice); } else if (node.type == "chest") { await chooseReward(run, { @@ -218,6 +228,11 @@ const finishUtilityNode = async (run, node) => { }); } else if (node.type == "shop") { + const result = applyRoomRelics(run, node, "enter"); + if (result) { + game.log(`遗物【餐券】进入商店回复${result.recovered}点生命`); + await saveRun(run); + } const price = 20; const choice = await chooseButtons("商店 Demo", [ { id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price || !canAcquireCard(run.player.character, "sha") }, @@ -448,12 +463,15 @@ const prepareBattle = async (run, node, encounter, session, resources) => { run.player.maxHp = me.maxHp; run.player.hp = me.hp; } + initializeRelicMaxHp(run); me.maxHp = run.player.maxHp; me.hp = Math.max(1, Math.min(run.player.hp, me.maxHp)); me.update(); currentBattle.relics = createRelicBattle(run, { active: () => session.active && me.hp > 0, draw: number => game.mengsanDraw_shuying(me, number), + effect: createOpeningEffects(game, me, + () => currentBattle.energyUI.get(me)?.()), log: (relic, effect) => { game.log(me, `遗物【${relic.name}】发动:${effect}`); currentBattle.relicUI?.refresh(); @@ -473,6 +491,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => { "mengsan_stun_skip_shuying", "mengsan_stun_recover_shuying", "mengsan_stun_clear_shuying", "mengsan_relics_shuying", + "mengsan_relic_attack_shuying", ]) { if (!lib.skill.global.includes(skill)) { game.addGlobalSkill(skill); @@ -556,8 +575,17 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({ return playDialogue(pending.victoryDialogue, { run: pending.base, title: "战后剧情" }); }, - settlement:createBattleSettlement({store:modeStorage, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct}), - chooseReward: (choices, options) => chooseBattleReward(choices, { + settlement:createBattleSettlement({store:modeStorage, config, + getRandomRewardChoices, applyReward, completeNode, enterNextAct, + logRewardPackage: (pack, selection) => { + const card = pack.upgradeChoices.find(card => + card.id === selection.cardId); + game.log(`梦三:过关奖励 ${pack.gold} 金币,` + + (card ? `强化【${get.translation(card.name)}】` : "无可强化牌")); + }, + }), + chooseReward: (choices, options) => (options.rewardPackage + ? chooseRewardPackage : chooseBattleReward)(choices, { ...options, fixedRewards: options.fixedRewards.map(id => ({ ...config.rewards[id], id, @@ -839,6 +867,7 @@ const createMode = identityMode => { skill: { ...(identityMode.skill || {}), ...createRelicSkills(() => activeBattle, () => game.me), + ...createRelicCombatSkills(() => activeBattle, () => game.me), mengsan_taoyuan_bond_shuying: { locked: true, mark: true, marktext: "义", intro: { content: "先天结识关羽、张飞,初始羁绊均为8级。助战角色在行军菜单中选择。" }, @@ -1088,6 +1117,10 @@ const createMode = identityMode => { mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。", mengsan_flyconid_action_shuying: "孢子行动", mengsan_raider_action_shuying: "劫掠行动", + mengsan_weak_shuying: "虚弱", + mengsan_weak_shuying_info: + "攻击伤害减少25%(向下取整),自身回合结束减少1层。", + mengsan_relic_attack_shuying: "遗物攻击修正", mengsan_raider_card_strength_shuying: "力量加成", mengsan_raider_strength_shuying: "力量", mengsan_raider_strength_shuying_info: "攻击造成的伤害按力量层数增加。", diff --git a/mengsan/progression/reward-package.js b/mengsan/progression/reward-package.js new file mode 100644 index 0000000..9bac913 --- /dev/null +++ b/mengsan/progression/reward-package.js @@ -0,0 +1,39 @@ +import { canUpgradeCard } from "../cards/upgrades.js"; +import { canAcquireRelic } from "../relics/definitions.js"; + +export function prepareRewardPackage(run, spec, randomChoices) { + if (!Number.isSafeInteger(spec.gold) || spec.gold < 0) { + throw new Error("奖励包金币无效"); + } + const relicChoices = randomChoices(run, spec.relicPool, 3); + if (relicChoices.some(reward => !reward.relic)) { + throw new Error("奖励包候选必须是遗物"); + } + return { + gold: spec.gold, + upgradeChoices: run.player.deck.filter(canUpgradeCard), + relicChoices, + }; +} + +export function applyRewardPackage(run, pack, selection, applyReward) { + if (!selection || typeof selection !== "object") { + throw new Error("奖励包选择无效"); + } + const card = run.player.deck.find(card => card.id === selection.cardId); + const relic = pack.relicChoices.find(choice => + choice.id === selection.relicId); + if (pack.upgradeChoices.length) { + if (!pack.upgradeChoices.some(card => card.id === selection.cardId) || + !canUpgradeCard(card)) throw new Error("强化目标无效"); + } else if (selection.cardId !== null) throw new Error("无可强化卡牌"); + if (pack.relicChoices.length) { + if (!relic || !canAcquireRelic(run, relic.relic)) { + throw new Error("遗物选择无效"); + } + } else if (selection.relicId !== null) throw new Error("无可选遗物"); + run.player.gold += pack.gold; + run.statistics.goldEarned += pack.gold; + if (pack.upgradeChoices.length) card.upgrade = (card.upgrade || 0) + 1; + if (relic) applyReward(run, relic.id); +} diff --git a/mengsan/progression/state.js b/mengsan/progression/state.js index 6709d57..1e2c0ff 100644 --- a/mengsan/progression/state.js +++ b/mengsan/progression/state.js @@ -254,6 +254,7 @@ export const getNodeEncounter = (run, node, override = null) => { requiredCharacter: encounter?.requiredCharacter || null, openingDialogue: encounter?.openingDialogue || [], fixedRewards: encounter?.fixedRewards || [], + rewardPackage: encounter?.rewardPackage || null, skipRandomReward: encounter?.skipRandomReward === true, victoryDialogue: encounter?.victoryDialogue || [], battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ? diff --git a/mengsan/relics/ASSETS.md b/mengsan/relics/ASSETS.md index d4ccf9e..c1c0d46 100644 --- a/mengsan/relics/ASSETS.md +++ b/mengsan/relics/ASSETS.md @@ -12,6 +12,14 @@ | 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 | | 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 | | 束带(既有道具) | `mengsan_hand_charm_shuying` | `hand_charm.png` | 待提供 | +| 餐券 | `mengsan_meal_ticket_shuying` | `meal_ticket.png` | 待提供 | +| 草莓 | `mengsan_strawberry_shuying` | `strawberry.png` | 待提供 | +| 弹珠袋 | `mengsan_bag_of_marbles_shuying` | `bag_of_marbles.png` | 待提供 | +| 灯笼 | `mengsan_lantern_shuying` | `lantern.png` | 待提供 | +| 红面具 | `mengsan_red_mask_shuying` | `red_mask.png` | 待提供 | +| 皇家枕头 | `mengsan_regal_pillow_shuying` | `regal_pillow.png` | 待提供 | +| 节日拉炮 | `mengsan_festive_poppers_shuying` | `festive_poppers.png` | 待提供 | +| 金刚杵 | `mengsan_vajra_shuying` | `vajra.png` | 待提供 | 建议使用统一尺寸的透明背景 PNG,实际尺寸和视觉风格在资源制作时确定。 图片仅负责外观,不承载名称、效果或触发计数。 diff --git a/mengsan/relics/README.md b/mengsan/relics/README.md index 512bb71..07cbc15 100644 --- a/mengsan/relics/README.md +++ b/mengsan/relics/README.md @@ -1,7 +1,7 @@ # 梦三遗物(道具) 本目录负责遗物定义、持有规则与战斗效果管理。 -已实现五件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。 +已实现十三件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。 ## 已实现的遗物 @@ -13,6 +13,22 @@ | 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 | | 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 | | 束带 | 原有手牌上限 +1,重复获得不叠加 | +| 餐券 | 进入商店时回复 15 点生命,每个商店节点一次 | +| 草莓 | 获得时生命上限 +7,当前生命不变,重复获得不叠加 | +| 弹珠袋 | 开战时给予当时所有存活敌人 1 层易伤 | +| 灯笼 | 首个自身回合可用费用 +1,费用上限不变,下回合恢复正常 | +| 红面具 | 开战时给予当时所有存活敌人 1 层虚弱 | +| 皇家枕头 | 选择休息回血时额外恢复 15;现有基础 8,总计 23 | +| 节日拉炮 | 开战时对当时所有存活敌人造成 9 点伤害 | +| 金刚杵 | 开战时获得 1 点力量,只在当前战斗有效 | + +虚弱使攻击伤害减少 25%(向下取整),自身回合结束减少 1 层; +力量使玩家攻击牌伤害增加对应点数,意图攻击沿用原有力量/虚弱计算。 +节日拉炮是非卡牌伤害,正常消耗目标护甲,不享受力量、易伤、虚弱的攻击修正。 +开场效果先摸牌和施加状态,再结算节日拉炮;仅影响开战时的敌人,不影响后续援军。 +餐券在打开商店前保存回血和节点记录,读档不会重复触发; +皇家枕头不影响磨砺、商店治疗或其他回复效果。所有回复均不超过生命上限。 +草莓若在角色生命尚未初始化时获得,会保存待应用加成,初始化后只应用一次。 遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。 所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合, @@ -21,7 +37,7 @@ ### 获得、保存和查看 -- 五件新遗物加入共享 Boss 奖励池及第一章宝箱奖励池; +- 新遗物通过自动生成的遗物奖励列表加入共享 Boss 和第一章宝箱奖励池; 后续章节使用共享 Boss 奖励池的宝箱也可出现它们。 - 角色默认不携带新遗物;未新增商店价格或精英掉落规则。 - 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。 diff --git a/mengsan/relics/battle.js b/mengsan/relics/battle.js index 245e804..6cace08 100644 --- a/mengsan/relics/battle.js +++ b/mengsan/relics/battle.js @@ -1,6 +1,6 @@ import { heldRelics } from "./definitions.js"; -export function createRelicBattle(run, { active, draw, log }) { +export function createRelicBattle(run, { active, draw, log, effect }) { const relics = heldRelics(run); let started = false, turns = 0, firstLoss = false; let openingHandBonus = 0, pendingHpDraw = null; @@ -24,6 +24,15 @@ export function createRelicBattle(run, { active, draw, log }) { for (const relic of relics) { if (relic.effect === "openingDraw") await drawEffect(relic); } + for (const relic of relics.filter(item => + ["openingVulnerable", "openingWeak", "openingStrength", + "openingDamage"].includes(item.effect)) + .sort((a, b) => Number(a.effect === "openingDamage") - + Number(b.effect === "openingDamage"))) { + if (!usable()) break; + log(relic, relic.description); + await effect(relic); + } if (pendingHpDraw) { const relic = pendingHpDraw; pendingHpDraw = null; @@ -35,6 +44,11 @@ export function createRelicBattle(run, { active, draw, log }) { seenTurns.add(event); turns++; for (const relic of relics) { + if (relic.effect === "firstTurnEnergy" && turns === 1 && + usable()) { + log(relic, `获得${relic.amount}点临时费用`); + await effect(relic); + } if ((relic.effect === "earlyTurnDraw" && turns <= relic.turns) || (relic.effect === "periodicTurnDraw" && @@ -63,9 +77,12 @@ export function createRelicBattle(run, { active, draw, log }) { if (relic.effect === "periodicTurnDraw") { return `回合计数:${turns % relic.turns}/${relic.turns}`; } - if (relic.effect === "openingDraw") { + if (relic.effect.startsWith("opening")) { return started ? "本场已触发" : "等待战斗开始"; } + if (relic.effect === "firstTurnEnergy") { + return turns ? "本场已触发" : "等待首个自身回合"; + } return "持续持有"; }, }; diff --git a/mengsan/relics/combat.js b/mengsan/relics/combat.js new file mode 100644 index 0000000..9a935eb --- /dev/null +++ b/mengsan/relics/combat.js @@ -0,0 +1,78 @@ +export function createOpeningEffects(game, owner, refreshEnergy) { + const enemies = () => game.players.filter(player => + player !== owner && player.isAlive() && + player.storage?.mengsanCamp_shuying === "enemy"); + const addStatus = (player, key, skill, amount) => { + player.storage[key] = (player.storage[key] || 0) + amount; + player.addSkill(skill); + player.markSkill(skill); + }; + return async relic => { + if (relic.effect === "firstTurnEnergy") { + owner.storage.mengsanEnergy_shuying += relic.amount; + refreshEnergy(); + } else if (relic.effect === "openingStrength") { + addStatus(owner, "mengsanStrength_shuying", + "mengsan_raider_strength_shuying", relic.amount); + } else { + for (const target of enemies()) { + if (!owner.isAlive()) break; + if (!target.isAlive()) continue; + if (relic.effect === "openingVulnerable") { + addStatus(target, "mengsanVulnerable_shuying", + "mengsan_vulnerable_shuying", relic.amount); + } else if (relic.effect === "openingWeak") { + addStatus(target, "mengsanWeak_shuying", + "mengsan_weak_shuying", relic.amount); + } else if (relic.effect === "openingDamage") { + const damage = target.damage(relic.amount, owner, "nocard"); + damage.mengsanScriptedSkill_shuying = true; + await damage; + } + } + } + }; +} + +export function createRelicCombatSkills(getBattle, getOwner) { + return { + mengsan_relic_attack_shuying: { + trigger: { global: "damageBegin1" }, + forced: true, silent: true, popup: false, priority: 80, + filter(event) { + const source = event.source; + return Boolean(getBattle()?.session.active && event.card && + !event.mengsanScriptedSkill_shuying && + ((source === getOwner() && + source?.storage?.mengsanStrength_shuying > 0) || + source?.storage?.mengsanWeak_shuying > 0)); + }, + async content(event, trigger) { + const source = trigger.source; + if (source === getOwner()) { + trigger.num += source.storage.mengsanStrength_shuying || 0; + } + if (source.storage.mengsanWeak_shuying > 0) { + trigger.num = Math.floor(trigger.num * 0.75); + } + }, + }, + mengsan_weak_shuying: { + mark: true, marktext: "弱", + onremove(player) { delete player.storage.mengsanWeak_shuying; }, + intro: { content(storage, player) { + return `剩余${player.storage.mengsanWeak_shuying || 0}回合:` + + "攻击伤害减少25%(向下取整)"; + } }, + trigger: { player: "phaseAfter" }, + forced: true, silent: true, popup: false, + async content(event, trigger, player) { + player.storage.mengsanWeak_shuying = Math.max(0, + (player.storage.mengsanWeak_shuying || 0) - 1); + if (player.storage.mengsanWeak_shuying) { + player.markSkill("mengsan_weak_shuying"); + } else player.removeSkill("mengsan_weak_shuying"); + }, + }, + }; +} diff --git a/mengsan/relics/definitions.js b/mengsan/relics/definitions.js index d5ee57a..c4af7b5 100644 --- a/mengsan/relics/definitions.js +++ b/mengsan/relics/definitions.js @@ -17,6 +17,30 @@ const entries = [ { id: "mengsan_hand_charm_shuying", name: "束带", description: "基础手牌上限额外+1;重复获得不叠加。", effect: "handLimit", amount: 1 }, + { id: "mengsan_meal_ticket_shuying", name: "餐券", + description: "进入商店房间时回复15点生命。", + effect: "shopHeal", amount: 15 }, + { id: "mengsan_strawberry_shuying", name: "草莓", + description: "获得时最大生命值提升7;不额外回复生命。", + effect: "gainMaxHp", amount: 7 }, + { id: "mengsan_bag_of_marbles_shuying", name: "弹珠袋", + description: "每场战斗开始时给予所有敌人1层易伤。", + effect: "openingVulnerable", amount: 1 }, + { id: "mengsan_lantern_shuying", name: "灯笼", + description: "每场战斗首个自身回合额外获得1点临时费用。", + effect: "firstTurnEnergy", amount: 1 }, + { id: "mengsan_red_mask_shuying", name: "红面具", + description: "每场战斗开始时给予所有敌人1层虚弱。", + effect: "openingWeak", amount: 1 }, + { id: "mengsan_regal_pillow_shuying", name: "皇家枕头", + description: "选择休息回血时额外回复15点生命。", + effect: "restHeal", amount: 15 }, + { id: "mengsan_festive_poppers_shuying", name: "节日拉炮", + description: "每场战斗开始时对所有敌人造成9点伤害。", + effect: "openingDamage", amount: 9 }, + { id: "mengsan_vajra_shuying", name: "金刚杵", + description: "每场战斗开始时获得1点力量。", + effect: "openingStrength", amount: 1 }, ]; export const relicDefinitions = Object.freeze(Object.fromEntries( @@ -39,16 +63,31 @@ export function canAcquireRelic(run, id) { export function grantRelic(run, id) { if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`); if (!canAcquireRelic(run, id)) return false; + const relic = getRelic(id); run.player.items ||= []; run.player.items.push(id); - const relic = getRelic(id); if (relic.effect === "handLimit") { run.player.handLimitBonus = (run.player.handLimitBonus || 0) + relic.amount; } + if (relic.effect === "gainMaxHp") { + if (Number.isFinite(run.player.maxHp)) { + run.player.maxHp += relic.amount; + } else { + run.player.pendingRelicMaxHp = + (run.player.pendingRelicMaxHp || 0) + relic.amount; + } + } return true; } +export function initializeRelicMaxHp(run) { + const amount = run.player.pendingRelicMaxHp || 0; + if (!amount || !Number.isFinite(run.player.maxHp)) return; + run.player.maxHp += amount; + delete run.player.pendingRelicMaxHp; +} + export const relicRewardIds = entries.filter(entry => entry.effect !== "handLimit").map(entry => `shared.reward.relic.${entry.id}`); diff --git a/mengsan/relics/rooms.js b/mengsan/relics/rooms.js new file mode 100644 index 0000000..0b58978 --- /dev/null +++ b/mengsan/relics/rooms.js @@ -0,0 +1,22 @@ +import { heldRelics } from "./definitions.js"; + +export const restRecovery = run => 8 + heldRelics(run) + .filter(relic => relic.effect === "restHeal") + .reduce((sum, relic) => sum + relic.amount, 0); + +export function applyRoomRelics(run, node, action) { + const resting = node.type === "rest" && action === "heal"; + if (node.type !== "shop" && !resting) return null; + const receipt = `${node.type}:${node.id}`; + const receipts = run.player.relicRoomReceipts || []; + if (receipts.includes(receipt)) return null; + const effect = resting ? "restHeal" : "shopHeal"; + const relics = heldRelics(run).filter(relic => relic.effect === effect); + const amount = (resting ? 8 : 0) + relics.reduce((sum, relic) => + sum + relic.amount, 0); + if (!amount) return null; + const before = run.player.hp; + run.player.hp = Math.min(run.player.maxHp, before + amount); + run.player.relicRoomReceipts = [...receipts, receipt]; + return { recovered: run.player.hp - before, relics }; +} diff --git a/mengsan/ui/reward-package.js b/mengsan/ui/reward-package.js new file mode 100644 index 0000000..165ad37 --- /dev/null +++ b/mengsan/ui/reward-package.js @@ -0,0 +1,81 @@ +import { get } from "../../../../noname.js"; +import { cardUpgradeRule } from "../cards/upgrades.js"; +import { getRelic } from "../relics/definitions.js"; +import { + chooseCardDialog, chooseRelicDialog, chooseVictoryOptions, + RETURN_TO_VICTORY, +} from "./reward-ui.js"; + +const upgradeDescription = choice => { + const card = choice.card; + const rule = cardUpgradeRule(card.name); + const damage = card.name === "sha" ? 6 : 8; + const suit = get.translation(card.suit); + return `${suit}${card.number} · 伤害 ${damage} → ${rule.damage}` + + (rule.vulnerable ? `;易伤 2 → ${rule.vulnerable} 层` : "") + + "。费用不变,最多强化一次。"; +}; + +export async function chooseRewardPackage(choices, { + rewardPackage: pack, fixedRewards = [], +}) { + let cardId = null, relicId = null; + const upgradeChoices = pack.upgradeChoices.map(card => ({ + id: card.id, card, + })); + const ready = () => (!upgradeChoices.length || cardId !== null) && + (!pack.relicChoices.length || relicId !== null); + const upgrade = { + id: "upgrade", label: "强化一张牌", + description: upgradeChoices.length + ? "从个人牌库中择一磨砺,查看强化前后的变化。" + : "当前没有可强化卡牌,此项无需选择。", + isDisabled: () => !upgradeChoices.length, + async choose() { + const selected = await chooseCardDialog(upgradeChoices, { + title: "磨砺 · 强化一张牌", skipLabel: "返回奖励", + description: "择一磨砺,锋芒更盛。只强化你选中的一张牌。", + describeChoice: upgradeDescription, actionLabel: "强化此牌", + }); + if (selected !== null) { + cardId = selected; + const choice = upgradeChoices.find(card => card.id === selected); + upgrade.label = `已选强化 · ${get.translation(choice.card.name)}`; + upgrade.description = upgradeDescription(choice); + } + return RETURN_TO_VICTORY; + }, + }; + const relic = { + id: "relic", label: "遗物 · 三选一", + description: pack.relicChoices.length + ? "检视本次随机珍藏,择一伴你踏上下一段征途。" + : "已持有奖励池中的全部遗物,此项无需选择。", + isDisabled: () => !pack.relicChoices.length, + async choose() { + const selected = await chooseRelicDialog(pack.relicChoices, { + skipLabel: "返回奖励", actionLabel: "携此遗物", + }); + if (selected !== null) { + relicId = selected; + const choice = pack.relicChoices.find(item => item.id === selected); + const definition = getRelic(choice.relic); + relic.label = `已选遗物 · ${definition.name}`; + relic.description = definition.description; + } + return RETURN_TO_VICTORY; + }, + }; + return chooseVictoryOptions([upgrade, relic, { + id: "claim-package", label: "领取全部奖励", + description: "完成以上选择后,金币、强化、遗物与固定卡牌一并入囊。", + isDisabled: () => !ready(), + choose: () => ({ cardId, relicId }), + }], { + title: "初识的卢", allowSkip: false, + rewardLabel: "固定收获 · 完成两项选择后领取", + description: `固定获得 ${pack.gold} 金币与${fixedRewards.map( + reward => `【${reward.name}】`).join("、")}。` + + "另可强化一张牌,并从随机遗物中择一件。", + }); +} diff --git a/mengsan/ui/reward-ui.js b/mengsan/ui/reward-ui.js index 4a6cb6f..52e69f2 100644 --- a/mengsan/ui/reward-ui.js +++ b/mengsan/ui/reward-ui.js @@ -1,6 +1,7 @@ import { lib, get } from "../../../../noname.js"; import { cardCost } from "../battle/combat-rules.js"; import { cardUpgradeRule } from "../cards/upgrades.js"; +import { getRelic } from "../relics/definitions.js"; import { chooseButtons } from "./flow-ui.js"; const element = (tag, className, text, parent) => { @@ -42,14 +43,39 @@ const viewCard = choice => { }; const restoreFocus = previous => { if (previous?.isConnected) previous.focus(); }; -const RETURN_TO_VICTORY = Symbol("return to victory choice"); +export const RETURN_TO_VICTORY = Symbol("return to victory choice"); // Reusable card-only selector. It accepts three cards today and can lay out four or five later. // Callers supply stable choice IDs; this UI never creates or awards cards itself. -export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip = true, skipLabel = "跳过" } = {}) { +export function chooseCardDialog(choices, options = {}) { if (!Array.isArray(choices) || !choices.length || choices.some(choice => !choice?.id || !isCardReward(choice))) { return Promise.reject(new TypeError("chooseCardDialog requires nonempty card choices with IDs")); } + return chooseTilesDialog(choices, options, viewCard); +} + +export function chooseRelicDialog(choices, options = {}) { + if (!Array.isArray(choices) || !choices.length || choices.some(choice => + !choice?.id || !getRelic(choice.relic))) { + return Promise.reject(new TypeError("遗物候选无效")); + } + return chooseTilesDialog(choices, { + title: "择一件遗物", + description: `${choices.length} 件珍藏,择一伴你远行。`, + actionLabel: "携此遗物", + ...options, + }, choice => { + const relic = getRelic(choice.relic); + return { name: relic.name, type: "遗物", image: null, cost: null, + description: relic.description }; + }); +} + +function chooseTilesDialog(choices, { + title = "选择一张牌", allowSkip = true, skipLabel = "跳过", + description = "择一入阵,余者留于身后", describeChoice, + actionLabel = "选择此牌", +} = {}, viewChoice) { return new Promise((resolve, reject) => { const previousFocus = document.activeElement; const dialog = element("dialog", "mengsan-card-choice-dialog-shuying", null); @@ -58,7 +84,7 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip const banner = element("header", "mengsan-card-choice-heading-shuying", null, stage); element("span", "mengsan-reward-overline-shuying", "梦三 · 战利品", banner); element("h2", "", title, banner); - element("p", "", "择一入阵,余者留于身后", banner); + element("p", "", description, banner); const cards = element("div", "mengsan-card-choice-grid-shuying", null, stage); cards.style.setProperty("--choice-count", String(Math.min(choices.length, 5))); let settled = false; @@ -71,12 +97,15 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip resolve(id); }; for (const choice of choices) { - const view = viewCard(choice); + const view = viewChoice(choice); + if (describeChoice) view.description = describeChoice(choice); const card = element("button", "mengsan-card-choice-tile-shuying", null, cards); card.type = "button"; - card.setAttribute("aria-label", `${view.name},${view.type},${view.description}。选择此牌`); + card.setAttribute("aria-label", `${view.name},${view.type},${view.description}。${actionLabel}`); card.addEventListener("click", () => finish(choice.id)); - element("span", "mengsan-card-choice-cost-shuying", String(view.cost), card); + if (view.cost !== null) { + element("span", "mengsan-card-choice-cost-shuying", String(view.cost), card); + } element("span", "mengsan-card-choice-name-shuying", view.name, card); const artwork = element("span", "mengsan-card-choice-art-shuying", null, card); if (view.image) { @@ -87,6 +116,7 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip element("span", "mengsan-card-choice-glyph-shuying", view.name.slice(0, 1), artwork); element("span", "mengsan-card-choice-kind-shuying", view.type, card); element("span", "mengsan-card-choice-description-shuying", view.description, card); + element("span", "mengsan-reward-tile-action-shuying", actionLabel, card); } if (allowSkip) { const skip = element("button", "mengsan-card-choice-skip-shuying", skipLabel, stage); @@ -125,6 +155,15 @@ export function chooseVictoryOptions(options, { const board = element("div", "mengsan-victory-options-shuying", null, stage); element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board); let settled = false, choosing = false; + const entries = []; + const refresh = () => { + for (const { option, entry, label, detail } of entries) { + label.textContent = option.label; + detail.textContent = option.description || ""; + entry.disabled = Boolean(option.isDisabled?.()); + entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`); + } + }; const finish = id => { if (settled) return; settled = true; @@ -139,19 +178,20 @@ export function chooseVictoryOptions(options, { entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`); element("span", "mengsan-victory-option-index-shuying", String(index + 1).padStart(2, "0"), entry); const copy = element("span", "mengsan-victory-option-copy-shuying", null, entry); - element("strong", "", option.label, copy); - if (option.description) element("small", "", option.description, copy); + const label = element("strong", "", option.label, copy); + const detail = element("small", "", option.description || "", copy); + entries.push({ option, entry, label, detail }); element("span", "mengsan-victory-option-arrow-shuying", "›", entry).setAttribute("aria-hidden", "true"); entry.addEventListener("click", async () => { - if (settled || choosing) return; + if (settled || choosing || entry.disabled) return; choosing = true; entry.disabled = true; if (dialog.open) dialog.close(); try { const selected = option.choose ? await option.choose() : option.id; if (selected === RETURN_TO_VICTORY) { + refresh(); dialog.showModal(); - entry.disabled = false; choosing = false; entry.focus(); } else finish(selected); @@ -161,6 +201,7 @@ export function chooseVictoryOptions(options, { } }); } + refresh(); if (allowSkip) { const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage); skip.type = "button"; @@ -169,7 +210,10 @@ export function chooseVictoryOptions(options, { dialog.addEventListener("cancel", event => event.preventDefault()); dialog.addEventListener("keydown", event => event.stopPropagation()); document.documentElement.appendChild(dialog); - try { dialog.showModal(); board.querySelector("button")?.focus(); } + try { + dialog.showModal(); + entries.find(({ entry }) => !entry.disabled)?.entry.focus(); + } catch (error) { dialog.remove(); restoreFocus(previousFocus); reject(error); } }); } diff --git a/mengsan/ui/style.css b/mengsan/ui/style.css index 6621bef..f31adda 100644 --- a/mengsan/ui/style.css +++ b/mengsan/ui/style.css @@ -1100,6 +1100,11 @@ button.mengsan-card-choice-tile-shuying:hover, button.mengsan-card-choice-tile-s .mengsan-card-choice-glyph-shuying { color: #e9d4ae; font: 700 52px/1 "KaiTi",serif; } .mengsan-card-choice-kind-shuying { align-self: center; margin-bottom: 6px; padding: 1px 12px; border: 1px solid #b99a6b; color: #f0dfba; font-size: 12px; } .mengsan-card-choice-description-shuying { display: block !important; flex: 1; overflow: hidden; padding: 2px 3px 0; color: #e8e5d6; font-size: 13px; line-height: 1.55; } +.mengsan-reward-tile-action-shuying { + display: block !important; margin-top: 12px; padding: 10px 8px; + border-top: 1px solid #9e8261; color: #f4d7a0; + font: 700 16px/1.4 "KaiTi", "STKaiti", serif; +} button.mengsan-card-choice-skip-shuying { min-height: 48px; padding: 9px 28px; border: 1px solid #82978b; border-radius: 3px; color: var(--reward-muted); background: #1a2b2f; font: 16px/1.4 "Microsoft YaHei",sans-serif; cursor: pointer; diff --git a/tools/generate_manifest.py b/tools/generate_manifest.py index 4bc7e21..3691de0 100644 --- a/tools/generate_manifest.py +++ b/tools/generate_manifest.py @@ -18,6 +18,7 @@ EXCLUDE_DIRS = { "__pycache__", } EXCLUDE_FILES = {".gitignore", "log.txt"} +EXCLUDE_EXTENSIONS = {".md", ".mjs"} def sha256_file(path: Path) -> str: @@ -33,6 +34,7 @@ def should_include(path: Path, root: Path) -> bool: return ( path.is_file() and relative.name not in EXCLUDE_FILES + and path.suffix.lower() not in EXCLUDE_EXTENSIONS and not any(part in EXCLUDE_DIRS for part in relative.parts) ) diff --git a/tools/test_relics.mjs b/tools/test_relics.mjs index 86b73fc..46e57fe 100644 --- a/tools/test_relics.mjs +++ b/tools/test_relics.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { grantRelic, canAcquireRelic, getRelic, heldRelics, - relicRewardIds } from "../mengsan/relics/definitions.js"; + relicRewardIds, initializeRelicMaxHp } from "../mengsan/relics/definitions.js"; +import { applyRoomRelics, restRecovery } from "../mengsan/relics/rooms.js"; +import { createOpeningEffects, createRelicCombatSkills } + from "../mengsan/relics/combat.js"; import { createRelicBattle, createRelicSkills, applyBattleEndRelics } from "../mengsan/relics/battle.js"; import { canAcquireReward, getRandomRewardChoices } @@ -32,6 +35,97 @@ const runtime = run => { return { battle, draws, logs, stop: () => { running = false; } }; }; +test("strawberry raises max HP once without healing, including early grants", () => { + const run = newRun(); + grant(run, "strawberry", "strawberry"); + assert.equal(run.player.maxHp, 57); + assert.equal(run.player.hp, 20); + run.player.maxHp = null; + run.player.items = []; + grant(run, "strawberry"); + run.player.maxHp = 50; + initializeRelicMaxHp(run); + initializeRelicMaxHp(run); + assert.equal(run.player.maxHp, 57); +}); + +test("shop entrance and rest healing cap HP and survive reload without repeats", () => { + const run = newRun(); + grant(run, "meal_ticket", "regal_pillow"); + const shop = { id: "shop1", type: "shop" }; + assert.equal(applyRoomRelics(run, shop, "enter").recovered, 15); + const restored = structuredClone(run); + assert.equal(applyRoomRelics(restored, shop, "enter"), null); + const rest = { id: "rest1", type: "rest" }; + assert.equal(restRecovery(run), 23); + assert.equal(applyRoomRelics(run, rest, "upgrade"), null); + assert.equal(applyRoomRelics(run, rest, "heal").recovered, 15); + assert.equal(run.player.hp, 50); + assert.equal(applyRoomRelics(run, rest, "heal"), null); + assert.equal(restRecovery(newRun()), 8); +}); + +test("opening effects target enemies, apply buffs before damage, and lantern is temporary", async () => { + const run = newRun(); + grant(run, "festive_poppers", "bag_of_marbles", "red_mask", "vajra", "lantern"); + const hits = []; + const actor = camp => ({ storage: { mengsanCamp_shuying: camp }, + isAlive: () => true, addSkill() {}, markSkill() {}, + damage(amount, source, nature) { + hits.push({ amount, source, nature, + vulnerable: this.storage.mengsanVulnerable_shuying, + weak: this.storage.mengsanWeak_shuying }); + return Promise.resolve(); + } }); + const owner = actor("player"), enemy = actor("enemy"), ally = actor("ally"); + owner.storage.mengsanEnergy_shuying = 3; + owner.storage.mengsanMaxEnergy_shuying = 3; + let refreshes = 0; + const battle = createRelicBattle(run, { active: () => true, + draw: async () => {}, log: () => {}, + effect: createOpeningEffects({ players: [owner, enemy, ally] }, + owner, () => { refreshes++; }) }); + await battle.start(); + await battle.start(); + assert.equal(hits.length, 1); + assert.equal(hits[0].amount, 9); + assert.equal(hits[0].vulnerable, 1); + assert.equal(hits[0].weak, 1); + assert.equal(hits[0].nature, "nocard"); + assert.equal(owner.storage.mengsanStrength_shuying, 1); + assert.equal(ally.storage.mengsanWeak_shuying, undefined); + const first = {}; + await battle.turnStart(first); + await battle.turnStart(first); + assert.equal(owner.storage.mengsanEnergy_shuying, 4); + assert.equal(owner.storage.mengsanMaxEnergy_shuying, 3); + owner.storage.mengsanEnergy_shuying = owner.storage.mengsanMaxEnergy_shuying; + await battle.turnStart({}); + assert.equal(owner.storage.mengsanEnergy_shuying, 3); + assert.equal(refreshes, 1); +}); + +test("card attacks add owner strength before weakness and skip scripted damage", async () => { + const owner = { storage: { mengsanStrength_shuying: 1, + mengsanWeak_shuying: 1 } }; + const skills = createRelicCombatSkills(() => ({ session: { active: true } }), + () => owner); + const attack = skills.mengsan_relic_attack_shuying; + const event = { source: owner, card: {}, num: 6 }; + assert.equal(attack.filter(event), true); + await attack.content({}, event); + assert.equal(event.num, 5); + assert.equal(attack.filter({ ...event, card: null }), false); + assert.equal(attack.filter({ ...event, mengsanScriptedSkill_shuying: true }), false); + const enemy = { storage: { mengsanWeak_shuying: 1 }, + removeSkill(skill) { assert.equal(skill, "mengsan_weak_shuying"); } }; + const hit = { source: enemy, card: {}, num: 9 }; + await attack.content({}, hit); + assert.equal(hit.num, 6); + await skills.mengsan_weak_shuying.content({}, {}, enemy); + assert.equal(enemy.storage.mengsanWeak_shuying, 0); +}); + test("relics use existing items, reject duplicates and preserve belt saves", () => { const run = newRun(); assert.equal(grantRelic(run, id("burning_blood")), true);