fixed # 更新刘备配置和奖励

This commit is contained in:
2026-09-30 15:43:07 +08:00
parent 82d80397b6
commit 0ccb14cecd
22 changed files with 681 additions and 30 deletions
+4 -3
View File
@@ -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
/Slay the Spire 2
*.mjs
+1
View File
@@ -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;
+24 -4
View File
@@ -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;
});
},
+1
View File
@@ -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",
@@ -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",
+7
View File
@@ -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}`);
+1
View File
@@ -16,6 +16,7 @@
- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。
- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。
- `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。
- `rewardPackage` 额外固定获得400金币、手动强化1张牌、随机遗物三选一。遗物候选复用当前新遗物奖励池,排除已持有项,随机结果在打开界面前保存;不足三件时显示剩余候选。强化只允许选择尚未满级且没有永恒词缀的可强化牌;没有候选时明确提示并跳过该项。两项选择完成后,与金币、的卢及节点完成一起提交,重试或恢复存档不会重复领取。遗物池耗尽时同样提示并跳过遗物选择。
- 固定奖励与选择奖励共用过关奖励界面,以“固定战利品”/“可选战利品”等文字区分;固定奖励显示名称和说明,点击“领取固定奖励”后继续结算,不能放弃。若同时配置两类奖励,固定奖励全部获得,候选奖励仍择一领取或放弃。
- 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。
- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。
+140
View File
@@ -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 时是否提示玩家切回原生手牌显示
+38 -5
View File
@@ -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: "攻击造成的伤害按力量层数增加。",
+39
View File
@@ -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);
}
+1
View File
@@ -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 ?
+8
View File
@@ -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,实际尺寸和视觉风格在资源制作时确定。
图片仅负责外观,不承载名称、效果或触发计数。
+18 -2
View File
@@ -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`,已持有遗物会从随机奖励中排除。
+19 -2
View File
@@ -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 "持续持有";
},
};
+78
View File
@@ -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");
},
},
};
}
+40 -1
View File
@@ -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}`);
+22
View File
@@ -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 };
}
+81
View File
@@ -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("、")}。` +
"另可强化一张牌,并从随机遗物中择一件。",
});
}
+55 -11
View File
@@ -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); }
});
}
+5
View File
@@ -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;
+2
View File
@@ -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)
)
+95 -1
View File
@@ -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);