境界功法系统
This commit is contained in:
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)
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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: [] } },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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: [] } },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user