境界功法系统

This commit is contained in:
桃树夭 committed 2026-09-10 22:12:59 +08:00
1 parent a1ab5b24ce
commit 550ff7b935
41 files changed
+10617 -1941

No files matched your search

@@ -1,5 +1,7 @@
# combat-v6 Phase 5A 功法内容与数值基线
> 历史记录:2026-09-10 起被[境界功法层数养成](./combat-v6-manual-system-design.md)替代,本文数值和本篇/真解规则不再用于当前实现。
> 状态:已实现(2026-09-03)
> 上位设计:[`combat-v6 功法系统设计`](./combat-v6-manual-system-design.md)
+48 -631
View File
@@ -1,649 +1,66 @@
# combat-v6 功法系统设计
# combat-v6 境界功法系统
> 状态:Phase 5A 纯逻辑纵切已实现(Canonical)
> 建立日期:2026-09-02
> 上位设计:[`combat-v6 梦幻式战斗体系设计稿与迁移路线图`](./combat-v6-mhxy-redesign-roadmap.md)
> 适用范围:combat-v6 人物跨宗门被动、功法玉简、参悟与战斗投影
> 2026-09-10:经用户确认,替代 Phase 5A/9F 的本篇、真解和覆盖散功方案。无旧数据迁移或兼容。
> 上位设计:[改造路线图](./combat-v6-mhxy-redesign-roadmap.md)。
本文定义 combat-v6 的全新功法系统。它在玩法上采用“角色版魔兽要诀”结构:玩家消耗功法玉简,将跨宗门被动铭刻到有限槽位中;槽满后需要主动改修并替换已有功法。
## 玩法规则
本系统从内容定义、角色状态、学习规则到战斗投影均为 combat-v6 独立领域。首版不使用 AI,不读取或转换其他功法系统的数据与运行时产物。
- 炼气、筑基、金丹、元婴各开放一位,总共四位;更高境界不增加位置。
- 功法有固定境界归属,只能在对应位置激活。效果类型不按境界硬限制,由内容设计体现方向差异。
- 每个位置可以学习多本,每本独立保存层数和已解锁上限,同时只激活一本。
- 学习消耗一本同名玉简,从第一层开始。空位自动激活;已有激活功法时,新学功法保存在收藏中。
- 免费切换,无冷却,不丢失培养进度。沿用现有战斗/结算占用保护,战斗结束后才能操作,下一场生效。
- 默认九层,三层、六层消耗一本同名玉简突破瓶颈,只解锁后续层数,不直接升级。三本玉简加八次修炼完成一本功法。
- 升层消耗当前可用修为与道心感悟;修为继续用于境界提升,不能透支或导致境界倒退。功法满层不是角色突破条件。
- 未达到功法对应境界时,可持有和交易玉简,不能学习或培养。
- 未激活功法允许培养。第一版只有六维固定属性加成,稀有功法可同时提供两项效果,两项从一层同时生效。
Phase 5A 的首批十谱系、数值和精确结算语义见 [`combat-v6 Phase 5A 功法内容与数值基线`](./combat-v6-manual-phase-5a-balance.md)。
## 策划数据包
---
入口:`src/shared/engine/combat-v6/manuals/data/manual-pack.json`。
## 1. 已确认的首版约束
同目录 JSON Schema 提供编辑器提示;`pack.ts` 使用 Zod 严格校验结构和跨字段规则。构建加载即校验,不做在线热更新、不支持任意公式或脚本。
1. 功法只提供跨宗门被动,不提供主动技能。
2. 玩家在炼气境初始拥有 2 个功法槽位。
3. 每提升一个大境界增加 1 个槽位。
4. 槽位最多 6 个,达到化神后不再增加。
5. 境界小阶段不会增加槽位。
6. 首版每个角色只有一套功法构筑,不能保存或切换方案。
7. 首版不提供固印、锁定或锁槽功能。
8. 槽满时由玩家明确选择要被替换的功法,不随机覆盖。
9. 功法玉简参悟必定成功,不设置失败概率。
10. 首版功法的规则、名称、描述、数值和掉落表均无 AI 参与。
11. 功法没有随机属性;同一功法 ID 的玉简效果完全一致。
12. 功法不提供人物等级、技能等级和四项修炼等级。
13. 功法不能按宗门 ID、宗门技能 ID 或经脉节点 ID 编写特判。
14. 首版只实现一套构筑,但领域边界必须允许未来平滑增加多构筑方案。
---
## 2. 定位与系统边界
功法是角色长期学习的跨宗门被动 build。它的主要价值是改变战斗行为,而不是继续叠加一套垂直属性成长。
功法负责:
- 追击、反击、反震、回生等触发型被动。
- 会心、法术会心、封禁反制等战斗倾向。
- 气血、法力和治疗相关的条件式续航。
- 感知、隐匿识破等通用能力。
- 通过有限槽位形成攻、防、续航和反制取舍。
功法不负责:
- 宗门主动技能和默认攻击。
- 宗门心法等级与技能等级。
- 经脉对宗门技能的 patch、grant 或 revoke。
- 攻法、防御、法术、抗法四项修炼。
- 道装白字、附灵、器蕴或器诀。
- 可主动下达的战斗指令。
- 任意脚本或运行时生成效果。
系统职责:
| 系统 | 主要职责 |
| 字段 | 含义 |
| --- | --- |
| 修炼 | 四修炼与生命根基的永久数值成长 |
| 道装 | 可装卸面板、器蕴与器诀 |
| 功法 | 铭刻于角色的跨宗门通用被动 |
| 宗门心法 | 宗门技能等级、解锁与少量宗门面板 |
| 经脉 | 宗门技能和宗门流派改造 |
| 法宝 | 未来独立的特殊战斗器物 |
| version | 数据包格式版本 |
| progressions | 共用培养规则,以 ID 引用 |
| maxLevel / bottlenecks | 满层与瓶颈层数,瓶颈递增且小于满层 |
| costsByRealm | 各功法归属境界的逐层成本数组;第一项为一升二,必须覆盖所有后续层 |
| id | 稳定功法 ID,同时派生玉简和被动 ID |
| realm | 功法位归属 |
| rarity | common/rare,显示用,不强制效果数量 |
| effects | 一至两项不重复的六维属性;valuePerLevel 为每层固定加成 |
| dropWeight | 现有野外玉简组内相对权重,与稀有度独立配置 |
---
当前十六本:每境界三本常见单属性、一本珍稀双属性。炼气/筑基每项每层 +1,金丹/元婴每项每层 +2。四本珍稀全部九层提供六维合计 108 点,是初始调参基线,不代表长期平衡结论。
## 3. 世界观术语
默认修为逐层费用:炼气 20、筑基 100、金丹 500、元婴 2000,分别乘以当前层数;道心感悟二至九层分别消耗 2 至 9。全本共消耗基础修为费用的 36 倍与 44 感悟。成本按功法归属境界计算,不因角色升境变化。
| 机制 | 游戏内名称 | 说明 |
| --- | --- | --- |
| Manual system | 功法 | 跨宗门被动系统 |
| Manual item | 功法玉简 | 未参悟时可持有、掉落和交易的物品 |
| Passive slot | 道印位 | 角色承载功法的有限槽位 |
| Learned manual | 道印 | 已铭刻于角色的功法 |
| Learn | 参悟 | 消耗玉简并写入道印位 |
| Replace | 改修 | 用新功法替换已有功法 |
| Forget | 散功 | 主动移除功法并腾出槽位 |
| Base version | 本篇 | 功法谱系的基础版本 |
| Advanced version | 真解 | 同一谱系的强化版本 |
| Duplicate fragments | 悟法残页 | 重复玉简的回收产物 |
野外既有玉简组总掉落率保持 3%,常见权重 100、珍稀权重 5;现有池暂覆盖全部十六本,每本珍稀的相对掉率为常见的 1/20。玉简沿用统一库存、堆叠和拍卖交易。暂不新增获取渠道。
世界观解释:外来功法需要被炼成道印铭刻于道基。角色境界越高,道基越稳固,能够同时承载的道印越多;达到六印后,后续境界提升不再扩张数量,而是通过宗门、经脉、修炼和其他系统继续成长。
修改内容无需修改 TypeScript;新增效果机制才扩展编译器。Schema 结构源为 ManualPackShape,结构变更后通过 Zod toJSONSchema 更新同目录 schema;重复 ID、重复属性、缺失引用和不完整成本表由运行时细化校验拒绝。
---
## 实现边界
## 4. 槽位规则
- 已学进度保存在功法状态的 learned JSONB;激活位置沿用独立槽位表,不创建多套构筑。
- 纯 changeManual 与 previewManualAction 产生确定性状态和费用计划。服务端负责身份、玉简归属、版本、战斗占用及资源事实。
- 角色锁、数据库事务与版本比较覆盖扣费、玉简消费、功法进度、激活位置和资源事件;任一步失败整体回滚。
- 每本激活功法编译一个被动标识和一至两项六维贡献。贡献在装备/宗门/炼体面板计算前加入临时属性,派生值遵循现有 v6 公式,不写入永久六维。
- 被动身份用于记录来源与层数;属性修正由人物编译器应用一次,不再在战斗开始时重复增加。
- 公开构筑只显示激活功法与层数,不暴露未激活收藏或培养记录。
- 需要应用迁移 `0043_manual-level-progression.sql`;没有旧功法数据兼容。历史 5A/9F 文档保留为阶段记录,不再作为当前规则。
当前大境界顺序为:
## 验证
```text
炼气 → 筑基 → 金丹 → 元婴 → 化神 → 炼虚 → 合体 → 大乘 → 渡劫
```
共享规则测试覆盖境界位、三本玉简九层成长、资源不足、版本冲突、收藏独立进度、免费切换、非法状态、双属性投影、数据包校验与现有掉落权重。服务端和页面采用静态检查及真实本地流程验证,不添加数据库或 API 单元测试。
首版槽位表:
### 本地验收(2026-09-10)
| 大境界 | 可用道印位 |
| --- | ---: |
| 炼气 | 2 |
| 筑基 | 3 |
| 金丹 | 4 |
| 元婴 | 5 |
| 化神 | 6 |
| 炼虚 | 6 |
| 合体 | 6 |
| 大乘 | 6 |
| 渡劫 | 6 |
建议唯一公式:
```ts
function getManualSlotCount(realm: RealmType): number {
return Math.min(6, 2 + REALM_ORDER[realm]);
}
```
规则要求:
- 槽位数量由当前大境界派生,不作为独立权威值持久化。
- 初期、中期、后期、圆满等小阶段不影响槽位。
- 突破到新大境界后,新槽位自动可用,不需要额外购买或激活。
- 达到化神后的所有大境界均固定为 6 槽。
- 未来增加更高大境界时仍受 6 槽上限约束,除非先修改本设计并发布新规则版本。
---
## 5. 首版单构筑与未来多构筑
### 5.1 首版行为
首版每个角色只有一套功法构筑:
- 没有方案列表。
- 没有方案命名。
- 没有方案复制。
- 没有切换按钮或切换接口。
- 战斗投影始终读取唯一构筑。
### 5.2 首版数据边界
建议把“构筑”定义为独立值对象,但首版状态只持有一个:
```ts
interface ManualBuildV1 {
slots: Array<{
slot: number;
manualId: string;
}>;
}
interface CultivatorManualStateV1 {
version: 1;
revision: number;
build: ManualBuildV1;
}
```
`slots` 只保存已占用槽位。合法槽位为 1~6,是否已解锁由角色大境界实时校验。
首版刻意不保存:
```text
activeBuildId
savedBuilds
buildName
locked
switchCooldown
```
### 5.3 保留未来多构筑的技术可行性
所有参悟、改修、散功、校验和投影逻辑应以 `ManualBuildV1` 为输入,不直接把算法写死在角色持久状态上。
未来可以演进为:
```ts
interface CultivatorManualStateV2 {
version: 2;
revision: number;
activeBuildId: string;
builds: Array<{
id: string;
name: string;
build: ManualBuildV1;
}>;
}
```
这样未来增加保存和切换方案时,只需:
1. 把 V1 的唯一 `build` 迁移为 V2 默认方案。
2. 让投影器读取 `activeBuildId` 对应的 `ManualBuildV1`。
3. 复用现有构筑校验和编译逻辑。
首版不提前实现 V2 字段、接口和 UI。这里保留的是聚合边界与迁移路线,不是预埋未使用功能。
未来若开放切换,至少应限制为战斗外操作,并通过 revision 保证原子切换;不能在已创建的战斗快照中途更换。
---
## 6. 功法玉简与参悟
功法玉简是确定性物品:同一个 `manualId` 对应唯一功法定义,不生成随机数值、随机词条或临时脚本。
### 6.1 有空槽位
```text
选择玉简
→ 预览效果和冲突
→ 选择可用空槽
→ 服务端校验
→ 消耗玉简
→ 写入道印
```
### 6.2 槽位已满
```text
选择玉简
→ 选择要被替换的现有道印
→ 展示失去/获得的完整差异
→ 二次确认
→ 在同一事务中消耗玉简并完成改修
```
替换目标由玩家明确选择,不做随机打掉。
### 6.3 参悟约束
- 参悟必定成功。
- 不能学习完全相同的功法 ID。
- 同一谱系不能同时保留本篇和真解。
- 已有真解时不能学习同谱系本篇。
- 学习真解时,若已有同谱系本篇,优先原位升级,不额外占槽。
- 存在显式机制冲突时,必须先选择被替换的冲突功法。
- 未解锁槽位不能写入。
- 战斗、匹配或已锁定战斗快照期间不能参悟或改修。
- 玉简只有在所有校验通过后才被消耗。
---
## 7. 不设计固印
首版没有功法锁定状态:
- 道印位不含 `locked` 字段。
- 没有固印材料、固印数量或解锁成本。
- 改修依赖明确选择和二次确认防止误操作。
- 服务端请求必须携带预期 `revision` 和被替换的 `manualId`,防止并发或过期页面误覆盖。
示例:
```ts
interface ReplaceManualCommandV1 {
expectedRevision: number;
slot: number;
expectedManualId: string;
newManualItemId: string;
}
```
如果槽位内容已经变化,操作整体失败并返回最新状态,不能继续覆盖。
---
## 8. 散功
玩家可以主动移除一个已学功法:
- 散功后槽位变为空位。
- 不返还原功法玉简。
- 可以返还少量悟法残页,具体数量属于经济配置。
- 散功不影响宗门、经脉、修炼和道装状态。
- 战斗、匹配或已锁定快照期间不能散功。
- 散功也使用 revision 进行并发保护。
首版没有“恢复最近遗忘功法”功能。
---
## 9. 本篇与真解
每个功法谱系首版最多两个版本:
```text
追击诀 → 追击真解
回生诀 → 回生真解
反震诀 → 反震真解
```
```ts
type ManualRankV1 = 'base' | 'true';
```
规则:
- 本篇和真解共享 `lineageId`。
- 真解是独立内容定义,不是随机品质或功法等级。
- 真解强化同一核心机制,不应额外拼接多个无关效果。
- 真解替换本篇后仍只占一个道印位。
- 不设计功法经验、熟练度和 1~100 级成长。
- 若以后需要第三层级,必须重新评估内容膨胀和获取经济,首版不预留枚举值。
---
## 10. 内容契约
```ts
interface CharacterManualDefV1 {
id: string;
lineageId: string;
rank: ManualRankV1;
name: string;
description: string;
passiveId: string;
conflictGroups: string[];
capabilityKeys: string[];
replaces?: string[];
}
```
功法定义只引用 v6 内容注册表中的被动 ID。角色持久状态不保存 Hook、`SkillDef` 或面板计算结果。
```ts
interface ManualProjectionV1 {
passiveIds: string[];
panel: CombatV6PanelContribution[];
unitTags: string[];
diagnostics: CombatV6ProjectionDiagnostic[];
}
```
### 10.1 功法允许输出
- v6 被动 `SkillDef` ID。
- 少量经过白名单校验的面板贡献。
- innate 能力。
- 通用单位标签。
### 10.2 功法禁止输出
- 主动技能和器诀。
- 人物等级、宗门技能等级。
- 四项修炼等级。
- 角色持久六维修改。
- 宗门技能 patch。
- 经脉节点选择。
- 任意代码、表达式脚本或未注册效果。
---
## 11. 通用触发条件
功法只能依赖通用战斗语义:
- 使用物理攻击后。
- 使用法术后。
- 造成伤害后。
- 受到伤害时。
- 受到致命伤害时。
- 回合开始或结束。
- 造成或受到治疗时。
- 自身气血、法力比例。
- 物理、法术、治疗、封禁等通用技能标签。
- 目标种类或通用单位标签。
- 每回合首次、每场首次或明确触发上限。
功法不得依赖:
```text
具体宗门 ID
具体宗门技能 ID
具体经脉节点 ID
具体道装实例 ID
具体器蕴或器诀 ID
```
确有技能联动需求时,只能匹配稳定的通用技能标签,保证功法跨宗门成立。
---
## 12. 冲突与跨系统能力键
冲突必须在创建战斗单位前完成解析,不能把两项冲突能力同时交给 core 再依赖运行时顺序决定。
### 12.1 同源覆盖
```text
追击真解 > 追击诀
```
最终投影只输出真解对应被动。
### 12.2 机制互斥
通过 `conflictGroups` 声明不能同时生效的机制,例如多个致命复起、多个同类追击或会形成无限循环的组合。
### 12.3 跨系统能力键
功法、器蕴和经脉可能提供相似能力,必须使用全局能力键:
```ts
interface CombatV6CapabilityContribution {
capabilityKey: string;
sourceType: 'manual' | 'equipment' | 'sect' | 'meridian';
sourceId: string;
stackPolicy: 'unique' | 'highest' | 'stack';
priority: number;
passiveId: string;
}
```
例如:
```text
功法·回生
+ 道装器蕴·不屈
+ 经脉·护命
→ survival.revive-on-fatal
→ 按 stackPolicy 只保留合法结果
```
当前 core 的 `SkillDef.conflicts` 可以保留为最终安全校验,但本篇/真解优先级、功法学习冲突和跨系统合并以投影编译器为权威。
---
## 13. 触发链约束
首版统一遵守:
1. Hook 产生的追击默认不能再次触发追击。
2. 反击不能触发对方再次反击。
3. 反震不能触发吸血、追击或另一次反震。
4. 一次致命事件最多结算一个复起能力。
5. 功法可以声明每回合或每场触发上限。
6. 多个伤害、治疗修改器必须具有稳定优先级。
7. 所有概率使用 Battle RNG,禁止 `Math.random`。
8. 相同种子、构筑、内容版本和指令序列必须产生相同事件流。
v6 当前已有 `suppressHooks` 防止部分二次打击互相触发;新增功法时仍需为每个触发族编写黄金测试,不能只依赖该全局开关推断安全。
---
## 14. 首批内容建议
首版建议先交付 10~12 个谱系,每个谱系拥有本篇与真解。
### 14.1 攻伐类
| 功法 | 作用方向 |
| --- | --- |
| 追击诀 | 物理命中后概率追加攻击 |
| 法连诀 | 法术行动后概率追加法术打击 |
| 断岳诀 | 物理会心倾向 |
| 凝光诀 | 法术会心倾向 |
| 饮血诀 | 物理伤害后恢复气血 |
### 14.2 防御类
| 功法 | 作用方向 |
| --- | --- |
| 回生诀 | 致命伤害时概率复起 |
| 反击诀 | 受到物理伤害时概率反击 |
| 反震诀 | 受到物理伤害时概率反震 |
| 护元诀 | 满足低气血条件时减伤 |
| 定魂诀 | 封禁抵抗或首次封禁保护 |
### 14.3 续航与功能类
| 功法 | 作用方向 |
| --- | --- |
| 冥思诀 | 回合结束恢复法力 |
| 生息诀 | 回合结束恢复气血 |
| 感知诀 | 看破隐匿单位 |
| 破禁诀 | 提高封禁命中 |
| 轻身诀 | 条件式速度调整 |
首批不必一次实现全部建议。应优先选择现有 v6 Hook 和 Effect 原语可以稳定表达的 10~12 个谱系,再根据内容缺口扩展通用原语。
---
## 15. 数值原则
- 本篇提供完整机制,真解只提高同一机制的概率、系数或上限。
- 功法主要价值来自机制,不成为大量固定面板的来源。
- 功法不直接增加六维和四项修炼。
- 固定伤害、治疗或恢复量应通过人物等级或面板缩放。
- 每场一次的强能力可以明显强于每回合可触发能力。
- 追击、法连、回生等高价值机制必须有触发链和次数限制。
- 同一 `manualId` 没有随机数值,不存在同名不同强度的玉简。
具体概率和系数属于内容版本平衡表,不写进 combat-v6 core。
---
## 16. 无 AI 设计
首版功法系统所有战斗和内容事实均由人工配置、代码审查和测试确定。
AI 不参与:
- 功法 ID、名称和描述生成。
- 功法效果、Hook、条件、概率和系数生成。
- 功法玉简掉落池与掉落权重生成。
- 参悟、覆盖、散功和冲突决策。
- 功法强度评估与自动平衡。
- 玩家功法推荐和自动构筑。
允许的内容来源只有:
- 版本控制中的人工内容表。
- 确定性规则配置。
- 经过测试的 v6 被动、Hook、Effect 和条件原语。
任何未来引入 AI 的提案都必须单独修改本文;首版不预留 AI provider、prompt、生成状态或降级链路。
---
## 17. 获得与经济循环
- 未参悟的功法玉简可以作为独立物品持有和交易。
- 参悟后只在角色功法状态保存 `manualId`,玉简被消耗。
- 重复玉简可以分解为悟法残页。
- 悟法残页可用于定向兑换部分本篇;真解的兑换范围和成本单独配置。
- 掉落只随机决定获得哪个已注册玉简,不随机生成战斗效果。
- 所有同名玉简完全同质,市场不需要比较同名玉简数值。
- 参悟、改修、散功和物品消耗必须在同一事务边界完成。
具体掉落、兑换、交易和回收实现属于后续数据与经济阶段,本设计只锁定功法领域事实。
---
## 18. 战斗投影
```text
读取唯一 ManualBuildV1
→ 按境界校验已解锁槽位
→ 解析 manualId 内容定义
→ 处理本篇/真解和 conflictGroups
→ 生成 capability contributions
→ 与道装、宗门、经脉贡献统一合并
→ 输出最终 passiveIds、面板和标签
→ 写入投影诊断
```
投影器不修改角色功法状态,也不创建 `BattleSession`。
阻止开战的错误包括:
- 使用未解锁槽位。
- 重复功法 ID。
- 同一谱系同时存在多个版本。
- 引用了不存在的功法或被动 ID。
- 存在未解决的互斥冲突。
- 功法输出主动技能、四修炼或非法面板属性。
- 内容版本和投影版本缺失。
---
## 19. 第一版实施范围
### 19.1 必须交付
- `getManualSlotCount(realm)` 及完整境界表测试。
- `ManualBuildV1` 和 `CultivatorManualStateV1`。
- 唯一构筑的读取与保存。
- 功法定义和被动内容注册表。
- 玉简参悟、明确覆盖和散功。
- 本篇/真解同源升级。
- 冲突组与跨系统能力键。
- 功法到 v6 passives/innate/panel 的投影器。
- 10~12 个可完整测试的功法谱系。
- revision 并发保护。
- 无 AI 的确定性内容与操作链路。
### 19.2 首版明确不做
- 多构筑保存和切换。
- 固印、锁槽和解锁材料。
- 随机覆盖目标。
- 参悟失败。
- 功法等级、经验和熟练度。
- 多于本篇/真解的层级。
- 主动功法技能。
- 宗门专属功法。
- 随机数值或随机效果玉简。
- AI 命名、描述、效果、掉落和构筑推荐。
### 19.3 后期可演进
- `CultivatorManualStateV1` 迁移为多构筑 V2。
- 保存、命名、复制和切换功法方案。
- 战斗外切换冷却或成本。
- 更丰富的通用 Hook 与功法谱系。
后期功能不得改变“战斗创建后使用不可变功法快照”的原则。
---
## 20. 测试与质量门禁
纯领域测试位于 `src/shared`,至少覆盖:
- 炼气至渡劫的槽位数分别为 2、3、4、5、6、6、6、6、6。
- 小境界变化不改变槽位。
- 未解锁槽位不能参悟。
- 有空位时参悟成功且只消耗一份玉简。
- 槽满时必须明确指定替换对象。
- `expectedRevision` 或 `expectedManualId` 不一致时不消耗玉简。
- 同名功法不能重复学习。
- 真解原位替换本篇且不增加槽位占用。
- 已有真解时拒绝本篇。
- 散功后槽位释放且不返还原玉简。
- 所有冲突在创建 `Unit` 前被解决。
- Hook 二次伤害不会形成无限触发。
- 相同输入、种子和版本产生相同战斗事件。
- 首版状态和内容契约不存在固印与 AI 字段。
架构测试还必须保证功法领域不导入其他功法实现、AI provider、prompt、creation-v2 或 battle-v5。
---
## 21. 尚待经济与后续内容确认
- 功法玉简的掉落来源和权重。
- 本篇、真解分解得到的悟法残页数量。
- 悟法残页定向兑换范围和成本。
- 散功是否返还残页及返还数量。
- 第七、第八、第九类后续功法的内容扩展节奏。
- 未来多构筑切换是否有冷却或成本。
这些内容通过版本化配置管理,不得写入 combat-v6 core。
---
## 22. 决策记录
| 日期 | 决策 | 影响 |
| --- | --- | --- |
| 2026-09-02 | 新功法定位为角色版魔兽要诀式跨宗门被动 | 功法只进入被动和通用能力编译,不承担主动宗门技能 |
| 2026-09-02 | 炼气初始 2 槽,每个大境界增加 1 槽,化神达到 6 槽上限 | 炼虚及以后不再增加功法槽位 |
| 2026-09-02 | 首版只有唯一构筑,不支持保存和切换 | 以独立 `ManualBuildV1` 保留未来迁移到多构筑 V2 的可能 |
| 2026-09-02 | 首版不实现固印或锁槽 | 使用明确替换、二次确认和 revision 防止误覆盖 |
| 2026-09-02 | 首版无 AI 参与 | 内容、掉落与构筑操作全部来自人工版本化配置 |
| 2026-09-03 | Phase 5A 锁定十谱系二十定义与 `character_build_v1` | 功法只强化人物既有行动;连击、法连、库存和 UI 不进入本阶段 |
- 本地 127.0.0.1:5174/3001,复用 local2 测试角色(炼气后期,无旧功法)。通过已有 dev 工具准备三本长春功、一本松鹤养元经、1000 修为及 100 感悟。
- 真实页面完成首本学习、八次修炼、三/六层两次瓶颈解锁,长春功九层圆满。剩余修为 280、感悟 56,与配置总消耗 720/44 一致;三本玉简全部消耗。
- 同位学习松鹤养元经后未覆盖长春功;双效果第一层即提供体魄+1、意志+1。免费来回激活,修为与感悟不变化,刷新后九层/一层分别保留。
- 只读数据库确认永久体魄仍为 50;长春功九层激活时页面气血上限由 1552 增至 1732,切换松鹤后为 1572,法力上限 900 增至 910,符合派生公式。
- 桌面及 360×800 页面和详情抽屉已检查,无横向溢出;当前新测试页面控制台无 error。未开展本轮真实 PvP、概率掉落采样、拍卖成交及并发故障注入;对应确定性边界由共享测试与事务检查覆盖。
- 准备性修为/感悟恢复原值 235/0;保留真实学成的两本功法记录,已发四本玉简全部正常消费,没有删除既有角色或旧测试数据。迁移仅应用本地,不涉及预发布、生产或部署。
- 检查命令:聚焦 Vitest、全量 `bun run test`、`bun run lint`、`bunx tsc -b tsconfig.app.json tsconfig.node.json`、`bun run build`、`git diff --check`。数据迁移由 Drizzle Kit generate 生成并检查,使用显式 local.env 执行 migrate。
+2
View File
@@ -1,5 +1,7 @@
# combat-v6 梦幻式战斗体系设计稿与迁移路线图
> **2026-09-10 功法方案更新:**本篇/真解、六道印与覆盖散功规则已由[境界功法层数养成](./combat-v6-manual-system-design.md)替代。炼气至元婴逐境界开放四位,每位多本独立培养、同时一本生效、免费切换;修为+感悟升层、同名玉简破瓶颈。首批十六本固定六维单/双效果功法采用 JSON 数据包。下文 5A/9F 及旧决策表为历史阶段记录,冲突处以新设计为准,不做旧数据兼容。
> 状态:方向基线(Canonical)
> 建立日期:2026-09-01
> 适用分支:`codex/combat-v6`
+2
View File
@@ -1,5 +1,7 @@
# Phase 9F:功法玉简与人物道印养成闭环
> 历史记录:2026-09-10 起采用[境界功法层数养成](./combat-v6-manual-system-design.md),本文旧操作及验收不代表新方案已验收。
> 2026-09-07:实现完成,核心本地链路已验收。未验证项单独列于文末。
## 确认范围
@@ -0,0 +1 @@
ALTER TABLE "wanjiedaoyou_combat_v6_manual_states" ADD COLUMN "learned" jsonb DEFAULT '[]'::jsonb NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -302,6 +302,13 @@
"when": 1788792210469,
"tag": "0042_v6_sect_depth",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1789048437114,
"tag": "0043_manual-level-progression",
"breakpoints": true
}
]
}
@@ -49,7 +49,7 @@ export function CultivatorLoadoutSections({
<h5 className="font-semibold">已装配功法</h5>
{build.manuals.length ? (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{build.manuals.map(({ slot, manualId }) => {
{build.manuals.map(({ slot, manualId, level }) => {
const definition = itemDefinition('jade.' + manualId);
return (
<div key={slot}>
@@ -63,7 +63,7 @@ export function CultivatorLoadoutSections({
}}
/>
<p className="text-center text-xs">
功法位 <span className="font-mono">{slot}</span>
<span className="font-mono">{level}</span> 层
</p>
</div>
);
@@ -1,49 +1,61 @@
import { GameSceneFrame } from '@app/components/game-shell/GameSceneFrame';
import { InkButton } from '@app/components/ui/InkButton';
import { InkDetailDrawer } from '@app/components/ui/InkDetailDrawer';
import { InkTooltip } from '@app/components/ui/InkTooltip';
import { consumeResourceMutation } from '@app/lib/resources/mutations';
import type {
ManualAction,
ManualView,
} from '@shared/contracts/combatV6Manuals';
import { getManualSlotCount } from '@shared/engine/combat-v6/manuals/compiler';
import { CHARACTER_MANUALS_V1 } from '@shared/engine/combat-v6/manuals/content';
import type { ManualSlotV1 } from '@shared/engine/combat-v6/manuals/types';
import { itemDefinition, type InventoryItem } from '@shared/inventory';
import {
getManualSlotCount,
manualSlot,
} from '@shared/engine/combat-v6/manuals/compiler';
import {
CHARACTER_MANUALS_V1,
manualRule,
} from '@shared/engine/combat-v6/manuals/content';
import { MANUAL_REALMS } from '@shared/engine/combat-v6/manuals/pack';
import type { CharacterManualDefV1 } from '@shared/engine/combat-v6/manuals/types';
import { itemDefinition } from '@shared/inventory';
import { previewManualAction } from '@shared/manuals/action';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { combatV6Request, mutationBody } from '../combat-v6/request';
const endpoint = '/api/combat-v6/manuals';
const definitions = new Map(
CHARACTER_MANUALS_V1.map((manual) => [manual.id, manual]),
);
const slots: ManualSlotV1[] = [1, 2, 3, 4, 5, 6];
const unlockRealms = ['炼气', '炼气', '筑基', '金丹', '元婴', '化神'];
const labels = {
vitality: '体魄',
strength: '力量',
spirit: '精神',
endurance: '耐力',
speed: '速度',
willpower: '意志',
};
function effects(manual: CharacterManualDefV1, level: number) {
return manual.effects
.map((e) => labels[e.attribute] + ' +' + e.valuePerLevel * level)
.join(' · ');
}
export function ManualRoom() {
const [params] = useSearchParams();
const [view, setView] = useState<ManualView>();
const [refresh, setRefresh] = useState(0);
const [selected, setSelected] = useState<ManualSlotV1>();
const [selected, setSelected] = useState<string>();
const [confirmation, setConfirmation] = useState<ManualAction>();
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const busy = useRef(false);
const reader = useRef<AbortController | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
reader.current?.abort();
};
}, []);
useEffect(() => {
const controller = new AbortController();
reader.current = controller;
void combatV6Request<ManualView>(endpoint, { signal: controller.signal })
.then((data) => {
if (!controller.signal.aborted) setView(data);
@@ -53,13 +65,12 @@ export function ManualRoom() {
});
return () => controller.abort();
}, [refresh]);
async function submit(action: ManualAction) {
if (busy.current) return;
busy.current = true;
setPending(true);
setError('');
setNotice('');
reader.current?.abort();
try {
await consumeResourceMutation(
await fetch(endpoint, {
@@ -69,290 +80,325 @@ export function ManualRoom() {
);
if (mounted.current)
setNotice(
action.action === 'forget'
? '道印已散去。'
: '道印已铭刻,下一场战斗生效。',
{
learn: '功法已习得。',
unlock: '瓶颈已突破,可以继续修炼。',
train: '功法精进一层。',
activate: '功法已激活,下一场战斗生效。',
}[action.action],
);
} catch (e) {
if (mounted.current)
setError(
`${e instanceof Error ? e.message : '请求失败'};请核对道印与剩余玉简后操作。`,
);
setError(e instanceof Error ? e.message : '操作失败,请刷新核对');
} finally {
busy.current = false;
if (mounted.current) {
setPending(false);
setSelected(undefined);
setConfirmation(undefined);
setView(undefined);
setRefresh((n) => n + 1);
}
}
}
const jade = view?.items.find((item) => item.id === params.get('itemId'));
const available = view ? getManualSlotCount(view.realm) : 0;
const hintedItem = view?.items.find(
(item) => item.id === params.get('itemId'),
);
const hintedManual =
hintedItem && itemDefinition(hintedItem.definitionId).manualId;
const manual = CHARACTER_MANUALS_V1.find(
(m) => m.id === (selected ?? hintedManual),
);
const learned = view?.state?.learned.find((m) => m.manualId === manual?.id);
const rule = manual && manualRule(manual);
const jade =
manual &&
view?.items.find(
(item) => itemDefinition(item.definitionId).manualId === manual.id,
);
const unlocked = view ? getManualSlotCount(view.realm) : 0;
const active = view?.state?.build.slots.some(
(s) => s.manualId === manual?.id,
);
const canAct =
!!view?.state &&
!view.blockedReason &&
!pending &&
!!manual &&
manualSlot(manual) <= unlocked;
function action(kind: ManualAction['action']): ManualAction | undefined {
if (!view?.state || !manual) return;
const target = {
expectedRevision: view.state.revision,
slot: manualSlot(manual),
manualId: manual.id,
};
if (kind === 'learn' || kind === 'unlock')
return jade
? {
...target,
action: kind,
item: { id: jade.id, revision: jade.revision },
}
: undefined;
return { ...target, action: kind };
}
const nextKind = !learned
? 'learn'
: learned.level === learned.unlockedLevel &&
learned.level !== rule?.maxLevel
? 'unlock'
: 'train';
const next = action(nextKind);
const preview =
view?.state && next
? previewManualAction(
view.state,
view.realm,
next,
view.resources,
jade || undefined,
)
: undefined;
const confirmed =
view?.state && confirmation
? previewManualAction(
view.state,
view.realm,
confirmation,
view.resources,
jade || undefined,
)
: undefined;
const cap = view?.resources.experienceCap ?? 1;
return (
<GameSceneFrame variant="workflow">
<div className="space-y-4 text-sm">
{error ? (
<p role="alert" className="text-crimson">
{error}{' '}
<button
className="underline"
disabled={pending}
onClick={() => {
setError('');
setRefresh((n) => n + 1);
}}
>
重新读取
</button>
<button onClick={() => setRefresh((n) => n + 1)}>刷新重试</button>
</p>
) : null}
{notice ? <p role="status">{notice}</p> : null}
{!view ? (
<p className="text-ink-secondary">正在查看道印……</p>
<p>正在翻阅功法……</p>
) : (
<>
{view.blockedReason ? (
<p role="status">
{view.blockedReason}{' '}
<button
className="underline"
disabled={pending}
onClick={() => {
setView(undefined);
setSelected(undefined);
setRefresh((n) => n + 1);
}}
<p role="status">{view.blockedReason}</p>
) : null}
{MANUAL_REALMS.map((realm, index) => {
const entry = view.state?.build.slots.find(
(s) => s.slot === index + 1,
);
const current = CHARACTER_MANUALS_V1.find(
(m) => m.id === entry?.manualId,
);
const currentLevel = view.state?.learned.find(
(m) => m.manualId === entry?.manualId,
)?.level;
return (
<section
key={realm}
className="border-ink/10 space-y-3 border-b pb-4"
>
刷新状态
</button>
</p>
) : null}
{jade ? (
<p>
已选「{itemDefinition(jade.definitionId).name}」,请选择道印位。
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{slots.map((slot) => {
const learned = view.state?.build.slots.find(
(entry) => entry.slot === slot,
);
const manual = learned
? definitions.get(learned.manualId)
: undefined;
return (
<button
key={slot}
disabled={slot > available || !view.state || pending}
onClick={() => setSelected(slot)}
className="border-ink/15 hover:bg-ink/5 min-w-0 space-y-2 rounded border p-4 text-left disabled:opacity-50"
>
<span className="text-ink-secondary block">
道印位 {slot}
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold">{realm}功法</h3>
<span className="text-ink-secondary text-xs">
{index >= unlocked
? realm + '开放'
: current
? current.name + ' · ' + currentLevel + '层'
: '尚未激活'}
</span>
<span className="block font-medium">
{slot > available
? `${unlockRealms[slot - 1]}解锁`
: (manual?.name ?? '选择玉简参悟')}
</span>
{manual ? (
<span className="text-ink-secondary line-clamp-2 block">
{manual.description}
</span>
) : null}
</button>
);
})}
</div>
<p className="text-ink-secondary">
储物袋中玉简{' '}
{view.items.reduce((n, item) => n + item.quantity, 0)} 本{' '}
<InkTooltip label="道印规则">
参悟和改修只消耗一本玉简,必定成功。真解可直接参悟;同源本篇原位升级。散功不返还,活动战斗期间不可调整。
</InkTooltip>
</p>
{selected && view.state ? (
<ManualDrawer
key={`${selected}:${view.state.revision}`}
view={view}
slot={selected}
initialItemId={jade?.id}
pending={pending}
close={() => {
if (!busy.current) setSelected(undefined);
}}
submit={submit}
/>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
{CHARACTER_MANUALS_V1.filter((m) => m.realm === realm).map(
(m) => {
const progress = view.state?.learned.find(
(p) => p.manualId === m.id,
);
return (
<button
key={m.id}
onClick={() => {
setSelected(m.id);
setConfirmation(undefined);
}}
className="hover:bg-ink/5 rounded px-3 py-2 text-left"
>
<span className="block">
{m.name}
{m.rarity === 'rare' ? ' · 珍稀' : ''}
</span>
<span className="text-ink-secondary block text-xs">
{progress ? progress.level + '层' : '未习得'}
{entry?.manualId === m.id ? ' · 已激活' : ''}
</span>
</button>
);
},
)}
</div>
</section>
);
})}
</>
)}
</div>
<InkDetailDrawer
isOpen={!!manual}
onClose={() => {
setSelected('');
setConfirmation(undefined);
}}
title={manual?.name ?? '功法'}
>
{manual && view && rule ? (
<div className="space-y-4 text-sm">
<p>{manual.description}</p>
<p>
{manual.realm} · {manual.rarity === 'rare' ? '珍稀' : '常见'} ·{' '}
{learned ? learned.level + '/' + rule.maxLevel + '层' : '未习得'}
</p>
<p className="font-mono">{effects(manual, learned?.level ?? 1)}</p>
<p className="text-ink-secondary">
{learned ? '圆满效果:' : '一层即可获得上述效果;圆满效果:'}
<span className="font-mono">
{effects(manual, rule.maxLevel)}
</span>
</p>
<p>
持有同名玉简{' '}
<span className="font-mono">
{view.items
.filter(
(i) =>
itemDefinition(i.definitionId).manualId === manual.id,
)
.reduce((n, i) => n + i.quantity, 0)}
</span>{' '}
本
</p>
{learned && !active ? (
<InkButton
disabled={!canAct}
onClick={() => {
const a = action('activate');
if (a) void submit(a);
}}
>
免费激活
</InkButton>
) : null}
{learned?.level === rule.maxLevel ? (
<p>功法已圆满</p>
) : (
<>
{nextKind === 'unlock' ? (
<p>已遇瓶颈,需一本同名玉简解锁后续层数。</p>
) : null}
<InkButton
disabled={!canAct || !preview?.ok}
onClick={() => setConfirmation(next)}
>
{nextKind === 'learn'
? '学习功法'
: nextKind === 'unlock'
? '突破瓶颈'
: '修炼下一层'}
</InkButton>
{!preview?.ok ? (
<p className="text-ink-secondary">
{preview?.diagnostics.map((d) => d.message).join(';') ??
'需要一本同名玉简'}
</p>
) : null}
</>
)}
{confirmation && confirmed?.ok ? (
<div className="border-ink/10 space-y-3 border-t pt-4">
{confirmation.action === 'train' ? (
<>
<p>
升至{' '}
<span className="font-mono">
{(learned?.level ?? 0) + 1}
</span>{' '}
层:
<span className="font-mono">
{effects(manual, (learned?.level ?? 0) + 1)}
</span>
</p>
<p>
消耗修为{' '}
<span className="font-mono">
{confirmed.cost.experience}
</span>
,道心感悟{' '}
<span className="font-mono">
{confirmed.cost.insight}
</span>
。
</p>
<p>
剩余修为{' '}
<span className="font-mono">
{view.resources.experience - confirmed.cost.experience}
</span>
,境界进度{' '}
<span className="font-mono">
{Math.min(
100,
((view.resources.experience -
confirmed.cost.experience) /
cap) *
100,
).toFixed(1)}
%
</span>
;剩余感悟{' '}
<span className="font-mono">
{view.resources.insight - confirmed.cost.insight}
</span>
。
</p>
</>
) : (
<p>
消耗一本《{manual.name}》玉简
{confirmation.action === 'unlock'
? ',解锁后仍需修炼升层'
: ''}
。
</p>
)}
<div className="flex gap-3">
<InkButton
disabled={pending}
onClick={() => void submit(confirmation)}
>
确认
{confirmation.action === 'train'
? '修炼'
: confirmation.action === 'learn'
? '学习'
: '突破'}
</InkButton>
<InkButton
disabled={pending}
onClick={() => setConfirmation(undefined)}
>
取消
</InkButton>
</div>
</div>
) : null}
</div>
) : null}
</InkDetailDrawer>
</GameSceneFrame>
);
}
function ManualDrawer({
view,
slot,
initialItemId,
pending,
close,
submit,
}: {
view: ManualView;
slot: ManualSlotV1;
initialItemId?: string;
pending: boolean;
close: () => void;
submit: (action: ManualAction) => Promise<void>;
}) {
const [itemId, setItemId] = useState(initialItemId ?? '');
const [forgetting, setForgetting] = useState(false);
const state = view.state!;
const learned = state.build.slots.find((entry) => entry.slot === slot);
const current = learned ? definitions.get(learned.manualId) : undefined;
const item = view.items.find((entry) => entry.id === itemId);
const next = item
? definitions.get(itemDefinition(item.definitionId).manualId!)
: undefined;
const actionFor = (jade: InventoryItem): ManualAction => ({
action: 'learn',
slot,
expectedRevision: state.revision,
expectedManualId: learned?.manualId ?? null,
item: { id: jade.id, revision: jade.revision },
});
const action: ManualAction | undefined =
forgetting && learned
? {
action: 'forget',
slot,
expectedRevision: state.revision,
expectedManualId: learned.manualId,
}
: item
? actionFor(item)
: undefined;
const preview = action
? previewManualAction(state, view.realm, action, item)
: undefined;
const problem =
view.blockedReason ??
(preview && !preview.ok
? preview.diagnostics.map((d) => d.message).join(';')
: null);
const upgrade =
current &&
next &&
current.lineageId === next.lineageId &&
current.rank === 'base' &&
next.rank === 'true';
const label = forgetting
? '散功'
: upgrade
? '升级真解'
: current
? '改修'
: '参悟';
return (
<InkDetailDrawer isOpen title={`道印位 ${slot}`} onClose={close} size="sm">
<div className="space-y-4 text-sm">
{current ? (
<section className="space-y-2">
<p className="font-medium">当前 · {current.name}</p>
<p className="text-ink-secondary">{current.description}</p>
</section>
) : (
<p>此位尚未铭刻道印。</p>
)}
{forgetting ? (
<p>散去「{current?.name}」,不返还玉简或资源,无法撤销。</p>
) : (
<>
<p className="text-ink-secondary">储物袋中的玉简</p>
{!view.items.length ? (
<p>暂无可用玉简,可通过野外战斗获取。储藏室中的玉简需先取出。</p>
) : (
view.items.map((jade) => {
const definition = itemDefinition(jade.definitionId);
const manual = definitions.get(definition.manualId!)!;
const result = previewManualAction(
state,
view.realm,
actionFor(jade),
jade,
);
return (
<div
key={jade.id}
className="border-ink/10 space-y-1 border-b pb-3"
>
<div className="flex items-center gap-2">
<button
disabled={pending || !!view.blockedReason || !result.ok}
aria-pressed={itemId === jade.id}
className="text-left underline underline-offset-4 disabled:opacity-50"
onClick={() => setItemId(jade.id)}
>
{definition.name} ×{jade.quantity}
</button>
<InkTooltip label={`${manual.name}说明`}>
{manual.description}
</InkTooltip>
</div>
{!result.ok ? (
<p className="text-ink-secondary">
{result.diagnostics.map((d) => d.message).join(';')}
</p>
) : null}
</div>
);
})
)}
{next ? (
<section className="space-y-2">
<p className="font-medium">
{upgrade ? '升级为' : '铭刻'} · {next.name}
</p>
<p>{next.description}</p>
<p>
消耗「{itemDefinition(item!.definitionId).name}」×1。
{current ? '当前道印将被替换,不返还。' : '必定成功。'}
</p>
</section>
) : null}
</>
)}
{problem ? (
<p role="alert" className="text-crimson">
{problem}
</p>
) : null}
<div className="flex flex-wrap gap-3">
{action ? (
<InkButton
pending={pending}
disabled={!!problem || !preview?.ok}
onClick={() => void submit(action)}
>
确认{label}
</InkButton>
) : null}
{current ? (
<InkButton
disabled={pending || !!view.blockedReason}
onClick={() => {
setForgetting(!forgetting);
setItemId('');
}}
>
{forgetting ? '返回改修' : '散功'}
</InkButton>
) : null}
</div>
</div>
</InkDetailDrawer>
);
}
+1
View File
@@ -620,6 +620,7 @@ export const combatV6ManualStates = pgTable(
.notNull(),
schemaVersion: integer('schema_version').notNull().default(1),
revision: integer('revision').notNull().default(0),
learned: jsonb('learned').$type<Array<{ manualId: string; level: number; unlockedLevel: number }>>().notNull().default([]),
},
(table) => [
uniqueIndex('combat_v6_manual_profile_unique').on(table.profileId),
@@ -129,9 +129,10 @@ export async function loadActiveCombatV6Build(
const manuals: CultivatorManualStateV1 = {
version: 1,
revision: manualState.revision,
learned: manualState.learned,
build: {
slots: slots.map((slot) => ({
slot: slot.slot as 1 | 2 | 3 | 4 | 5 | 6,
slot: slot.slot as 1 | 2 | 3 | 4,
manualId: slot.manualId,
})),
},
@@ -3,6 +3,7 @@ import {
combatV6BuildProfiles,
combatV6ManualSlots,
combatV6ManualStates,
cultivators,
inventoryItems,
} from '@server/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
@@ -11,13 +12,19 @@ import {
loadActiveCombatV6Build,
} from '@server/lib/repositories/combatV6BuildRepository';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import {
getOrInitCultivationProgress,
stripExpCapForStorage,
syncBottleneckState,
} from '@server/utils/cultivationUtils';
import type {
ManualAction,
ManualView,
} from '@shared/contracts/combatV6Manuals';
import { findItemDefinition } from '@shared/items/registry';
import { previewManualAction } from '@shared/manuals/action';
import type { RealmType } from '@shared/types/constants';
import type { RealmStage, RealmType } from '@shared/types/constants';
import type { CultivationProgress } from '@shared/types/cultivator';
import { and, eq, sql } from 'drizzle-orm';
import {
assertInventoryIdle,
@@ -31,13 +38,25 @@ async function readManualFacts(owner: string, q: DbExecutor) {
const character = await characterIdentityRow(owner, q);
if (!character) throw new InventoryError('角色不可用');
const build = await loadActiveCombatV6Build(owner, q);
return { character, build };
const [row] = await q
.select({
progress: cultivators.cultivation_progress,
stage: cultivators.realm_stage,
})
.from(cultivators)
.where(eq(cultivators.id, owner));
const progress = getOrInitCultivationProgress(
row.progress as CultivationProgress,
character.realm as RealmType,
row.stage as RealmStage,
);
return { character, build, progress };
}
export async function readManuals(owner: string): Promise<ManualView> {
return db.transaction(
async (tx) => {
const { character, build } = await readManualFacts(owner, tx);
const { character, build, progress } = await readManualFacts(owner, tx);
const rows = await tx
.select()
.from(inventoryItems)
@@ -55,11 +74,16 @@ export async function readManuals(owner: string): Promise<ManualView> {
await assertInventoryIdle(owner);
} catch (error) {
if (!(error instanceof InventoryError)) throw error;
blockedReason = '请先结束战斗与结算,再调整道印';
blockedReason = '请先结束战斗与结算,再调整功法';
}
}
return {
realm: character.realm as RealmType,
resources: {
experience: progress.cultivation_exp,
insight: progress.comprehension_insight,
experienceCap: progress.exp_cap,
},
state: build?.manuals ?? null,
items: rows
.filter(
@@ -86,16 +110,16 @@ export async function mutateManuals(owner: string, action: ManualAction) {
db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, owner);
await assertInventoryIdle(owner);
const { character, build } = await readManualFacts(owner, tx);
const { character, build, progress } = await readManualFacts(owner, tx);
if (!build) throw new InventoryError('请先在宗门完成战斗构筑');
const [manualState] = await tx
.select()
.from(combatV6ManualStates)
.where(eq(combatV6ManualStates.profileId, build.profileId));
if (!manualState || manualState.revision !== action.expectedRevision)
throw new InventoryError('道印已变化,请刷新后重试');
throw new InventoryError('功法已变化,请刷新后重试');
const rows =
action.action === 'learn'
'item' in action
? await tx
.select()
.from(inventoryItems)
@@ -112,6 +136,10 @@ export async function mutateManuals(owner: string, action: ManualAction) {
build.manuals,
character.realm as RealmType,
action,
{
experience: progress.cultivation_exp,
insight: progress.comprehension_insight,
},
item,
);
if (!result.ok)
@@ -121,7 +149,10 @@ export async function mutateManuals(owner: string, action: ManualAction) {
const updated = await tx
.update(combatV6ManualStates)
.set({ revision: result.state.revision })
.set({
revision: result.state.revision,
learned: result.state.learned,
})
.where(
and(
eq(combatV6ManualStates.id, manualState.id),
@@ -130,7 +161,7 @@ export async function mutateManuals(owner: string, action: ManualAction) {
)
.returning({ id: combatV6ManualStates.id });
if (!updated.length)
throw new InventoryError('道印已变化,请刷新后重试');
throw new InventoryError('功法已变化,请刷新后重试');
await tx
.delete(combatV6ManualSlots)
.where(
@@ -146,7 +177,7 @@ export async function mutateManuals(owner: string, action: ManualAction) {
await tx
.insert(combatV6ManualSlots)
.values({ stateId: manualState.id, ...nextSlot });
if (action.action === 'learn') {
if ('item' in action) {
await saveInventoryPlan(
owner,
before,
@@ -162,6 +193,18 @@ export async function mutateManuals(owner: string, action: ManualAction) {
tx,
);
}
if (action.action === 'train') {
progress.cultivation_exp -= result.cost.experience;
progress.comprehension_insight -= result.cost.insight;
syncBottleneckState(progress);
await tx
.update(cultivators)
.set({
cultivation_progress: stripExpCapForStorage(progress),
updatedAt: new Date(),
})
.where(eq(cultivators.id, owner));
}
await tx
.update(combatV6BuildProfiles)
.set({ revision: sql`${combatV6BuildProfiles.revision} + 1` })
@@ -171,6 +214,16 @@ export async function mutateManuals(owner: string, action: ManualAction) {
source: 'combat-v6-manuals',
scopeDefaults: { cultivatorId: owner },
changes: [
{
resourceTopic: 'player.progress',
operation: 'invalidate',
eventType: 'combat_v6.manuals.changed',
},
{
resourceTopic: 'player.profile',
operation: 'invalidate',
eventType: 'combat_v6.manuals.changed',
},
{
resourceTopic: 'player.combat-v6-build',
operation: 'invalidate',
+1 -1
View File
@@ -231,7 +231,7 @@ for (const definition of Object.values(COMBAT_V6_SECT_DEFINITIONS_V4)) {
})) as CombatV6TrainingPlayerInput['sect']['meridianLoadouts'],
},
equipment: {},
manuals: { version: 1, revision: 0, build: { slots: [] } },
manuals: { version: 1, revision: 0, learned: [], build: { slots: [] } },
});
const input = compileRankingBattle([player('a'), player('b')], 47);
const battle = createBattle({ ...input, ruleset: daoyouRulesetV6 });
+15 -1
View File
@@ -33,7 +33,7 @@ describe('public V6 build', () => {
createFreshCombatV6MethodLevels('lingxiao'),
),
equipment: {},
manuals: { version: 1, revision: 7, build: { slots: [] } },
manuals: { version: 1, revision: 7, learned: [], build: { slots: [] } },
};
}
it('matches the character panel and omits private build state', () => {
@@ -62,4 +62,18 @@ describe('public V6 build', () => {
expect(result.build.equipment).not.toBe(input.equipment);
expect(result.build.manuals).not.toBe(input.manuals.build.slots);
});
it('active manual levels affect the full panel while learned alternatives stay private', () => {
const input = 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 },
];
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(60);
expect(character.attributes.vitality).toBe(20);
});
});
+1 -1
View File
@@ -39,7 +39,7 @@ export function publicCombatV6Build(
pathName: definition.paths.find((p) => p.id === build.sect.activePathId)!
.name,
equipment: structuredClone(build.equipment),
manuals: structuredClone(build.manuals.build.slots),
manuals: build.manuals.build.slots.map(entry => ({ ...entry, level: build.manuals.learned.find(m => m.manualId === entry.manualId)!.level })),
skills: (projection.unit.skills ?? []).map((id) => {
const skill = skills.find((s) => s.id === id)!;
return {
+11 -28
View File
@@ -5,41 +5,24 @@ import type { RealmType } from '../types/constants';
const target = {
expectedRevision: z.number().int().nonnegative(),
slot: z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
]),
slot: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
manualId: z.string().min(1).max(160),
};
const item = z.strictObject({
id: z.string().min(1).max(160),
revision: z.number().int().nonnegative(),
});
export const ManualActionSchema = z.discriminatedUnion('action', [
z
.object({
action: z.literal('learn'),
...target,
expectedManualId: z.string().min(1).max(160).nullable(),
item: z
.object({
id: z.string().min(1).max(160),
revision: z.number().int().nonnegative(),
})
.strict(),
})
.strict(),
z
.object({
action: z.literal('forget'),
...target,
expectedManualId: z.string().min(1).max(160),
})
.strict(),
z.strictObject({ action: z.literal('learn'), ...target, item }),
z.strictObject({ action: z.literal('unlock'), ...target, item }),
z.strictObject({ action: z.literal('train'), ...target }),
z.strictObject({ action: z.literal('activate'), ...target }),
]);
export type ManualAction = z.infer<typeof ManualActionSchema>;
export interface ManualView {
realm: RealmType;
state: CultivatorManualStateV1 | null;
items: InventoryItem[];
resources: { experience: number; insight: number; experienceCap: number };
blockedReason: string | null;
}
+2 -1
View File
@@ -573,10 +573,11 @@ export const PLAYER_RESOURCE_DATA_SCHEMAS = {
.object({
version: z.literal(1),
revision: z.number().int().nonnegative(),
learned: z.array(z.object({ manualId: z.string(), level: z.number().int().positive(), unlockedLevel: z.number().int().positive() })),
build: z.object({
slots: z.array(
z.object({
slot: combatV6SlotSchema,
slot: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
manualId: z.string(),
}),
),
+1 -1
View File
@@ -60,7 +60,7 @@ Phase 7C Redis权威运行时与回放归档见
- `rules-daoyou/`:提供兼容 v1~v4 与承载 Phase 6D 契约的 v5 规则集。
- `content/`:提供红尘剑宗、幽都、无相禅宗、天衍圣地、九劫天宫 v6 内容定义、版本化多宗门注册表、双流派经脉和宗门编译器。
- `equipment/`:提供独立道装类型、六模板、九灵纹、七器蕴、九器诀、v1/v2 确定性生成和两阶段装配编译校验。
- `manuals/`:提供功法状态、参悟/改修/散功、十个本篇/真解谱系、编译与能力解析。
- `manuals/`:提供四个境界位、独立功法层数、同名玉简瓶颈、免费激活切换、JSON 数据包与属性被动编译;旧本篇/真解规则已替代。
- `projection/`:保留各阶段入口,并提供 `projectCultivatorMultiSectV5ToCombatV6` 五宗门完整人物构筑入口。
- `encounter/`:提供训练 NPC、六类遭遇、内容校验、指令查询编排、确定性 NPC 策略和共享纯逻辑 Host。
- `build-state/`:提供五宗门旧心法显式映射、迁移归一化和一次性初始化读模型。
@@ -64,7 +64,7 @@ function player(id: string): CombatV6TrainingPlayerInput {
})) as CombatV6TrainingPlayerInput['sect']['meridianLoadouts'],
},
equipment: {},
manuals: { version: 1, revision: 0, build: { slots: [] } },
manuals: { version: 1, revision: 0, learned: [], build: { slots: [] } },
};
}
@@ -72,7 +72,7 @@ function player(sectId: CombatV6SectId) {
},
sect,
equipment: {},
manuals: { version: 1 as const, revision: 0, build: { slots: [] } },
manuals: { version: 1 as const, revision: 0, learned: [], build: { slots: [] } },
};
}
+114 -140
View File
@@ -1,166 +1,140 @@
import type { RealmType } from "@shared/types/constants"
import { SkillTag } from "../core/index.ts"
import type { CombatV6ProjectionDiagnostic } from "../projection/types.ts"
import { CHARACTER_MANUALS_V1 } from "./content.ts"
import { REALM_VALUES, type RealmType } from '@shared/types/constants';
import type {
CombatV6ProjectionDiagnostic,
CultivatorBaseCombatInput,
} from '../projection/types.ts';
import { CHARACTER_MANUALS_V1, manualRule } from './content.ts';
import { MANUAL_REALMS } from './pack.ts';
import type {
CharacterManualDefV1,
CharacterManualProjectionV1,
CompileCharacterManualsV1Result,
CultivatorManualStateV1,
ManualSlotV1,
} from "./types.ts"
const SLOT_BY_REALM: Record<RealmType, number> = {
炼气: 2,
筑基: 3,
金丹: 4,
元婴: 5,
化神: 6,
炼虚: 6,
合体: 6,
大乘: 6,
渡劫: 6,
}
} from './types.ts';
export function getManualSlotCount(realm: RealmType): number {
return SLOT_BY_REALM[realm]
return Math.min(4, REALM_VALUES.indexOf(realm) + 1);
}
function diagnostic(
code: CombatV6ProjectionDiagnostic["code"],
message: string,
path?: string,
): CombatV6ProjectionDiagnostic {
return { severity: "error", code, message, path }
export function isManualSlotV1(value: number): value is ManualSlotV1 {
return Number.isInteger(value) && value >= 1 && value <= 4;
}
function definitionDiagnostics(definitions: readonly CharacterManualDefV1[]): CombatV6ProjectionDiagnostic[] {
const diagnostics: CombatV6ProjectionDiagnostic[] = []
const ids = [
...definitions.map((definition) => definition.id),
...definitions.flatMap((definition) => definition.skill ? [definition.skill.id] : []),
]
if (ids.some((id) => !id.trim()) || new Set(ids).size !== ids.length) {
diagnostics.push(diagnostic("MANUAL_CONTENT_INVALID", "功法或被动内容 ID 缺失或重复"))
}
for (const definition of definitions) {
const invalid =
!definition.lineageId.trim() ||
!definition.name.trim() ||
!["base", "true"].includes(definition.rank) ||
(!definition.skill && !(definition.panel?.length)) ||
Boolean(definition.skill) !== Boolean(definition.capability) ||
definition.skill?.tags.includes(SkillTag.Passive) === false ||
(definition.skill?.effects.length ?? 0) > 0 ||
definition.conflictGroups.some((group) => !group.trim()) ||
new Set(definition.conflictGroups).size !== definition.conflictGroups.length ||
(definition.panel ?? []).some((item) =>
item.attr !== "sealResist" || item.mode !== "add" || !Number.isFinite(item.value),
)
if (invalid) diagnostics.push(diagnostic("MANUAL_CONTENT_INVALID", `功法内容无效:${definition.id}`))
}
const byLineage = new Map<string, CharacterManualDefV1[]>()
for (const definition of definitions) {
const list = byLineage.get(definition.lineageId) ?? []
list.push(definition)
byLineage.set(definition.lineageId, list)
}
for (const [lineageId, list] of byLineage) {
if (list.length !== 2 || new Set(list.map((item) => item.rank)).size !== 2) {
diagnostics.push(diagnostic("MANUAL_CONTENT_INVALID", `功法谱系必须各有一本本篇和真解:${lineageId}`))
}
}
return diagnostics
export function manualSlot(definition: CharacterManualDefV1): ManualSlotV1 {
return (MANUAL_REALMS.indexOf(definition.realm) + 1) as ManualSlotV1;
}
export function validateManualStateV1(
state: CultivatorManualStateV1,
realm: RealmType,
definitions: readonly CharacterManualDefV1[] = CHARACTER_MANUALS_V1,
): CombatV6ProjectionDiagnostic[] {
const diagnostics = definitionDiagnostics(definitions)
if (!state || state.version !== 1 || !state.build || !Array.isArray(state.build.slots)) {
return [...diagnostics, diagnostic("INVALID_MANUAL_STATE", "功法状态必须是 version=1 的唯一构筑")]
const diagnostics: CombatV6ProjectionDiagnostic[] = [];
const fail = (message: string) =>
diagnostics.push({
severity: 'error',
code: 'INVALID_MANUAL_STATE',
message,
});
if (
!state ||
state.version !== 1 ||
!Array.isArray(state.learned) ||
!Array.isArray(state.build?.slots)
) {
fail('功法状态缺少已学记录或激活槽位');
return diagnostics;
}
if (!Number.isInteger(state.revision) || state.revision < 0) {
diagnostics.push(diagnostic("INVALID_MANUAL_REVISION", "功法 revision 必须是非负整数", "revision"))
if (!Number.isSafeInteger(state.revision) || state.revision < 0)
fail('功法版本无效');
const byId = new Map(definitions.map((d) => [d.id, d]));
const learned = new Set<string>();
for (const entry of state.learned) {
const def = entry && byId.get(entry.manualId);
if (!def || learned.has(entry.manualId)) {
fail('已学功法未知或重复');
continue;
}
learned.add(entry.manualId);
const rule = manualRule(def);
if (manualSlot(def) > getManualSlotCount(realm))
fail('当前境界不能修炼该功法');
if (
!Number.isInteger(entry.level) ||
entry.level < 1 ||
entry.level > entry.unlockedLevel ||
![...rule.bottlenecks, rule.maxLevel].includes(entry.unlockedLevel)
)
fail('功法层数或瓶颈进度无效');
const previousCap =
[...rule.bottlenecks].reverse().find((n) => n < entry.unlockedLevel) ?? 1;
if (entry.level < previousCap) fail('不能提前解锁功法瓶颈');
}
const definitionsById = new Map(definitions.map((item) => [item.id, item]))
const occupiedSlots = new Set<number>()
const manualIds = new Set<string>()
const lineageIds = new Set<string>()
const conflictGroups = new Map<string, string>()
const unlocked = getManualSlotCount(realm)
for (const [index, entry] of state.build.slots.entries()) {
const path = `build.slots.${index}`
if (!entry || typeof entry !== "object") {
diagnostics.push(diagnostic("INVALID_MANUAL_STATE", "道印位记录必须是对象", path))
continue
}
if (!Number.isInteger(entry.slot) || entry.slot < 1 || entry.slot > 6) {
diagnostics.push(diagnostic("MANUAL_SLOT_INVALID", "道印位必须位于1~6", `${path}.slot`))
continue
}
if (entry.slot > unlocked) diagnostics.push(diagnostic("MANUAL_SLOT_LOCKED", `当前境界尚未解锁第${entry.slot}道印位`, `${path}.slot`))
if (occupiedSlots.has(entry.slot)) diagnostics.push(diagnostic("MANUAL_SLOT_OCCUPIED", `第${entry.slot}道印位重复占用`, `${path}.slot`))
occupiedSlots.add(entry.slot)
if (typeof entry.manualId !== "string" || !entry.manualId.trim()) {
diagnostics.push(diagnostic("UNKNOWN_MANUAL", "功法 ID 不能为空", `${path}.manualId`))
continue
}
if (manualIds.has(entry.manualId)) diagnostics.push(diagnostic("DUPLICATE_MANUAL", `功法重复:${entry.manualId}`, `${path}.manualId`))
manualIds.add(entry.manualId)
const definition = definitionsById.get(entry.manualId)
if (!definition) {
diagnostics.push(diagnostic("UNKNOWN_MANUAL", `未知功法:${entry.manualId}`, `${path}.manualId`))
continue
}
if (lineageIds.has(definition.lineageId)) diagnostics.push(diagnostic("MANUAL_LINEAGE_CONFLICT", `同一功法谱系不能同时存在:${definition.lineageId}`, `${path}.manualId`))
lineageIds.add(definition.lineageId)
for (const group of definition.conflictGroups) {
const previous = conflictGroups.get(group)
if (previous && previous !== definition.id) diagnostics.push(diagnostic("MANUAL_CONFLICT_REQUIRES_FORGET", `冲突组 ${group} 中已有 ${previous}`, `${path}.manualId`))
else conflictGroups.set(group, definition.id)
}
const occupied = new Set<number>();
for (const entry of state.build.slots) {
const def = entry && byId.get(entry.manualId);
if (
!def ||
!learned.has(entry.manualId) ||
!isManualSlotV1(entry.slot) ||
manualSlot(def) !== entry.slot ||
occupied.has(entry.slot)
)
fail('激活功法必须已学且归属对应境界位,每位只能一本');
if (entry) occupied.add(entry.slot);
}
return diagnostics
return diagnostics;
}
export function compileCharacterManualsV1(
input: { state: CultivatorManualStateV1; realm: RealmType },
definitions: readonly CharacterManualDefV1[] = CHARACTER_MANUALS_V1,
): CompileCharacterManualsV1Result {
const diagnostics = validateManualStateV1(input.state, input.realm, definitions)
if (diagnostics.some((item) => item.severity === "error")) return { ok: false, diagnostics }
const byId = new Map(definitions.map((item) => [item.id, item]))
const selected = [...input.state.build.slots]
.sort((left, right) => left.slot - right.slot)
.map((entry) => byId.get(entry.manualId)!)
const skills = selected.flatMap((definition) => definition.skill ? [definition.skill] : [])
const capabilities = selected.flatMap((definition) =>
definition.capability && definition.skill
? [{
...definition.capability,
sourceType: "manual" as const,
sourceId: definition.id,
passiveIds: [definition.skill.id],
}]
: [],
)
return {
ok: true,
projection: {
skills,
passiveSkillIds: skills.map((skill) => skill.id),
skillLevels: Object.fromEntries(skills.map((skill) => [skill.id, 0])),
panel: selected.flatMap((definition) => definition.panel ?? []),
unitTags: [...new Set(selected.flatMap((definition) => definition.unitTags ?? []))],
capabilities,
diagnostics,
},
const diagnostics = validateManualStateV1(
input.state,
input.realm,
definitions,
);
if (diagnostics.length) return { ok: false, diagnostics };
const projection: CharacterManualProjectionV1 = {
attributeBonuses: {},
skills: [],
passiveSkillIds: [],
skillLevels: {},
panel: [],
unitTags: [],
capabilities: [],
diagnostics,
};
for (const entry of [...input.state.build.slots].sort(
(a, b) => a.slot - b.slot,
)) {
const def = definitions.find((d) => d.id === entry.manualId)!;
const level = input.state.learned.find(
(d) => d.manualId === entry.manualId,
)!.level;
for (const effect of def.effects)
projection.attributeBonuses[effect.attribute] =
(projection.attributeBonuses[effect.attribute] ?? 0) +
effect.valuePerLevel * level;
projection.skills.push(def.skill);
projection.passiveSkillIds.push(def.skill.id);
projection.skillLevels[def.skill.id] = level;
projection.capabilities.push({
capabilityKey: def.id,
sourceType: 'manual',
sourceId: def.id,
stackPolicy: 'stack',
priority: 0,
passiveIds: [def.skill.id],
});
}
return { ok: true, projection };
}
export function isManualSlotV1(value: number): value is ManualSlotV1 {
return Number.isInteger(value) && value >= 1 && value <= 6
/** Apply before panel derivation; never mutate permanent attributes or apply the passive twice. */
export function withManualAttributes(
cultivator: CultivatorBaseCombatInput,
projection: CharacterManualProjectionV1,
): CultivatorBaseCombatInput {
const attributes = { ...cultivator.attributes };
for (const [key, value] of Object.entries(projection.attributeBonuses))
attributes[key as keyof typeof attributes] += value;
return { ...cultivator, attributes };
}
+17 -226
View File
@@ -1,229 +1,20 @@
import {
DamageKind,
DamageOrigin,
EffectType,
HookAim,
HookName,
SkillTag,
TargetSide,
type SkillDef,
type SkillHook,
} from "../core/index.ts"
import type { CharacterManualDefV1, ManualRankV1 } from "./types.ts"
import { SkillTag, TargetSide } from '../core/index.ts';
import data from './data/manual-pack.json';
import { loadManualPack } from './pack.ts';
import type { CharacterManualDefV1 } from './types.ts';
export const CHARACTER_MANUAL_LINEAGE_ID = {
Duanyue: "character_manual.lineage.duanyue",
Ningguang: "character_manual.lineage.ningguang",
Pojun: "character_manual.lineage.pojun",
Fenling: "character_manual.lineage.fenling",
Yinxue: "character_manual.lineage.yinxue",
Huyuan: "character_manual.lineage.huyuan",
Huisheng: "character_manual.lineage.huisheng",
Dinghun: "character_manual.lineage.dinghun",
Mingsi: "character_manual.lineage.mingsi",
Shengxi: "character_manual.lineage.shengxi",
} as const
const MANUAL_KEYS = Object.keys(CHARACTER_MANUAL_LINEAGE_ID) as Array<keyof typeof CHARACTER_MANUAL_LINEAGE_ID>
export const CHARACTER_MANUAL_ID = Object.fromEntries(
MANUAL_KEYS.flatMap((key) => {
const slug = CHARACTER_MANUAL_LINEAGE_ID[key].split(".").slice(-1)[0]
return [[`${key}Base`, `character_manual.${slug}.base`], [`${key}True`, `character_manual.${slug}.true`]]
}),
) as Record<`${keyof typeof CHARACTER_MANUAL_LINEAGE_ID}${"Base" | "True"}`, string>
export const CHARACTER_MANUAL_PASSIVE_ID = Object.fromEntries(
Object.entries(CHARACTER_MANUAL_ID).map(([key, id]) => [key, id.replace("character_manual.", "character_manual.passive.")]),
) as typeof CHARACTER_MANUAL_ID
const direct = [DamageOrigin.ActionDirect]
function passive(id: string, name: string, hooks: SkillHook[]): SkillDef {
return {
id,
name,
tags: [SkillTag.Passive],
targeting: { side: TargetSide.Self },
effects: [],
hooks,
}
}
function definition(
key: keyof typeof CHARACTER_MANUAL_LINEAGE_ID,
rank: ManualRankV1,
label: string,
description: string,
options: Pick<CharacterManualDefV1, "skill" | "panel" | "capability">,
): CharacterManualDefV1 {
const suffix = rank === "base" ? "Base" : "True"
const idKey = `${key}${suffix}` as keyof typeof CHARACTER_MANUAL_ID
return {
id: CHARACTER_MANUAL_ID[idKey],
lineageId: CHARACTER_MANUAL_LINEAGE_ID[key],
rank,
name: rank === "base" ? `《${label}诀》` : `《${label}真解》`,
description,
conflictGroups: [],
...options,
}
}
function critManual(
key: "Duanyue" | "Ningguang",
label: string,
kind: typeof DamageKind.Physical | typeof DamageKind.Spell,
rank: ManualRankV1,
chance: number,
): CharacterManualDefV1 {
const idKey = `${key}${rank === "base" ? "Base" : "True"}` as keyof typeof CHARACTER_MANUAL_PASSIVE_ID
return definition(key, rank, label, `主动直接${kind === DamageKind.Physical ? "物理" : "法术"}伤害暴击率提高${chance * 100}个百分点。`, {
skill: passive(CHARACTER_MANUAL_PASSIVE_ID[idKey], `${label}·${rank}`, [{
on: HookName.OnCritRoll,
sourceIsSelf: true,
requireKind: kind,
when: { damageOrigins: direct },
effects: [{ type: EffectType.ModifyChance, add: chance }],
}]),
capability: {
capabilityKey: `offense.crit.${kind}.direct`,
stackPolicy: "stack",
priority: rank === "true" ? 20 : 10,
strength: chance,
export const MANUAL_PACK = loadManualPack(data);
export const CHARACTER_MANUALS_V1: readonly CharacterManualDefV1[] =
MANUAL_PACK.manuals.map((manual) => ({
...manual,
skill: {
id: `${manual.id}.passive`,
name: manual.name,
tags: [SkillTag.Passive],
targeting: { side: TargetSide.Self },
effects: [],
},
})
}));
export function manualRule(manual: CharacterManualDefV1) {
return MANUAL_PACK.progressions[manual.progressionId];
}
function damageManual(
key: "Pojun" | "Fenling",
label: string,
kind: typeof DamageKind.Physical | typeof DamageKind.Spell,
rank: ManualRankV1,
factor: number,
): CharacterManualDefV1 {
const idKey = `${key}${rank === "base" ? "Base" : "True"}` as keyof typeof CHARACTER_MANUAL_PASSIVE_ID
return definition(key, rank, label, `主动直接${kind === DamageKind.Physical ? "物理" : "法术"}伤害提高${Math.round((factor - 1) * 100)}%。`, {
skill: passive(CHARACTER_MANUAL_PASSIVE_ID[idKey], `${label}·${rank}`, [{
on: HookName.OnHitCalc,
sourceIsSelf: true,
requireKind: kind,
when: { damageOrigins: direct },
effects: [{ type: EffectType.ModifyStrike, factor }],
}]),
capability: {
capabilityKey: `offense.damage.${kind}.direct`,
stackPolicy: "stack",
priority: rank === "true" ? 20 : 10,
strength: factor,
},
})
}
function yinxue(rank: ManualRankV1, ratio: number, cap: number): CharacterManualDefV1 {
const idKey = `Yinxue${rank === "base" ? "Base" : "True"}` as keyof typeof CHARACTER_MANUAL_PASSIVE_ID
return definition("Yinxue", rank, "饮血", `主动直接物理伤害后恢复实际伤害的${ratio * 100}%,单次行动不超过最大气血的${cap * 100}%。`, {
skill: passive(CHARACTER_MANUAL_PASSIVE_ID[idKey], `饮血·${rank}`, [{
on: HookName.AfterHit,
sourceIsSelf: true,
requireKind: DamageKind.Physical,
aim: HookAim.Self,
when: { damageOrigins: direct },
effects: [{ type: EffectType.RestoreHp, power: `floor(hpDamage * ${ratio})`, maxGainPerAction: `floor(maxHp * ${cap})` }],
}]),
capability: {
capabilityKey: "sustain.lifesteal.physical.direct",
stackPolicy: "highest",
priority: rank === "true" ? 20 : 10,
strength: ratio,
},
})
}
function huyuan(rank: ManualRankV1, reduction: number): CharacterManualDefV1 {
const idKey = `Huyuan${rank === "base" ? "Base" : "True"}` as keyof typeof CHARACTER_MANUAL_PASSIVE_ID
return definition("Huyuan", rank, "护元", `气血低于50%时,受到的物理和法术打击伤害降低${reduction * 100}%。`, {
skill: passive(CHARACTER_MANUAL_PASSIVE_ID[idKey], `护元·${rank}`, [{
on: HookName.OnHitCalc,
targetIsSelf: true,
when: { sourceHpRatioBelow: 0.5, damageOrigins: direct },
effects: [{ type: EffectType.ModifyStrike, factor: 1 - reduction }],
}]),
capability: {
capabilityKey: "survival.low-hp-damage-reduction",
stackPolicy: "highest",
priority: rank === "true" ? 20 : 10,
strength: reduction,
},
})
}
function huisheng(rank: ManualRankV1, chance: number, ratio: number): CharacterManualDefV1 {
const idKey = `Huisheng${rank === "base" ? "Base" : "True"}` as keyof typeof CHARACTER_MANUAL_PASSIVE_ID
return definition("Huisheng", rank, "回生", `每场首次致命事件有${chance * 100}%概率复起,并恢复${ratio * 100}%最大气血。`, {
skill: passive(CHARACTER_MANUAL_PASSIVE_ID[idKey], `回生·${rank}`, [{
on: HookName.OnFatal,
targetIsSelf: true,
aim: HookAim.Self,
chance,
when: { oncePerBattle: true },
limitConsumption: "onAttempt",
effects: [{ type: EffectType.RestoreHp, power: `floor(maxHp * ${ratio})`, revive: true, clearStatuses: true }],
}]),
capability: {
capabilityKey: "survival.revive-on-fatal",
stackPolicy: "unique",
priority: rank === "true" ? 20 : 10,
strength: ratio,
},
})
}
function roundRestore(
key: "Mingsi" | "Shengxi",
label: string,
rank: ManualRankV1,
ratio: number,
): CharacterManualDefV1 {
const idKey = `${key}${rank === "base" ? "Base" : "True"}` as keyof typeof CHARACTER_MANUAL_PASSIVE_ID
const mp = key === "Mingsi"
return definition(key, rank, label, `回合结束时恢复${mp ? "最大法力" : "最大气血"}的${ratio * 100}%。`, {
skill: passive(CHARACTER_MANUAL_PASSIVE_ID[idKey], `${label}·${rank}`, [{
on: HookName.OnRoundEnd,
aim: HookAim.Self,
when: { sourceStanding: true },
effects: [mp
? { type: EffectType.RestoreMp, power: `max(1, floor(maxMp * ${ratio}))` }
: { type: EffectType.RestoreHp, power: `max(1, floor(maxHp * ${ratio}))` }],
}]),
capability: {
capabilityKey: mp ? "sustain.round-end.mp" : "sustain.round-end.hp",
stackPolicy: "highest",
priority: rank === "true" ? 20 : 10,
strength: ratio,
},
})
}
export const CHARACTER_MANUALS_V1: readonly CharacterManualDefV1[] = [
critManual("Duanyue", "断岳", DamageKind.Physical, "base", 0.05),
critManual("Duanyue", "断岳", DamageKind.Physical, "true", 0.1),
critManual("Ningguang", "凝光", DamageKind.Spell, "base", 0.05),
critManual("Ningguang", "凝光", DamageKind.Spell, "true", 0.1),
damageManual("Pojun", "破军", DamageKind.Physical, "base", 1.05),
damageManual("Pojun", "破军", DamageKind.Physical, "true", 1.1),
damageManual("Fenling", "焚灵", DamageKind.Spell, "base", 1.05),
damageManual("Fenling", "焚灵", DamageKind.Spell, "true", 1.1),
yinxue("base", 0.1, 0.15),
yinxue("true", 0.15, 0.25),
huyuan("base", 0.08),
huyuan("true", 0.15),
huisheng("base", 0.2, 0.2),
huisheng("true", 0.3, 0.3),
definition("Dinghun", "base", "定魂", "封禁抵抗提高10点。", { panel: [{ attr: "sealResist", mode: "add", value: 10 }] }),
definition("Dinghun", "true", "定魂", "封禁抵抗提高20点。", { panel: [{ attr: "sealResist", mode: "add", value: 20 }] }),
roundRestore("Mingsi", "冥思", "base", 0.02),
roundRestore("Mingsi", "冥思", "true", 0.04),
roundRestore("Shengxi", "生息", "base", 0.01),
roundRestore("Shengxi", "生息", "true", 0.02),
]
@@ -0,0 +1,409 @@
{
"$schema": "./manual-pack.schema.json",
"version": 1,
"progressions": {
"standard": {
"maxLevel": 9,
"bottlenecks": [
3,
6
],
"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
}
],
"筑基": [
{
"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": 700,
"insight": 8
},
{
"experience": 800,
"insight": 9
}
],
"金丹": [
{
"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": 4000,
"insight": 9
}
],
"元婴": [
{
"experience": 2000,
"insight": 2
},
{
"experience": 4000,
"insight": 3
},
{
"experience": 6000,
"insight": 4
},
{
"experience": 8000,
"insight": 5
},
{
"experience": 10000,
"insight": 6
},
{
"experience": 12000,
"insight": 7
},
{
"experience": 14000,
"insight": 8
},
{
"experience": 16000,
"insight": 9
}
]
}
}
},
"manuals": [
{
"id": "character_manual.changchun",
"name": "长春功",
"realm": "炼气",
"rarity": "common",
"description": "温养肉身,培固根本。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "vitality",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.qingfeng",
"name": "轻风诀",
"realm": "炼气",
"rarity": "common",
"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
}
]
},
{
"id": "character_manual.jinque",
"name": "金阙固元经",
"realm": "筑基",
"rarity": "rare",
"description": "厚积根基,坚韧如山。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "endurance",
"valuePerLevel": 1
},
{
"attribute": "vitality",
"valuePerLevel": 1
}
]
},
{
"id": "character_manual.jianyu",
"name": "剑元诀",
"realm": "金丹",
"rarity": "common",
"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
}
]
},
{
"id": "character_manual.zixiao",
"name": "紫霄养神经",
"realm": "金丹",
"rarity": "rare",
"description": "澄明灵台,凝聚神元。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 2
},
{
"attribute": "willpower",
"valuePerLevel": 2
}
]
},
{
"id": "character_manual.taiqing",
"name": "太清诀",
"realm": "元婴",
"rarity": "common",
"description": "澄明灵台,凝聚神元。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "spirit",
"valuePerLevel": 2
}
]
},
{
"id": "character_manual.changsheng",
"name": "长生功",
"realm": "元婴",
"rarity": "common",
"description": "温养肉身,培固根本。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "vitality",
"valuePerLevel": 2
}
]
},
{
"id": "character_manual.zhenyue",
"name": "镇岳功",
"realm": "元婴",
"rarity": "common",
"description": "厚积根基,坚韧如山。",
"progressionId": "standard",
"dropWeight": 100,
"effects": [
{
"attribute": "endurance",
"valuePerLevel": 2
}
]
},
{
"id": "character_manual.shenxiao",
"name": "神霄御风经",
"realm": "元婴",
"rarity": "rare",
"description": "凝炼劲力,发力沉雄。",
"progressionId": "standard",
"dropWeight": 5,
"effects": [
{
"attribute": "strength",
"valuePerLevel": 2
},
{
"attribute": "speed",
"valuePerLevel": 2
}
]
}
]
}
@@ -0,0 +1,179 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"version": {
"type": "number",
"const": 1
},
"progressions": {
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1
},
"additionalProperties": {
"type": "object",
"properties": {
"maxLevel": {
"type": "integer",
"minimum": 2,
"maximum": 99
},
"bottlenecks": {
"type": "array",
"items": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"costsByRealm": {
"type": "object",
"propertyNames": {
"type": "string",
"enum": [
"炼气",
"筑基",
"金丹",
"元婴"
]
},
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"experience": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 1000000000
},
"insight": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": [
"experience",
"insight"
],
"additionalProperties": false
}
},
"required": [
"炼气",
"筑基",
"金丹",
"元婴"
]
}
},
"required": [
"maxLevel",
"bottlenecks",
"costsByRealm"
],
"additionalProperties": false
}
},
"manuals": {
"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
}
}
},
"required": [
"version",
"progressions",
"manuals"
],
"additionalProperties": false
}
+5 -30
View File
@@ -1,30 +1,5 @@
export {
CHARACTER_MANUAL_ID,
CHARACTER_MANUAL_LINEAGE_ID,
CHARACTER_MANUAL_PASSIVE_ID,
CHARACTER_MANUALS_V1,
} from "./content.ts"
export {
compileCharacterManualsV1,
getManualSlotCount,
isManualSlotV1,
validateManualStateV1,
} from "./compiler.ts"
export { resolveCombatCapabilitiesV1 } from "./capabilities.ts"
export { forgetManualV1, learnManualV1, replaceManualV1 } from "./state.ts"
export type {
CharacterManualDefV1,
CharacterManualProjectionV1,
CombatV6CapabilityContribution,
CombatV6CapabilityStackPolicy,
CompileCharacterManualsV1Result,
CultivatorManualStateV1,
ForgetManualV1Input,
LearnManualV1Input,
ManualBuildV1,
ManualRankV1,
ManualSlotV1,
ManualStateChangeResult,
ReplaceManualV1Input,
ResolveCombatCapabilitiesV1Result,
} from "./types.ts"
export { CHARACTER_MANUALS_V1 } from './content.ts';
export { compileCharacterManualsV1, getManualSlotCount, isManualSlotV1, validateManualStateV1, withManualAttributes } from './compiler.ts';
export { resolveCombatCapabilitiesV1 } from './capabilities.ts';
export { changeManual } from './state.ts';
export type * from './types.ts';
@@ -1,205 +1,216 @@
import { describe, expect, it } from "vitest"
import type { RealmType } from "@shared/types/constants"
import { describe, expect, it } from 'vitest';
import { compileCharacterPanelV1 } from '../projection/character-panel-v1';
import { resolveCombatCapabilitiesV1 } from './capabilities';
import {
CHARACTER_MANUAL_ID,
CHARACTER_MANUALS_V1,
compileCharacterManualsV1,
forgetManualV1,
getManualSlotCount,
learnManualV1,
replaceManualV1,
resolveCombatCapabilitiesV1,
validateManualStateV1,
type CharacterManualDefV1,
type CombatV6CapabilityContribution,
type CultivatorManualStateV1,
} from "./index.ts"
withManualAttributes,
} from './compiler';
import { MANUAL_PACK } from './content';
import { loadManualPack } from './pack';
import { changeManual } from './state';
import type { CultivatorManualStateV1 } from './types';
function empty(revision = 0): CultivatorManualStateV1 {
return { version: 1, revision, build: { slots: [] } }
const id = 'character_manual.changchun';
const empty = (): CultivatorManualStateV1 => ({
version: 1,
revision: 0,
learned: [],
build: { slots: [] },
});
function act(
state: CultivatorManualStateV1,
action: 'learn' | 'train' | 'unlock' | 'activate',
manualId = id,
) {
return changeManual({
state,
action,
manualId,
slot: 1,
realm: '炼气',
expectedRevision: state.revision,
resources: { experience: 100000, insight: 100 },
});
}
describe("combat-v6 Phase 5A 功法状态", () => {
it("按大境界派生2~6个道印位", () => {
const realms: RealmType[] = ["炼气", "筑基", "金丹", "元婴", "化神", "炼虚", "合体", "大乘", "渡劫"]
expect(realms.map(getManualSlotCount)).toEqual([2, 3, 4, 5, 6, 6, 6, 6, 6])
})
it("显式参悟、真解原位升级和散功均不可变且revision逐次增加", () => {
const original = empty(3)
const learned = learnManualV1({
state: original,
realm: "炼气",
slot: 2,
manualId: CHARACTER_MANUAL_ID.DuanyueBase,
expectedRevision: 3,
})
expect(learned.ok).toBe(true)
expect(original).toEqual(empty(3))
if (!learned.ok) return
expect(learned.state).toMatchObject({ revision: 4, build: { slots: [{ slot: 2, manualId: CHARACTER_MANUAL_ID.DuanyueBase }] } })
const upgraded = replaceManualV1({
state: learned.state,
realm: "炼气",
slot: 2,
expectedManualId: CHARACTER_MANUAL_ID.DuanyueBase,
manualId: CHARACTER_MANUAL_ID.DuanyueTrue,
expectedRevision: 4,
})
expect(upgraded.ok).toBe(true)
if (!upgraded.ok) return
expect(upgraded.state.revision).toBe(5)
expect(upgraded.state.build.slots[0].manualId).toBe(CHARACTER_MANUAL_ID.DuanyueTrue)
const forgotten = forgetManualV1({
state: upgraded.state,
realm: "炼气",
slot: 2,
expectedManualId: CHARACTER_MANUAL_ID.DuanyueTrue,
expectedRevision: 5,
})
expect(forgotten).toMatchObject({ ok: true, state: { revision: 6, build: { slots: [] } } })
})
it("拒绝锁定槽、占用槽、过期revision、旧内容不匹配和真解降级", () => {
const state: CultivatorManualStateV1 = {
version: 1,
revision: 2,
build: { slots: [{ slot: 1, manualId: CHARACTER_MANUAL_ID.DuanyueTrue }] },
function success(result: ReturnType<typeof act>) {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.diagnostics[0].message);
return result.state;
}
describe('境界功法培养', () => {
it('四个境界逐一开放,后续境界保持四位', () => {
expect(
['炼气', '筑基', '金丹', '元婴', '化神', '渡劫'].map((r) =>
getManualSlotCount(r as '炼气'),
),
).toEqual([1, 2, 3, 4, 4, 4]);
expect(
changeManual({
state: empty(),
action: 'learn',
manualId: 'character_manual.houtu',
slot: 2,
realm: '炼气',
expectedRevision: 0,
resources: { experience: 0, insight: 0 },
}).ok,
).toBe(false);
expect(act(empty(), 'learn', 'character_manual.houtu').ok).toBe(false);
});
it('三本玉简与八次修炼完成九层,瓶颈只解锁不跳层', () => {
const original = empty();
let state = success(act(original, 'learn'));
expect(original).toEqual(empty());
let jades = 1;
for (let target = 2; target <= 9; target++) {
const p = state.learned[0];
if (p.level === p.unlockedLevel) {
expect(act(state, 'train').ok).toBe(false);
const before = p.level;
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(learnManualV1({ state, realm: "炼气", slot: 3, manualId: CHARACTER_MANUAL_ID.NingguangBase, expectedRevision: 2 })).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_SLOT_LOCKED" }] })
expect(learnManualV1({ state, realm: "炼气", slot: 1, manualId: CHARACTER_MANUAL_ID.NingguangBase, expectedRevision: 2 })).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_SLOT_OCCUPIED" }] })
expect(learnManualV1({ state, realm: "炼气", slot: 2, manualId: CHARACTER_MANUAL_ID.NingguangBase, expectedRevision: 1 })).toMatchObject({ ok: false, diagnostics: [{ code: "INVALID_MANUAL_REVISION" }] })
expect(forgetManualV1({ state, realm: "炼气", slot: 1, expectedManualId: "stale", expectedRevision: 2 })).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_EXPECTED_MISMATCH" }] })
expect(replaceManualV1({ state, realm: "炼气", slot: 1, expectedManualId: CHARACTER_MANUAL_ID.DuanyueTrue, manualId: CHARACTER_MANUAL_ID.DuanyueBase, expectedRevision: 2 })).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_RANK_DOWNGRADE" }] })
expect(state).toEqual({ version: 1, revision: 2, build: { slots: [{ slot: 1, manualId: CHARACTER_MANUAL_ID.DuanyueTrue }] } })
})
it("满槽时允许显式改修一个无冲突功法", () => {
const state: CultivatorManualStateV1 = {
version: 1,
revision: 4,
build: { slots: [
{ slot: 1, manualId: CHARACTER_MANUAL_ID.DuanyueBase },
{ slot: 2, manualId: CHARACTER_MANUAL_ID.PojunBase },
] },
}
const result = replaceManualV1({
expect(jades).toBe(3);
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'));
expect(state.build.slots[0].manualId).toBe(id);
const switched = act(state, 'activate', 'character_manual.qingfeng');
expect(switched.ok && switched.cost).toEqual({ experience: 0, insight: 0 });
state = success(switched);
state = success(act(state, 'activate'));
expect(state.learned.map((p) => p.level)).toEqual([2, 2]);
expect(state.build.slots).toEqual([{ slot: 1, manualId: id }]);
expect(act(state, 'learn').ok).toBe(false);
});
it('资源不足或过期操作不修改状态,精确余额可以消费', () => {
const state = success(act(empty(), 'learn'));
const base = {
state,
realm: "炼气",
slot: 2,
expectedManualId: CHARACTER_MANUAL_ID.PojunBase,
manualId: CHARACTER_MANUAL_ID.NingguangBase,
expectedRevision: 4,
})
expect(result).toMatchObject({
ok: true,
state: { revision: 5, build: { slots: [{ slot: 1 }, { slot: 2, manualId: CHARACTER_MANUAL_ID.NingguangBase }] } },
})
expect(state.build.slots[1].manualId).toBe(CHARACTER_MANUAL_ID.PojunBase)
})
it("同谱系不可共存,冲突组要求先散功", () => {
const conflicted = CHARACTER_MANUALS_V1.map((definition) =>
[CHARACTER_MANUAL_ID.DuanyueBase, CHARACTER_MANUAL_ID.NingguangBase].includes(definition.id)
? { ...definition, conflictGroups: ["stance.focus"] }
: definition,
)
const state: CultivatorManualStateV1 = {
version: 1,
revision: 1,
build: { slots: [
{ slot: 1, manualId: CHARACTER_MANUAL_ID.DuanyueBase },
{ slot: 2, manualId: CHARACTER_MANUAL_ID.PojunBase },
] },
}
expect(learnManualV1({ state, realm: "筑基", slot: 3, manualId: CHARACTER_MANUAL_ID.DuanyueTrue, expectedRevision: 1 })).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_LINEAGE_CONFLICT" }] })
expect(replaceManualV1({ state, realm: "筑基", slot: 2, expectedManualId: CHARACTER_MANUAL_ID.PojunBase, manualId: CHARACTER_MANUAL_ID.NingguangBase, expectedRevision: 1 }, conflicted)).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_CONFLICT_REQUIRES_FORGET" }] })
expect(learnManualV1({ state, realm: "筑基", slot: 3, manualId: CHARACTER_MANUAL_ID.NingguangBase, expectedRevision: 1 }, conflicted)).toMatchObject({ ok: false, diagnostics: [{ code: "MANUAL_CONFLICT_REQUIRES_FORGET" }] })
})
it("校验20个确定性定义并把被动技能等级固定为0", () => {
expect(CHARACTER_MANUALS_V1).toHaveLength(20)
expect(new Set(CHARACTER_MANUALS_V1.map((definition) => definition.lineageId)).size).toBe(10)
const state: CultivatorManualStateV1 = {
version: 1,
revision: 0,
build: {
slots: [
{ slot: 1, manualId: CHARACTER_MANUAL_ID.DuanyueBase },
{ slot: 2, manualId: CHARACTER_MANUAL_ID.DinghunTrue },
],
action: 'train' as const,
manualId: id,
slot: 1 as const,
realm: '炼气' as const,
expectedRevision: state.revision,
};
expect(
changeManual({ ...base, resources: { experience: 19, insight: 100 } }).ok,
).toBe(false);
expect(
changeManual({ ...base, resources: { experience: 20, insight: 1 } }).ok,
).toBe(false);
expect(
changeManual({
...base,
expectedRevision: 0,
resources: { experience: 20, insight: 2 },
}).ok,
).toBe(false);
expect(
changeManual({ ...base, resources: { experience: 20, insight: 2 } }),
).toMatchObject({ ok: true, cost: { experience: 20, insight: 2 } });
expect(state.learned[0].level).toBe(1);
});
it('拒绝伪造层数、提前解锁、重复槽位和未学激活', () => {
const state = success(act(empty(), 'learn'));
for (const invalid of [
{ ...state, learned: [{ manualId: id, level: 10, unlockedLevel: 9 }] },
{ ...state, learned: [{ manualId: id, level: 1, unlockedLevel: 9 }] },
{
...state,
build: { slots: [...state.build.slots, ...state.build.slots] },
},
}
const result = compileCharacterManualsV1({ state, realm: "炼气" })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(Object.values(result.projection.skillLevels)).toEqual([0])
expect(result.projection.panel).toEqual([{ attr: "sealResist", mode: "add", value: 20 }])
expect(validateManualStateV1(state, "炼气")).toEqual([])
})
it("锁定十个谱系的本篇与真解强度", () => {
const expected = [
["Duanyue", 0.05, 0.1], ["Ningguang", 0.05, 0.1],
["Pojun", 1.05, 1.1], ["Fenling", 1.05, 1.1],
["Yinxue", 0.1, 0.15], ["Huyuan", 0.08, 0.15],
["Huisheng", 0.2, 0.3], ["Mingsi", 0.02, 0.04],
["Shengxi", 0.01, 0.02],
] as const
for (const [key, baseStrength, trueStrength] of expected) {
const base = CHARACTER_MANUALS_V1.find((item) => item.id === CHARACTER_MANUAL_ID[`${key}Base`])
const advanced = CHARACTER_MANUALS_V1.find((item) => item.id === CHARACTER_MANUAL_ID[`${key}True`])
expect([base?.capability?.strength, advanced?.capability?.strength]).toEqual([baseStrength, trueStrength])
}
const dinghun = CHARACTER_MANUALS_V1
.filter((item) => item.lineageId.endsWith("dinghun"))
.map((item) => item.panel?.[0]?.value)
expect(dinghun).toEqual([10, 20])
})
})
describe("combat-v6 Phase 5A 能力解析", () => {
const contribution = (
sourceId: string,
stackPolicy: CombatV6CapabilityContribution["stackPolicy"],
options: Partial<CombatV6CapabilityContribution> = {},
): CombatV6CapabilityContribution => ({
capabilityKey: "test.capability",
sourceType: "manual",
sourceId,
stackPolicy,
priority: 1,
strength: 1,
passiveIds: [`passive.${sourceId}`],
...options,
})
it("支持stack、unique与highest的稳定选择", () => {
expect(resolveCombatCapabilitiesV1([contribution("b", "stack"), contribution("a", "stack")])).toMatchObject({
{ ...state, learned: [] },
])
expect(validateManualStateV1(invalid, '炼气').length).toBeGreaterThan(0);
});
it('双效果从一层生效,未激活功法不贡献,属性只在投影中累加一次', () => {
let state = success(act(empty(), 'learn', 'character_manual.songhe'));
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,
});
expect(result.projection.passiveSkillIds).toHaveLength(1);
const character = {
id: 'c',
name: 'c',
realm: '炼气' as const,
realm_stage: '初期' as const,
attributes: {
vitality: 10,
strength: 10,
spirit: 10,
endurance: 10,
speed: 10,
willpower: 10,
},
};
const derived = withManualAttributes(character, result.projection);
expect(character.attributes.vitality).toBe(10);
expect(compileCharacterPanelV1(derived.attributes).maxHp).toBe(650);
expect(compileCharacterPanelV1(derived.attributes).maxMp).toBe(350);
});
});
describe('功法数据包', () => {
it('十六本内容与逐层成本可以加载', () => {
expect(loadManualPack(MANUAL_PACK).manuals).toHaveLength(16);
});
it.each(['duplicate', 'reference', 'attribute', 'cost', 'bottleneck'])(
'无效配置 %s 在加载时失败',
(kind) => {
const pack = structuredClone(MANUAL_PACK);
if (kind === 'duplicate') pack.manuals.push(pack.manuals[0]);
if (kind === 'reference') pack.manuals[0].progressionId = 'missing';
if (kind === 'attribute')
pack.manuals[0].effects.push(pack.manuals[0].effects[0]);
if (kind === 'cost') pack.progressions.standard.costsByRealm.炼气.pop();
if (kind === 'bottleneck')
pack.progressions.standard.bottlenecks = [6, 3];
expect(() => loadManualPack(pack)).toThrow();
},
);
});
describe('通用能力解析仍独立工作', () => {
it('stack 累加,highest 取强度更高项,混合策略拒绝', () => {
const a = {
capabilityKey: 'x',
sourceType: 'manual' as const,
sourceId: 'a',
stackPolicy: 'stack' as const,
priority: 0,
passiveIds: ['a'],
};
const b = { ...a, sourceId: 'b', passiveIds: ['b'] };
expect(resolveCombatCapabilitiesV1([a, b])).toMatchObject({
ok: true,
passiveIds: ["passive.a", "passive.b"],
})
expect(resolveCombatCapabilitiesV1([
contribution("low", "unique"),
contribution("high", "unique", { priority: 2 }),
])).toMatchObject({ ok: true, passiveIds: ["passive.high"] })
expect(resolveCombatCapabilitiesV1([
contribution("priority", "highest", { priority: 9, strength: 1 }),
contribution("strength", "highest", { priority: 1, strength: 2 }),
])).toMatchObject({ ok: true, passiveIds: ["passive.strength"] })
})
it("策略冲突和无法决胜的并列阻止解析", () => {
expect(resolveCombatCapabilitiesV1([contribution("a", "stack"), contribution("b", "unique")])).toMatchObject({ ok: false, diagnostics: [{ code: "CAPABILITY_POLICY_CONFLICT" }] })
expect(resolveCombatCapabilitiesV1([contribution("a", "unique"), contribution("b", "unique")])).toMatchObject({ ok: false, diagnostics: [{ code: "CAPABILITY_RESOLUTION_CONFLICT" }] })
})
it("自定义内容错误会阻止编译", () => {
const invalid: CharacterManualDefV1[] = [{ ...CHARACTER_MANUALS_V1[0], id: "" }]
const result = compileCharacterManualsV1({ state: empty(), realm: "炼气" }, invalid)
expect(result.ok).toBe(false)
expect(result.ok ? [] : result.diagnostics.map((item) => item.code)).toContain("MANUAL_CONTENT_INVALID")
})
})
passiveIds: ['a', 'b'],
});
expect(
resolveCombatCapabilitiesV1([
{ ...a, stackPolicy: 'highest', strength: 1 },
{ ...b, stackPolicy: 'highest', strength: 2 },
]),
).toMatchObject({ ok: true, passiveIds: ['b'] });
expect(
resolveCombatCapabilitiesV1([a, { ...b, stackPolicy: 'unique' }]).ok,
).toBe(false);
});
});
@@ -0,0 +1,84 @@
import { z } from 'zod';
export const MANUAL_REALMS = ['炼气', '筑基', '金丹', '元婴'] as const;
export const MANUAL_ATTRIBUTES = [
'vitality',
'strength',
'spirit',
'endurance',
'speed',
'willpower',
] as const;
const positive = z.number().int().positive().max(1_000_000_000);
const cost = z.strictObject({
experience: positive,
insight: z.number().int().min(1).max(100),
});
export const ManualPackShape = z.strictObject({
$schema: z.string().optional(),
version: z.literal(1),
progressions: z.record(
z.string().min(1),
z.strictObject({
maxLevel: z.number().int().min(2).max(99),
bottlenecks: z.array(z.number().int().positive()),
costsByRealm: z.record(z.enum(MANUAL_REALMS), z.array(cost)),
}),
),
manuals: z
.array(
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),
valuePerLevel: z.number().int().min(1).max(100),
}),
)
.min(1)
.max(2),
}),
)
.min(1),
});
export const ManualPackSchema = ManualPackShape.superRefine((pack, ctx) => {
const issue = (path: (string | number)[], message: string) =>
ctx.addIssue({ code: 'custom', path, message });
const ids = new Set<string>();
pack.manuals.forEach((manual, i) => {
if (ids.has(manual.id)) issue(['manuals', i, 'id'], '功法 ID 重复');
ids.add(manual.id);
if (!pack.progressions[manual.progressionId])
issue(['manuals', i, 'progressionId'], '培养规则不存在');
if (
new Set(manual.effects.map((e) => e.attribute)).size !==
manual.effects.length
)
issue(['manuals', i, 'effects'], '属性不能重复');
});
for (const [id, rule] of Object.entries(pack.progressions)) {
if (
rule.bottlenecks.some(
(n, i) => n >= rule.maxLevel || (i > 0 && n <= rule.bottlenecks[i - 1]),
)
)
issue(['progressions', id, 'bottlenecks'], '瓶颈必须递增且低于满层');
for (const realm of MANUAL_REALMS) {
if (rule.costsByRealm[realm].length !== rule.maxLevel - 1)
issue(
['progressions', id, 'costsByRealm', realm],
'必须逐层配置二层至满层的费用',
);
}
}
});
export function loadManualPack(data: unknown) {
return ManualPackSchema.parse(data);
}
+83 -138
View File
@@ -1,144 +1,89 @@
import type { CombatV6ProjectionDiagnostic } from "../projection/types.ts"
import { CHARACTER_MANUALS_V1 } from "./content.ts"
import { getManualSlotCount, isManualSlotV1, validateManualStateV1 } from "./compiler.ts"
import type { RealmType } from '@shared/types/constants';
import {
getManualSlotCount,
manualSlot,
validateManualStateV1,
} from './compiler';
import { CHARACTER_MANUALS_V1, manualRule } from './content';
import type {
CharacterManualDefV1,
CultivatorManualStateV1,
ForgetManualV1Input,
LearnManualV1Input,
ManualSlotV1,
ManualStateChangeResult,
ReplaceManualV1Input,
} from "./types.ts"
} from './types';
function error(
code: CombatV6ProjectionDiagnostic["code"],
message: string,
path?: string,
): ManualStateChangeResult {
return { ok: false, diagnostics: [{ severity: "error", code, message, path }] }
}
function cloneState(state: CultivatorManualStateV1): CultivatorManualStateV1 {
return {
version: 1,
revision: state.revision,
build: { slots: state.build.slots.map((entry) => ({ ...entry })) },
export function changeManual(input: {
state: CultivatorManualStateV1;
realm: RealmType;
expectedRevision: number;
slot: ManualSlotV1;
manualId: string;
action: 'learn' | 'activate' | 'train' | 'unlock';
resources: { experience: number; insight: number };
}): ManualStateChangeResult {
const { state, realm, action } = input;
const fail = (message: string): ManualStateChangeResult => ({
ok: false,
diagnostics: [{ severity: 'error', code: 'INVALID_MANUAL_STATE', message }],
});
const diagnostics = validateManualStateV1(state, realm);
if (diagnostics.length) return { ok: false, diagnostics };
if (state.revision !== input.expectedRevision)
return fail('功法已变化,请刷新后重试');
const def = CHARACTER_MANUALS_V1.find((d) => d.id === input.manualId);
if (
!def ||
manualSlot(def) !== input.slot ||
input.slot > getManualSlotCount(realm)
)
return fail('功法不属于已开放的境界位');
const next = structuredClone(state);
const learned = next.learned.find((d) => d.manualId === def.id);
const rule = manualRule(def);
const cost = { experience: 0, insight: 0 };
if (action === 'learn') {
if (learned) return fail('已学会此功法,请修炼或突破瓶颈');
next.learned.push({
manualId: def.id,
level: 1,
unlockedLevel: rule.bottlenecks[0] ?? rule.maxLevel,
});
if (!next.build.slots.some((s) => s.slot === input.slot))
next.build.slots.push({ slot: input.slot, manualId: def.id });
} else {
if (!learned) return fail('尚未学习此功法');
if (action === 'activate') {
if (
next.build.slots.some(
(s) => s.slot === input.slot && s.manualId === def.id,
)
)
return fail('此功法已激活');
next.build.slots = next.build.slots.filter((s) => s.slot !== input.slot);
next.build.slots.push({ slot: input.slot, manualId: def.id });
} else if (action === 'unlock') {
if (
learned.level !== learned.unlockedLevel ||
learned.level === rule.maxLevel
)
return fail('当前未遇到功法瓶颈');
learned.unlockedLevel =
rule.bottlenecks.find((n) => n > learned.level) ?? rule.maxLevel;
} else {
if (learned.level === rule.maxLevel) return fail('功法已圆满');
if (learned.level === learned.unlockedLevel)
return fail('需要同名玉简突破瓶颈');
Object.assign(cost, rule.costsByRealm[def.realm][learned.level - 1]);
if (
!Number.isFinite(input.resources.experience) ||
!Number.isFinite(input.resources.insight) ||
input.resources.experience < cost.experience ||
input.resources.insight < cost.insight
)
return fail('修为或道心感悟不足');
learned.level += 1;
}
}
}
function preflight(
input: { state: CultivatorManualStateV1; realm: LearnManualV1Input["realm"]; expectedRevision: number },
definitions: readonly CharacterManualDefV1[],
): ManualStateChangeResult | undefined {
const diagnostics = validateManualStateV1(input.state, input.realm, definitions)
if (diagnostics.some((item) => item.severity === "error")) return { ok: false, diagnostics }
if (!Number.isInteger(input.expectedRevision) || input.expectedRevision !== input.state.revision) {
return error("INVALID_MANUAL_REVISION", "功法状态已变化,请刷新后重试", "expectedRevision")
}
return undefined
}
function definitionOf(
id: string,
definitions: readonly CharacterManualDefV1[],
): CharacterManualDefV1 | undefined {
return definitions.find((item) => item.id === id)
}
function conflictWithBuild(
definition: CharacterManualDefV1,
state: CultivatorManualStateV1,
definitions: readonly CharacterManualDefV1[],
): CharacterManualDefV1 | undefined {
const groups = new Set(definition.conflictGroups)
return state.build.slots
.map((entry) => definitionOf(entry.manualId, definitions))
.find((current): current is CharacterManualDefV1 =>
Boolean(current && current.conflictGroups.some((group) => groups.has(group))),
)
}
function lineageInBuild(
definition: CharacterManualDefV1,
state: CultivatorManualStateV1,
definitions: readonly CharacterManualDefV1[],
): CharacterManualDefV1 | undefined {
return state.build.slots
.map((entry) => definitionOf(entry.manualId, definitions))
.find((current) => current?.lineageId === definition.lineageId)
}
function success(state: CultivatorManualStateV1): ManualStateChangeResult {
state.revision += 1
state.build.slots.sort((left, right) => left.slot - right.slot)
return { ok: true, state, diagnostics: [] }
}
export function learnManualV1(
input: LearnManualV1Input,
definitions: readonly CharacterManualDefV1[] = CHARACTER_MANUALS_V1,
): ManualStateChangeResult {
const failed = preflight(input, definitions)
if (failed) return failed
if (!isManualSlotV1(input.slot)) return error("MANUAL_SLOT_INVALID", "道印位必须位于1~6", "slot")
if (input.slot > getManualSlotCount(input.realm)) return error("MANUAL_SLOT_LOCKED", `当前境界尚未解锁第${input.slot}道印位`, "slot")
if (input.state.build.slots.some((entry) => entry.slot === input.slot)) return error("MANUAL_SLOT_OCCUPIED", `第${input.slot}道印位已有功法`, "slot")
const definition = definitionOf(input.manualId, definitions)
if (!definition) return error("UNKNOWN_MANUAL", `未知功法:${input.manualId}`, "manualId")
const lineage = lineageInBuild(definition, input.state, definitions)
if (lineage?.rank === "true" && definition.rank === "base") return error("MANUAL_RANK_DOWNGRADE", "已有同源真解,不能参悟本篇", "manualId")
if (lineage) return error("MANUAL_LINEAGE_CONFLICT", "同一谱系只能保留一本功法", "manualId")
if (input.state.build.slots.some((entry) => entry.manualId === definition.id)) return error("DUPLICATE_MANUAL", "不能重复参悟同一本功法", "manualId")
const conflict = conflictWithBuild(definition, input.state, definitions)
if (conflict) return error("MANUAL_CONFLICT_REQUIRES_FORGET", `必须先散去冲突功法 ${conflict.name}`, "manualId")
const next = cloneState(input.state)
next.build.slots.push({ slot: input.slot, manualId: definition.id })
return success(next)
}
export function replaceManualV1(
input: ReplaceManualV1Input,
definitions: readonly CharacterManualDefV1[] = CHARACTER_MANUALS_V1,
): ManualStateChangeResult {
const failed = preflight(input, definitions)
if (failed) return failed
if (!isManualSlotV1(input.slot)) return error("MANUAL_SLOT_INVALID", "道印位必须位于1~6", "slot")
if (input.slot > getManualSlotCount(input.realm)) return error("MANUAL_SLOT_LOCKED", `当前境界尚未解锁第${input.slot}道印位`, "slot")
const current = input.state.build.slots.find((entry) => entry.slot === input.slot)
if (!current) return error("MANUAL_SLOT_EMPTY", `第${input.slot}道印位为空`, "slot")
if (current.manualId !== input.expectedManualId) return error("MANUAL_EXPECTED_MISMATCH", "目标道印内容已变化", "expectedManualId")
const currentDefinition = definitionOf(current.manualId, definitions)!
const nextDefinition = definitionOf(input.manualId, definitions)
if (!nextDefinition) return error("UNKNOWN_MANUAL", `未知功法:${input.manualId}`, "manualId")
if (currentDefinition.id === nextDefinition.id) return error("DUPLICATE_MANUAL", "新旧功法相同", "manualId")
const withoutCurrent = cloneState(input.state)
withoutCurrent.build.slots = withoutCurrent.build.slots.filter((entry) => entry.slot !== input.slot)
const lineage = lineageInBuild(nextDefinition, withoutCurrent, definitions)
if (lineage?.rank === "true" && nextDefinition.rank === "base") return error("MANUAL_RANK_DOWNGRADE", "已有同源真解,不能改修本篇", "manualId")
if (lineage) return error("MANUAL_LINEAGE_CONFLICT", "同一谱系只能保留一本功法", "manualId")
const sameLineageUpgrade = currentDefinition.lineageId === nextDefinition.lineageId && currentDefinition.rank === "base" && nextDefinition.rank === "true"
if (currentDefinition.lineageId === nextDefinition.lineageId && !sameLineageUpgrade) {
return error("MANUAL_RANK_DOWNGRADE", "同源功法只允许本篇原位升级真解", "manualId")
}
const conflict = conflictWithBuild(nextDefinition, withoutCurrent, definitions)
if (conflict) return error("MANUAL_CONFLICT_REQUIRES_FORGET", `必须先散去冲突功法 ${conflict.name}`, "manualId")
withoutCurrent.build.slots.push({ slot: input.slot, manualId: nextDefinition.id })
return success(withoutCurrent)
}
export function forgetManualV1(
input: ForgetManualV1Input,
definitions: readonly CharacterManualDefV1[] = CHARACTER_MANUALS_V1,
): ManualStateChangeResult {
const failed = preflight(input, definitions)
if (failed) return failed
if (!isManualSlotV1(input.slot)) return error("MANUAL_SLOT_INVALID", "道印位必须位于1~6", "slot")
if (input.slot > getManualSlotCount(input.realm)) return error("MANUAL_SLOT_LOCKED", `当前境界尚未解锁第${input.slot}道印位`, "slot")
const current = input.state.build.slots.find((entry) => entry.slot === input.slot)
if (!current) return error("MANUAL_SLOT_EMPTY", `第${input.slot}道印位为空`, "slot")
if (current.manualId !== input.expectedManualId) return error("MANUAL_EXPECTED_MISMATCH", "目标道印内容已变化", "expectedManualId")
const next = cloneState(input.state)
next.build.slots = next.build.slots.filter((entry) => entry.slot !== input.slot)
return success(next)
next.revision += 1;
next.build.slots.sort((a, b) => a.slot - b.slot);
return { ok: true, state: next, cost, diagnostics: [] };
}
+47 -64
View File
@@ -1,95 +1,78 @@
import type { RealmType } from "@shared/types/constants"
import type { SkillDef } from "../core/index.ts"
import type { SkillDef } from '../core/index.ts';
import type {
CombatV6PanelContribution,
CombatV6ProjectionDiagnostic,
} from "../projection/types.ts"
} from '../projection/types.ts';
export type ManualRankV1 = "base" | "true"
export type ManualSlotV1 = 1 | 2 | 3 | 4 | 5 | 6
export type ManualSlotV1 = 1 | 2 | 3 | 4;
export interface ManualBuildV1 {
slots: Array<{ slot: ManualSlotV1; manualId: string }>
slots: Array<{ slot: ManualSlotV1; manualId: string }>;
}
export interface CultivatorManualStateV1 {
version: 1
revision: number
build: ManualBuildV1
version: 1;
revision: number;
learned: Array<{ manualId: string; level: number; unlockedLevel: number }>;
build: ManualBuildV1;
}
export type CombatV6CapabilityStackPolicy = "stack" | "unique" | "highest"
export type CombatV6CapabilityStackPolicy = 'stack' | 'unique' | 'highest';
export interface CombatV6CapabilityContribution {
capabilityKey: string
sourceType: "manual" | "equipment" | "sect" | "meridian"
sourceId: string
stackPolicy: CombatV6CapabilityStackPolicy
priority: number
strength?: number
passiveIds: string[]
capabilityKey: string;
sourceType: 'manual' | 'equipment' | 'sect' | 'meridian';
sourceId: string;
stackPolicy: CombatV6CapabilityStackPolicy;
priority: number;
strength?: number;
passiveIds: string[];
}
export interface CharacterManualDefV1 {
id: string
lineageId: string
rank: ManualRankV1
name: string
description: string
conflictGroups: string[]
skill?: SkillDef
panel?: CombatV6PanelContribution[]
unitTags?: string[]
capability?: Omit<CombatV6CapabilityContribution, "sourceType" | "sourceId" | "passiveIds">
id: string;
realm: '炼气' | '筑基' | '金丹' | '元婴';
rarity: 'common' | 'rare';
name: string;
description: string;
progressionId: string;
dropWeight: number;
effects: Array<{
attribute: keyof import('@shared/types/cultivator').Attributes;
valuePerLevel: number;
}>;
skill: SkillDef;
}
export interface CharacterManualProjectionV1 {
skills: SkillDef[]
passiveSkillIds: string[]
skillLevels: Record<string, number>
panel: CombatV6PanelContribution[]
unitTags: string[]
capabilities: CombatV6CapabilityContribution[]
diagnostics: CombatV6ProjectionDiagnostic[]
attributeBonuses: Partial<import('@shared/types/cultivator').Attributes>;
skills: SkillDef[];
passiveSkillIds: string[];
skillLevels: Record<string, number>;
panel: CombatV6PanelContribution[];
unitTags: string[];
capabilities: CombatV6CapabilityContribution[];
diagnostics: CombatV6ProjectionDiagnostic[];
}
export type CompileCharacterManualsV1Result =
| { ok: true; projection: CharacterManualProjectionV1 }
| { ok: false; diagnostics: CombatV6ProjectionDiagnostic[] }
| { ok: false; diagnostics: CombatV6ProjectionDiagnostic[] };
export type ResolveCombatCapabilitiesV1Result =
| {
ok: true
contributions: CombatV6CapabilityContribution[]
passiveIds: string[]
diagnostics: CombatV6ProjectionDiagnostic[]
ok: true;
contributions: CombatV6CapabilityContribution[];
passiveIds: string[];
diagnostics: CombatV6ProjectionDiagnostic[];
}
| { ok: false; diagnostics: CombatV6ProjectionDiagnostic[] }
export interface LearnManualV1Input {
state: CultivatorManualStateV1
realm: RealmType
slot: ManualSlotV1
manualId: string
expectedRevision: number
}
export interface ReplaceManualV1Input extends LearnManualV1Input {
expectedManualId: string
}
export interface ForgetManualV1Input {
state: CultivatorManualStateV1
realm: RealmType
slot: ManualSlotV1
expectedManualId: string
expectedRevision: number
}
| { ok: false; diagnostics: CombatV6ProjectionDiagnostic[] };
export type ManualStateChangeResult =
| {
ok: true
state: CultivatorManualStateV1
diagnostics: CombatV6ProjectionDiagnostic[]
ok: true;
state: CultivatorManualStateV1;
cost: { experience: number; insight: number };
diagnostics: CombatV6ProjectionDiagnostic[];
}
| { ok: false; diagnostics: CombatV6ProjectionDiagnostic[] }
| { ok: false; diagnostics: CombatV6ProjectionDiagnostic[] };
@@ -1,4 +1,4 @@
import { CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { withManualAttributes, CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { COMBAT_V6_PHASE_6B_VERSIONS } from "../version.ts"
import { projectCultivatorWithEquipmentSpecialInternal } from "./project-cultivator-with-equipment-special.ts"
import type { CombatV6ProjectionDiagnostic, CombatV6ProjectionResult, ProjectCultivatorMultiSectToCombatV6Input } from "./types.ts"
@@ -12,10 +12,10 @@ function manualContentConflicts(existingSkills: string[], existingStatuses: stri
export function projectCultivatorMultiSectV3ToCombatV6(input: ProjectCultivatorMultiSectToCombatV6Input): CombatV6ProjectionResult {
const versions = { ...COMBAT_V6_PHASE_6B_VERSIONS }
const base = projectCultivatorWithEquipmentSpecialInternal(input, versions, true, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const manuals = compileCharacterManualsV1({ state: input.manuals, realm: input.cultivator.realm })
if (!manuals.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.diagnostics], versions }
if (!manuals.ok) return { ok: false, diagnostics: manuals.diagnostics, versions }
const base = projectCultivatorWithEquipmentSpecialInternal({ ...input, cultivator: withManualAttributes(input.cultivator, manuals.projection) }, versions, true, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const capabilities = resolveCombatCapabilitiesV1(manuals.projection.capabilities)
if (!capabilities.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.projection.diagnostics, ...capabilities.diagnostics], versions }
const diagnostics = [
@@ -1,4 +1,4 @@
import { CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { withManualAttributes, CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { COMBAT_V6_PHASE_6C_VERSIONS } from "../version.ts"
import { projectCultivatorWithEquipmentSpecialInternal } from "./project-cultivator-with-equipment-special.ts"
import type { CombatV6ProjectionDiagnostic, CombatV6ProjectionResult, ProjectCultivatorMultiSectToCombatV6Input } from "./types.ts"
@@ -12,10 +12,10 @@ function manualContentConflicts(existingSkills: string[], existingStatuses: stri
export function projectCultivatorMultiSectV4ToCombatV6(input: ProjectCultivatorMultiSectToCombatV6Input): CombatV6ProjectionResult {
const versions = { ...COMBAT_V6_PHASE_6C_VERSIONS }
const base = projectCultivatorWithEquipmentSpecialInternal(input, versions, true, true, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const manuals = compileCharacterManualsV1({ state: input.manuals, realm: input.cultivator.realm })
if (!manuals.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.diagnostics], versions }
if (!manuals.ok) return { ok: false, diagnostics: manuals.diagnostics, versions }
const base = projectCultivatorWithEquipmentSpecialInternal({ ...input, cultivator: withManualAttributes(input.cultivator, manuals.projection) }, versions, true, true, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const capabilities = resolveCombatCapabilitiesV1(manuals.projection.capabilities)
if (!capabilities.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.projection.diagnostics, ...capabilities.diagnostics], versions }
const diagnostics = [
@@ -1,4 +1,4 @@
import { CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { withManualAttributes, CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { COMBAT_V6_PHASE_6D_VERSIONS } from "../version.ts"
import { projectCultivatorWithEquipmentSpecialInternal } from "./project-cultivator-with-equipment-special.ts"
import type { CombatV6ProjectionDiagnostic, CombatV6ProjectionResult, ProjectCultivatorMultiSectToCombatV6Input } from "./types.ts"
@@ -12,10 +12,10 @@ function manualContentConflicts(existingSkills: string[], existingStatuses: stri
export function projectCultivatorMultiSectV5ToCombatV6(input: ProjectCultivatorMultiSectToCombatV6Input): CombatV6ProjectionResult {
const versions = { ...COMBAT_V6_PHASE_6D_VERSIONS }
const base = projectCultivatorWithEquipmentSpecialInternal(input, versions, true, true, true, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const manuals = compileCharacterManualsV1({ state: input.manuals, realm: input.cultivator.realm })
if (!manuals.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.diagnostics], versions }
if (!manuals.ok) return { ok: false, diagnostics: manuals.diagnostics, versions }
const base = projectCultivatorWithEquipmentSpecialInternal({ ...input, cultivator: withManualAttributes(input.cultivator, manuals.projection) }, versions, true, true, true, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const capabilities = resolveCombatCapabilitiesV1(manuals.projection.capabilities)
if (!capabilities.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.projection.diagnostics, ...capabilities.diagnostics], versions }
const diagnostics = [
@@ -1,4 +1,4 @@
import { CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { withManualAttributes, CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { COMBAT_V6_PHASE_6A_VERSIONS } from "../version.ts"
import { projectCultivatorWithEquipmentSpecialInternal } from "./project-cultivator-with-equipment-special.ts"
import type { CombatV6ProjectionDiagnostic, CombatV6ProjectionResult, ProjectCultivatorMultiSectToCombatV6Input } from "./types.ts"
@@ -12,10 +12,10 @@ function manualContentConflicts(existingSkills: string[], existingStatuses: stri
export function projectCultivatorMultiSectToCombatV6(input: ProjectCultivatorMultiSectToCombatV6Input): CombatV6ProjectionResult {
const versions = { ...COMBAT_V6_PHASE_6A_VERSIONS }
const base = projectCultivatorWithEquipmentSpecialInternal(input, versions, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const manuals = compileCharacterManualsV1({ state: input.manuals, realm: input.cultivator.realm })
if (!manuals.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.diagnostics], versions }
if (!manuals.ok) return { ok: false, diagnostics: manuals.diagnostics, versions }
const base = projectCultivatorWithEquipmentSpecialInternal({ ...input, cultivator: withManualAttributes(input.cultivator, manuals.projection) }, versions, true)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const capabilities = resolveCombatCapabilitiesV1(manuals.projection.capabilities)
if (!capabilities.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.projection.diagnostics, ...capabilities.diagnostics], versions }
const diagnostics = [
@@ -1,4 +1,5 @@
import {
withManualAttributes,
CHARACTER_MANUALS_V1,
compileCharacterManualsV1,
resolveCombatCapabilitiesV1,
@@ -31,10 +32,10 @@ export function projectCultivatorToCombatV6(
input: ProjectCultivatorToCombatV6Input,
): CombatV6ProjectionResult {
const versions = { ...COMBAT_V6_PHASE_5A_VERSIONS }
const base = projectCultivatorWithEquipmentSpecialToCombatV6(input)
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const manuals = compileCharacterManualsV1({ state: input.manuals, realm: input.cultivator.realm })
if (!manuals.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.diagnostics], versions }
if (!manuals.ok) return { ok: false, diagnostics: manuals.diagnostics, versions }
const base = projectCultivatorWithEquipmentSpecialToCombatV6({ ...input, cultivator: withManualAttributes(input.cultivator, manuals.projection) })
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const capabilities = resolveCombatCapabilitiesV1(manuals.projection.capabilities)
if (!capabilities.ok) return { ok: false, diagnostics: [...base.diagnostics, ...manuals.projection.diagnostics, ...capabilities.diagnostics], versions }
@@ -36,7 +36,7 @@ function player(id: string): CombatV6TrainingPlayerInput {
})) as CombatV6TrainingPlayerInput['sect']['meridianLoadouts'],
},
equipment: {},
manuals: { version: 1, revision: 0, build: { slots: [] } },
manuals: { version: 1, revision: 0, learned: [], build: { slots: [] } },
};
}
it('首发合法性、满资源及输入隔离', () => {
@@ -65,7 +65,7 @@ function player(id: string): CombatV6TrainingPlayerInput {
})) as CombatV6TrainingPlayerInput['sect']['meridianLoadouts'],
},
equipment: {},
manuals: { version: 1, revision: 0, build: { slots: [] } },
manuals: { version: 1, revision: 0, learned: [], build: { slots: [] } },
};
}
@@ -69,7 +69,7 @@ function player(sectId: CombatV6SectId) {
},
sect,
equipment: {},
manuals: { version: 1 as const, revision: 0, build: { slots: [] } },
manuals: { version: 1 as const, revision: 0, learned: [], build: { slots: [] } },
};
}
+1 -1
View File
@@ -15,7 +15,7 @@ const build: CharacterDisplayBuild = {
activePathId: definition.paths[0].id, meridianDepth: 0,
meridianLoadouts: definition.paths.map(p => ({ pathId: p.id, nodeIds: [], revision: 0 })) as CharacterDisplayBuild['sect']['meridianLoadouts'],
},
equipment: {}, manuals: { version: 1, revision: 0, build: { slots: [] } },
equipment: {}, manuals: { version: 1, revision: 0, learned: [], build: { slots: [] } },
};
describe('V6 character panel authority', () => {
+68 -146
View File
@@ -1,174 +1,96 @@
import { describe, expect, it } from 'vitest';
import { expect, it } from 'vitest';
import {
ManualActionSchema,
type ManualAction,
} from '../contracts/combatV6Manuals';
import {
CHARACTER_MANUALS_V1,
CHARACTER_MANUAL_ID as ID,
} from '../engine/combat-v6/manuals/content';
import type { CultivatorManualStateV1 } from '../engine/combat-v6/manuals/types';
import { addItems, type InventoryItem } from '../inventory';
import { inventoryStackIdentity } from '../inventory/stack-key';
import { MANUAL_JADES } from '../items/definitions/manual-jades';
import type { InventoryItem } from '../inventory';
import { QINGXI_POOL_V2 } from '../rewards/wild';
import { previewManualAction } from './action';
const empty: CultivatorManualStateV1 = {
const state: CultivatorManualStateV1 = {
version: 1,
revision: 0,
learned: [],
build: { slots: [] },
};
const jade = (manualId = ID.DuanyueTrue): InventoryItem => ({
const manualId = 'character_manual.changchun';
const item: InventoryItem = {
id: 'jade',
definitionId: `jade.${manualId}`,
definitionId: 'jade.' + manualId,
quantity: 1,
revision: 0,
location: 'bag',
slotIndex: 0,
instanceData: null,
stackKey: `definition.v1:jade.${manualId}`,
});
stackKey: 'definition.v1:jade.' + manualId,
};
const action: ManualAction = {
action: 'learn',
expectedRevision: 0,
expectedManualId: null,
manualId,
slot: 1,
expectedRevision: 0,
item: { id: 'jade', revision: 0 },
};
describe('manual jade use', () => {
it('directly learns true rank without mutating state or item', () => {
const item = jade();
const result = previewManualAction(empty, '炼气', action, item);
expect(result.ok && result.state.build.slots).toEqual([
{ slot: 1, manualId: ID.DuanyueTrue },
]);
expect(empty.build.slots).toEqual([]);
expect(item.quantity).toBe(1);
});
it('rejects storage, stale inventory, missing item, wrong type and stale manual revision', () => {
for (const item of [
undefined,
{ ...jade(), location: 'storage' as const, slotIndex: null },
{ ...jade(), revision: 1 },
{ ...jade(), definitionId: 'book.beast.combo' },
]) {
expect(previewManualAction(empty, '炼气', action, item).ok).toBe(false);
}
expect(
previewManualAction(
empty,
'炼气',
{ ...action, expectedRevision: 1 },
jade(),
).ok,
).toBe(false);
expect(
previewManualAction(empty, '炼气', { ...action, slot: 3 }, jade()).ok,
).toBe(false);
});
it('upgrades in place and rejects duplication or downgrading', () => {
const state: CultivatorManualStateV1 = {
...empty,
build: { slots: [{ slot: 1, manualId: ID.DuanyueBase }] },
};
const upgraded = previewManualAction(
state,
'炼气',
{ ...action, expectedManualId: ID.DuanyueBase },
jade(),
);
expect(upgraded.ok && upgraded.state.build.slots).toEqual([
{ slot: 1, manualId: ID.DuanyueTrue },
]);
expect(
previewManualAction(state, '炼气', { ...action, slot: 2 }, jade()).ok,
).toBe(false);
if (!upgraded.ok) throw new Error('upgrade failed');
expect(
previewManualAction(
upgraded.state,
'炼气',
{ ...action, expectedRevision: 1, expectedManualId: ID.DuanyueTrue },
jade(ID.DuanyueBase),
).ok,
).toBe(false);
expect(previewManualAction(upgraded.state, '炼气', action, jade()).ok).toBe(
false,
);
});
it('replaces only the selected slot and forgets without requiring inventory', () => {
const state: CultivatorManualStateV1 = {
...empty,
build: {
slots: [
{ slot: 1, manualId: ID.DuanyueBase },
{ slot: 2, manualId: ID.MingsiBase },
],
},
};
const changed = previewManualAction(
state,
'炼气',
{ ...action, expectedManualId: ID.DuanyueBase },
jade(ID.NingguangBase),
);
expect(changed.ok && changed.state.build.slots).toEqual([
{ slot: 1, manualId: ID.NingguangBase },
{ slot: 2, manualId: ID.MingsiBase },
]);
const forgotten = previewManualAction(state, '炼气', {
action: 'forget',
slot: 1,
expectedRevision: 0,
expectedManualId: ID.DuanyueBase,
});
expect(forgotten.ok && forgotten.state.build.slots).toEqual([
{ slot: 2, manualId: ID.MingsiBase },
]);
});
it('validates target and inventory revision references', () => {
expect(ManualActionSchema.safeParse(action).success).toBe(true);
expect(ManualActionSchema.safeParse({ ...action, slot: 7 }).success).toBe(
false,
);
expect(
ManualActionSchema.safeParse({ ...action, item: { id: 'jade' } }).success,
).toBe(false);
});
});
it('registers twenty fixed jades, stacks at 99 and preserves rank-specific identity', () => {
expect(MANUAL_JADES).toHaveLength(20);
for (const item of MANUAL_JADES)
expect(
CHARACTER_MANUALS_V1.some((manual) => manual.id === item.manualId),
).toBe(true);
let id = 0;
const definitionId = `jade.${ID.DuanyueBase}`;
const key = inventoryStackIdentity(definitionId, undefined);
const result = addItems(
[],
{ definitionId, quantity: 100 },
'bag',
const resources = { experience: 0, insight: 0 };
it('学习仅接受储物袋中版本一致的同名玉简', () => {
expect(previewManualAction(state, '炼气', action, resources, item).ok).toBe(
true,
() => `id${id++}`,
key,
);
expect(result.map((item) => item.quantity)).toEqual([99, 1]);
expect(inventoryStackIdentity(`jade.${ID.DuanyueTrue}`, undefined)).not.toBe(
key,
for (const bad of [
undefined,
{ ...item, revision: 1 },
{ ...item, quantity: 0 },
{ ...item, definitionId: 'jade.character_manual.songhe' },
{ ...item, location: 'warehouse' as InventoryItem['location'] },
])
expect(previewManualAction(state, '炼气', action, resources, bad).ok).toBe(
false,
);
expect(item.quantity).toBe(1);
});
it('严格请求拒绝旧改修协议、非法槽位和客户端自报费用', () => {
expect(ManualActionSchema.safeParse(action).success).toBe(true);
expect(ManualActionSchema.safeParse({ ...action, slot: 5 }).success).toBe(
false,
);
expect(ManualActionSchema.safeParse({ ...action, cost: 0 }).success).toBe(
false,
);
expect(
ManualActionSchema.safeParse({ ...action, action: 'forget' }).success,
).toBe(false);
});
it('configures an independent 3% jade group with equal lineage and 95/5 rank weights', () => {
const group = QINGXI_POOL_V2.groups.find((group) => group.id === 'manuals')!;
expect(group.chance).toBe(0.03);
expect(group.entries).toHaveLength(20);
for (const rank of ['base', 'true']) {
expect(
group.entries
.filter((entry) => entry.rewardId.endsWith(`.${rank}`))
.reduce((sum, entry) => sum + entry.weight, 0),
).toBe(rank === 'base' ? 190 : 10);
}
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('常见与稀有玉简在既有掉落池采用数据包权重', () => {
const entries = QINGXI_POOL_V2.groups.find(
(g) => g.id === 'manuals',
)!.entries;
expect(entries).toHaveLength(16);
expect(entries.find((e) => e.rewardId === item.definitionId)!.weight).toBe(
100,
);
expect(
entries.find((e) => e.rewardId === 'jade.character_manual.songhe')!.weight,
).toBe(5);
});
+12 -27
View File
@@ -1,9 +1,5 @@
import type { ManualAction } from '../contracts/combatV6Manuals';
import {
forgetManualV1,
learnManualV1,
replaceManualV1,
} from '../engine/combat-v6/manuals/state';
import { changeManual } from '../engine/combat-v6/manuals/state';
import type {
CultivatorManualStateV1,
ManualStateChangeResult,
@@ -12,27 +8,22 @@ import type { InventoryItem } from '../inventory';
import { findItemDefinition } from '../items/registry';
import type { RealmType } from '../types/constants';
/** Shared preview and authoritative validation; no inventory mutation until validation succeeds. */
/** Shared preview and authoritative validation, before any resource or inventory mutation. */
export function previewManualAction(
state: CultivatorManualStateV1,
realm: RealmType,
action: ManualAction,
resources: { experience: number; insight: number },
item?: InventoryItem,
): ManualStateChangeResult {
const input = { ...action, state, realm };
if (action.action === 'forget')
return forgetManualV1({
...input,
expectedManualId: action.expectedManualId,
});
const manualId = item && findItemDefinition(item.definitionId)?.manualId;
if (
!item ||
item.id !== action.item.id ||
item.revision !== action.item.revision ||
item.location !== 'bag' ||
item.quantity < 1 ||
!manualId
'item' in action &&
(!item ||
item.id !== action.item.id ||
item.revision !== action.item.revision ||
item.location !== 'bag' ||
item.quantity < 1 ||
findItemDefinition(item.definitionId)?.manualId !== action.manualId)
) {
return {
ok: false,
@@ -40,16 +31,10 @@ export function previewManualAction(
{
severity: 'error',
code: 'INVALID_MANUAL_STATE',
message: '玉简已变化或未在储物袋,请刷新后重试',
message: '需要储物袋中的同名功法玉简,请刷新核对',
},
],
};
}
return action.expectedManualId === null
? learnManualV1({ ...input, manualId })
: replaceManualV1({
...input,
manualId,
expectedManualId: action.expectedManualId,
});
return changeManual({ ...action, state, realm, resources });
}
+1 -3
View File
@@ -63,9 +63,7 @@ export const QINGXI_POOL_V2 = DropPoolSchema.parse({
entry(
jade.id,
CHARACTER_MANUALS_V1.find((manual) => manual.id === jade.manualId)!
.rank === 'base'
? 19
: 1,
.dropWeight,
),
),
},