功法改造

This commit is contained in:
桃树夭 committed 2026-09-16 15:57:33 +08:00
1 parent 6c644baf44
commit 3f0265e40f
40 files changed
+10815 -504

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
# combat-v6 首版功法改造计划
> 2026-09-16:根据用户确认的 24 本设计编制。状态:首版代码已实施、本地迁移已执行;表中数值是首轮验证基线,不代表已通过战斗平衡验收。
> 现行规则见 [境界功法系统](combat-v6-manual-system-design.md)。下文保留实施分解,第 8、9 节记录最终决定和本次证据。
## 1. 目标与范围
将现有 16 本纯六维功法替换为 24 本有明确意象与构筑取舍的功法。每本由一项随层数增加的六维贡献,以及一个随层数增强的简单机制组成。
确认保留:
- 炼气、筑基、金丹、元婴各一个激活位,更高境界不新增位置;同位只激活一本。
- 九层培养;三、六层使用同名玉简解锁后续层数;每本独立培养,战外免费切换,受既有战斗与结算占用保护。
- 学习一本玉简,三层突破两本、六层突破三本(合计六本);修炼成本采用本文末尾已确认的升级消耗修订,不引入品质倍率。
- 六维只加入战斗/展示投影,不写入永久属性。升层与切换不得免费补满气血、法力。
- 取消功法 common/rare 稀有度。供给由产出概率、权重、渠道等配置决定,不增加战斗预算。
本轮不做:连续行动、连招计数、目标记忆、失败补偿、解封反应、治疗转盾、DOT 跳数强化、内部冷却、额外行动、功法主动技能、五行伤害体系、灵根门槛、战外修为倍率、化神以上新功法位、多套构筑存档。
按用户“按照推荐,开始实施”的授权,每槽最多收藏六本,仍只激活一本,无遗忘或退款功能。
## 2. 内容与成长基线
### 2.1 来源与命名
灵感资料为用户提供的《凡人修仙传_功法境界表.xlsx》,工作表「功法表」A2:C54;其中包含五行基础功法泛称及同一功法的分段条目。只取名称、描述和意象,以下数值与效果均为本项目游戏改编,不宣称原著存在这些机制。
- 炼气取长春功,另自拟金、木、水、火、土五本入门功法。
- 筑基来源重点为 A4:C11;金丹来源重点为 A20:C30;元婴来源重点为 A32:C38。
- 使用项目正式六维名:体魄 vitality、力道 strength、灵力 spirit、根骨 endurance、身法 speed、神识 willpower。
- 游戏境界统一使用「金丹」,不引入资料中的「结丹」作为第二个境界键。
### 2.2 成长公式
| 归属境界 | 一层加成 | 后续每层增加 | 九层加成 |
| --- | ---: | ---: | ---: |
| 炼气 | 12 | 2 | 28 |
| 筑基 | 18 | 2 | 34 |
| 金丹 | 24 | 4 | 56 |
| 元婴 | 36 | 4 | 68 |
四槽九层合计 186 点六维。每本仅一项属性,不为难获取功法增加第二项完整属性。
机制一层即开放,每层线性成长:`value(L) = valueAt1 + (valueAt9 - valueAt1) × (L - 1) / 8`。条件阈值固定,突破仅解锁升级上限,不新增第二个机制。
配置存数值,比例以 0~1 表示;概率修正与伤害倍率使用不同 effect 类型,避免把「降低 3 个百分点」误写成「乘以 0.97」。展示不反向参与计算。金额/气血/法力只在既有最终结算处取整,不在每层插值时提前截断。概率与面板尺度转换保留所需精度,不能沿用整数面板处理使部分等级升级无效。
### 2.3 炼气六本
| 功法 | 意象 | 六维 | 机制 | 一/五/九层 |
| --- | --- | --- | --- | --- |
| 长春功 | 生机绵长,温养自身 | 体魄 | 回合末自身气血低于 50%,恢复最大气血的一定比例 | 0.25% / 0.5% / 0.75% |
| 庚金诀(自拟) | 金气锐利,先破敌锋 | 力道 | 对气血高于 70% 的目标,主动直接物理伤害提高 | 1% / 2% / 3% |
| 青木养元诀(自拟) | 木气滋生,扶伤续命 | 神识 | 主动治疗气血低于 50% 的主目标,治疗量提高 | 3% / 6% / 9% |
| 清泉诀(自拟) | 涓流回补,法力不竭 | 灵力 | 回合末自身法力低于 50%,恢复最大法力的一定比例 | 0.25% / 0.5% / 0.75% |
| 赤炎诀(自拟) | 精气充盈,火势方盛 | 灵力 | 自身气血高于 70%,主动直接法术伤害提高 | 1% / 2% / 3% |
| 厚土诀(自拟) | 厚土承压,守势稳固 | 根骨 | 自身处于防御状态,受到的直接物理伤害额外降低 | 3% / 6% / 9% |
### 2.4 筑基六本
| 功法 | 意象 | 六维 | 机制 | 一/五/九层 |
| --- | --- | --- | --- | --- |
| 归元功 | 护体灵甲,稳健防御 | 根骨 | 自身拥有有效护盾,受到的直接物理、法术伤害降低 | 2% / 4% / 6% |
| 幻灵诀 | 幻影扰目,难辨真身 | 身法 | 受到直接物理攻击,该次攻击命中率降低 | 1 / 2 / 3 个百分点 |
| 青元剑诀 | 真元养剑,气盛剑锐 | 力道 | 自身气血高于 70%,主动直接物理伤害提高 | 2% / 3% / 4% |
| 大衍诀 | 壮大神识,分神守心 | 神识 | 受到封印判定,对方封印成功率降低 | 1 / 2 / 3 个百分点 |
| 三转重元功 | 真元压缩,法力精纯 | 灵力 | 自身法力高于 70%,主动直接法术伤害提高 | 2% / 3% / 4% |
| 煞妖诀 | 血煞催身,险中争胜 | 体魄 | 自身气血低于 35%,主动直接伤害提高 | 2% / 3% / 4% |
### 2.5 金丹六本
| 功法 | 意象 | 六维 | 机制 | 一/五/九层 |
| --- | --- | --- | --- | --- |
| 太白剑诀 | 剑气破障,锋芒穿护 | 力道 | 对拥有有效护盾的目标,主动直接物理伤害提高 | 3% / 4.5% / 6% |
| 泰阳诀 | 精纯火力,盛阳外放 | 灵力 | 自身气血高于 70%,主动直接法术伤害提高 | 3% / 4.5% / 6% |
| 玄阴大法 | 阴魂蚀体,阴气乘隙 | 灵力 | 对带 DOT 状态的目标,主动直接法术、普通固定伤害提高 | 3% / 4.5% / 6% |
| 玉丹功 | 玉润丹养,扶危固本 | 神识 | 主动治疗气血低于 50% 的主目标,治疗量提高 | 4% / 8% / 12% |
| 天罗真功 | 儒门正气,护佑危局 | 神识 | 为气血低于 50% 的主目标施加护盾,护盾量提高 | 5% / 10% / 15% |
| 六极真魔功 | 真魔法体,受压显威 | 力道 | 自身气血低于 35%,主动直接物理、法术伤害提高 | 3% / 4.5% / 6% |
### 2.6 元婴六本
| 功法 | 意象 | 六维 | 机制 | 一/五/九层 |
| --- | --- | --- | --- | --- |
| 托天魔功 | 肉身强横,重压不折 | 根骨 | 自身气血低于 50%,受到的直接物理伤害降低 | 3% / 5% / 7% |
| 明王诀 | 法体坚固,受制不溃 | 体魄 | 自身处于可解除封印,受到的直接物理、法术伤害降低 | 4% / 7% / 10% |
| 元磁神光 | 元磁牵制,护持周身 | 灵力 | 自身法力高于 50%,受到的直接法术伤害降低 | 3% / 5% / 7% |
| 浩然正气诀 | 邪扰不侵,正气护神 | 神识 | 自身带 Debuff 减益,受到的直接法术伤害降低 | 4% / 7% / 10% |
| 千浪诀 | 身随浪走,危中脱身 | 身法 | 自身气血低于 50%,直接物理攻击者该次命中率降低 | 2 / 4 / 6 个百分点 |
| 幽杀诀 | 杀意逼迫,追索残敌 | 力道 | 对气血低于 35% 的目标,主动直接伤害提高 | 3% / 4.5% / 6% |
素女轮回功、真灵变化、采补与战外修炼效率类暂不纳入;高境界功法保留后续内容空间,不拆成同名升级书。
## 3. 战斗结算合同
这些口径需先写成共享领域测试,再接入全部内容。
### 3.1 适用对象与效果来源
- 功法只装配到人物,不给灵兽继承属性或被动。
- 输出增幅作用于人物普攻、宗门主动技能的主目标;按物理、法术、普通固定伤害分别编译。通用伤害钩子默认跳过 Fixed,不能遗漏对普通固伤的显式分支。
- 不强化器诀、物品、反击、连击式派生追击、HookDerived 伤害、DOT 或宗门派生追伤。宗门原技能明确声明的多段直接攻击属于原行动,不因段数新增触发计数。
- 普通固定伤害可以强化;按目标气血比例扣血、资源扣除等特殊效果不得强化。实施前盘点效果级来源,不通过解析公式字符串、技能中文名或在 core 中硬编码宗门/功法 ID 判断。
- 仅 skillId 白名单可能无法区分同一混合技能的不同效果。优先利用已有类型与 origin;不足时由内容编译层声明最小的通用效果分类,并由伤害路径透传,避免误伤其他系统原有修正。
- 治疗与护盾增幅只作用于宗门主动技能的主目标。排除器诀、物品、被动恢复和功法回合恢复,不因群体目标数量获得多倍功法增益。
- 受击减伤/闪避是佩戴者的防御能力,可应对敌方人物、灵兽、NPC 的相应直接攻击,包括敌方直接攻击器诀;与己方输出不强化器诀的规则分开。
### 3.2 条件与时机
- 「低于」「高于」严格使用 `<` 与 `>`,恰好 35%、50%、70% 均不满足对应严格条件。
- 比例气血以当前气血 / 最大气血判断,不以伤势后的可恢复上限替换分母;法力同理。条件在每一段打击或治疗/护盾计算前读取当前事实。
- 多段攻击跨阈值后,后续段按新血量判断;不添加行动开始快照。
- 有效护盾指当前尚有正数吸收量的护盾。归元与太白在吸收前判断,最后一层盾被打破后后续段不再获得护盾条件收益。
- 玄阴读取目标当前的 Dot 类状态,任意施加者均可;不追踪归属、不延长持续、不改 DOT 本身。同一行动先伤害后挂 DOT 的首段不能倒用新状态。
- 浩然仅读取 Debuff 类;Dot、Control 不混为一类。
- 明王读取可解除的封印控制。复用并核对既有解控类别口径,排除宗门休息/反噬、不可驱散状态和禁复活状态;不能简单等同于「所有 Control」。
- 大衍使用现有封印公式尺度和上下限,不绕过免疫,不给必中状态额外掷骰。幻灵/千浪沿用命中钩子和下限,不把必中攻击改成可以闪避。
- 保护发生后,防守功法读取实际承伤者;输出仍服从既有主目标身份语义,增加保护重定向专项测试。
### 3.3 恢复、叠加与显示
- 长春/清泉只在回合末对在场存活人物生效,沿用现有回合结束、DOT、战斗结束顺序,不为回血改变胜负裁定。
- 恢复基数为相应最大资源,最终恢复受当前缺口约束。长春遵守既有禁止恢复、减疗和伤势上限,不复活,不清伤势;恢复不进入主动治疗强化链。
- 恢复向下取整,允许低等级小基数时为 0,不私自增加保底 1。展示应准确说明比例;验证时检查低境界实际体感。
- 伤害、治疗等倍率沿用既有计算链,不新增「功法统一加算并封顶」系统。概率差额按现有公式聚合。
- 四槽机制输出最大倍率粗估为 `1.03 × 1.04 × 1.06 × 1.06 = 1.20360032`。这不是最终战力上限:六维、命中、抗封、恢复、护盾和其他系统交互必须另计。
- 不增加功法专属通用触发平台。利用现有战报表达回血/回蓝及最终伤害;条件与本层效果在功法详情可见。额外逐击触发提示不作为首版必做项。
## 4. 当前代码事实与最小实现边界
| 区域 | 当前事实 | 计划变更 |
| --- | --- | --- |
| `manuals/pack.ts`、`types.ts` | effects 仅支持 1~2 项六维,rarity 为必填 | 改为一项属性成长+一个类型化机制;删除功法 rarity;更新 Zod 和生成的 JSON Schema |
| `manuals/content.ts` | 为每本生成空 effects 的被动 | 编译真实机制,不把全部机制开放为策划任意脚本 |
| `manuals/compiler.ts` | 累加属性,携带被动与 skillLevels | 按层数求机制值,输出 hooks/必要的面板贡献,避免重复应用属性 |
| `projection/compose-character-manuals.ts` | 支持追加技能/面板;面板 add 当前统一 floor | 保留投影顺序;概率尺度贡献必须保留精度;面板与战斗输出一致 |
| `core/when.ts`、`types.ts` | 已有血量、主目标、来源、类型、防御、目标状态条件 | 按需补法力比例、自身/目标护盾、自身状态筛选;只做本清单需要的字段 |
| `core/passives.ts`、`hooks.ts` | 支持修改伤害、治疗、护盾、命中 | 复用;核对固伤显式匹配、来源过滤和必要的效果分类透传 |
| `equipment/special-compiler.ts` | 已有封印概率转换为 sealResist 的范例 | 大衍复用同一规则尺度,不复制一套封印公式、不新增封印失败钩子 |
| `manuals/state.ts`、`shared/manuals/action.ts` | 学习、激活、修炼、解锁的纯规则已存在 | 保持流程;仅按收藏决策改上限或另立替换需求 |
| `items/definitions/manual-jades.ts` | 玉简 ID 自动派生 `jade.${manual.id}` | 新 ID 与旧资产切换绑定处理,不能删旧定义后才处理引用 |
| `rewards/wild-pack.ts` | 自动把所有玉简纳入 manualJades 组,权重读功法包 | 把供给名单/权重放到奖励配置,避免「新增内容即自动投放」 |
| `CombatV6ManualService.ts`、`characterLoadoutRepository.ts` | learned JSONB、槽位表、版本校验与事务 | 默认不改持久结构;保留角色锁、归属、revision、占用和提交后事件 |
| 功法页面与物品 preview | 效果偏六维,页面仍显示常见/珍稀 | 共用效果描述器,展示本层/下层六维与机制;去掉功法稀有度文案 |
完整模块路径以 `src/shared/engine/combat-v6` 为基准;服务/仓储位于 `src/server/lib/services/combat-v6`、`src/server/lib/repositories`。
### 4.1 功法内容格式
建议数据包格式升为下一版,结构仅包含稳定身份、境界、意象描述、培养规则引用、单属性成长、单机制定义。机制用有限的 discriminated union 表达:伤害增幅、承伤减免、治疗增幅、护盾增幅、物理命中抵抗、封印抵抗、回合气血恢复、回合法力恢复。
各类型只开放需要的条件、类型范围、valueAt1/valueAt9。由编译器落实主目标/来源限制,避免策划漏填后把加成扩散到全场。阈值与效果文字由同一数据生成,意象描述单独保存。数据包不接受任意 JS、自由公式、嵌套条件树或 hook 脚本。
没有新增状态的机制不人为生成 StatusDef。若实现证明需要临时状态,才补投影状态表合并及 ID 冲突检查,不能预先搭建新框架。
静态内容包格式、战斗内容版本与角色 learned 状态 version 是不同概念,不能为了内容变更一并升级所有持久版本。
### 4.2 展示范围
- `ManualRoom.tsx`、`ManualRealmSlot.tsx`、`ManualJadePicker.tsx`:已学显示实际层数;修炼显示本层→下层的两部分变化;瓶颈明确仅解锁上限。
- `presentation/basic.ts` 的 manualAdapter:继续「功法名 / 类型→传承境界→数量 / 所载功法效果 / 玉简描述」,不加重复功法名或第三层标题。
- 未学习玉简显示一层效果和九层成长终点,不假装玩家已具有满层收益。已学上下文不得把玉简本身当成带层数的实例。
- 共享描述器放在纯 shared 层,复用于页面、物品、公开构筑及查探。概率文案明确为加减百分点,UI 可采用规范允许的简洁符号表达,不能与相对比例混淆。
- 不新增 rarity 徽章、品质筛选或难获取配色。不影响其他物品自己的品质体系。
- 公开构筑只暴露激活功法和层数,不暴露收藏;保持现有布局、操作入口、响应式结构和 GameIcon 规范。
## 5. 分阶段实施与完成标准
阶段顺序为 A → B → C → D → E → F。B/C 可在纯领域数据夹具中先验证,E 的投放及旧资产切换在第 8 节决策落实前不执行。
### A. 冻结合同与引用清单
- 固化第 2、3 节内容;整理 24 本新 manualId/玉简 ID 清单。长春可评估保留原 ID,名称相近但境界不同的功法不能复用身份(例如原筑基厚土功与新炼气厚土诀)。
- 盘点普通固伤、按目标血量比例效果、混合技能、主目标治疗/护盾、可解除封印的现行分类。
- 梳理定义引用:已学记录、槽位、bag/storage、拍卖、邮件附件、宝库及其他奖励载体。只读盘点,不推断任何环境已为空。
- 确定收藏、旧资产与首批投放决策;形成可审阅清单,再进入对应写入阶段。
完成标准:没有靠技能中文名、模糊「控制」或「固伤」描述才能判断的效果;新旧 ID 引用处理路线明确。
### B. 最小战斗能力与代表功法
- 在 shared 下为缺少的通用条件先写边界测试,再实现。
- 用代表功法覆盖能力族:长春/清泉(恢复)、庚金(条件输出)、归元/太白(盾存在)、幻灵/千浪(命中)、大衍(封印尺度)、玄阴(状态+固伤)、玉丹/天罗(治疗与盾)、明王/浩然(自身状态)。
- 验证普攻主目标、普通固伤与特殊扣血、器诀排除、派生伤害、保护、必中和恢复上限。
完成标准:不新增行动历史、专属计数器、core 功法 ID 分支;新增条件有独立确定性测试,现有相关 combat-v6 测试通过。
### C. 内容包、编译与角色投影
- 接入有限机制 schema、统一层数求值与描述器;移除 rarity。
- 一次性录入 24 本,检查每境界六本、单属性单机制、范围/层数/引用有效。
- 更新 schema、导出、纯领域测试;人物展示与真实入场使用同一投影。
- 培养费用按末尾最新修订;保留玉简消耗、未激活功法可培养和战外切换规则;收藏变更只按已决策方案实施。
完成标准:所有功法 1~9 层值正确且单调,未激活不生效,永久六维不变;最高资源变化遵守 condition 规则;概率成长无截断台阶错误。
### D. 页面、玉简与公开展示
- 接入统一效果说明,去除功法 common/rare 所有展示分支。
- 学习/升层/突破/切换的实际效果与描述一致,升级预览同时展示属性和机制差值。
- 按现有物品预览规范检查背包、货架、聊天晒物等复用入口,不重构公共预览布局。
- 覆盖本人、他人查探、未入宗个人面板与未开放境界位置。
完成标准:桌面与 360px 实际浏览器检查通过;无前端/服务端单测;旧 revision、资源不足和战斗占用时无多扣、多发或假成功。
### E. 产出配置与资产切换
- 供给名单与权重由奖励配置拥有。现有 fixed entries 已能显式配置玉简,可优先复用;不为本轮引入新掉落框架。
- 删除玩法包中的 rarity 与供给耦合;manualJades 自动展开分支若因此无人使用才删除。更新 reward schema 与对应内容版本。
- 既有玉简组 3% 总掉率是否维持,由投放清单记录。不要把 24 本全部等权自动投放视为已确认,也不在本轮默认新增地图或商店。
- 按批准的新旧资产方案切换;必要的数据变更复用正式迁移/事务边界。不在计划阶段清空记录、不借机处理其他遗留系统。
- 更新相关战斗内容版本。发布前安排旧活动战局正常排空或使用既有维护策略,保证恢复战局不混用两版功法;不清 Redis 战局逃避兼容。
- 核对历史回放依赖归档内容还是重新投影,确保旧回放可读。迁移回滚要覆盖数据与内容版本,不能只回退代码。
完成标准:无未知功法/玉简引用、无错位槽位、无丢失进度/物品;产出与展示无 rarity 概念;未完成旧资产盘点与处置决策不得切换目标环境。
### F. 平衡与整体验收
- 在 shared 纯引擎测试中准备可重复样本,使用固定种子集合对随机命中/封印效果做比较;不创建独立 benchmark、seed、smoke 或故障注入脚本。
- 比较三组:无功法、仅功法六维、完整功法。装备、灵兽、宗门、对手、初始资源与行动策略保持可比。
- 覆盖炼气/筑基/金丹/元婴四阶段;高血物理、高血法术、残血魔道、固伤消耗、治疗护盾、抗控防守等组合。
- 覆盖短战/长战、单体/群体/多段、带盾/无盾、带 DOT/无 DOT、封印/减益、人物+灵兽和队伍配合。
- 记录有效伤害、实际治疗、有效吸盾、恢复法力、存活回合、控制命中及胜负分布。比例上界、无效触发和过量治疗不能冒充实际收益。
- 初始目标是适配组合有感、整体贡献小于完整装备与正常出战灵兽;人物输出平均增幅约 8%~12% 作为观察目标,超过约 15% 的持续收益重点复核。机制瞬时约 20% 并不能豁免六维预算。不同定位不能直接用一个「总战力百分比」验收。
- 检查低层恢复取整是否长期为零、防守/治疗组合是否形成拖延僵局、某一功法是否成为同位无条件最优。
完成标准:提交样本、命令与结果记录;未达目标先调整现有数值,不加复杂触发补丁。同步现行设计、产出说明与收尾清单,再标记完成。
## 6. 测试与检查清单
### 共享领域自动验证
- [ ] 数据包:24 本、四境界各六本、唯一 ID、单属性、单机制、无 rarity、无非法条件组合、引用完整。
- [ ] 成长:全部 1~9 层;1/5/9 锚点、分数百分点精度、下一层差值;永久属性不变。
- [ ] 血量/法力:阈值下方、恰好阈值、上方;伤势与满资源;低血多段跨阈值。
- [ ] 护盾:不存在、零量、存在、最后一层打破、多层盾;归元与太白同场。
- [ ] 状态:Dot/Debuff/Control 分离;不可驱散、休息、禁复活不冒充可解除封印。
- [ ] 来源:人物普攻/宗门技能/器诀/灵兽/被动/派生/持续伤害;主目标与次目标。
- [ ] 固伤:普通固伤得到预期加成,目标血量比例扣血与资源扣除不被放大;混合技能逐效果验证。
- [ ] 恢复:回合末存活、倒地、禁疗、减疗、伤势、缺口、取整、战斗结束顺序;不触发主动治疗增幅。
- [ ] 命中/封印:百分点加减、既有上下限、必中、免疫、其他来源同时存在;无额外随机调用。
- [ ] 治疗/护盾:主目标、群体、过量、器诀排除、功法恢复排除。
- [ ] 状态变更:学习/培养/切换/瓶颈/旧 revision/未激活/未开放境界;收藏规则按最终决策。
- [ ] 恢复与重放:相同种子、输入及战局快照得到一致结果,保存恢复不丢条件事实。
- [ ] 产出:只含显式投放项,权重独立于功法强度,未知/重复奖励拒绝。
### 本地流程验收
按 [测试规范](testing.md) 复用本地测试账号,完成真实学习、升层、瓶颈、切换、刷新、查探与一场战斗。仅在 local 环境准备测试资源。核对当前资源不因面板变化免费补满,失败请求不扣资源,浏览器多页使用旧版本提交时正确刷新。
不在 `src/react-app`、`src/server` 新增 test/spec;不 mock 数据库、API、Redis、SMTP 或其他第三方服务。
建议实施检查命令:
```bash
bun run test src/shared/engine/combat-v6/manuals src/shared/manuals
bun run test src/shared/engine/combat-v6/core src/shared/engine/combat-v6/projection src/shared/engine/combat-v6/rules-daoyou
bun run lint
bun run test
bun run build
git diff --check
```
先执行与当前改动对应的聚焦测试;本轮涉及通用条件和投影,最终需要全量 shared 回归。build 已包含 client/server 类型检查。schema 更新使用项目已有 Zod toJSONSchema 方式,不手写与源结构不一致的 schema。
## 7. 文档与交付物
- 本文:内容、边界、阶段、验收与待决策项。
- 实施后更新 `combat-v6-manual-system-design.md`:正式替代原 16 本属性版,不删除历史验收事实。
- 实施后更新 `combat-v6-polish-and-closure-plan.md` 中功法供给/平衡条目,链接实际验收记录。
- 如果收藏规则、产出或资产方案变更,先更新本文相关决定,避免代码与计划各自解释。
- 每阶段报告修改文件、执行命令、结果及未验项;没有本地真实流程证据不能标记完整交付。
## 8. 已落实的切换决定
用户明确要求删除旧定义、删除旧数据、不要兼容代码,并按推荐开始实施。
- 每境界收藏上限六本,同位只激活一本。
- 旧 16 本定义被新 24 本替换;不做 ID 映射、退款或旧状态适配。
- 迁移 `0049_manual_first_release.sql` 清空旧槽位、learned、库存玉简、拍卖玉简及邮件/兑换码玉简附件。保留状态 revision 并递增,防止旧请求重放;邮件其他附件不受影响。
- 初次产出沿用现有野外渠道与 3% 组概率,24 本各权重 100,配置由奖励包显式维护。
- 仅本地数据库已执行迁移。部署其他环境应停止旧版本写入、排空活动战局、执行迁移后切换版本;迁移不可仅靠回退代码还原。
- 冻结历史回放保留,不是可学习、领取或交易的功法数据;无需旧功法运行时兼容。
## 9. 实施验证(2026-09-16)
- 运行配置为 manual pack v3,野外 contentRevision/poolVersion 同步递增。没有改动原始 Excel。
- 本地迁移后、发放新验收玉简前:槽位 0;两个状态 learned 均为空;库存、拍卖、邮件、兑换码中的旧玉简引用均为 0。
- 已执行聚焦功法/奖励/公开构筑测试、全量 shared 测试、lint、TypeScript 及客户端/服务端构建。全量结果:168 个文件通过,1390 项通过;仅原有炼体标签快照失败(src/shared/lib/bodyCultivation/pack.test.ts:20),该目录本次无修改。聚焦机制测试 20 项通过。lint、build、tsc 与 git diff --check 均通过。
- 本地已有账号“本地道友1”真实页面验收:新玉简学习、首本自动激活、后续收藏不覆盖、六本上限隐藏学习入口、免费切换、刷新保留、长春一升二/二升三、三层玉简解锁、感悟不足禁用修炼。当前气血未因属性增长补满。
- 学习详情与升级确认同时展示六维、当前/下一层/圆满机制。首屏保持简洁,完整机制放在参悟与物品预览层。
- 九层成长、六层瓶颈、条件边界、来源/主目标过滤、比例固伤排除、状态分类、回合恢复和封印小数精度由纯共享测试覆盖。
- 未完成真实四槽跨宗门对战强度矩阵、线上产出周期测算、断线恢复和多页并发验收;这些不标记为通过。尚不能宣称功法相对装备/灵兽的长期平衡已验证。
### 浏览器与清理补充
桌面和 360px 页面及机制详情已检查,浏览器 error 日志为空。六本收藏、切换和升级结果持久化正常;本次未使用的验收玉简已清理,真实学习培养进度保留。未调整准备性境界、六维或修炼资源。
### 基础属性调优
根据用户关于基础属性感受不明显的反馈,将炼气/筑基每层提高至 +2,金丹/元婴每层提高至 +4;四槽满层由合计 54 点提高至 108 点。机制数值与培养成本不变,现有层数直接按新配置投影,无需重置进度或执行清库迁移。
本次数值调整验证:功法/投影/培养操作/公开构筑/奖励聚焦测试 11 个文件、87 项通过;lint 与 diff 检查通过。仅修改成长配置和对应预期,未重新执行全量测试或浏览器流程。
### 入门属性基数
根据后续反馈,采用一层初始值+后续每层成长:`valueAt1 + (level - 1) × valuePerLevel`。一层直接获得 12/18/24/36 点,不额外加一次成长;九层分别 28/34/56/68 点。数据包结构升为 v3,valueAt1 必填,不加旧格式兼容。投影、槽位展示、玉简与参悟预览复用同一计算函数;层数、成本及机制不变,不执行清库迁移。此前数值调优记录保留为历史。
### 升级消耗修订
按用户确认的档位,仅调整后续升级费用。L 为升级前层数(1~8),一层学习和瓶颈解锁仍不扣修为、感悟。
| 归属境界 | 单次修为 | 单次感悟 | 九层累计修为 | 九层累计感悟 |
| --- | ---: | ---: | ---: | ---: |
| 炼气 | 40 × L | 2 × (L + 1) | 1,440 | 88 |
| 筑基 | 200 × L | 3 × (L + 1) | 7,200 | 132 |
| 金丹 | 1,000 × L | 4 × (L + 1) | 36,000 | 176 |
| 元婴 | 4,000 × L | 5 × (L + 1) | 144,000 | 220 |
不调整玉简供给、基础属性、机制、层数和感悟上限。现有进度保留,不追溯补扣费用,不执行数据迁移。页面升级预览和服务端扣费均通过 previewManualAction 读取同一配置。
本次费用修订验证:`bun run test` 聚焦功法、培养操作、投影、公开构筑及奖励目录,11 个文件、92 项通过;覆盖四境界总预算、八升九精确余额、资源不足拒绝及高境界补修低境界的费用。`bun run lint`、`bun run build`、`git diff --check` 通过。本次未重复全量测试或浏览器流程。
### 玉简消耗修订
学习消耗 1 本,三层瓶颈消耗 2 本,六层瓶颈消耗 3 本,单本功法圆满共需 6 本同名玉简。共享数量规则统一驱动预览校验、选择器、确认文案和服务端事务扣减;数量不足拒绝突破,数量恰好耗尽删除库存行,有剩余则保留并递增版本。已有解锁不追补,修为及感悟档位不变。
本次玉简修订验证:功法与培养操作聚焦测试 4 个文件、48 项通过;lint、前后端 build、git diff --check 通过。本地道友1真实页面验收三层仅1本时不可选、持有5本突破扣2本剩3本、六层突破扣3本归零并解锁九层;数据库核对该玉简库存行已删除。五次真实升级共扣600修为和40感悟,未增加修为;准备性感悟已恢复原值2,保留实际培养进度,无剩余验收玉简,浏览器 error 日志为空。本轮未重复全量测试。
+18 -17
View File
@@ -3,17 +3,19 @@
> 2026-09-10:经用户确认,替代 Phase 5A/9F 的本篇、真解和覆盖散功方案。无旧数据迁移或兼容。
> 上位设计:[改造路线图](./combat-v6-mhxy-redesign-roadmap.md)。
> 2026-09-16:首版 24 本功法已实现。每本六维+单条件机制同步成长,取消稀有度;每位最多收藏六本。具体数值与验证记录见[首版功法改造计划](combat-v6-manual-first-release-plan.md)。
## 玩法规则
- 炼气、筑基、金丹、元婴各开放一位,总共四位;更高境界不增加位置。
- 功法有固定境界归属,只能在对应位置激活。效果类型不按境界硬限制,由内容设计体现方向差异。
- 每个位置最多学习三种功法,每本独立保存层数和已解锁上限,同时只激活一本。暂不支持遗忘或替换;学满后不能继续学习新的功法,同名玉简突破不占额外名额。
- 每个位置最多学习六种功法,每本独立保存层数和已解锁上限,同时只激活一本。暂不支持遗忘或替换;学满后不能继续学习新的功法,同名玉简突破不占额外名额。
- 学习消耗一本同名玉简,从第一层开始。空位自动激活;已有激活功法时,新学功法保存在收藏中。
- 免费切换,无冷却,不丢失培养进度。沿用现有战斗/结算占用保护,战斗结束后才能操作,下一场生效。
- 默认九层,三层、六层消耗一本同名玉简突破瓶颈,只解锁后续层数,不直接升级。三本玉简加八次修炼完成一本功法。
- 默认九层,三层、六层分别消耗两本、三本同名玉简突破瓶颈,只解锁后续层数,不直接升级。六本玉简加八次修炼完成一本功法。
- 升层消耗当前可用修为与道心感悟;修为继续用于境界提升,不能透支或导致境界倒退。功法满层不是角色突破条件。
- 未达到功法对应境界时,可持有和交易玉简,不能学习或培养。
- 未激活功法允许培养。第一版只有六维固定属性加成,稀有功法可同时提供两项效果,两项从一层同时生效。
- 未激活功法允许培养。每本提供一项六维属性与一个机制,从一层同时生效,九层内均逐层增强。
## 策划数据包
@@ -29,15 +31,14 @@
| costsByRealm | 各功法归属境界的逐层成本数组;第一项为一升二,必须覆盖所有后续层 |
| id | 稳定功法 ID,同时派生玉简和被动 ID |
| realm | 功法位归属 |
| rarity | common/rare,显示用,不强制效果数量 |
| effects | 一至两项不重复的六维属性;valuePerLevel 为每层固定加成 |
| dropWeight | 现有野外玉简组内相对权重,与稀有度独立配置 |
| effects | 一项六维属性;valueAt1 为一层初始加成,valuePerLevel 为后续每升一层的增量 |
| mechanism | 机制类型、单条件、适用伤害种类(如需要)、valueAt1 / valueAt9;九层线性插值 |
当前十六本:每境界三本常见单属性、一本珍稀双属性。炼气/筑基每项每层 +1,金丹/元婴每项每层 +2。四本珍稀全部九层提供六维合计 108 点,是初始调参基线,不代表长期平衡结论。
当前 24 本,每境界六本。炼气/筑基/金丹/元婴一层分别 +12/18/24/36,后续每层分别 +2/2/4/4,四本激活功法全部九层提供六维合计 186 点。机制包括条件增伤、减伤、治疗/护盾增强、闪避、封印抵抗和回合末恢复,详见首版计划。数值是首轮调参基线,尚非长期平衡结论。
默认修为逐层费用:炼气 20、筑基 100、金丹 500、元婴 2000,分别乘以当前层数;道心感悟二至九层分别消耗 2 至 9。全本共消耗基础修为费用的 36 倍与 44 感悟。成本按功法归属境界计算,不因角色升境变化。
修为逐层费用:炼气 40、筑基 200、金丹 1000、元婴 4000,分别乘以当前层数 L(1~8);感悟费用分别为 2/3/4/5 × (L + 1)。单本九层累计修为分别 1,440/7,200/36,000/144,000,累计感悟分别 88/132/176/220。最高单次感悟消耗为 45,不改变 100 点储存上限。成本按功法归属境界计算,不因角色升境变化;旧已完成升级不追溯补扣。
野外既有玉简组总掉落率保持 3%,常见权重 100、珍稀权重 5;现有池暂覆盖全部十六本,每本珍稀的相对掉率为常见的 1/20。玉简沿用统一库存、堆叠和拍卖交易。暂不新增获取渠道。
野外既有玉简组总掉落率保持 3%,24 本玉简以 fixed 条目显式配置,初始各权重 100。功法包不再存稀有度或产出权重,产出包独立决定供应。玉简沿用统一库存、堆叠和拍卖交易;暂不新增渠道。
修改内容无需修改 TypeScript;新增效果机制才扩展编译器。Schema 结构源为 ManualPackShape,结构变更后通过 Zod toJSONSchema 更新同目录 schema;重复 ID、重复属性、缺失引用和不完整成本表由运行时细化校验拒绝。
@@ -45,26 +46,26 @@
- 已学进度保存在功法状态的 learned JSONB;激活位置沿用独立槽位表,不创建多套构筑。
- 纯 changeManual 与 previewManualAction 产生确定性状态和费用计划。服务端负责身份、玉简归属、版本、战斗占用及资源事实。
- 每境界三种上限由共享状态校验与学习规则同时检查,服务端在扣减玉简前执行,不只依赖前端隐藏学习入口。
- 每境界六种上限由共享状态校验与学习规则同时检查,服务端在扣减玉简前执行,不只依赖前端隐藏学习入口。
- 角色锁、数据库事务与版本比较覆盖扣费、玉简消费、功法进度、激活位置和资源事件;任一步失败整体回滚。
- 每本激活功法编译一个被动标识和一至两项六维贡献。贡献在装备/宗门/炼体面板计算前加入临时属性,派生值遵循现有 v6 公式,不写入永久六维。
- 被动身份用于记录来源与层数;属性修正由人物编译器应用一次,不再在战斗开始时重复增加。
- 每本激活功法编译一个随层数编译的机制被动和一项六维贡献。贡献在装备/宗门/炼体面板计算前加入临时属性,派生值遵循现有 v6 公式,不写入永久六维。
- 被动负责单条件机制结算并记录来源与层数;属性修正由人物编译器应用一次,不再在战斗开始时重复增加。
- 公开构筑只显示激活功法与层数,不暴露未激活收藏或培养记录。
- 需要应用迁移 `0043_manual-level-progression.sql`;没有旧功法数据兼容。历史 5A/9F 文档保留为阶段记录,不再作为当前规则。
- 需要应用迁移 `0049_manual_first_release.sql`:清空旧已学进度、槽位、库存玉简、拍卖与邮件/兑换码中的玉简附件;不映射、不退款、不留旧定义兼容。冻结历史战斗归档保留,自包含快照不作为可用功法资产。切换环境应先停止旧版本写入并排空活动战局,再执行迁移与部署。历史 5A/9F 文档保留为阶段记录,不再作为当前规则。
## 功法页面(2026-09-11)
- 左侧按角色性别展示透明底盘坐墨像,右侧自上而下固定排列炼气、筑基、金丹、元婴四位。桌面与窄屏均保持左右关系,墨像完整显示,无混合模式或整体透明度修补。
- 左侧按角色性别展示透明底盘坐墨像,右侧自上而下固定排列炼气、筑基、金丹、元婴四位。桌面保持左右关系,窄屏纵向排列,墨像完整显示,无混合模式或整体透明度修补。
- 未学习时显示空槽,未开放时显示对应境界提示,不铺开全部功法配置。
- 每位仅显示当前激活功法、层数、固定属性贡献和参悟入口。两种以上已学功法显示箭头与圆点,左右触摸滑动或箭头点击直接免费切换;提交期间禁用重复操作,服务端成功后更新显示,失败保留或重新读取权威状态。
- 未满三种时显示学习入口;学满后隐藏入口,仍可培养或切换。学习确认层明确告知三种上限、暂不支持遗忘或替换。
- 未满六种时显示学习入口;学满后隐藏入口,仍可培养或切换。学习确认层明确告知六种上限、暂不支持遗忘或替换。
- 学习及瓶颈突破复用公共 InventoryItems/ItemSlot 与 InkDetailDrawer;只提供对应境界可学习玉简或同名突破玉简。玩家明确选中具体玉简后再确认消费,不自动挑选库存。
- 学习第一本自动激活,第二、第三本保留原激活项。培养成本、层数与属性配置保持现有规则。
- 学习第一本自动激活,后续各本保留原激活项。培养成本、层数与属性配置保持现有规则。
- 美术生成提示词、透明处理和生产文件见 [功法墨像素材记录](./combat-v6-manual-portrait-assets.md)。
## 验证
共享规则测试覆盖境界位、三本玉简九层成长、资源不足、版本冲突、收藏独立进度、免费切换、非法状态、双属性投影、数据包校验与现有掉落权重。服务端和页面采用静态检查及真实本地流程验证,不添加数据库或 API 单元测试。
共享规则测试覆盖境界位、六本玉简九层成长、资源不足、版本冲突、收藏独立进度、免费切换、非法状态、双属性投影、数据包校验与现有掉落权重。服务端和页面采用静态检查及真实本地流程验证,不添加数据库或 API 单元测试。
### 本地验收(2026-09-10)
+1 -1
View File
@@ -59,7 +59,7 @@ R08依赖R01~R07及首发范围内的核心场景通过;不能仅靠健康
| G05 P1 待验/待调优 | 六种突破、金丹以上幻境、全境界排名主要有链路证据,强化测试属性不能证明正常难度 | 先确定正式可得装备/功法/修炼/心法样本,再制定目标回合、失败率、恢复消耗与收益范围;比较清心前后心魔、同级和跨档挑战,记录实际结果再调系数 | [10F](combat-v6-phase-10f-breakthrough-plan.md)、[10B](combat-v6-phase-10b-tower-plan.md)、[10C](combat-v6-phase-10c-ranking-plan.md) |
| G06 P1 待验/待调优 | 五宗门双流派已实现,器蕴/器诀逐项实战、同队手动AUTO、特殊状态组合未全验 | 覆盖五宗十流派、关键经脉、复活/伤势/护盾/五行/形态和器诀,优先容易叠加或重复计效的组合;纯确定性边界使用shared测试,真实页面验证可选、可用及说明一致 | [宗门设计](combat-v6-sect-skill-meridian-system-design.md)、[9D](combat-v6-phase-9d-forging-plan.md)、[10E](combat-v6-phase-10e-auto-sect-tasks-plan.md) |
| G07 P1 待调优 | 炼体0~60级节奏、丹毒、配方/灵果供给没有整体经济验证;心法经脉成本已配置但未整体平衡 | 对照正式来源核算升级所需时间、药力、灵石、修为和感悟;发现不可达或明显失衡再改配置。保留消耗品提升五轨、确定性位阶晋升,不新增直接点修 | [修炼设计§13](combat-v6-training-system-design.md#13-后续平衡项)、[宗门设计§13](combat-v6-sect-skill-meridian-system-design.md#13-后续专项平衡项) |
| G08 P1 待调优 | 新功法四境界位、16本九层已完成;全部玉简暂共用青溪3%组,珍稀权重为常见1/20,没有新渠道 | 评估三本同名玉简与八次修炼的实际养成时间、境界供给和交易价格;先决定分层掉落或兑换是否必要,再扩渠道。只调新版层数和成本,不回到本篇/真解 | [功法设计](combat-v6-manual-system-design.md)、[wild.ts](../src/shared/rewards/wild.ts) |
| G08 P1 待调优 | 新功法四境界位、24本九层、每位收藏六本已实现;六维与单条件机制同步成长;全部玉简暂共用青溪3%组、各权重100,没有稀有度或新渠道 | 评估三本同名玉简与八次修炼的实际养成时间、境界供给和交易价格;先决定分层掉落或兑换是否必要,再扩渠道。按[首版验证记录](combat-v6-manual-first-release-plan.md)继续验证机制收益及其低于装备/灵兽的整体预算 | [功法设计](combat-v6-manual-system-design.md)、[wild.ts](../src/shared/rewards/wild.ts) |
| G09 P1 待调优 | 蜃楼物资仍复用凡品固定材料,高境界收益未调;掉落长期经济样本不足 | 联合G02/G05建立野外、秘境、蜃楼、宗门、炼器与NPC回收的产销对照,关注资源通胀与无用途产物;每个开放奖励有实际用途,记录版本与既有快照边界 | [10B](combat-v6-phase-10b-tower-plan.md)、[tower奖励](../src/shared/rewards/tower.ts)、[9E](combat-v6-phase-9e-drops-plan.md) |
| G10 P1 待验 | 多人/展示:旧8A曾有真实4v4,后续带宠、观众、慢连接与新协议未完成全矩阵 | 8个独立角色每人1宠、另设观众;混合手动/AUTO、倒地、复活、多备用宠换宠、逃跑、重连与终局;确认公开视图不泄露精确属性,按首发容量测请求与播放积压 | [9A](combat-v6-phase-9a-summoned-beast-plan.md)、[8C](combat-v6-phase-8c-spectator-design.md)、[战斗UI](combat-v6-battle-ui.md) |
| G11 P1 部分已验 | 10Z已验证恢复丹具体堆叠消耗、人物MP跨场继承、换宠后两只灵兽实际经验到账、真实战败刷新结算;修正休整旧库存来源和扣药占用冲突 | 继续补灵兽MP实际消耗与死亡本轮回归、全地图高等级、任务消费者并发去重及完整状态/构筑入口互斥矩阵;不把部分入口通过外推全部 | [10Z验收](combat-v6-phase-10z-dungeon-continuity.md)、[10A](combat-v6-phase-10a-dungeon-plan.md#待专项验收) |
+39
View File
@@ -0,0 +1,39 @@
-- Unreleased manual prototype: discard learned progress and all old jade assets.
-- Run during maintenance before the 24-manual content is made available.
DELETE FROM "wanjiedaoyou_cultivator_manual_slots";
--> statement-breakpoint
-- Keep the concurrency revision monotonic so a pre-reset request cannot replay.
UPDATE "wanjiedaoyou_cultivator_manual_states"
SET "learned" = '[]'::jsonb, "revision" = "revision" + 1, "updated_at" = now();
--> statement-breakpoint
DELETE FROM "wanjiedaoyou_inventory_items"
WHERE starts_with("definition_id", 'jade.character_manual.');
--> statement-breakpoint
DELETE FROM "wanjiedaoyou_auction_listings"
WHERE "item_type" = 'manual_jade'
OR starts_with("item_snapshot" #>> '{item,definitionId}', 'jade.character_manual.');
--> statement-breakpoint
-- Remove only jade attachments; retain the mail and any unrelated rewards.
UPDATE "wanjiedaoyou_mails" AS m
SET "attachments" = (
SELECT coalesce(jsonb_agg(a.value ORDER BY a.ordinality), '[]'::jsonb)
FROM jsonb_array_elements(m."attachments") WITH ORDINALITY AS a(value, ordinality)
WHERE NOT coalesce(starts_with(a.value #>> '{inventory,definitionId}', 'jade.character_manual.'), false)
)
WHERE jsonb_typeof(m."attachments") = 'array'
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(m."attachments") AS a(value)
WHERE starts_with(a.value #>> '{inventory,definitionId}', 'jade.character_manual.')
);
--> statement-breakpoint
UPDATE "wanjiedaoyou_redeem_codes" AS r
SET "reward_attachments" = (
SELECT coalesce(jsonb_agg(a.value ORDER BY a.ordinality), '[]'::jsonb)
FROM jsonb_array_elements(r."reward_attachments") WITH ORDINALITY AS a(value, ordinality)
WHERE NOT coalesce(starts_with(a.value #>> '{inventory,definitionId}', 'jade.character_manual.'), false)
)
WHERE jsonb_typeof(r."reward_attachments") = 'array'
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(r."reward_attachments") AS a(value)
WHERE starts_with(a.value #>> '{inventory,definitionId}', 'jade.character_manual.')
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -344,6 +344,13 @@
"when": 1789465138319,
"tag": "0048_competitive_replay_retention",
"breakpoints": true
},
{
"idx": 49,
"version": "7",
"when": 1789533721588,
"tag": "0049_manual_first_release",
"breakpoints": true
}
]
}
@@ -7,6 +7,7 @@ import {
} from '@shared/engine/combat-v6/beasts/content';
import { BEAST_REFINEMENT } from '@shared/engine/combat-v6/beasts/refinement-config';
import { CHARACTER_MANUALS_V1 } from '@shared/engine/combat-v6/manuals/content';
import { manualEffectLines } from '@shared/engine/combat-v6/manuals/presentation';
import { EQUIPMENT_SLOT_NAMES } from '@shared/items/definitions/equipment-blueprints';
import { MATERIAL_TYPE_NAMES } from '@shared/items/definitions/materials';
import { SeedFactsSchema } from '@shared/items/definitions/seeds';
@@ -116,7 +117,18 @@ export const manualAdapter: ItemAdapter = (item, def) => {
field('传承境界', manual.realm),
quantity(item, options),
],
sections: [{ title: '所载功法', entries: lines(manual.description) }],
sections: [
{
title: '所载功法',
entries: lines(
[
...manualEffectLines(manual, 1),
'九层:' + manualEffectLines(manual, 9).join(';'),
manual.description,
].join('\n'),
),
},
],
description: '封存功法传承的玉简,可于悟道室参悟其中法门。',
}),
};
@@ -9,7 +9,7 @@ import { manualSlot } from '@shared/engine/combat-v6/manuals/compiler';
import { CHARACTER_MANUALS_V1 } from '@shared/engine/combat-v6/manuals/content';
import type { CharacterManualDefV1 } from '@shared/engine/combat-v6/manuals/types';
import { itemDefinition } from '@shared/inventory';
import { previewManualAction } from '@shared/manuals/action';
import { manualJadeCost, previewManualAction } from '@shared/manuals/action';
import { InventoryItems } from '../items/InventoryItems';
export function ManualJadePicker({
@@ -25,6 +25,10 @@ export function ManualJadePicker({
disabled: boolean;
onChoose: (action: ManualAction) => void;
}) {
const jadeCost =
manualId && view.state
? manualJadeCost(view.state, { action: 'unlock', manualId })
: 1;
const bag = useInventoryBag();
const items = bag.data?.items ?? [];
const choices = items.flatMap((item) => {
@@ -68,7 +72,7 @@ export function ManualJadePicker({
/>
<p className="text-ink-secondary">
{manualId
? '选择一本同名玉简,突破当前瓶颈。'
? `选择同名玉简,本次突破消耗 ${jadeCost} 本。`
: `选择一本${realm}功法玉简,开始修习。`}
</p>
{bag.error ? (
@@ -79,7 +83,7 @@ export function ManualJadePicker({
{bag.data && !bag.error && choices.length === 0 ? (
<p role="status">
{manualId
? '物品栏中暂无可用于突破的同名玉简。'
? `物品栏中暂无数量足够的同名玉简,本次需要 ${jadeCost} 本。`
: `物品栏中暂无可学习的${realm}功法玉简。`}
</p>
) : null}
@@ -1,3 +1,4 @@
import { manualAttributeValue } from '@shared/engine/combat-v6/manuals/attributes';
import { MAX_MANUALS_PER_SLOT } from '@shared/engine/combat-v6/manuals/compiler';
import { manualRule } from '@shared/engine/combat-v6/manuals/content';
import type {
@@ -146,7 +147,6 @@ export function ManualRealmSlot({
<span className="block text-sm font-medium">{current.name}</span>
<span className="text-ink-secondary text-xs">
<span className="font-mono">{progress?.level}</span> 层
{current.rarity === 'rare' ? ' · 珍稀' : ''}
</span>
</button>
{manuals.length > 1 ? (
@@ -166,7 +166,7 @@ export function ManualRealmSlot({
<span key={effect.attribute}>
{CHARACTER_ATTRIBUTE_LABELS[effect.attribute]}{' '}
<span className="font-mono">
+{effect.valuePerLevel * (progress?.level ?? 1)}
+{manualAttributeValue(effect, progress?.level ?? 1)}
</span>
</span>
))}
@@ -18,10 +18,10 @@ import {
manualRule,
} from '@shared/engine/combat-v6/manuals/content';
import { MANUAL_REALMS } from '@shared/engine/combat-v6/manuals/pack';
import { manualEffectLines } from '@shared/engine/combat-v6/manuals/presentation';
import type { CharacterManualDefV1 } from '@shared/engine/combat-v6/manuals/types';
import { itemDefinition } from '@shared/inventory';
import { CHARACTER_ATTRIBUTE_LABELS } from '@shared/lib/characterAttributeLabels';
import { previewManualAction } from '@shared/manuals/action';
import { manualJadeCost, previewManualAction } from '@shared/manuals/action';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { combatV6Request, mutationBody } from '../combat-v6/request';
@@ -30,14 +30,7 @@ import { ManualRealmSlot } from './ManualRealmSlot';
const endpoint = '/api/combat-v6/manuals';
function effects(manual: CharacterManualDefV1, level: number) {
return manual.effects
.map(
(e) =>
CHARACTER_ATTRIBUTE_LABELS[e.attribute] +
' +' +
e.valuePerLevel * level,
)
.join(' · ');
return manualEffectLines(manual, level).join(';');
}
export function ManualRoom() {
@@ -323,15 +316,13 @@ export function ManualRoom() {
<div className="space-y-4 text-sm">
<p>{manual.description}</p>
<p>
{manual.realm} · {manual.rarity === 'rare' ? '珍稀' : '常见'} ·{' '}
{manual.realm} ·{' '}
{learned ? learned.level + '/' + rule.maxLevel + '层' : '未习得'}
</p>
<p className="font-mono">{effects(manual, learned?.level ?? 1)}</p>
<p>{effects(manual, learned?.level ?? 1)}</p>
<p className="text-ink-secondary">
{learned ? '圆满效果:' : '一层即可获得上述效果;圆满效果:'}
<span className="font-mono">
{effects(manual, rule.maxLevel)}
</span>
<span>{effects(manual, rule.maxLevel)}</span>
</p>
<p>
持有同名玉简{' '}
@@ -362,7 +353,7 @@ export function ManualRoom() {
<span className="font-mono">
{learnedInRealm}/{MAX_MANUALS_PER_SLOT}
</span>{' '}
种。最多学习三种,暂不支持遗忘或替换,请谨慎选择。
种。最多收藏六种,同位仅一本生效,可免费切换。
</p>
) : null}
{learned?.level === rule.maxLevel ? (
@@ -370,7 +361,16 @@ export function ManualRoom() {
) : (
<>
{nextKind === 'unlock' ? (
<p>已遇瓶颈,需一本同名玉简解锁后续层数。</p>
<p>
已遇瓶颈,需{' '}
<span className="font-mono">
{manualJadeCost(view.state!, {
action: 'unlock',
manualId: manual.id,
})}
</span>{' '}
本同名玉简解锁后续层数。
</p>
) : null}
{!confirmation ? (
<InkButton
@@ -401,12 +401,12 @@ export function ManualRoom() {
) : null}
{nextKind === 'learn' &&
learnedInRealm >= MAX_MANUALS_PER_SLOT ? (
<p>该境界位已学满三种功法,不能继续学习新的功法。</p>
<p>该境界位已学满六种功法,不能继续学习新的功法。</p>
) : null}
{nextKind === 'train' && !preview?.ok ? (
<p className="text-ink-secondary">
{preview?.diagnostics.map((d) => d.message).join(';') ??
'需要一本同名玉简'}
'暂时无法修炼,请刷新核对'}
</p>
) : null}
</>
@@ -466,7 +466,11 @@ export function ManualRoom() {
</>
) : (
<p>
消耗一本《{manual.name}》玉简
消耗{' '}
<span className="font-mono">
{manualJadeCost(view.state!, confirmation)}
</span>{' '}
本《{manual.name}》玉简
{confirmation.action === 'unlock'
? ',解锁后仍需修炼升层'
: ''}
@@ -20,7 +20,7 @@ import type {
ManualAction,
ManualView,
} from '@shared/contracts/combatV6Manuals';
import { previewManualAction } from '@shared/manuals/action';
import { manualJadeCost, previewManualAction } from '@shared/manuals/action';
import type { RealmStage, RealmType } from '@shared/types/constants';
import type { CultivationProgress } from '@shared/types/cultivator';
import { and, eq } from 'drizzle-orm';
@@ -156,15 +156,16 @@ export async function mutateManuals(owner: string, action: ManualAction) {
.insert(cultivatorManualSlots)
.values({ cultivatorId: owner, ...nextSlot });
if ('item' in action) {
const jadeCost = manualJadeCost(manuals, action);
await saveInventoryPlan(
owner,
before,
item.quantity === 1
item.quantity === jadeCost
? []
: [
{
...item,
quantity: item.quantity - 1,
quantity: item.quantity - jadeCost,
revision: item.revision + 1,
},
],
+3 -3
View File
@@ -67,13 +67,13 @@ describe('public V6 build', () => {
const baseline = publicCombatV6Build(character, input);
input.manuals.learned = [
{ manualId: 'character_manual.changchun', level: 3, unlockedLevel: 3 },
{ manualId: 'character_manual.songhe', level: 9, unlockedLevel: 9 },
{ manualId: 'character_manual.qingmu', level: 9, unlockedLevel: 9 },
];
input.manuals.build.slots = [{ slot: 1, manualId: 'character_manual.changchun' }];
const result = publicCombatV6Build(character, input);
expect(result.build.manuals).toEqual([{ slot: 1, manualId: 'character_manual.changchun', level: 3 }]);
expect(JSON.stringify(result.build)).not.toContain('songhe');
expect(result.combatPanel.maxHp - baseline.combatPanel.maxHp).toBe(24);
expect(JSON.stringify(result.build)).not.toContain('qingmu');
expect(result.combatPanel.maxHp - baseline.combatPanel.maxHp).toBe(128);
expect(character.attributes.vitality).toBe(20);
});
});
@@ -84,7 +84,7 @@ export function compileJiujieCombat(pack: ReturnType<typeof loadJiujieCombat>) {
function compileEffect(effect: z.infer<typeof effectShape>): SkillEffect[] {
if (effect.type === 'fiveThunderBranches') return pack.judgment.branches.map(branch => ({
type: EffectType.RandomBranch, branchId: branch.branchId, chance: branch.chance, when: branch.when,
successEffects: structuredClone(pack.judgment.successEffects), failureEffects: structuredClone(pack.judgment.failureEffects),
successEffects: pack.judgment.successEffects.map(e => e.type === EffectType.FixedHit ? { ...e, percentageDamage: true } : structuredClone(e)), failureEffects: pack.judgment.failureEffects.map(e => e.type === EffectType.FixedHit ? { ...e, percentageDamage: true } : structuredClone(e)),
}));
if (effect.type === 'detonation') {
const rule = pack.detonation;
@@ -9,8 +9,8 @@ import { compileSectDefinitionV6 } from './compiler';
import { createEmptySectCombatProgressV6, createFreshCombatV6MethodLevels } from '../build-state';
describe('宗门技能学习配置', () => {
it('所有技能、状态、流派节点和心法完整定义与迁移前一致', () => {
expect(createHash('sha256').update(JSON.stringify(COMBAT_V6_SECT_DEFINITIONS)).digest('hex')).toBe('e76aa73d14ab25af362a3a41140607242f80f6d0601c0382755cb16e01287a5c');
it('技能、状态、流派和心法保持基线(含比例伤害分类)', () => {
expect(createHash('sha256').update(JSON.stringify(COMBAT_V6_SECT_DEFINITIONS)).digest('hex')).toBe('0773c048fc7c062f41d00ba6dbdfd42efaefd521d96b3046cf49addd17424565');
});
it('Schema 与编辑器一致', () => {
expect(z.toJSONSchema(SectSkillLearningShape)).toEqual(schema);
@@ -15,6 +15,7 @@ import type { DamageKind as DamageKindType, DamageOrigin as DamageOriginType, Sc
import { damageTakenFactor, effectiveAttrs, healDealtFactor, healTakenFactor, isStanding, recoverableHp } from "./units.ts"
export type StrikeInput = {
percentageDamage?: boolean
source: Unit
target: Unit
kind: DamageKindType
@@ -88,6 +89,7 @@ export function resolveStrike(ctx: BattleContext, input: StrikeInput): void {
raw = floorAtLeast(MIN_DAMAGE, raw * damageTakenFactor(target, input.kind))
const hooked = ctx.hooks.emit(HookName.OnHitCalc, {
percentageDamage: input.percentageDamage,
source,
target,
damage: raw,
@@ -367,6 +369,7 @@ export function applyHeal(ctx: BattleContext, source: Unit, target: Unit, power:
ctx.currentAction?.primaryTargetId !== undefined && target.id === ctx.currentAction.primaryTargetId
const taken = healTakenFactor(target) * (fixedBase ? 1 : healDealtFactor(source))
const hooked = ctx.hooks.emit(HookName.OnHealCalc, {
origin: ctx.suppressHooks > 0 ? DamageOrigin.HookDerived : DamageOrigin.ActionDirect,
source,
target,
heal: amount * taken,
+3 -1
View File
@@ -5,7 +5,7 @@ import { DEFAULT_HITS } from "./constants.ts"
import type { BattleContext } from "./context.ts"
import { applyBarrier } from "./barriers.ts"
import { applyHeal, applyHpRestore, applyMpDamage, applyRevive, applyWound, changeWound, resolveStrike } from "./damage.ts"
import { DamageKind, EffectType, EventType, FailReason, FormulaFamily, StatusCategory, StatusHit, StatusRemoveReason } from "./enums.ts"
import { DamageKind, DamageOrigin, EffectType, EventType, FailReason, FormulaFamily, StatusCategory, StatusHit, StatusRemoveReason } from "./enums.ts"
import { evalExpr } from "./expr.ts"
import { atLeast, floorAtLeast } from "./math.ts"
import { applyStatus, copyStatusInstance, envFor, removeStatus } from "./status.ts"
@@ -63,6 +63,7 @@ const handlers: { [K in SkillEffect["type"]]?: EffectHandler<Extract<SkillEffect
kind: effect.kind,
name: effect.name,
amount: ctx.hooks.emit("onBarrierCalc", {
origin: ctx.suppressHooks > 0 ? DamageOrigin.HookDerived : DamageOrigin.ActionDirect,
source,
target,
barrier: evalExpr(effect.power, { ...env, target }),
@@ -397,6 +398,7 @@ function handleHit(
// 横扫中途打死目标则后续刀取消。
if (!isStanding(source) || !isStanding(t) || ctx.state.result) break
resolveStrike(ctx, {
percentageDamage: effect.type === EffectType.FixedHit ? effect.percentageDamage : undefined,
source,
target: t,
kind,
@@ -30,6 +30,7 @@ export type HookContext = {
isPrimary?: boolean
crit?: boolean
chance?: number
percentageDamage?: boolean
}
export type HookFn = (ctx: HookContext) => void
@@ -61,6 +61,7 @@ export function bindDataHooks(ctx: BattleContext): void {
kind: hctx.kind ?? hook.requireKind,
origin: hctx.origin,
isPrimary: hctx.isPrimary,
percentageDamage: hctx.percentageDamage,
markKey: `${skill.id}:${hookIndex}`,
}
if (!matchesWhen(ctx, hook.when, scope)) return
+10
View File
@@ -314,6 +314,14 @@ export type SkillTargeting = {
/** 钩子/效果的通用过滤。引擎只做匹配,不要在这里写门派名。 */
export type EffectWhen = {
excludeSkillTags?: SkillTag[];
excludePercentageDamage?: boolean;
sourceMpRatioBelow?: number;
sourceMpRatioAbove?: number;
sourceHasBarrier?: boolean;
targetHasBarrier?: boolean;
sourceStatusCategories?: StatusCategory[];
sourceRemovableControl?: boolean;
skillIds?: SkillId[];
skillTags?: SkillTag[];
requireStatusIds?: StatusId[];
@@ -391,6 +399,8 @@ type EffectCore =
}
| {
type: typeof EffectType.FixedHit;
/** Damage based on the target's current/maximum HP, not an ordinary attack. */
percentageDamage?: boolean;
hits?: Expr;
coeff?: number | number[];
/** Multiplies the resolved damage, rather than the attack formula. */
+13
View File
@@ -15,6 +15,7 @@ export type WhenScope = {
kind?: DamageKind
origin?: DamageOrigin
isPrimary?: boolean
percentageDamage?: boolean
/** 写入 marks 的前缀,通常是被动技能 id + 钩子下标 */
markKey?: string
}
@@ -64,6 +65,18 @@ export function matchesWhen(ctx: Pick<BattleContext, 'statusDefs' | 'currentActi
if (!when) return true
const skillId = scope.skillId ?? scope.skill?.id ?? ctx.currentAction?.skillId
const skill = scope.skill
if (when.excludeSkillTags?.some(tag => skill?.tags.includes(tag))) return false
if (when.excludePercentageDamage && scope.percentageDamage) return false
const mpRatio = scope.source.attrs.mp / Math.max(1, scope.source.attrs.maxMp)
if (when.sourceMpRatioBelow !== undefined && mpRatio >= when.sourceMpRatioBelow) return false
if (when.sourceMpRatioAbove !== undefined && mpRatio <= when.sourceMpRatioAbove) return false
if (when.sourceHasBarrier !== undefined && scope.source.barriers.some(b => b.current > 0) !== when.sourceHasBarrier) return false
if (when.targetHasBarrier !== undefined && (!scope.target || scope.target.barriers.some(b => b.current > 0) !== when.targetHasBarrier)) return false
if (when.sourceStatusCategories && !hasCategory(ctx, scope.source, when.sourceStatusCategories)) return false
if (when.sourceRemovableControl && !scope.source.statuses.some(s => {
const def = ctx.statusDefs.get(s.id)
return def?.category === 'control' && def.dispellable !== false && !def.blocksRevive
})) return false
const isPrimary =
scope.isPrimary ??
(scope.target !== undefined && scope.target.id === ctx.currentAction?.primaryTargetId)
@@ -0,0 +1,9 @@
import type { CharacterManualDefV1 } from './types';
/** valueAt1 is the complete first-level bonus; growth starts at level two. */
export function manualAttributeValue(
effect: CharacterManualDefV1['effects'][number],
level: number,
): number {
return effect.valueAt1 + effect.valuePerLevel * (level - 1);
}
@@ -3,7 +3,10 @@ import type {
CombatV6ProjectionDiagnostic,
CultivatorBaseCombatInput,
} from '../projection/types.ts';
import { DaoyouRule } from '../rules-daoyou/constants';
import { manualAttributeValue } from './attributes';
import { CHARACTER_MANUALS_V1, manualRule } from './content.ts';
import { compileManualSkill, manualMechanismValue } from './mechanism';
import { MANUAL_REALMS } from './pack.ts';
import type {
CharacterManualDefV1,
@@ -13,7 +16,7 @@ import type {
ManualSlotV1,
} from './types.ts';
export const MAX_MANUALS_PER_SLOT = 3;
export const MAX_MANUALS_PER_SLOT = 6;
export function getManualSlotCount(realm: RealmType): number {
return Math.min(4, REALM_VALUES.indexOf(realm) + 1);
@@ -60,7 +63,7 @@ export function validateManualStateV1(
const slot = manualSlot(def);
const count = (learnedCounts.get(slot) ?? 0) + 1;
learnedCounts.set(slot, count);
if (count > MAX_MANUALS_PER_SLOT) fail('每个境界位最多学习三种功法');
if (count > MAX_MANUALS_PER_SLOT) fail('每个境界位最多学习六种功法');
const rule = manualRule(def);
if (manualSlot(def) > getManualSlotCount(realm))
fail('当前境界不能修炼该功法');
@@ -120,8 +123,16 @@ export function compileCharacterManualsV1(
for (const effect of def.effects)
projection.attributeBonuses[effect.attribute] =
(projection.attributeBonuses[effect.attribute] ?? 0) +
effect.valuePerLevel * level;
projection.skills.push(def.skill);
manualAttributeValue(effect, level);
projection.skills.push(compileManualSkill(def, level));
if (def.mechanism.type === 'sealResist')
projection.panel.push({
attr: 'sealResist',
mode: 'add',
value:
manualMechanismValue(def.mechanism, level) *
DaoyouRule.hitChanceScale,
});
projection.passiveSkillIds.push(def.skill.id);
projection.skillLevels[def.skill.id] = level;
projection.capabilities.push({
@@ -1,6 +1,6 @@
{
"$schema": "./manual-pack.schema.json",
"version": 1,
"version": 3,
"progressions": {
"standard": {
"maxLevel": 9,
@@ -10,139 +10,139 @@
],
"costsByRealm": {
"炼气": [
{
"experience": 20,
"insight": 2
},
{
"experience": 40,
"insight": 3
},
{
"experience": 60,
"insight": 4
},
{
"experience": 80,
"insight": 5
},
{
"experience": 100,
"insight": 6
},
{
"experience": 120,
"insight": 7
},
{
"experience": 140,
"insight": 8
},
{
"experience": 160,
"insight": 9
"insight": 10
},
{
"experience": 200,
"insight": 12
},
{
"experience": 240,
"insight": 14
},
{
"experience": 280,
"insight": 16
},
{
"experience": 320,
"insight": 18
}
],
"筑基": [
{
"experience": 100,
"insight": 2
},
{
"experience": 200,
"insight": 3
},
{
"experience": 300,
"insight": 4
},
{
"experience": 400,
"insight": 5
},
{
"experience": 500,
"insight": 6
},
{
"experience": 600,
"insight": 7
"experience": 400,
"insight": 9
},
{
"experience": 700,
"insight": 8
"experience": 600,
"insight": 12
},
{
"experience": 800,
"insight": 9
"insight": 15
},
{
"experience": 1000,
"insight": 18
},
{
"experience": 1200,
"insight": 21
},
{
"experience": 1400,
"insight": 24
},
{
"experience": 1600,
"insight": 27
}
],
"金丹": [
{
"experience": 500,
"insight": 2
},
{
"experience": 1000,
"insight": 3
},
{
"experience": 1500,
"insight": 4
},
{
"experience": 2000,
"insight": 5
},
{
"experience": 2500,
"insight": 6
},
{
"experience": 3000,
"insight": 7
},
{
"experience": 3500,
"insight": 8
},
{
"experience": 2000,
"insight": 12
},
{
"experience": 3000,
"insight": 16
},
{
"experience": 4000,
"insight": 9
"insight": 20
},
{
"experience": 5000,
"insight": 24
},
{
"experience": 6000,
"insight": 28
},
{
"experience": 7000,
"insight": 32
},
{
"experience": 8000,
"insight": 36
}
],
"元婴": [
{
"experience": 2000,
"insight": 2
},
{
"experience": 4000,
"insight": 3
},
{
"experience": 6000,
"insight": 4
"insight": 10
},
{
"experience": 8000,
"insight": 5
},
{
"experience": 10000,
"insight": 6
"insight": 15
},
{
"experience": 12000,
"insight": 7
},
{
"experience": 14000,
"insight": 8
"insight": 20
},
{
"experience": 16000,
"insight": 9
"insight": 25
},
{
"experience": 20000,
"insight": 30
},
{
"experience": 24000,
"insight": 35
},
{
"experience": 28000,
"insight": 40
},
{
"experience": 32000,
"insight": 45
}
]
}
@@ -153,257 +153,537 @@
"id": "character_manual.changchun",
"name": "长春功",
"realm": "炼气",
"rarity": "common",
"description": "温养肉身,培固根本。",
"description": "生机绵长,温养自身。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "vitality",
"valuePerLevel": 1
"valuePerLevel": 2,
"valueAt1": 12
}
]
],
"mechanism": {
"type": "restoreHp",
"condition": "selfHpBelow50",
"valueAt1": 0.0025,
"valueAt9": 0.0075
}
},
{
"id": "character_manual.qingfeng",
"name": "轻风诀",
"id": "character_manual.gengjin",
"name": "庚金诀",
"realm": "炼气",
"rarity": "common",
"description": "行气轻灵,身随风动。",
"description": "金气锐利,先破敌锋。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "speed",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.yangshen",
"name": "养神诀",
"realm": "炼气",
"rarity": "common",
"description": "澄明灵台,凝聚神元。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.songhe",
"name": "松鹤养元经",
"realm": "炼气",
"rarity": "rare",
"description": "温养肉身,培固根本。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "vitality",
"valuePerLevel": 1
},
{
"attribute": "willpower",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.houtu",
"name": "厚土功",
"realm": "筑基",
"rarity": "common",
"description": "厚积根基,坚韧如山。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "endurance",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.ningyuan",
"name": "凝元诀",
"realm": "筑基",
"rarity": "common",
"description": "澄明灵台,凝聚神元。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.duanyue",
"name": "断岳功",
"realm": "筑基",
"rarity": "common",
"description": "凝炼劲力,发力沉雄。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "strength",
"valuePerLevel": 1
"valuePerLevel": 2,
"valueAt1": 12
}
]
],
"mechanism": {
"type": "damage",
"condition": "targetHpAbove70",
"valueAt1": 0.01,
"valueAt9": 0.03,
"kinds": [
"physical"
]
}
},
{
"id": "character_manual.jinque",
"name": "金阙固元经",
"id": "character_manual.qingmu",
"name": "青木养元诀",
"realm": "炼气",
"description": "木气滋生,扶伤续命。",
"progressionId": "standard",
"effects": [
{
"attribute": "willpower",
"valuePerLevel": 2,
"valueAt1": 12
}
],
"mechanism": {
"type": "heal",
"condition": "targetHpBelow50",
"valueAt1": 0.03,
"valueAt9": 0.09
}
},
{
"id": "character_manual.qingquan",
"name": "清泉诀",
"realm": "炼气",
"description": "涓流回补,法力不竭。",
"progressionId": "standard",
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 2,
"valueAt1": 12
}
],
"mechanism": {
"type": "restoreMp",
"condition": "selfMpBelow50",
"valueAt1": 0.0025,
"valueAt9": 0.0075
}
},
{
"id": "character_manual.chiyan",
"name": "赤炎诀",
"realm": "炼气",
"description": "精气充盈,火势方盛。",
"progressionId": "standard",
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 2,
"valueAt1": 12
}
],
"mechanism": {
"type": "damage",
"condition": "selfHpAbove70",
"valueAt1": 0.01,
"valueAt9": 0.03,
"kinds": [
"spell"
]
}
},
{
"id": "character_manual.houtu_jue",
"name": "厚土诀",
"realm": "炼气",
"description": "厚土承压,守势稳固。",
"progressionId": "standard",
"effects": [
{
"attribute": "endurance",
"valuePerLevel": 2,
"valueAt1": 12
}
],
"mechanism": {
"type": "mitigation",
"condition": "defending",
"valueAt1": 0.03,
"valueAt9": 0.09,
"kinds": [
"physical"
]
}
},
{
"id": "character_manual.guiyuan",
"name": "归元功",
"realm": "筑基",
"rarity": "rare",
"description": "厚积根基,坚韧如山。",
"description": "真元归一,凝甲护身。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "endurance",
"valuePerLevel": 1
},
{
"attribute": "vitality",
"valuePerLevel": 1
"valuePerLevel": 2,
"valueAt1": 18
}
]
],
"mechanism": {
"type": "mitigation",
"condition": "selfBarrier",
"valueAt1": 0.02,
"valueAt9": 0.06,
"kinds": [
"physical",
"spell"
]
}
},
{
"id": "character_manual.jianyu",
"name": "剑元诀",
"realm": "金丹",
"rarity": "common",
"description": "凝炼劲力,发力沉雄。",
"id": "character_manual.huanling",
"name": "幻灵诀",
"realm": "筑基",
"description": "幻影扰目,难辨真身。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "strength",
"valuePerLevel": 2
}
]
},
{
"id": "character_manual.dinghun",
"name": "定魂功",
"realm": "金丹",
"rarity": "common",
"description": "凝神守一,心志不移。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "willpower",
"valuePerLevel": 2
}
]
},
{
"id": "character_manual.liuyun",
"name": "流云诀",
"realm": "金丹",
"rarity": "common",
"description": "行气轻灵,身随风动。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "speed",
"valuePerLevel": 2
"valuePerLevel": 2,
"valueAt1": 18
}
]
],
"mechanism": {
"type": "evasion",
"condition": "always",
"valueAt1": 0.01,
"valueAt9": 0.03
}
},
{
"id": "character_manual.zixiao",
"name": "紫霄养神经",
"realm": "金丹",
"rarity": "rare",
"description": "澄明灵台,凝聚神元。",
"id": "character_manual.qingyuan",
"name": "青元剑诀",
"realm": "筑基",
"description": "真元养剑,气盛剑锐。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 2
},
"attribute": "strength",
"valuePerLevel": 2,
"valueAt1": 18
}
],
"mechanism": {
"type": "damage",
"condition": "selfHpAbove70",
"valueAt1": 0.02,
"valueAt9": 0.04,
"kinds": [
"physical"
]
}
},
{
"id": "character_manual.dayan",
"name": "大衍诀",
"realm": "筑基",
"description": "壮大神识,分神守心。",
"progressionId": "standard",
"effects": [
{
"attribute": "willpower",
"valuePerLevel": 2
"valuePerLevel": 2,
"valueAt1": 18
}
]
],
"mechanism": {
"type": "sealResist",
"condition": "always",
"valueAt1": 0.01,
"valueAt9": 0.03
}
},
{
"id": "character_manual.taiqing",
"name": "太清诀",
"realm": "元婴",
"rarity": "common",
"description": "澄明灵台,凝聚神元。",
"id": "character_manual.sanzhuan",
"name": "三转重元功",
"realm": "筑基",
"description": "真元重炼,压缩精纯。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 2
"valuePerLevel": 2,
"valueAt1": 18
}
]
],
"mechanism": {
"type": "damage",
"condition": "selfMpAbove70",
"valueAt1": 0.02,
"valueAt9": 0.04,
"kinds": [
"spell"
]
}
},
{
"id": "character_manual.changsheng",
"name": "长生功",
"realm": "元婴",
"rarity": "common",
"description": "温养肉身,培固根本。",
"id": "character_manual.shayao",
"name": "煞妖诀",
"realm": "筑基",
"description": "血煞催身,险中争胜。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "vitality",
"valuePerLevel": 2
"valuePerLevel": 2,
"valueAt1": 18
}
]
],
"mechanism": {
"type": "damage",
"condition": "selfHpBelow35",
"valueAt1": 0.02,
"valueAt9": 0.04,
"kinds": [
"physical",
"spell",
"fixed"
]
}
},
{
"id": "character_manual.zhenyue",
"name": "镇岳功",
"realm": "元婴",
"rarity": "common",
"description": "厚积根基,坚韧如山。",
"id": "character_manual.taibai",
"name": "太白剑诀",
"realm": "金丹",
"description": "剑气破障,锋芒穿护。",
"progressionId": "standard",
"effects": [
{
"attribute": "strength",
"valuePerLevel": 4,
"valueAt1": 24
}
],
"mechanism": {
"type": "damage",
"condition": "targetBarrier",
"valueAt1": 0.03,
"valueAt9": 0.06,
"kinds": [
"physical"
]
}
},
{
"id": "character_manual.taiyang",
"name": "泰阳诀",
"realm": "金丹",
"description": "精纯火力,盛阳外放。",
"progressionId": "standard",
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 4,
"valueAt1": 24
}
],
"mechanism": {
"type": "damage",
"condition": "selfHpAbove70",
"valueAt1": 0.03,
"valueAt9": 0.06,
"kinds": [
"spell"
]
}
},
{
"id": "character_manual.xuanyin",
"name": "玄阴大法",
"realm": "金丹",
"description": "阴魂蚀体,阴气乘隙。",
"progressionId": "standard",
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 4,
"valueAt1": 24
}
],
"mechanism": {
"type": "damage",
"condition": "targetDot",
"valueAt1": 0.03,
"valueAt9": 0.06,
"kinds": [
"spell",
"fixed"
]
}
},
{
"id": "character_manual.yudan",
"name": "玉丹功",
"realm": "金丹",
"description": "玉润丹养,扶危固本。",
"progressionId": "standard",
"effects": [
{
"attribute": "willpower",
"valuePerLevel": 4,
"valueAt1": 24
}
],
"mechanism": {
"type": "heal",
"condition": "targetHpBelow50",
"valueAt1": 0.04,
"valueAt9": 0.12
}
},
{
"id": "character_manual.tianluo",
"name": "天罗真功",
"realm": "金丹",
"description": "儒门正气,护佑危局。",
"progressionId": "standard",
"effects": [
{
"attribute": "willpower",
"valuePerLevel": 4,
"valueAt1": 24
}
],
"mechanism": {
"type": "barrier",
"condition": "targetHpBelow50",
"valueAt1": 0.05,
"valueAt9": 0.15
}
},
{
"id": "character_manual.liuji",
"name": "六极真魔功",
"realm": "金丹",
"description": "真魔法体,受压显威。",
"progressionId": "standard",
"effects": [
{
"attribute": "strength",
"valuePerLevel": 4,
"valueAt1": 24
}
],
"mechanism": {
"type": "damage",
"condition": "selfHpBelow35",
"valueAt1": 0.03,
"valueAt9": 0.06,
"kinds": [
"physical",
"spell"
]
}
},
{
"id": "character_manual.tuotian",
"name": "托天魔功",
"realm": "元婴",
"description": "肉身强横,重压不折。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "endurance",
"valuePerLevel": 2
"valuePerLevel": 4,
"valueAt1": 36
}
]
],
"mechanism": {
"type": "mitigation",
"condition": "selfHpBelow50",
"valueAt1": 0.03,
"valueAt9": 0.07,
"kinds": [
"physical"
]
}
},
{
"id": "character_manual.shenxiao",
"name": "神霄御风经",
"id": "character_manual.mingwang",
"name": "明王诀",
"realm": "元婴",
"rarity": "rare",
"description": "凝炼劲力,发力沉雄。",
"description": "法体坚固,受制不溃。",
"progressionId": "standard",
"effects": [
{
"attribute": "vitality",
"valuePerLevel": 4,
"valueAt1": 36
}
],
"mechanism": {
"type": "mitigation",
"condition": "selfControl",
"valueAt1": 0.04,
"valueAt9": 0.1,
"kinds": [
"physical",
"spell"
]
}
},
{
"id": "character_manual.yuanci",
"name": "元磁神光",
"realm": "元婴",
"description": "元磁牵制,护持周身。",
"progressionId": "standard",
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 4,
"valueAt1": 36
}
],
"mechanism": {
"type": "mitigation",
"condition": "selfMpAbove50",
"valueAt1": 0.03,
"valueAt9": 0.07,
"kinds": [
"spell"
]
}
},
{
"id": "character_manual.haoran",
"name": "浩然正气诀",
"realm": "元婴",
"description": "邪扰不侵,正气护神。",
"progressionId": "standard",
"effects": [
{
"attribute": "willpower",
"valuePerLevel": 4,
"valueAt1": 36
}
],
"mechanism": {
"type": "mitigation",
"condition": "selfDebuff",
"valueAt1": 0.04,
"valueAt9": 0.1,
"kinds": [
"spell"
]
}
},
{
"id": "character_manual.qianlang",
"name": "千浪诀",
"realm": "元婴",
"description": "身随浪走,危中脱身。",
"progressionId": "standard",
"effects": [
{
"attribute": "speed",
"valuePerLevel": 4,
"valueAt1": 36
}
],
"mechanism": {
"type": "evasion",
"condition": "selfHpBelow50",
"valueAt1": 0.02,
"valueAt9": 0.06
}
},
{
"id": "character_manual.yousha",
"name": "幽杀诀",
"realm": "元婴",
"description": "杀意逼迫,追索残敌。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "strength",
"valuePerLevel": 2
},
{
"attribute": "speed",
"valuePerLevel": 2
"valuePerLevel": 4,
"valueAt1": 36
}
]
],
"mechanism": {
"type": "damage",
"condition": "targetHpBelow35",
"valueAt1": 0.03,
"valueAt9": 0.06,
"kinds": [
"physical",
"spell",
"fixed"
]
}
}
]
}
@@ -7,7 +7,7 @@
},
"version": {
"type": "number",
"const": 1
"const": 3
},
"progressions": {
"type": "object",
@@ -85,88 +85,7 @@
"minItems": 1,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^character_manual\\.[a-z][a-z0-9_-]*$"
},
"name": {
"type": "string",
"minLength": 1
},
"realm": {
"type": "string",
"enum": [
"炼气",
"筑基",
"金丹",
"元婴"
]
},
"rarity": {
"type": "string",
"enum": [
"common",
"rare"
]
},
"description": {
"type": "string",
"minLength": 1
},
"progressionId": {
"type": "string",
"minLength": 1
},
"dropWeight": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 1000000000
},
"effects": {
"minItems": 1,
"maxItems": 2,
"type": "array",
"items": {
"type": "object",
"properties": {
"attribute": {
"type": "string",
"enum": [
"vitality",
"strength",
"spirit",
"endurance",
"speed",
"willpower"
]
},
"valuePerLevel": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": [
"attribute",
"valuePerLevel"
],
"additionalProperties": false
}
}
},
"required": [
"id",
"name",
"realm",
"rarity",
"description",
"progressionId",
"dropWeight",
"effects"
],
"additionalProperties": false
"$ref": "#/$defs/__schema0"
}
}
},
@@ -175,5 +94,237 @@
"progressions",
"manuals"
],
"additionalProperties": false
"additionalProperties": false,
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^character_manual\\.[a-z][a-z0-9_-]*$"
},
"name": {
"type": "string",
"minLength": 1
},
"realm": {
"type": "string",
"enum": [
"炼气",
"筑基",
"金丹",
"元婴"
]
},
"description": {
"type": "string",
"minLength": 1
},
"progressionId": {
"type": "string",
"minLength": 1
},
"effects": {
"minItems": 1,
"maxItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema1"
}
},
"mechanism": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "damage"
},
"condition": {
"$ref": "#/$defs/__schema2"
},
"kinds": {
"minItems": 1,
"maxItems": 3,
"type": "array",
"items": {
"$ref": "#/$defs/__schema3"
}
},
"valueAt1": {
"$ref": "#/$defs/__schema4"
},
"valueAt9": {
"$ref": "#/$defs/__schema5"
}
},
"required": [
"type",
"condition",
"kinds",
"valueAt1",
"valueAt9"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mitigation"
},
"condition": {
"$ref": "#/$defs/__schema2"
},
"kinds": {
"minItems": 1,
"maxItems": 2,
"type": "array",
"items": {
"$ref": "#/$defs/__schema6"
}
},
"valueAt1": {
"$ref": "#/$defs/__schema4"
},
"valueAt9": {
"$ref": "#/$defs/__schema5"
}
},
"required": [
"type",
"condition",
"kinds",
"valueAt1",
"valueAt9"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"heal",
"barrier",
"evasion",
"sealResist",
"restoreHp",
"restoreMp"
]
},
"condition": {
"$ref": "#/$defs/__schema2"
},
"valueAt1": {
"$ref": "#/$defs/__schema4"
},
"valueAt9": {
"$ref": "#/$defs/__schema5"
}
},
"required": [
"type",
"condition",
"valueAt1",
"valueAt9"
],
"additionalProperties": false
}
]
}
},
"required": [
"id",
"name",
"realm",
"description",
"progressionId",
"effects",
"mechanism"
],
"additionalProperties": false
},
"__schema1": {
"type": "object",
"properties": {
"attribute": {
"type": "string",
"enum": [
"vitality",
"strength",
"spirit",
"endurance",
"speed",
"willpower"
]
},
"valueAt1": {
"type": "integer",
"minimum": 1,
"maximum": 1000
},
"valuePerLevel": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": [
"attribute",
"valueAt1",
"valuePerLevel"
],
"additionalProperties": false
},
"__schema2": {
"type": "string",
"enum": [
"always",
"selfHpBelow50",
"selfHpBelow35",
"selfHpAbove70",
"targetHpBelow50",
"targetHpBelow35",
"targetHpAbove70",
"selfMpBelow50",
"selfMpAbove50",
"selfMpAbove70",
"defending",
"selfBarrier",
"targetBarrier",
"targetDot",
"selfControl",
"selfDebuff"
]
},
"__schema3": {
"type": "string",
"enum": [
"physical",
"spell",
"fixed"
]
},
"__schema4": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 0.2
},
"__schema5": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 0.2
},
"__schema6": {
"type": "string",
"enum": [
"physical",
"spell"
]
}
}
}
@@ -25,7 +25,7 @@ function fullSlot(): CultivatorManualStateV1 {
learned: [],
build: { slots: [] },
};
for (const manual of manuals.slice(0, 3)) {
for (const manual of manuals) {
const result = learn(state, manual);
if (!result.ok) throw new Error(result.diagnostics[0].message);
state = result.state;
@@ -33,11 +33,11 @@ function fullSlot(): CultivatorManualStateV1 {
return state;
}
describe('功法每境界三种学习上限', () => {
it('第三种可以学习,第四种拒绝且保留全部进度和激活项', () => {
describe('功法每境界六种学习上限', () => {
it('六种可以全部学习,重复学习拒绝且保留进度和激活项', () => {
const state = fullSlot();
const before = structuredClone(state);
expect(state.learned).toHaveLength(3);
expect(state.learned).toHaveLength(6);
expect(state.build.slots).toEqual([{ slot: 1, manualId: manuals[0].id }]);
expect(learn(state, manuals[3])).toMatchObject({ ok: false });
expect(state).toEqual(before);
@@ -48,7 +48,7 @@ describe('功法每境界三种学习上限', () => {
state,
CHARACTER_MANUALS_V1.find((m) => m.realm === '筑基')!,
);
expect(result.ok && result.state.learned).toHaveLength(4);
expect(result.ok && result.state.learned).toHaveLength(7);
expect(result.ok && result.state.build.slots).toHaveLength(2);
});
it('学满后仍可修炼、用同名玉简突破及免费切换', () => {
@@ -66,16 +66,17 @@ describe('功法每境界三种学习上限', () => {
expect(result.ok).toBe(true);
if (result.ok) state = result.state;
}
expect(state.learned).toHaveLength(3);
expect(state.learned).toHaveLength(6);
expect(state.learned[0]).toMatchObject({ level: 3, unlockedLevel: 6 });
expect(state.build.slots[0].manualId).toBe(manuals[1].id);
});
it('状态校验拒绝同境界超过三种功法', () => {
it('状态校验拒绝同境界超过六种功法', () => {
const state = fullSlot();
state.learned.push({ manualId: manuals[3].id, level: 1, unlockedLevel: 3 });
expect(validateManualStateV1(state, '筑基')).toEqual(
const extra = { ...manuals[0], id: 'character_manual.extra' };
state.learned.push({ manualId: extra.id, level: 1, unlockedLevel: 3 });
expect(validateManualStateV1(state, '筑基', [...CHARACTER_MANUALS_V1, extra])).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: '每个境界位最多学习三种功法' }),
expect.objectContaining({ message: '每个境界位最多学习六种功法' }),
]),
);
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { manualJadeCost } from '../../../manuals/action';
import { compileCharacterPanelV1 } from '../projection/character-panel-v1';
import { resolveCombatCapabilitiesV1 } from './capabilities';
import {
@@ -50,16 +51,16 @@ describe('境界功法培养', () => {
changeManual({
state: empty(),
action: 'learn',
manualId: 'character_manual.houtu',
manualId: 'character_manual.guiyuan',
slot: 2,
realm: '炼气',
expectedRevision: 0,
resources: { experience: 0, insight: 0 },
}).ok,
).toBe(false);
expect(act(empty(), 'learn', 'character_manual.houtu').ok).toBe(false);
expect(act(empty(), 'learn', 'character_manual.guiyuan').ok).toBe(false);
});
it('三本玉简与八次修炼完成九层,瓶颈只解锁不跳层', () => {
it('六本玉简与八次修炼完成九层,瓶颈只解锁不跳层', () => {
const original = empty();
let state = success(act(original, 'learn'));
expect(original).toEqual(empty());
@@ -69,27 +70,27 @@ describe('境界功法培养', () => {
if (p.level === p.unlockedLevel) {
expect(act(state, 'train').ok).toBe(false);
const before = p.level;
jades += manualJadeCost(state, { action: 'unlock', manualId: id });
state = success(act(state, 'unlock'));
expect(state.learned[0].level).toBe(before);
expect(act(state, 'unlock').ok).toBe(false);
jades++;
}
const result = act(state, 'train');
expect(result.ok && result.cost.experience).toBeGreaterThan(0);
state = success(result);
expect(state.learned[0].level).toBe(target);
}
expect(jades).toBe(3);
expect(jades).toBe(6);
expect(act(state, 'train').ok).toBe(false);
expect(act(state, 'unlock').ok).toBe(false);
});
it('多个功法独立培养,免费激活保留原有进度', () => {
let state = success(act(empty(), 'learn'));
state = success(act(state, 'train'));
state = success(act(state, 'learn', 'character_manual.qingfeng'));
state = success(act(state, 'train', 'character_manual.qingfeng'));
state = success(act(state, 'learn', 'character_manual.gengjin'));
state = success(act(state, 'train', 'character_manual.gengjin'));
expect(state.build.slots[0].manualId).toBe(id);
const switched = act(state, 'activate', 'character_manual.qingfeng');
const switched = act(state, 'activate', 'character_manual.gengjin');
expect(switched.ok && switched.cost).toEqual({ experience: 0, insight: 0 });
state = success(switched);
state = success(act(state, 'activate'));
@@ -108,21 +109,21 @@ describe('境界功法培养', () => {
expectedRevision: state.revision,
};
expect(
changeManual({ ...base, resources: { experience: 19, insight: 100 } }).ok,
changeManual({ ...base, resources: { experience: 39, insight: 100 } }).ok,
).toBe(false);
expect(
changeManual({ ...base, resources: { experience: 20, insight: 1 } }).ok,
changeManual({ ...base, resources: { experience: 40, insight: 3 } }).ok,
).toBe(false);
expect(
changeManual({
...base,
expectedRevision: 0,
resources: { experience: 20, insight: 2 },
resources: { experience: 40, insight: 4 },
}).ok,
).toBe(false);
expect(
changeManual({ ...base, resources: { experience: 20, insight: 2 } }),
).toMatchObject({ ok: true, cost: { experience: 20, insight: 2 } });
changeManual({ ...base, resources: { experience: 40, insight: 4 } }),
).toMatchObject({ ok: true, cost: { experience: 40, insight: 4 } });
expect(state.learned[0].level).toBe(1);
});
it('拒绝伪造层数、提前解锁、重复槽位和未学激活', () => {
@@ -138,15 +139,14 @@ describe('境界功法培养', () => {
])
expect(validateManualStateV1(invalid, '炼气').length).toBeGreaterThan(0);
});
it('双效果从一层生效,未激活功法不贡献,属性只在投影中累加一次', () => {
let state = success(act(empty(), 'learn', 'character_manual.songhe'));
it('属性与机制从一层生效,未激活功法不贡献,属性只在投影中累加一次', () => {
let state = success(act(empty(), 'learn', 'character_manual.qingmu'));
state = success(act(state, 'learn'));
const result = compileCharacterManualsV1({ state, realm: '炼气' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.projection.attributeBonuses).toEqual({
vitality: 1,
willpower: 1,
willpower: 12,
});
expect(result.projection.passiveSkillIds).toHaveLength(1);
const character = {
@@ -165,15 +165,15 @@ describe('境界功法培养', () => {
};
const derived = withManualAttributes(character, result.projection);
expect(character.attributes.vitality).toBe(10);
expect(compileCharacterPanelV1(derived.attributes).maxHp).toBe(488);
expect(compileCharacterPanelV1(derived.attributes).maxMp).toBe(305);
expect(compileCharacterPanelV1(derived.attributes).maxHp).toBe(480);
expect(compileCharacterPanelV1(derived.attributes).maxMp).toBe(360);
});
});
describe('功法数据包', () => {
it('十六本内容与逐层成本可以加载', () => {
expect(loadManualPack(MANUAL_PACK).manuals).toHaveLength(16);
it('二十四本内容与逐层成本可以加载', () => {
expect(loadManualPack(MANUAL_PACK).manuals).toHaveLength(24);
});
it.each(['duplicate', 'reference', 'attribute', 'cost', 'bottleneck'])(
it.each(['duplicate', 'reference', 'attribute', 'initial', 'cost', 'bottleneck'])(
'无效配置 %s 在加载时失败',
(kind) => {
const pack = structuredClone(MANUAL_PACK);
@@ -181,6 +181,7 @@ describe('功法数据包', () => {
if (kind === 'reference') pack.manuals[0].progressionId = 'missing';
if (kind === 'attribute')
pack.manuals[0].effects.push(pack.manuals[0].effects[0]);
if (kind === 'initial') pack.manuals[0].effects[0].valueAt1 = 0;
if (kind === 'cost') pack.progressions.standard.costsByRealm.炼气.pop();
if (kind === 'bottleneck')
pack.progressions.standard.bottlenecks = [6, 3];
@@ -0,0 +1,435 @@
import { describe, expect, it } from 'vitest';
import { JIUJIE_COMBAT } from '../content/jiujie-pack';
import {
CommandType,
DamageOrigin,
EffectType,
HookName,
SkillTag,
TargetSide,
createBattle,
type SkillDef,
type StatusDef,
} from '../core';
import { createDaoyouRuleset } from '../rules-daoyou';
import { COMBAT_V6_PHASE_1_VERSIONS } from '../version';
import { compileCharacterManualsV1, manualSlot } from './compiler';
import { CHARACTER_MANUALS_V1, MANUAL_PACK } from './content';
import { compileManualSkill, manualMechanismValue } from './mechanism';
import { loadManualPack } from './pack';
import { manualEffectLines } from './presentation';
const statuses: StatusDef[] = [
{ id: 'dot', name: '毒', kind: 'dot', category: 'dot' },
{ id: 'debuff', name: '减益', kind: 'debuff', category: 'debuff' },
{
id: 'control',
name: '封法',
kind: 'control',
category: 'control',
blocksSpell: true,
},
{
id: 'revive',
name: '锢魂',
kind: 'revive',
category: 'control',
blocksRevive: true,
},
{
id: 'locked',
name: '不可驱散',
kind: 'locked',
category: 'control',
dispellable: false,
},
];
const attack: SkillDef = {
id: 'strike',
name: '打击',
tags: [SkillTag.Physical],
targeting: { side: TargetSide.Enemy },
effects: [{ type: EffectType.PhysicalHit }],
};
function setup(ids: string[], level = 9, skill = attack) {
const passives = ids.map((id) =>
compileManualSkill(
CHARACTER_MANUALS_V1.find((m) => m.id === `character_manual.${id}`)!,
level,
),
);
const session = createBattle({
seed: 42,
versions: COMBAT_V6_PHASE_1_VERSIONS,
ruleset: createDaoyouRuleset({
formulas: {
baseDamage: () => 1000,
physicalHitChance: () => 1,
spellHitChance: () => 1,
fluctuationMin: 1,
fluctuationMax: 1,
defendPhysicalFactor: 1,
},
}),
skills: [skill, ...passives],
statusDefs: statuses,
units: [
{
id: 'a',
name: 'a',
side: 0,
kind: 'player',
skills: [skill.id],
passives: passives.map((s) => s.id),
attrs: {
hp: 10000,
maxHp: 10000,
mp: 1000,
maxMp: 1000,
speed: 20,
physicalAtk: 100,
physicalDef: 0,
critRate: 0,
spellCritRate: 0,
},
},
{
id: 'b',
name: 'b',
side: 1,
kind: 'npc',
attrs: {
hp: 10000,
maxHp: 10000,
speed: 10,
physicalAtk: 1,
physicalDef: 0,
critRate: 0,
},
},
],
});
return session;
}
function status(session: ReturnType<typeof setup>, id: string, unitId = 'a') {
const def = statuses.find((s) => s.id === id)!;
session.unit(unitId).statuses.push({
id,
kind: def.kind,
sourceId: 'b',
remainingRounds: 2,
appliedRound: 0,
speedMod: 0,
attrMods: {},
damageTakenPhysical: 1,
damageTakenSpell: 1,
healTaken: 1,
healDealt: 1,
stacks: 1,
});
}
function barrier(
session: ReturnType<typeof setup>,
unitId = 'a',
amount = 100,
) {
session.unit(unitId).barriers.push({
id: 'shield',
kind: 'shield',
name: '盾',
current: amount,
remainingRounds: 2,
sourceId: 'a',
appliedRound: 0,
});
}
function strike(
session: ReturnType<typeof setup>,
kind: 'physical' | 'spell' | 'fixed',
defense = false,
extra = {},
) {
return session.hooks.emit(HookName.OnHitCalc, {
source: session.unit(defense ? 'b' : 'a'),
target: session.unit(defense ? 'a' : 'b'),
kind,
damage: 1000,
origin: DamageOrigin.ActionDirect,
skillId: attack.id,
isPrimary: true,
...extra,
}).damage!;
}
describe('首版功法内容合同', () => {
it('24本各六种,六维与机制逐层成长且无品质/产出字段', () => {
for (const realm of ['炼气', '筑基', '金丹', '元婴'])
expect(
CHARACTER_MANUALS_V1.filter((m) => m.realm === realm),
).toHaveLength(6);
for (const manual of CHARACTER_MANUALS_V1) {
expect(manual).not.toHaveProperty('rarity');
expect(manual).not.toHaveProperty('dropWeight');
expect(manual.effects).toHaveLength(1);
for (let level = 1; level <= 9; level++) {
const result = compileCharacterManualsV1({
realm: '元婴',
state: {
version: 1,
revision: 0,
learned: [
{
manualId: manual.id,
level,
unlockedLevel: level <= 3 ? 3 : level <= 6 ? 6 : 9,
},
],
build: {
slots: [{ slot: manualSlot(manual), manualId: manual.id }],
},
},
});
expect(result.ok).toBe(true);
if (!result.ok) continue;
expect(
result.projection.attributeBonuses[manual.effects[0].attribute],
).toBe(
manual.effects[0].valueAt1 +
(level - 1) * manual.effects[0].valuePerLevel,
);
expect(manualEffectLines(manual, level)).toHaveLength(2);
expect(manualMechanismValue(manual.mechanism, level)).toBeGreaterThan(
level === 1 ? 0 : manualMechanismValue(manual.mechanism, level - 1),
);
}
}
});
it('拒绝旧稀有度字段和倒退成长', () => {
expect(() =>
loadManualPack({
...MANUAL_PACK,
manuals: [{ ...MANUAL_PACK.manuals[0], rarity: 'rare' }],
}),
).toThrow();
const data = structuredClone(MANUAL_PACK);
data.manuals[0].mechanism.valueAt9 = 0.0001;
expect(() => loadManualPack(data)).toThrow();
});
it('拒绝不成长的机制与回合恢复的目标条件', () => {
const data = structuredClone(MANUAL_PACK);
data.manuals[0].mechanism.valueAt9 = data.manuals[0].mechanism.valueAt1;
expect(() => loadManualPack(data)).toThrow();
data.manuals[0].mechanism.valueAt9 = 0.01;
data.manuals[0].mechanism.condition = 'targetHpBelow50';
expect(() => loadManualPack(data)).toThrow();
});
it('大衍的每层概率差值保留小数面板点数', () => {
const result = compileCharacterManualsV1({
realm: '筑基',
state: {
version: 1,
revision: 0,
learned: [
{ manualId: 'character_manual.dayan', level: 2, unlockedLevel: 3 },
],
build: { slots: [{ slot: 2, manualId: 'character_manual.dayan' }] },
},
});
expect(result.ok && result.projection.panel).toEqual([
{ attr: 'sealResist', mode: 'add', value: 2.5 },
]);
});
});
describe('单条件机制结算', () => {
it.each([
['gengjin', 1030],
['qingyuan', 1040],
] as const)(
'%s只强化主目标直接攻击,排除派生、比例扣血及器诀',
(id, damage) => {
const s = setup([id]);
expect(strike(s, 'physical')).toBeCloseTo(damage);
expect(strike(s, 'physical', false, { isPrimary: false })).toBe(1000);
expect(
strike(s, 'physical', false, { origin: DamageOrigin.HookDerived }),
).toBe(1000);
expect(strike(s, 'physical', false, { percentageDamage: true })).toBe(
1000,
);
const art = setup([id], 9, {
...attack,
tags: [SkillTag.Physical, SkillTag.Art],
});
expect(strike(art, 'physical')).toBe(1000);
},
);
it('血量阈值严格,多段跨过70%后停止增伤', () => {
const s = setup(['gengjin'], 9, {
...attack,
effects: [{ type: EffectType.PhysicalHit, hits: 2 }],
});
s.unit('b').attrs.hp = 7500;
s.submit('a', {
type: CommandType.Skill,
skillId: attack.id,
targets: ['b'],
});
s.submit('b', { type: CommandType.Defend });
s.lockAndResolve();
expect(s.unit('b').attrs.hp).toBe(5470);
const exact = setup(['gengjin']);
exact.unit('b').attrs.hp = 7000;
expect(strike(exact, 'physical')).toBe(1000);
});
it.each(['shayao', 'liuji', 'yousha'])('%s只在对应残血条件增伤', (id) => {
const s = setup([id]);
expect(strike(s, 'physical')).toBe(1000);
s.unit(id === 'yousha' ? 'b' : 'a').attrs.hp = 3500;
expect(strike(s, 'physical')).toBe(1000);
s.unit(id === 'yousha' ? 'b' : 'a').attrs.hp = 3499;
expect(strike(s, 'physical')).toBeCloseTo(id === 'shayao' ? 1040 : 1060);
expect(strike(s, 'fixed')).toBeCloseTo(
id === 'liuji' ? 1000 : id === 'shayao' ? 1040 : 1060,
);
});
it('法力条件与法术加成区分气血和法力', () => {
const s = setup(['sanzhuan', 'chiyan', 'taiyang']);
expect(strike(s, 'spell')).toBeCloseTo(1000 * 1.04 * 1.03 * 1.06);
s.unit('a').attrs.mp = 700;
expect(strike(s, 'spell')).toBeCloseTo(1000 * 1.03 * 1.06);
s.unit('a').attrs.hp = 7000;
expect(strike(s, 'spell')).toBe(1000);
});
it('有效护盾与防御条件,零盾不触发', () => {
const s = setup(['guiyuan', 'houtu_jue', 'taibai']);
expect(strike(s, 'physical', true)).toBe(1000);
barrier(s, 'a');
barrier(s, 'b');
expect(strike(s, 'physical', true)).toBeCloseTo(940);
expect(strike(s, 'physical')).toBeCloseTo(1060);
s.unit('a').flags.defending = true;
expect(strike(s, 'physical', true)).toBeCloseTo(940 * 0.91);
s.unit('b').barriers[0].current = 0;
expect(strike(s, 'physical')).toBe(1000);
});
it('玄阴不强化持续伤害,减益与持续伤害不能混用', () => {
const s = setup(['xuanyin']);
status(s, 'debuff', 'b');
expect(strike(s, 'fixed')).toBe(1000);
status(s, 'dot', 'b');
expect(strike(s, 'fixed')).toBeCloseTo(1060);
expect(strike(s, 'spell')).toBeCloseTo(1060);
expect(strike(s, 'physical')).toBe(1000);
expect(strike(s, 'fixed', false, { origin: DamageOrigin.Status })).toBe(
1000,
);
});
it('托天、元磁、明王和浩然分别识别条件', () => {
const s = setup(['tuotian', 'yuanci', 'mingwang', 'haoran']);
expect(strike(s, 'physical', true)).toBe(1000);
expect(strike(s, 'spell', true)).toBeCloseTo(930);
s.unit('a').attrs.mp = 500;
expect(strike(s, 'spell', true)).toBe(1000);
s.unit('a').attrs.hp = 4999;
expect(strike(s, 'physical', true)).toBeCloseTo(930);
status(s, 'revive');
status(s, 'locked');
expect(strike(s, 'spell', true)).toBe(1000);
status(s, 'control');
expect(strike(s, 'spell', true)).toBeCloseTo(900);
status(s, 'debuff');
expect(strike(s, 'spell', true)).toBeCloseTo(810);
});
it('幻灵/千浪为概率差额,千浪只在半血以下生效', () => {
const s = setup(['huanling', 'qianlang']);
const roll = () =>
s.hooks.emit(HookName.OnHitRoll, {
source: s.unit('b'),
target: s.unit('a'),
chance: 0.95,
kind: 'physical',
origin: DamageOrigin.ActionDirect,
}).chance;
expect(roll()).toBeCloseTo(0.92);
s.unit('a').attrs.hp = 4999;
expect(roll()).toBeCloseTo(0.86);
});
it('治疗/护盾只增强低血主目标,不增强派生恢复', () => {
const s = setup(['qingmu', 'yudan', 'tianluo']);
s.unit('b').attrs.hp = 4999;
const base = {
source: s.unit('a'),
target: s.unit('b'),
skillId: attack.id,
isPrimary: true,
origin: DamageOrigin.ActionDirect,
};
expect(
s.hooks.emit(HookName.OnHealCalc, { ...base, heal: 1000 }).heal,
).toBeCloseTo(1220.8);
expect(
s.hooks.emit(HookName.OnBarrierCalc, { ...base, barrier: 1000 }).barrier,
).toBeCloseTo(1150);
expect(
s.hooks.emit(HookName.OnHealCalc, {
...base,
heal: 1000,
isPrimary: false,
}).heal,
).toBe(1000);
expect(
s.hooks.emit(HookName.OnHealCalc, {
...base,
heal: 1000,
origin: DamageOrigin.HookDerived,
}).heal,
).toBe(1000);
});
it('回合恢复受半血/半蓝、伤势和存活限制,且向下取整', () => {
const s = setup(['changchun', 'qingquan', 'qingmu', 'yudan']);
s.unit('a').attrs.hp = 4990;
s.unit('a').attrs.mp = 490;
s.hooks.emit(HookName.OnRoundEnd);
expect(s.unit('a').attrs.hp).toBe(5065);
expect(s.unit('a').attrs.mp).toBe(497);
s.unit('a').wound = 5100;
s.unit('a').attrs.hp = 4800;
s.hooks.emit(HookName.OnRoundEnd);
s.hooks.emit(HookName.OnRoundEnd);
expect(s.unit('a').attrs.hp).toBe(4900);
s.unit('a').flags.downed = true;
s.unit('a').attrs.hp = 0;
s.hooks.emit(HookName.OnRoundEnd);
expect(s.unit('a').attrs.hp).toBe(0);
});
it.each([false, true])('实际行动保留比例固伤分类:%s', (percentageDamage) => {
const s = setup(['yousha'], 9, {
...attack,
effects: [{ type: EffectType.FixedHit, power: 1000, percentageDamage }],
});
s.unit('b').attrs.hp = 3000;
s.submit('a', {
type: CommandType.Skill,
skillId: attack.id,
targets: ['b'],
});
s.submit('b', { type: CommandType.Defend });
s.lockAndResolve();
expect(s.unit('b').attrs.hp).toBe(percentageDamage ? 2000 : 1940);
});
it('五雷的所有百分比伤害分支有显式分类,保持原有其他效果', () => {
const skill = JIUJIE_COMBAT.skills.find((s) =>
s.definition.effects.some((e) => e.type === EffectType.RandomBranch),
)!.definition;
for (const effect of skill.effects)
if (effect.type === EffectType.RandomBranch) {
for (const branch of [
...effect.successEffects,
...effect.failureEffects,
])
if (branch.type === EffectType.FixedHit)
expect(branch.percentageDamage).toBe(true);
}
});
});
@@ -0,0 +1,166 @@
import {
DamageOrigin,
EffectType,
HookAim,
HookName,
SkillTag,
StatusCategory,
TargetSide,
type EffectWhen,
type SkillDef,
type SkillHook,
} from '../core';
import type { ManualMechanism } from './pack';
export function manualMechanismValue(
mechanism: ManualMechanism,
level: number,
): number {
return Number(
(
mechanism.valueAt1 +
((mechanism.valueAt9 - mechanism.valueAt1) * (level - 1)) / 8
).toFixed(8),
);
}
function conditionWhen(condition: ManualMechanism['condition']): EffectWhen {
switch (condition) {
case 'always':
return {};
case 'selfHpBelow50':
return { sourceHpRatioBelow: 0.5 };
case 'selfHpBelow35':
return { sourceHpRatioBelow: 0.35 };
case 'selfHpAbove70':
return { sourceHpRatioAbove: 0.7 };
case 'targetHpBelow50':
return { targetHpRatioBelow: 0.5 };
case 'targetHpBelow35':
return { targetHpRatioBelow: 0.35 };
case 'targetHpAbove70':
return { targetHpRatioAbove: 0.7 };
case 'selfMpBelow50':
return { sourceMpRatioBelow: 0.5 };
case 'selfMpAbove50':
return { sourceMpRatioAbove: 0.5 };
case 'selfMpAbove70':
return { sourceMpRatioAbove: 0.7 };
case 'defending':
return { sourceDefending: true };
case 'selfBarrier':
return { sourceHasBarrier: true };
case 'targetBarrier':
return { targetHasBarrier: true };
case 'targetDot':
return { targetStatusCategories: [StatusCategory.Dot] };
case 'selfControl':
return { sourceRemovableControl: true };
case 'selfDebuff':
return { sourceStatusCategories: [StatusCategory.Debuff] };
}
}
export function compileManualSkill(
manual: { id: string; name: string; mechanism: ManualMechanism },
level: number,
): SkillDef {
const mechanism = manual.mechanism;
const value = manualMechanismValue(mechanism, level);
const when: EffectWhen = {
...conditionWhen(mechanism.condition),
sourceStanding: true,
};
const direct: EffectWhen = {
...when,
damageOrigins: [DamageOrigin.ActionDirect],
};
const active: EffectWhen = {
...direct,
excludeSkillTags: [SkillTag.Art, SkillTag.Passive],
targetSlot: 'primary',
excludePercentageDamage: true,
};
let hooks: SkillHook[] = [];
switch (mechanism.type) {
case 'damage':
case 'mitigation':
hooks = mechanism.kinds.map((kind) => ({
on: HookName.OnHitCalc,
...(mechanism.type === 'damage'
? { sourceIsSelf: true }
: { targetIsSelf: true }),
requireKind: kind,
when: mechanism.type === 'damage' ? active : direct,
effects: [
{
type: EffectType.ModifyStrike,
factor: mechanism.type === 'damage' ? 1 + value : 1 - value,
},
],
}));
break;
case 'heal':
case 'barrier':
hooks = [
{
on:
mechanism.type === 'heal'
? HookName.OnHealCalc
: HookName.OnBarrierCalc,
sourceIsSelf: true,
when: active,
effects: [
{
type:
mechanism.type === 'heal'
? EffectType.ModifyHeal
: EffectType.ModifyBarrier,
factor: 1 + value,
},
],
},
];
break;
case 'evasion':
hooks = [
{
on: HookName.OnHitRoll,
targetIsSelf: true,
requireKind: 'physical',
when: direct,
effects: [{ type: EffectType.ModifyChance, add: -value }],
},
];
break;
case 'restoreHp':
case 'restoreMp':
hooks = [
{
on: HookName.OnRoundEnd,
aim: HookAim.Self,
when,
effects: [
{
type:
mechanism.type === 'restoreHp'
? EffectType.RestoreHp
: EffectType.RestoreMp,
power: `floor(source.${mechanism.type === 'restoreHp' ? 'maxHp' : 'maxMp'} * ${value})`,
},
],
},
];
break;
case 'sealResist':
break; // Compiled as ruleset-scaled panel points, before seal caps.
}
return {
id: `${manual.id}.passive`,
name: manual.name,
tags: [SkillTag.Passive],
targeting: { side: TargetSide.Self },
effects: [],
hooks,
};
}
+76 -4
View File
@@ -14,9 +14,64 @@ const cost = z.strictObject({
experience: positive,
insight: z.number().int().min(1).max(100),
});
const growth = {
valueAt1: z.number().positive().max(0.2),
valueAt9: z.number().positive().max(0.2),
};
export const ManualConditionShape = z.enum([
'always',
'selfHpBelow50',
'selfHpBelow35',
'selfHpAbove70',
'targetHpBelow50',
'targetHpBelow35',
'targetHpAbove70',
'selfMpBelow50',
'selfMpAbove50',
'selfMpAbove70',
'defending',
'selfBarrier',
'targetBarrier',
'targetDot',
'selfControl',
'selfDebuff',
]);
export const ManualMechanismShape = z.discriminatedUnion('type', [
z.strictObject({
type: z.literal('damage'),
condition: ManualConditionShape,
kinds: z
.array(z.enum(['physical', 'spell', 'fixed']))
.min(1)
.max(3),
...growth,
}),
z.strictObject({
type: z.literal('mitigation'),
condition: ManualConditionShape,
kinds: z
.array(z.enum(['physical', 'spell']))
.min(1)
.max(2),
...growth,
}),
z.strictObject({
type: z.enum([
'heal',
'barrier',
'evasion',
'sealResist',
'restoreHp',
'restoreMp',
]),
condition: ManualConditionShape,
...growth,
}),
]);
export type ManualMechanism = z.infer<typeof ManualMechanismShape>;
export const ManualPackShape = z.strictObject({
$schema: z.string().optional(),
version: z.literal(1),
version: z.literal(3),
progressions: z.record(
z.string().min(1),
z.strictObject({
@@ -31,19 +86,19 @@ export const ManualPackShape = z.strictObject({
id: z.string().regex(/^character_manual\.[a-z][a-z0-9_-]*$/),
name: z.string().min(1),
realm: z.enum(MANUAL_REALMS),
rarity: z.enum(['common', 'rare']),
description: z.string().min(1),
progressionId: z.string().min(1),
dropWeight: positive,
effects: z
.array(
z.strictObject({
attribute: z.enum(MANUAL_ATTRIBUTES),
valueAt1: z.number().int().min(1).max(1000),
valuePerLevel: z.number().int().min(1).max(100),
}),
)
.min(1)
.max(2),
.max(1),
mechanism: ManualMechanismShape,
}),
)
.min(1),
@@ -55,6 +110,23 @@ export const ManualPackSchema = ManualPackShape.superRefine((pack, ctx) => {
pack.manuals.forEach((manual, i) => {
if (ids.has(manual.id)) issue(['manuals', i, 'id'], '功法 ID 重复');
ids.add(manual.id);
const mechanism = manual.mechanism;
if (mechanism.valueAt9 <= mechanism.valueAt1)
issue(['manuals', i, 'mechanism'], '机制必须随层数增强');
if (
'kinds' in mechanism &&
new Set(mechanism.kinds).size !== mechanism.kinds.length
)
issue(['manuals', i, 'mechanism'], '伤害类型重复');
if (
['mitigation', 'evasion', 'restoreHp', 'restoreMp'].includes(
mechanism.type,
) &&
mechanism.condition.startsWith('target')
)
issue(['manuals', i, 'mechanism'], '防护与回合恢复仅支持自身条件');
if (mechanism.type === 'sealResist' && mechanism.condition !== 'always')
issue(['manuals', i, 'mechanism'], '封印抵抗为常驻能力');
if (!pack.progressions[manual.progressionId])
issue(['manuals', i, 'progressionId'], '培养规则不存在');
if (
@@ -0,0 +1,63 @@
import { CHARACTER_ATTRIBUTE_LABELS } from '@shared/lib/characterAttributeLabels';
import { manualAttributeValue } from './attributes';
import { manualMechanismValue } from './mechanism';
import type { ManualMechanism } from './pack';
import type { CharacterManualDefV1 } from './types';
const conditions: Record<ManualMechanism['condition'], string> = {
always: '',
selfHpBelow50: '自身气血低于50%时,',
selfHpBelow35: '自身气血低于35%时,',
selfHpAbove70: '自身气血高于70%时,',
targetHpBelow50: '目标气血低于50%时,',
targetHpBelow35: '目标气血低于35%时,',
targetHpAbove70: '目标气血高于70%时,',
selfMpBelow50: '自身法力低于50%时,',
selfMpAbove50: '自身法力高于50%时,',
selfMpAbove70: '自身法力高于70%时,',
defending: '自身防御时,',
selfBarrier: '自身有护盾时,',
targetBarrier: '目标有护盾时,',
targetDot: '目标带有持续伤害状态时,',
selfControl: '自身处于可解除封印时,',
selfDebuff: '自身带有减益状态时,',
};
const damageLabels = { physical: '物理', spell: '法术', fixed: '普通固定' };
export function manualMechanismDescription(
manual: CharacterManualDefV1,
level: number,
): string {
const effect = manual.mechanism;
const value = Number((manualMechanismValue(effect, level) * 100).toFixed(6));
const prefix = conditions[effect.condition];
switch (effect.type) {
case 'damage':
return `${prefix}普攻、宗门主动技能对主目标的直接${effect.kinds.map((k) => damageLabels[k]).join('、')}伤害+${value}%`;
case 'mitigation':
return `${prefix}受到的直接${effect.kinds.map((k) => damageLabels[k]).join('、')}伤害-${value}%`;
case 'heal':
return `${prefix}宗门主动技能对主目标的治疗量+${value}%`;
case 'barrier':
return `${prefix}宗门主动技能对主目标的护盾量+${value}%`;
case 'evasion':
return `${prefix}直接物理攻击的命中概率-${value}%(概率差值)`;
case 'sealResist':
return `受到封印时,对方封印成功概率-${value}%(概率差值,遵守封印上下限)`;
case 'restoreHp':
return `回合结束时,${prefix}恢复最大气血的${value}%`;
case 'restoreMp':
return `回合结束时,${prefix}恢复最大法力的${value}%`;
}
}
export function manualEffectLines(
manual: CharacterManualDefV1,
level: number,
): string[] {
return [
...manual.effects.map(
(e) =>
`${CHARACTER_ATTRIBUTE_LABELS[e.attribute]} +${manualAttributeValue(e, level)}`,
),
manualMechanismDescription(manual, level),
];
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function changeManual(input: {
),
).length;
if (count >= MAX_MANUALS_PER_SLOT)
return fail('该境界位已学满三种功法,暂不支持遗忘或学习新的功法');
return fail('该境界位已学满六种功法');
next.learned.push({
manualId: def.id,
level: 1,
+2 -2
View File
@@ -32,13 +32,13 @@ export interface CombatV6CapabilityContribution {
export interface CharacterManualDefV1 {
id: string;
realm: '炼气' | '筑基' | '金丹' | '元婴';
rarity: 'common' | 'rare';
name: string;
description: string;
progressionId: string;
dropWeight: number;
mechanism: import('./pack').ManualMechanism;
effects: Array<{
attribute: keyof import('@shared/types/cultivator').Attributes;
valueAt1: number;
valuePerLevel: number;
}>;
skill: SkillDef;
@@ -30,7 +30,9 @@ export function composeCharacterManuals(
const attrs = { ...base.unit.attrs }
for (const contribution of manuals.projection.panel) {
attrs[contribution.attr] = contribution.mode === "add"
? Math.floor((attrs[contribution.attr] ?? 0) + contribution.value)
? (contribution.attr === 'sealResist'
? (attrs[contribution.attr] ?? 0) + contribution.value
: Math.floor((attrs[contribution.attr] ?? 0) + contribution.value))
: Math.floor((attrs[contribution.attr] ?? 0) * contribution.value)
}
const passiveIds = new Set(capabilities.passiveIds)
@@ -1253,9 +1253,9 @@
"3cc7408629789c6f1cca943d940341721c5f9522f62517e035c1f2a8dd6b391c",
"a4364fe8654b80b97c8f79d404b8e9d5571928378b4d610cfde476acdd472dc9",
"3cc7408629789c6f1cca943d940341721c5f9522f62517e035c1f2a8dd6b391c",
"219d2a207d1ad62fb2457f28247c1e9a95d0b4c60bfb4f0dfa68c2546e04c43d",
"dbc3f5435d3f12b38bf20454767a254a3ecf3c201be3173bfbbb3aa1ae7b9ca2",
"3cc7408629789c6f1cca943d940341721c5f9522f62517e035c1f2a8dd6b391c",
"8bf817269ef20247852612920390c90593d3f7c569cb8bb79123703236ba26f5",
"c756dfbb95211771b11066aac8cd19b82eae870bfbef7af605be9a92d77d5ca7",
"3cc7408629789c6f1cca943d940341721c5f9522f62517e035c1f2a8dd6b391c"
]
}
+103 -26
View File
@@ -3,10 +3,11 @@ import {
ManualActionSchema,
type ManualAction,
} from '../contracts/combatV6Manuals';
import { MANUAL_PACK } from '../engine/combat-v6/manuals/content';
import type { CultivatorManualStateV1 } from '../engine/combat-v6/manuals/types';
import type { InventoryItem } from '../inventory';
import { QINGXI_POOL_V2 } from '../rewards/wild';
import { previewManualAction } from './action';
import { manualJadeCost, previewManualAction } from './action';
const state: CultivatorManualStateV1 = {
version: 1,
@@ -33,6 +34,56 @@ const action: ManualAction = {
item: { id: 'jade', revision: 0 },
};
const resources = { experience: 0, insight: 0 };
it.each([
['炼气', 'changchun', 1, 320, 18, 1440, 88],
['筑基', 'guiyuan', 2, 1600, 27, 7200, 132],
['金丹', 'taibai', 3, 8000, 36, 36000, 176],
['元婴', 'tuotian', 4, 32000, 45, 144000, 220],
] as const)(
'%s费用按功法境界计算,预览遵守精确余额和最新总预算',
(realm, key, slot, experience, insight, totalExperience, totalInsight) => {
const manualId = `character_manual.${key}`;
const learned: CultivatorManualStateV1 = {
version: 1,
revision: 0,
learned: [{ manualId, level: 8, unlockedLevel: 9 }],
build: { slots: [{ slot, manualId }] },
};
const action: ManualAction = {
action: 'train',
manualId,
slot,
expectedRevision: 0,
};
expect(
previewManualAction(learned, '元婴', action, { experience, insight }),
).toMatchObject({
ok: true,
cost: { experience, insight },
state: { learned: [{ level: 9 }] },
});
expect(
previewManualAction(learned, '元婴', action, {
experience: experience - 1,
insight,
}).ok,
).toBe(false);
expect(
previewManualAction(learned, '元婴', action, {
experience,
insight: insight - 1,
}).ok,
).toBe(false);
expect(learned.learned[0].level).toBe(8);
const costs = MANUAL_PACK.progressions.standard.costsByRealm[realm];
expect(costs.reduce((sum, cost) => sum + cost.experience, 0)).toBe(
totalExperience,
);
expect(costs.reduce((sum, cost) => sum + cost.insight, 0)).toBe(
totalInsight,
);
},
);
it('学习仅接受储物袋中版本一致的同名玉简', () => {
expect(previewManualAction(state, '炼气', action, resources, item).ok).toBe(
true,
@@ -41,7 +92,7 @@ it('学习仅接受储物袋中版本一致的同名玉简', () => {
undefined,
{ ...item, revision: 1 },
{ ...item, quantity: 0 },
{ ...item, definitionId: 'jade.character_manual.songhe' },
{ ...item, definitionId: 'jade.character_manual.qingmu' },
{ ...item, location: 'warehouse' as InventoryItem['location'] },
])
expect(previewManualAction(state, '炼气', action, resources, bad).ok).toBe(
@@ -61,36 +112,62 @@ it('严格请求拒绝旧改修协议、非法槽位和客户端自报费用', (
ManualActionSchema.safeParse({ ...action, action: 'forget' }).success,
).toBe(false);
});
it('同名瓶颈玉简校验与学习一致', () => {
const learned: CultivatorManualStateV1 = {
version: 1,
revision: 1,
learned: [{ manualId, level: 3, unlockedLevel: 3 }],
build: { slots: [{ slot: 1, manualId }] },
};
const unlock = { ...action, action: 'unlock' as const, expectedRevision: 1 };
expect(
previewManualAction(learned, '炼气', unlock, resources, {
...item,
definitionId: 'jade.character_manual.songhe',
}).ok,
).toBe(false);
expect(
previewManualAction(learned, '炼气', unlock, resources, item),
).toMatchObject({
ok: true,
state: { learned: [{ level: 3, unlockedLevel: 6 }] },
});
it.each([
[3, 2, 6],
[6, 3, 9],
] as const)(
'%s层突破需%s本,数量不足或版本过期不解锁',
(level, quantity, unlockedLevel) => {
const learned: CultivatorManualStateV1 = {
version: 1,
revision: 1,
learned: [{ manualId, level, unlockedLevel: level }],
build: { slots: [{ slot: 1, manualId }] },
};
const unlock = {
...action,
action: 'unlock' as const,
expectedRevision: 1,
};
expect(manualJadeCost(learned, unlock)).toBe(quantity);
for (const bad of [
{ ...item, quantity: quantity - 1 },
{ ...item, quantity, definitionId: 'jade.character_manual.qingmu' },
{ ...item, quantity, revision: 1 },
{ ...item, quantity, location: 'warehouse' as InventoryItem['location'] },
])
expect(
previewManualAction(learned, '炼气', unlock, resources, bad).ok,
).toBe(false);
for (const count of [quantity, quantity + 1]) {
expect(
previewManualAction(learned, '炼气', unlock, resources, {
...item,
quantity: count,
}),
).toMatchObject({
ok: true,
cost: { experience: 0, insight: 0 },
state: { learned: [{ level, unlockedLevel }] },
});
}
expect(learned.learned[0].unlockedLevel).toBe(level);
},
);
it('学习消耗一本,修炼与切换不额外消耗玉简', () => {
expect(manualJadeCost(state, action)).toBe(1);
expect(manualJadeCost(state, { ...action, action: 'train' })).toBe(0);
expect(manualJadeCost(state, { ...action, action: 'activate' })).toBe(0);
});
it('常见与稀有玉简在既有掉落池采用数据包权重', () => {
it('玉简由奖励包显式等权投放,不依赖稀有度', () => {
const entries = QINGXI_POOL_V2.groups.find(
(g) => g.id === 'manuals',
)!.entries;
expect(entries).toHaveLength(16);
expect(entries).toHaveLength(24);
expect(entries.find((e) => e.rewardId === item.definitionId)!.weight).toBe(
100,
);
expect(
entries.find((e) => e.rewardId === 'jade.character_manual.songhe')!.weight,
).toBe(5);
entries.find((e) => e.rewardId === 'jade.character_manual.qingmu')!.weight,
).toBe(100);
});
+21 -2
View File
@@ -1,4 +1,8 @@
import type { ManualAction } from '../contracts/combatV6Manuals';
import {
CHARACTER_MANUALS_V1,
manualRule,
} from '../engine/combat-v6/manuals/content';
import { changeManual } from '../engine/combat-v6/manuals/state';
import type {
CultivatorManualStateV1,
@@ -8,6 +12,20 @@ import type { InventoryItem } from '../inventory';
import { findItemDefinition } from '../items/registry';
import type { RealmType } from '../types/constants';
/** Learning costs one jade; successive bottlenecks cost two, then three. */
export function manualJadeCost(
state: CultivatorManualStateV1,
action: Pick<ManualAction, 'action' | 'manualId'>,
): number {
if (action.action === 'learn') return 1;
if (action.action !== 'unlock') return 0;
const manual = CHARACTER_MANUALS_V1.find((m) => m.id === action.manualId);
const learned = state.learned.find((m) => m.manualId === action.manualId);
if (!manual || !learned) return 0;
const index = manualRule(manual).bottlenecks.indexOf(learned.level);
return index < 0 ? 0 : index + 2;
}
/** Shared preview and authoritative validation, before any resource or inventory mutation. */
export function previewManualAction(
state: CultivatorManualStateV1,
@@ -16,13 +34,14 @@ export function previewManualAction(
resources: { experience: number; insight: number },
item?: InventoryItem,
): ManualStateChangeResult {
const jadeCost = manualJadeCost(state, action);
if (
'item' in action &&
(!item ||
item.id !== action.item.id ||
item.revision !== action.item.revision ||
item.location !== 'bag' ||
item.quantity < 1 ||
item.quantity < jadeCost ||
findItemDefinition(item.definitionId)?.manualId !== action.manualId)
) {
return {
@@ -31,7 +50,7 @@ export function previewManualAction(
{
severity: 'error',
code: 'INVALID_MANUAL_STATE',
message: '需要储物袋中的同名功法玉简,请刷新核对',
message: `需要储物袋中数量足够的同名功法玉简(本次 ${jadeCost} 本),请刷新核对`,
},
],
};
+2 -2
View File
@@ -11,8 +11,8 @@
- 每组 `chance` 为独立掉落概率,`fixed` 条目维护注册物品 ID、权重和数量上下界。
- `equipment`/`blueprints` 按 `equipmentLevels` 和有序 `slots` 生成装备/图纸条目;装备一次只能产出一件,图纸可配置固定条目的数量。
- `manualJades` 从角色功法目录生成玉简条目,并使用功法包的 `dropWeight`,不另复制稀有度权重。
- 区域引用、等级/部位/条目/组重复、未知奖励及非法数量在加载时拒绝。奖励组身份和池版本保持既有值;版本不等于数据包的 `contentRevision`。
- 功法玉简使用 `fixed` 显式配置:首版 24 本各权重 100、组概率 3%;功法包不存产出权重或稀有度。
- 区域引用、等级/部位/条目/组重复、未知奖励及非法数量在加载时拒绝。奖励组身份保持既有值;调整条目时同步递增池版本;版本不等于数据包的 `contentRevision`。
- 加载和编译不抽随机数,物品生成继续由既有掉落与铸造函数负责。
`dungeon.json` 定义副本探索、战斗、通关三种来源的奖励。
+197 -3
View File
@@ -1,10 +1,10 @@
{
"$schema": "./wild.schema.json",
"formatVersion": 1,
"contentRevision": 11,
"contentRevision": 12,
"nodeId": "SAT_TN_08",
"poolId": "qingxi",
"poolVersion": 12,
"poolVersion": 13,
"equipmentLevels": [
10
],
@@ -629,7 +629,201 @@
"id": "manuals",
"chance": 0.03,
"source": {
"kind": "manualJades"
"kind": "fixed",
"entries": [
{
"rewardId": "jade.character_manual.changchun",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.gengjin",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.qingmu",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.qingquan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.chiyan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.houtu_jue",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.guiyuan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.huanling",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.qingyuan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.dayan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.sanzhuan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.shayao",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.taibai",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.taiyang",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.xuanyin",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.yudan",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.tianluo",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.liuji",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.tuotian",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.mingwang",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.yuanci",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.haoran",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.qianlang",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
},
{
"rewardId": "jade.character_manual.yousha",
"weight": 100,
"quantity": {
"min": 1,
"max": 1
}
}
]
}
}
]
-13
View File
@@ -117,19 +117,6 @@
"slots"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "manualJades"
}
},
"required": [
"kind"
],
"additionalProperties": false
}
]
}
+1 -4
View File
@@ -3,9 +3,7 @@ import { formatContentPackErrors } from '@shared/lib/content-pack-errors';
import { z } from 'zod';
import { DropPoolSchema, type DropPool } from '../drops';
import { DAO_EQUIPMENT_SLOTS, type DaoEquipmentSlot } from '../engine/combat-v6/equipment/types';
import { CHARACTER_MANUALS_V1 } from '../engine/combat-v6/manuals/content';
import { WILD_REGION } from '../engine/combat-v6/wild/content';
import { MANUAL_JADES } from '../items/definitions/manual-jades';
import { findItemDefinition } from '../items/registry';
import raw from './data/wild.json';
@@ -22,7 +20,6 @@ export const WildRewardPackShape = z.strictObject({
source: z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('fixed'), entries: z.array(z.strictObject({ rewardId: z.string().min(1), weight: z.number().positive().max(1000000), quantity })).min(1) }),
z.strictObject({ kind: z.enum(['equipment', 'blueprints']), slots: z.array(z.enum(DAO_EQUIPMENT_SLOTS)).min(1) }),
z.strictObject({ kind: z.literal('manualJades') }),
]),
})).min(1),
});
@@ -33,7 +30,7 @@ function compilePool(pack: z.infer<typeof WildRewardPackShape>): DropPool {
groups: pack.groups.map(group => {
const source = group.source;
const entries = source.kind === 'fixed' ? source.entries
: source.kind === 'manualJades' ? MANUAL_JADES.map(jade => entry(jade.id, CHARACTER_MANUALS_V1.find(m => m.id === jade.manualId)!.dropWeight))
: pack.equipmentLevels.flatMap(level => source.slots.map(slot => entry(`${source.kind === 'blueprints' ? 'blueprint' : 'equipment'}.${slot}.${level}`)));
return { id: group.id, chance: group.chance, entries };
}),