功能:兽决跑通

This commit is contained in:
桃树夭 committed 2026-09-07 15:16:55 +08:00
1 parent 9194f6b3f8
commit 3c73b39da2
31 files changed
+19842 -51

No files matched your search

+6 -2
View File
@@ -919,9 +919,13 @@ Phase 8 整体完成条件:
捕捉复用施法、执行时 MP 支付和合法目标转向,MP 随物种携带等级线性增长。携带等级限制捕捉与出战,个体战斗等级超过人物时禁止出战和培养。胜利经验仅发给终局在场且存活的已有灵兽,不分摊。持有 24、携带 6,放生无收益;灵石休养,不开放洗点。
#### Phase 9C:兽诀培养
#### Phase 9C:统一物品系统与兽诀培养
兽诀获取、事务化随机覆盖打书、普通/高级同系生效规则。固定技能格不增长,不加入指定覆盖、锁定或保底。
> 2026-09-07:代码已实现;本地自然掉落入包、存取、学习、下一场连击生效及旧回放不变的闭环通过,并修复结算后刷新丢失奖励提示。满包发放与装卸已通过本地测试物品验收;自然掉落满包分支、有数据装备迁移、越权及故障矩阵仍待验收。见 [9C 实施与验收记录](./combat-v6-phase-9c-inventory-plan.md)。
建立 v6 唯一物品持有路径,直接使用角色 `cultivator_id`,以 `location` 区分 40 格背包和无限储藏室,不建立容器表。兽诀每格 99 本,战斗溢出奖励进入新储藏室。旧玩法库存保留为后续洞府储藏室来源,本阶段不迁移或兼容读取;未来取出时原子转换进入新系统。v6 当前装备等物品仍须接入新持有路径。
兽诀获取、事务化随机覆盖打书、普通/高级同系生效规则。固定技能格不增长,不加入指定覆盖、锁定或保底。并发使用角色 Redis 锁、数据库事务和 revision,不新增消费操作凭据表,响应不明通过刷新权威状态恢复。
后续再按需求增加:
+172
View File
@@ -0,0 +1,172 @@
# Phase 9C:统一物品系统与兽诀培养
> 2026-09-07:代码已实现,静态检查与基础本地流程通过;有物品的完整交互与掉落消费闭环尚未实测,见第 8 节。依据:[总路线图](./combat-v6-mhxy-redesign-roadmap.md)、[灵兽设计基线](./combat-v6-summoned-beast-system-design.md)。
## 1. 已确认范围
- 建立可扩展物品类别的统一且唯一的新持有系统,v6 当前物品的获得、持有、消费和装备引用接入该系统;不按兽诀、药品、材料分别建立持有表。
- 持有物品直接通过 `cultivator_id` 归属角色,不使用 `container_id`,不建立容器实体表。
- `location` 区分 `bag` 与 `storage`;背包初始 40 格,首版不开放扩容。新储藏室空间无限,兽诀每格最多 99 本。
- 背包不足时,战斗奖励剩余部分进入新储藏室,结算明确去向,不丢弃、不阻塞结算。使用道具须先取入背包。
- 旧存储概念上作为洞府储藏室来源保留,本阶段不迁移旧玩法库存、不接兼容读取、不双写。后续另行打通旧库存取出转换。
- 不新增消费操作凭据表。使用现有角色 Redis 锁、数据库事务和 revision;不把分布式锁视为数据库原子性的替代。
- 兽诀获取与随机打书接入新库存。固定技能格不增长、等概率覆盖一个既有格位,不提供指定覆盖、锁定、保底或撤销;普通/高级同系占格但只生效高级。
灵兽、已学技能、修炼进度与灵石余额仍是各自领域状态,不转为背包物品。
## 2. 持久模型
固定物品定义初期放在有类型校验的共享配置,包含稳定 ID、分类、名称、描述、堆叠上限、实例 schema 和使用类型。具体玩法效果由领域服务执行,不建设通用脚本执行器或配置后台。
建议新增 `wanjiedaoyou_inventory_items`:
| 字段 | 语义 |
| --------------------------- | ------------------------------------------ |
| `id` | 持有记录身份 |
| `cultivator_id` | 角色外键,唯一所有权来源 |
| `location` | `bag` 或 `storage` |
| `slot_index` | 背包格位 0~39;储藏室为 NULL |
| `definition_id` | 稳定物品定义 ID |
| `quantity` | 正整数;独立实例固定为 1 |
| `instance_data` | 经分类 schema 校验的个体事实,无差异时为空 |
| `revision` | 每次内容、数量或位置修改时递增 |
| `created_at` / `updated_at` | 创建与更新时间 |
数据库对 `location = 'bag'` 建立 `(cultivator_id, slot_index)` 部分唯一约束,并检查位置与格位组合、正数量及非负 revision。背包格位仅在数据库中约束非空且非负;容量上限由共享 `BAG_CAPACITY` 配置统一校验,不写死在数据库 CHECK 中。服务端验证定义、实例 schema 和堆叠上限,不接受客户端提供权威效果。名称、展示描述和战斗投影不重复持久化。
堆叠资格由定义及有效实例属性决定,不能按名称合并;同种物品可因数量上限形成多堆,不能对角色和物品定义建立唯一约束。入包优先填充已有堆叠,再占空格。独立实例一件一格。迁移或整理不能重新生成随机属性。
## 3. 并发、转移与结果不明
获得、消费、移动、拆分、合并及整理统一通过库存服务,并传递调用方事务。角色锁内仍使用数量与 revision 条件更新;失败时整个事务回滚。
打书在同一事务内扣书并修改灵兽,请求包含物品及灵兽 revision。成功后响应丢失,客户端刷新库存及灵兽;旧请求不得被包装为新操作自动重试。没有持久凭据时不承诺返回原动画或原操作详情,也不提供跨任意业务的持久请求去重保证。
野外掉落沿用既有战斗结算凭据,先冻结奖励结果,再在奖励事务内入包/入储藏室;重试不得重掷或重复发放。恢复未知结果不重新生成权威奖励。
新背包与新储藏室之间复用同一持有表。转移时原子修改位置、格位与数量;背包容量不足的主动取出拒绝且不改库存。未来旧储藏室取出必须在同一事务内扣除旧持有并写入新库存,不能留下可重复消费副本;该转换不在本阶段实现。
## 4. 现有代码入口与接入结果
已核对 `schema.ts`:旧 `materials`、`consumables` 和 `creation_products` 继续由旧玩法管理;消耗品 `spec` 有既有类型协议,不将新兽诀塞入该协议。
v6 原有 `combatV6EquipmentInstances` 与 `combatV6EquipmentLoadouts`;本轮将装备个体完整迁入 `inventory_items.instance_data`,保留原 ID,再切换装备栏外键和 `combatV6BuildRepository.ts` 的属性/所有权读取,最后删除旧 v6 装备实例表。没有保留第二份持有状态。当前应用未发现独立的 v6 装备获得写入服务,新库存的统一获得函数同时支持固定堆叠品和完整装备个体。
已装备物品仍占背包一格,装备栏引用同一物品;卸装不需要空格,存入储藏室前必须卸装。更换装备会校验完整装备组合并使构筑资源缓存失效。迁移时已装备个体优先进入背包,其他个体超过 40 格进入新储藏室。旧版装备及造物库存不迁移。
## 5. UI 与实施顺序
1. 清点 v6 物品链路及装备装卸规则,确定实际数据迁移与路由落点;区分新旧玩法来源。
2. 实现共享定义、实例验证、格位/堆叠计算与库存表,审查迁移 SQL 和约束。
3. 实现统一库存服务与 API;角色认证、所有权和使用条件由服务端校验。
4. 建立响应式格子背包,展示已用格/40、数量、分类与搜索;通过现有详情抽屉完成移动、合并、拆分、整理及新储藏室存取,不依赖拖拽。旧储藏室聚合展示留待后续。
5. 接入 v6 现有物品及装备身份,再实现兽诀掉落、学习确认、覆盖结果与战斗投影;背包入口与灵兽详情入口复用同一学习交互。
6. 更新协议、发布步骤和实际验收记录。维护期硬切已接入的 v6 路径,不增加长期双写或功能开关。
使用 React 最佳实践及仓库格式规范,不引入新的全局状态库。不扩展交易、赠送、分解、合宠、洗点、装备生成规则或战斗药品机制。
## 6. 本轮内容配置
按前序推荐实施初版参数:正常野外胜利时,每只实际击败野怪 3% 掉一本;灵火、岩甲护身、疾风爪、普通连击各占 24%,高级连击占 4%。首批五种书跨物种学习,完全相同技能禁止重复学习,普通/高级允许共存,不增设高级书独立门槛。尚未进行经济平衡验证。
奖励随机流从战斗身份与原始种子独立派生,不消耗战斗 RNG;终局奖励冻结在既有野外结算摘要中,复用结算凭据与事务发放。概率失败没有奖励,捕获退场不算击杀,逃跑/战败/技术作废不发兽诀。
## 7. 验收标准
- 纯共享测试覆盖堆叠上限、满包、部分填充与溢出、格位唯一、拆分合并的数量守恒、不可堆叠实例和兽诀覆盖/同系抑制。
- 本地实际验证掉落入包、满包溢出储藏室、取出、学习、下一场生效、旧回放不变;以及越权、过期 revision、连续点击、响应不明后的刷新和战中管理限制。
- 检查桌面及 360px 格子布局、详情和二级选择,避免弹窗层叠;新背包不读取旧物品作为可消费副本。
- 完成 v6 装备引用、持有归属与迁移核对;不留下平行所有权或失效引用。
- 按项目规范只在 `src/shared` 新增纯测试,不新增前端/服务端/数据库测试或一次性脚本。业务实现后执行 lint、build、适用共享测试、触及文件 Prettier 及差异检查。
- 9B 付费休养、结算故障重试和多人矩阵的未验收项继续单独记录,不能因 9C 完成自动标记通过。
## 8. 实施、发布与验证记录
### 代码落点
- `src/shared/inventory`:固定定义、实例 schema、获得/堆叠/溢出/整理、学习和掉落纯规则;具体效果仍由 v6 领域负责。
- `InventoryService.ts`:统一查询、转移、移动/交换/合并、拆分、整理、学习与装卸。角色锁与 SQL 行锁保护事务,数量和 revision 校验防止旧请求重复消费。没有新增消费凭据表。
- `/api/combat-v6/inventory`:GET 分页查询与 POST 操作,复用活跃角色认证;储藏室每页 40 条,不一次下载无限库存。
- `/game/inventory`:新版格子背包;`/game/cave/storage/new?location=storage`:新储藏室;`/game/cave/storage`:原物品页面保留为洞府旧藏。旧消费玩法返回入口指向旧藏,不会把旧库存当成新背包可消费记录。
- 灵兽详情的学习入口进入同一物品选择流程;同系普通技能保留并展示抑制原因。技能描述复用统一 tooltip;道装详情展示属性,不向玩家展示 JSON。
### 发布步骤
1. 停机维护,先结束旧活动战斗与待结算任务,再备份数据库。
2. 执行 `0039_inventory_v6.sql`:先复制 v6 装备身份和冻结事实,再切换外键并删除旧 v6 装备实例表;不迁移旧版材料、消耗品或造物表。发布前核对每个角色已装备实例数量不超过背包容量,避免已有多套装配超出迁移前提。
3. 继续执行 `0040_inventory_slot_capacity.sql`,移除数据库格位约束中写死的容量上限,保留非负格位、位置组合、正数量、非负 revision 和格位唯一性;容量仍由应用配置控制为 40 格。本地已执行并核对实际约束。
4. 同步发布前后端,新野外内容戳为 `daoyou_wild_inventory_content_v1`;规则 RNG 未改,历史回放继续读取冻结资料。旧活动运行态不兼容续跑,页面需要刷新。
5. 恢复服务后验证库存、装备构筑、一次野外结算。无功能开关、双写或独立消费凭据。
本轮只在纯本地数据库执行迁移,未向预发布或生产写入,未部署。
### 已验证
- `bun run test`:212 文件、1974 项通过,其中库存纯测试 7 项。覆盖堆叠及溢出数量守恒、格位与实例校验、装备身份保留、整理合并、固定格位覆盖、重复技能拒绝和同系抑制。
- `bun run lint`、`bun run build` 通过。构建仍有体积提示;未为本阶段进行无关拆包整改。
- 触及文件 Prettier 与 `git diff --check` 通过。
- 本地 `bun run db:migrate` 成功;当前本地 v6 装备实例为 0,因此不能以迁移成功证明有装备数据的搬迁分支已通过。
- 使用已有 `local2` 登录会话,物品页显示 40 格、占用 0;空包整理成功,储藏室切换和分页空态正常。
- 实际野外战斗 `e1bce7e5-1fd1-4d28-bc2b-8162590241a2` 击败三只敌人,5 回合正常胜利、资源结算并退出;战中另一标签页整理背包被拒绝。本场未触发兽诀掉落。
- 360px 设备覆盖下内容宽度与滚动宽度一致(345px,另有滚动条占宽),没有横向溢出;测试后恢复尺寸覆盖。截图工具存在缩放呈现差异,不据此宣布完整移动端视觉验收。
### 初轮未验证(后续进展见下节)
- 实际获得兽诀后的存取、拆分合并、随机覆盖、刷新恢复和下一场技能生效;本地库存为空,本场正常掉落未命中,未通过数据库塞入物品制造通过记录。
- 实际满 40 格奖励溢出、已装备物品换装/存取、有数据装备迁移与多角色越权请求;纯规则与代码审查不能替代这些运行验收。
- 事务中断、Redis 锁过期、多标签页并发消费等故障矩阵,以及 9B 遗留付费休养和多人矩阵。
- 新储藏室旧物品取出转换仍明确不在本阶段范围;本轮不声称已完成旧数据迁移。
## 9. 2026-09-07 本地验收与修复
本轮暂停新增功能,使用本地 local2 已登录页面进行真实操作。为覆盖非空库存和容量边界,通过现有 `grantInventory` 服务准备兽诀及两件生成道装;这些是测试物品,不是自然野外掉落的验收证据。分页场景另移除一个测试堆叠以留下空格,未访问预发布或生产数据。
### 实际通过
- 五本连击拆成三本和两本,再合并为五本;异类物品交换格位;存入储藏室再取回,数量保持一致。储藏室不提供直接使用入口。
- 青灵狐学习连击,扣一本且将原灵火替换为连击;重复技能显示“已拥有”并禁止确认。随后学习高级连击,扣一本并完成替换。
- 两个标签页基于相同旧 revision 确认学习:首次成功,第二次明确拒绝并重新读取剩余数量,没有再次扣书。
- 装备第一件道装,再由第二件替换同部位;已装备道装禁止存入储藏室。满 40 格仍可卸装,不需要额外空格,最终装备引用为空。
- 满包取出储藏室物品、拆分到空格均被拒绝,源数量不变。通过真实发放服务填满背包后,多余堆叠进入储藏室;不等同于自然战斗结算掉落已验收。
- 储藏室 41 组物品的末页取出场景:修复前出现空白页及“2 / 1”;修复后自动回到有效页,显示剩余 40 组及“1 / 1”。
### 修复和检查
- 库存读取在请求页越界时回退至最后有效页;前端采用返回页码,避免末页物品取空后停留在无效页面。正常页仍并行读取列表和计数。
- 连击和高级连击描述分别展示权威技能配置中的 25% 和 40% 触发概率,补充共享测试验证描述随配置变化。浏览器确认详情已展示新说明。
- 请求失败提示不再提前声称异步刷新已经完成。
- 本轮重新运行 `bun run lint`、`bun run build` 成功;`bun run test` 为 212 文件、1975 项通过。构建体积提示仍存在。
- 测试结束删除 80 条测试库存记录,恢复青灵狐原技能及属性,revision 保持递增,避免重新接受旧请求;测试道装无残留装备引用。
### 本轮结束时仍需验收(闭环后续见第 10 节)
- 自然野外掉落到库存的完整链路、学习后下一场战斗效果以及旧回放冻结内容不变。
- 有装备数据的迁移分支、跨角色越权请求、事务中断/锁过期/响应丢失等故障场景。旧 revision 拒绝的双标签页检查不代表这些场景全部通过。
- 本轮未重新完成有物品的 360px 视觉验收,也未覆盖 9B 付费休养及多人矩阵。9C 仍不能标记为全部验收完毕。
## 10. 2026-09-07 自然掉落到战斗生效闭环
### 环境与结果
使用纯本地 `127.0.0.1:5174`、API 3001、PostgreSQL 15432 和 Redis 16379,复用 local2。通过浏览器正式入口连续完成 13 场野外胜利,未修改掉落概率、随机种子或奖励记录,未注入测试物品。
| 环节 | 实际证据 |
| --- | --- |
| 自然掉落与入包 | 前 11 场未掉落;第 12 场 `8b4fb0c3-eed1-42ea-a68f-89162bc582b8` 正常胜利,掉落 `book.beast.combo ×1`。数据库生成物品 `60a5195b-4609-4a12-97f1-9674a436f0db`,位于背包第 1 格,数量 1。 |
| 结算后恢复 | 确认临时结算摘要已经删除,运行会话仍保留同一奖励;真实刷新战斗页面后继续显示连击兽诀奖励。 |
| 存入、取回、学习 | 在物品页面将这一本书存入新储藏室,再取回背包;选择青灵狐确认学习,显示“灵火 → 连击”。最终数据库库存为空,灵兽技能为 `beast.combo`。 |
| 下一场生效 | 第 13 场 `0619adc4-0af6-4798-ae12-0caa1110f57c` 的初始灵兽投影为 `skills: []`、`passives: [beast.combo]`。人物防御、灵兽普攻,第 3 回合产生 41 和 29 点两段伤害,第 6 回合产生 44 和 32 点两段伤害。第 7 回合正常胜利。 |
| 旧回放不变 | 学习前保存的 22 份归档 JSON 内容哈希,学习后全部一致。第 12 场初始灵兽仍持有灵火,第 13 场初始灵兽持有连击;实际打开第 12 场回放并播放到 12/12 行动。 |
### 本轮修复
发现 `CombatV6WildStore.complete` 会在结算后删除临时摘要,而战斗视图原先从该摘要读取 `itemRewards`。因此即使已经发放,刷新或后续轮询也可能丢失奖励提示。
将终局奖励展示列表与同次最终运行会话一并写入 Redis,更新 `WildRuntimeSchema` 校验;视图从运行会话读取冻结列表。原结算摘要仍按原生命周期清理,发放仍复用既有事务与幂等凭据,不新增表、重抽奖励或更改战斗 RNG。列表最多 3 项,运行会话退出/过期时随之清理。此次自然掉落已验证摘要删除后的刷新路径。
### 检查与边界
- 本轮先运行库存与技能说明的共享测试,再于修复后运行 `bun run lint`、`bun run build`、`bun run test`;全量 212 文件、1975 项通过,构建仍有体积提示。触及文件 Prettier 与差异检查通过。
- 13 场活动战斗均通过页面正常结束。本轮自然获得的经验及连击学习结果保留在本地测试角色,未伪造或回滚正式游戏操作,未部署。
- **自然掉落 → 入包 → 存取 → 学习 → 下一场生效 → 旧回放不变的主链路通过。** 本轮没有制造满包条件,自然战斗奖励直接溢出储藏室的组合分支仍未验收;此前真实发放服务的满包检查继续作为独立证据。
- 有数据装备迁移、跨角色越权、事务/锁/响应丢失故障、完整移动端视觉和 9B 遗留矩阵仍单独追踪,不因本闭环通过而标记完成。
@@ -438,6 +438,8 @@ interface SummonedBeast {
## Phase 9 分期与补充决策(2026-09-06)
9C 扩展为统一物品系统与兽诀培养,实施及实际验证见 [9C 记录](./combat-v6-phase-9c-inventory-plan.md)。兽诀持有接入角色直属的新库存,40 格背包、每格 99 本,溢出进入新储藏室。按 2026-09-07 确认,本阶段不新增消费操作凭据表或持久随机证明记录,使用角色锁、数据库事务和物品/灵兽 revision 防止重复消费;响应不明时刷新实际结果,不保证重放原打书结果动画。此补充优先于第 6 节原先关于持久随机证明的建议。旧玩法库存暂不迁移。
9B 已实现规则、数值与验收边界见 [9B 实施记录](./combat-v6-phase-9b-capture-progression-plan.md)。捕捉按一次施法处理,执行时扣 MP,失效目标自动转向合法目标;MP 随物种携带等级线性增长。物种携带等级与个体战斗等级分离,个体等级超过人物时禁止出战和培养。胜利经验仅由终局在场且存活的已有灵兽获得;当前灵兽不设倒地。持有上限 24、放生无收益、灵石休养,9B 不开放洗点。本文涉及洗点的自然/自由属性区分仅为领域边界,不代表本阶段提供洗点入口。
用户已确认按 [9A 实施计划](./combat-v6-phase-9a-summoned-beast-plan.md)分期交付。本稿“首版范围”为 9A~9C 的整体范围,不要求在 9A 同时开放捕捉、升级加点和打书入口。
+36
View File
@@ -0,0 +1,36 @@
CREATE TABLE "wanjiedaoyou_inventory_items" (
"id" varchar(160) PRIMARY KEY NOT NULL,
"cultivator_id" uuid NOT NULL,
"location" varchar(16) NOT NULL,
"slot_index" integer,
"definition_id" varchar(160) NOT NULL,
"quantity" integer NOT NULL,
"instance_data" jsonb,
"revision" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "inventory_quantity_positive" CHECK ("wanjiedaoyou_inventory_items"."quantity" > 0 AND "wanjiedaoyou_inventory_items"."revision" >= 0),
CONSTRAINT "inventory_slot_valid" CHECK (("wanjiedaoyou_inventory_items"."location" = 'bag' AND "wanjiedaoyou_inventory_items"."slot_index" IS NOT NULL AND "wanjiedaoyou_inventory_items"."slot_index" BETWEEN 0 AND 39) OR ("wanjiedaoyou_inventory_items"."location" = 'storage' AND "wanjiedaoyou_inventory_items"."slot_index" IS NULL))
);
--> statement-breakpoint
-- Preserve v6 identities and frozen equipment facts. Equipped items get bag priority.
WITH ranked AS (
SELECT e.*, row_number() OVER (PARTITION BY e.cultivator_id ORDER BY
EXISTS (SELECT 1 FROM wanjiedaoyou_combat_v6_equipment_loadouts l WHERE l.equipment_instance_id = e.id) DESC,
e.created_at, e.id) - 1 AS position
FROM wanjiedaoyou_combat_v6_equipment_instances e
)
INSERT INTO wanjiedaoyou_inventory_items
(id, cultivator_id, location, slot_index, definition_id, quantity, instance_data, revision, created_at)
SELECT id, cultivator_id, CASE WHEN position < 40 THEN 'bag' ELSE 'storage' END,
CASE WHEN position < 40 THEN position::integer ELSE NULL END,
'equipment.v6', 1, instance, 0, created_at FROM ranked;
--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_combat_v6_equipment_loadouts" DROP CONSTRAINT "wanjiedaoyou_combat_v6_equipment_loadouts_equipment_instance_id_wanjiedaoyou_combat_v6_equipment_instances_id_fk";
--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_equipment_instances";
--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_inventory_items" ADD CONSTRAINT "wanjiedaoyou_inventory_items_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "inventory_owner_location_idx" ON "wanjiedaoyou_inventory_items" USING btree ("cultivator_id","location");--> statement-breakpoint
CREATE UNIQUE INDEX "inventory_bag_slot_unique" ON "wanjiedaoyou_inventory_items" USING btree ("cultivator_id","slot_index") WHERE "wanjiedaoyou_inventory_items"."location" = 'bag';--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_combat_v6_equipment_loadouts" ADD CONSTRAINT "wanjiedaoyou_combat_v6_equipment_loadouts_equipment_instance_id_wanjiedaoyou_inventory_items_id_fk" FOREIGN KEY ("equipment_instance_id") REFERENCES "public"."wanjiedaoyou_inventory_items"("id") ON DELETE restrict ON UPDATE no action;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "wanjiedaoyou_inventory_items" DROP CONSTRAINT "inventory_slot_valid";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_inventory_items" ADD CONSTRAINT "inventory_slot_valid" CHECK (("wanjiedaoyou_inventory_items"."location" = 'bag' AND "wanjiedaoyou_inventory_items"."slot_index" IS NOT NULL AND "wanjiedaoyou_inventory_items"."slot_index" >= 0) OR ("wanjiedaoyou_inventory_items"."location" = 'storage' AND "wanjiedaoyou_inventory_items"."slot_index" IS NULL));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -274,6 +274,20 @@
"when": 1788691868265,
"tag": "0038_v6_summoned_beasts",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1788755523519,
"tag": "0039_inventory_v6",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1788760243550,
"tag": "0040_inventory_slot_capacity",
"breakpoints": true
}
]
}
@@ -13,8 +13,8 @@ import type { TaskInstance } from '@shared/types/task';
import { cn } from '@shared/lib/utils';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { BreakthroughConfirmModal } from './BreakthroughConfirmModal';
import { BreakthroughChanceDetails } from './BreakthroughChanceDetails';
import { BreakthroughConfirmModal } from './BreakthroughConfirmModal';
import type {
RetreatBuffTag,
RetreatEfficiencyModel,
@@ -168,7 +168,7 @@ function RetreatBuffTags({
<span>{emptyHint}</span>
{showShortcuts ? (
<>
<InkButton href="/game/inventory" variant="ghost">
<InkButton href="/game/cave/storage" variant="ghost">
背包
</InkButton>
<InkButton href="/game/craft/alchemy" variant="ghost">
@@ -375,9 +375,7 @@ function BreakthroughPanel({
{(retreatEfficiency?.breakthroughTags.length ?? 0) > 0 ? (
<div className="space-y-2">
<p className="text-ink-secondary text-xs leading-5">破境准备</p>
<RetreatBuffTags
tags={retreatEfficiency?.breakthroughTags ?? []}
/>
<RetreatBuffTags tags={retreatEfficiency?.breakthroughTags ?? []} />
</div>
) : null}
</div>
@@ -45,6 +45,16 @@ export const gameDockGroups: GameNavGroup[] = [
href: '/game',
coreDockLabel: '洞府',
},
{
id: 'storage',
sceneLabel: '洞府储藏室',
href: '/game/cave/storage/new?location=storage',
},
{
id: 'legacy-storage',
sceneLabel: '洞府旧藏',
href: '/game/cave/storage',
},
{
id: 'cultivator',
sceneLabel: '道身',
+29 -1
View File
@@ -275,7 +275,9 @@ export const router = createBrowserRouter(
/>
<Route
path="inventory"
lazy={lazyRoute(() => import('@app/routes/game/inventory/route'))}
lazy={lazyRoute(
() => import('@app/routes/game/inventory/InventoryV6'),
)}
handle={scene(
{
id: 'inventory',
@@ -285,6 +287,32 @@ export const router = createBrowserRouter(
'储物袋',
)}
/>
<Route
path="cave/storage"
lazy={lazyRoute(() => import('@app/routes/game/inventory/route'))}
handle={scene(
{
id: 'legacy-storage',
presentation: 'service',
summary: '洞府收藏的旧有诸物。',
},
'洞府旧藏',
)}
/>
<Route
path="cave/storage/new"
lazy={lazyRoute(
() => import('@app/routes/game/inventory/InventoryV6'),
)}
handle={scene(
{
id: 'storage',
presentation: 'service',
summary: '收存随身之外的物品。',
},
'洞府储藏室',
)}
/>
<Route
path="craft/alchemy"
lazy={lazyRoute(
@@ -9,6 +9,7 @@ import { InkTooltip } from '@app/components/ui/InkTooltip';
import { combatV6SkillDetails } from '@shared/combat-v6/skill-details';
import type { BeastManagementView } from '@shared/contracts/combatV6Beasts';
import {
activeBeastSkills,
BEAST_SKILLS,
BEAST_SPECIES,
beastPanel,
@@ -18,6 +19,7 @@ import {
} from '@shared/engine/combat-v6/beasts';
import { nextBeastExp } from '@shared/engine/combat-v6/beasts/progression';
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router';
import { BeastActionDrawer, type BeastAction } from './BeastActionDrawer';
const base = '/api/combat-v6/beasts';
@@ -95,6 +97,9 @@ function BeastDetails({
{beast.skills.map((id) => (
<span key={id} className="inline-flex items-center gap-1">
{BEAST_SKILLS.find((skill) => skill.id === id)?.name}
{!activeBeastSkills(beast).includes(id) ? (
<span className="text-ink-secondary">(被高级技能抑制)</span>
) : null}
<InkTooltip label="查看兽诀说明">
{skillDetails[id]?.description}
</InkTooltip>
@@ -102,6 +107,12 @@ function BeastDetails({
))}
</div>
<dl className="grid grid-cols-2 gap-2">
<Link
className="col-span-2 underline"
to={`/game/inventory?beastId=${beast.id}`}
>
学习兽诀
</Link>
{Object.entries(attributeNames).map(([key, name]) => (
<div key={key}>
<dt className="text-ink-secondary">{name}</dt>
@@ -265,7 +265,7 @@ export default function IdentityReshapePage() {
if (!response.ok || !result.success) {
throw new Error(result.error ?? '放弃会话失败');
}
navigate('/game/inventory', { replace: true });
navigate('/game/cave/storage', { replace: true });
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '放弃会话失败',
@@ -278,13 +278,13 @@ export default function IdentityReshapePage() {
const temporarilyLeave = async () => {
if (!session) {
navigate('/game/inventory');
navigate('/game/cave/storage');
return;
}
if (!session.candidate) {
if (!(await saveDraft(answers, description))) return;
}
navigate('/game/inventory');
navigate('/game/cave/storage');
};
if (loading) {
@@ -323,7 +323,7 @@ export default function IdentityReshapePage() {
>
启封问命
</InkButton>
<InkButton href="/game/inventory" className="text-[#d9cfba]">
<InkButton href="/game/cave/storage" className="text-[#d9cfba]">
返回储物袋
</InkButton>
</div>
@@ -0,0 +1,571 @@
import {
combatV6Request,
mutationBody,
} from '@app/components/feature/combat-v6/request';
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 { combatV6SkillDetails } from '@shared/combat-v6/skill-details';
import type { BeastManagementView } from '@shared/contracts/combatV6Beasts';
import type {
InventoryAction,
InventoryView,
} from '@shared/contracts/inventory';
import {
BEAST_SKILLS,
activeBeastSkills,
} from '@shared/engine/combat-v6/beasts';
import { BAG_CAPACITY, itemDefinition } from '@shared/inventory';
import {
EQUIPMENT_ATTRIBUTE_NAMES,
InventoryEquipmentSchema,
} from '@shared/inventory/equipment';
import { useEffect, useRef, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
const endpoint = '/api/combat-v6/inventory';
const details = combatV6SkillDetails(BEAST_SKILLS, []);
const skillName = (id: string) =>
BEAST_SKILLS.find((s) => s.id === id)?.name ?? id;
type Item = InventoryView['items'][number];
export default function InventoryV6() {
const [params, setParams] = useSearchParams();
const route = useLocation();
const location =
params.get('location') === 'bag'
? 'bag'
: params.get('location') === 'storage' ||
route.pathname === '/game/cave/storage/new'
? 'storage'
: 'bag';
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const [kind, setKind] = useState('all');
const [data, setData] = useState<InventoryView>();
const [selected, setSelected] = useState<string>();
const [moving, setMoving] = useState<Item>();
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [pending, setPending] = useState(false);
const [refresh, setRefresh] = useState(0);
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;
const query = new URLSearchParams({
location,
page: String(page),
search,
kind,
});
void combatV6Request<InventoryView>(`${endpoint}?${query}`, {
signal: controller.signal,
})
.then((view) => {
if (!controller.signal.aborted) {
setData(view);
setPage(view.page);
}
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
});
return () => controller.abort();
}, [location, page, search, kind, refresh]);
async function act(action: InventoryAction) {
if (busy.current) return;
busy.current = true;
setPending(true);
setError('');
setNotice('');
reader.current?.abort();
try {
const result = await combatV6Request<{
oldSkill?: string;
newSkill?: string;
}>(endpoint, mutationBody(action));
if (!mounted.current) return;
setSelected(undefined);
setMoving(undefined);
setNotice(
result.newSkill
? `${skillName(result.oldSkill!)} → ${skillName(result.newSkill)}`
: '已完成',
);
} catch (e) {
if (mounted.current)
setError(
`${e instanceof Error ? e.message : '请求失败'};请重新核对物品状态后操作。`,
);
} finally {
busy.current = false;
if (mounted.current) {
setPending(false);
setRefresh((v) => v + 1);
}
}
}
const item = data?.items.find((i) => i.id === selected);
const slots = new Map(data?.items.map((i) => [i.slotIndex, i]));
const filtered = !!search || kind !== 'all';
function choose(entry: Item | undefined, slot: number) {
if (pending) return;
if (moving) {
void act({
action: 'move',
id: moving.id,
revision: moving.revision,
slot,
targetId: entry?.id ?? null,
targetRevision: entry?.revision ?? null,
});
return;
}
setSelected(entry?.id);
}
return (
<GameSceneFrame variant="workflow">
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
{(['bag', 'storage'] as const).map((value) => (
<button
key={value}
disabled={pending}
aria-pressed={location === value}
className={
location === value
? 'text-ink font-semibold underline underline-offset-4'
: 'text-ink-secondary'
}
onClick={() => {
setParams({ location: value });
setPage(0);
setData(undefined);
setSelected(undefined);
setMoving(undefined);
}}
>
{value === 'bag' ? '随身物品' : '洞府储藏室'}
</button>
))}
<span className="text-ink-secondary ml-auto">
{data?.used ?? '—'} / {BAG_CAPACITY} 格
</span>
</div>
<div className="flex flex-wrap gap-2">
<input
aria-label="搜索物品"
placeholder="搜索物品"
value={search}
className="border-ink/20 min-w-0 flex-1 border-b bg-transparent p-2 text-sm"
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
setData(undefined);
setMoving(undefined);
}}
/>
<select
aria-label="物品分类"
value={kind}
className="bg-transparent text-sm"
onChange={(e) => {
setKind(e.target.value);
setPage(0);
setData(undefined);
setMoving(undefined);
}}
>
<option value="all">全部</option>
<option value="beast_book">兽诀</option>
<option value="equipment">道装</option>
</select>
{location === 'bag' ? (
<InkButton
disabled={pending || !data || filtered}
onClick={() =>
void act({
action: 'sort',
items: data!.items.map(({ id, revision }) => ({
id,
revision,
})),
})
}
>
整理
</InkButton>
) : null}
</div>
{error ? (
<p role="alert" className="text-crimson text-sm">
{error}
<button
disabled={pending}
className="ml-2 underline"
onClick={() => {
setError('');
setRefresh((v) => v + 1);
}}
>
重新读取
</button>
</p>
) : null}
{notice ? (
<p role="status" className="text-sm">
{notice}
</p>
) : null}
{moving ? (
<p className="text-sm">
选择目标格位,同类合并,其他物品交换位置。
<button
className="ml-2 underline"
onClick={() => setMoving(undefined)}
>
取消移动
</button>
</p>
) : null}
{!data ? (
<p className="text-ink-secondary text-sm">正在查看物品……</p>
) : (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-8">
{(location === 'bag'
? Array.from({ length: BAG_CAPACITY }, (_, slot) => ({
entry: slots.get(slot),
slot,
}))
: data.items.map((entry, slot) => ({ entry, slot }))
).map(({ entry, slot }) => (
<button
key={location === 'bag' ? slot : entry!.id}
disabled={
pending || (!entry && !moving) || (filtered && !entry)
}
aria-label={
entry
? `${entry.name},${entry.quantity}件${entry.equipped ? ',已装备' : ''}`
: `空格 ${slot + 1}`
}
onClick={() => choose(entry, slot)}
className={`border-ink/15 relative flex min-h-20 min-w-0 flex-col items-center justify-center gap-1 rounded border p-1 text-xs ${moving?.id === entry?.id && moving ? 'ring-ink ring-1' : 'hover:bg-ink/5'}`}
>
{entry ? (
<>
<span aria-hidden className="text-ink-secondary text-lg">
{entry.definitionId === 'equipment.v6' ? '◇' : '卷'}
</span>
<span className="line-clamp-2 break-all">{entry.name}</span>
<span className="text-ink-secondary">
{entry.equipped ? '已装备' : `×${entry.quantity}`}
</span>
</>
) : (
<span aria-hidden className="text-ink/15">
·
</span>
)}
</button>
))}
</div>
)}
{location === 'storage' && data?.total === 0 ? (
<p className="text-ink-secondary text-sm">暂无物品</p>
) : null}
{location === 'storage' && data ? (
<div className="flex items-center justify-between text-sm">
<InkButton
disabled={!data.page || pending}
onClick={() => {
setPage(data.page - 1);
setData(undefined);
}}
>
上一页
</InkButton>
<span>
{data.page + 1} / {Math.max(1, Math.ceil(data.total / 40))}
</span>
<InkButton
disabled={(data.page + 1) * 40 >= data.total || pending}
onClick={() => {
setPage(data.page + 1);
setData(undefined);
}}
>
下一页
</InkButton>
</div>
) : null}
{item ? (
<ItemDrawer
key={`${item.id}:${item.revision}`}
item={item}
initialBeastId={params.get('beastId')}
pending={pending}
error={error}
close={() => {
if (!pending) setSelected(undefined);
}}
act={act}
move={() => {
setMoving(item);
setSelected(undefined);
setSearch('');
setKind('all');
}}
/>
) : null}
</div>
</GameSceneFrame>
);
}
function ItemDrawer({
item,
initialBeastId,
pending,
error,
close,
act,
move,
}: {
item: Item;
initialBeastId: string | null;
pending: boolean;
error: string;
close: () => void;
act: (action: InventoryAction) => Promise<void>;
move: () => void;
}) {
const definition = itemDefinition(item.definitionId);
const [learning, setLearning] = useState(false);
const [roster, setRoster] = useState<BeastManagementView>();
const [loadError, setLoadError] = useState('');
const [beastId, setBeastId] = useState(initialBeastId ?? '');
const [quantity, setQuantity] = useState(1);
useEffect(() => {
if (!learning) return;
const controller = new AbortController();
void combatV6Request<BeastManagementView>('/api/combat-v6/beasts', {
signal: controller.signal,
})
.then((value) => {
if (!controller.signal.aborted) setRoster(value);
})
.catch((e) => {
if (!controller.signal.aborted) setLoadError(e.message);
});
return () => controller.abort();
}, [learning]);
const beast = roster?.beasts.find((b) => b.id === beastId);
const valid =
!!beast &&
beast.skillSlotCapacity > 0 &&
beast.level <= roster!.ownerLevel &&
!beast.skills.includes(definition.skillId!);
const ref = { id: item.id, revision: item.revision };
return (
<InkDetailDrawer
isOpen
title={learning ? `学习 · ${item.name}` : item.name}
onClose={close}
size="sm"
>
<div className="space-y-4 text-sm">
{error || loadError ? (
<p role="alert" className="text-crimson">
{error || loadError}
</p>
) : null}
<p>
持有 {item.quantity} {definition.kind === 'beast_book' ? '本' : '件'}
</p>
{definition.skillId ? (
<p>
{details[definition.skillId]?.description}
<InkTooltip label="兽诀使用说明">
消耗一本,等概率覆盖一个现有技能,包括出生技能。普通和高级同系同时存在时仅高级生效。
</InkTooltip>
</p>
) : (
<EquipmentDetails data={item.instanceData} />
)}
{learning ? (
<>
{!roster && !loadError ? <p>正在查看灵兽……</p> : null}
<label className="block">
选择灵兽
<select
aria-label="选择学习灵兽"
className="border-ink/20 mt-2 w-full border bg-transparent p-2"
value={beastId}
disabled={pending}
onChange={(e) => setBeastId(e.target.value)}
>
<option value="">请选择</option>
{roster?.beasts.map((b) => (
<option
key={b.id}
value={b.id}
disabled={
b.level > roster.ownerLevel ||
!b.skillSlotCapacity ||
b.skills.includes(definition.skillId!)
}
>
{b.name} · {b.level}级
{b.skills.includes(definition.skillId!)
? ' · 已拥有'
: b.level > roster.ownerLevel
? ' · 等级过高'
: !b.skillSlotCapacity
? ' · 无技能格'
: ''}
</option>
))}
</select>
</label>
{beast ? (
<div className="space-y-2">
{beast.skills.map((id) => (
<p key={id}>
{skillName(id)}
{!activeBeastSkills(beast).includes(id)
? '(被高级技能抑制)'
: ''}
<InkTooltip label="技能说明">
{details[id]?.description}
</InkTooltip>
</p>
))}
</div>
) : null}
<p>随机覆盖其中一个技能,消耗一本,结果不可撤销。</p>
<div className="flex gap-3">
<InkButton disabled={pending} onClick={() => setLearning(false)}>
返回
</InkButton>
<InkButton
pending={pending}
disabled={!valid}
onClick={() =>
void act({
action: 'learn',
...ref,
beastId,
beastRevision: beast!.revision,
})
}
>
确认学习
</InkButton>
</div>
</>
) : (
<>
<div className="flex flex-wrap gap-3">
{item.location === 'bag' && definition.kind === 'beast_book' ? (
<InkButton disabled={pending} onClick={() => setLearning(true)}>
使用
</InkButton>
) : null}
{item.location === 'bag' && definition.kind === 'equipment' ? (
<InkButton
pending={pending}
onClick={() =>
void act({
action: 'equip',
...ref,
equipped: !item.equipped,
})
}
>
{item.equipped ? '卸下' : '装备'}
</InkButton>
) : null}
<InkButton
disabled={pending || item.equipped}
onClick={() =>
void act({
action: 'transfer',
...ref,
location: item.location === 'bag' ? 'storage' : 'bag',
})
}
>
{item.location === 'bag' ? '存入储藏室' : '取入背包'}
</InkButton>
{item.location === 'bag' ? (
<InkButton disabled={pending} onClick={move}>
移动/合并
</InkButton>
) : null}
</div>
{item.location === 'bag' && item.quantity > 1 ? (
<div className="flex items-center gap-3">
<input
aria-label="拆分数量"
type="number"
min={1}
max={item.quantity - 1}
value={quantity}
className="border-ink/20 w-20 border bg-transparent p-2"
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<InkButton
disabled={
pending ||
!Number.isInteger(quantity) ||
quantity < 1 ||
quantity >= item.quantity
}
onClick={() =>
void act({ action: 'split', ...ref, quantity })
}
>
拆分到空格
</InkButton>
</div>
) : null}
</>
)}
</div>
</InkDetailDrawer>
);
}
function EquipmentDetails({ data }: { data: unknown }) {
const equipment = InventoryEquipmentSchema.parse(data);
return (
<div className="space-y-2">
<p>
{equipment.equipmentLevel}级道装 · 需要人物 {equipment.requiredLevel}级
</p>
<dl className="grid grid-cols-2 gap-2">
{[...equipment.baseStats, ...equipment.attributeBonuses].map(
(roll, index) => (
<div key={index}>
<dt className="text-ink-secondary">
{EQUIPMENT_ATTRIBUTE_NAMES[roll.attr]}
</dt>
<dd>+{roll.value}</dd>
</div>
),
)}
</dl>
</div>
);
}
+15
View File
@@ -11,6 +11,7 @@ import {
useCultivatorCondition,
} from '@app/lib/resources/player';
import type { WildSessionView } from '@shared/contracts/combatV6Wild';
import { itemDefinition } from '@shared/inventory';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
@@ -249,6 +250,20 @@ function WildRegion({ nodeId }: { nodeId: string }) {
backLabel="返回地图"
/>
)}
{session?.settlement === 'settled' &&
!combat.playing &&
session.itemRewards?.length ? (
<p role="status" className="mt-3 text-sm">
获得{' '}
{session.itemRewards
.map(
(item) =>
`${itemDefinition(item.definitionId).name} ×${item.quantity}`,
)
.join('、')}
。已收存,背包不足的部分自动存入洞府储藏室。
</p>
) : null}
</CombatV6Page>
);
}
+28 -8
View File
@@ -9,7 +9,6 @@ import type {
BeastLineup,
SummonedBeast,
} from '@shared/engine/combat-v6/beasts';
import type { DaoEquipmentInstanceV1 } from '@shared/engine/combat-v6/equipment';
import type { SpiritFieldPlotState } from '@shared/engine/spirit-field/types';
import type {
ItemLibraryEditorConfig,
@@ -29,6 +28,7 @@ import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
check,
doublePrecision,
index,
integer,
@@ -648,20 +648,40 @@ export const combatV6ManualSlots = pgTable(
],
);
export const combatV6EquipmentInstances = pgTable(
'wanjiedaoyou_combat_v6_equipment_instances',
export const inventoryItems = pgTable(
'wanjiedaoyou_inventory_items',
{
id: varchar('id', { length: 160 }).primaryKey(),
cultivatorId: uuid('cultivator_id')
.references(() => cultivators.id, { onDelete: 'cascade' })
.notNull(),
schemaVersion: integer('schema_version').notNull().default(1),
generatorVersion: varchar('generator_version', { length: 64 }).notNull(),
instance: jsonb('instance').$type<DaoEquipmentInstanceV1>().notNull(),
location: varchar('location', { length: 16 })
.$type<'bag' | 'storage'>()
.notNull(),
slotIndex: integer('slot_index'),
definitionId: varchar('definition_id', { length: 160 }).notNull(),
quantity: integer('quantity').notNull(),
instanceData: jsonb('instance_data'),
revision: integer('revision').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
index('combat_v6_equipment_cultivator_idx').on(table.cultivatorId),
index('inventory_owner_location_idx').on(
table.cultivatorId,
table.location,
),
uniqueIndex('inventory_bag_slot_unique')
.on(table.cultivatorId, table.slotIndex)
.where(sql`${table.location} = 'bag'`),
check(
'inventory_quantity_positive',
sql`${table.quantity} > 0 AND ${table.revision} >= 0`,
),
check(
'inventory_slot_valid',
sql`(${table.location} = 'bag' AND ${table.slotIndex} IS NOT NULL AND ${table.slotIndex} >= 0) OR (${table.location} = 'storage' AND ${table.slotIndex} IS NULL)`,
),
],
);
@@ -674,7 +694,7 @@ export const combatV6EquipmentLoadouts = pgTable(
.notNull(),
slot: varchar('slot', { length: 32 }).notNull(),
equipmentInstanceId: varchar('equipment_instance_id', { length: 160 })
.references(() => combatV6EquipmentInstances.id, { onDelete: 'restrict' })
.references(() => inventoryItems.id, { onDelete: 'restrict' })
.notNull(),
},
(table) => [
@@ -1,7 +1,6 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6EquipmentInstances,
combatV6EquipmentLoadouts,
combatV6ManualSlots,
combatV6ManualStates,
@@ -9,6 +8,7 @@ import {
combatV6MeridianNodes,
combatV6MethodProgress,
cultivators,
inventoryItems,
sectMemberships,
} from '@server/lib/drizzle/schema';
import type { CombatV6PersistedBuildV1 } from '@shared/contracts/combatV6';
@@ -48,10 +48,7 @@ export async function findActiveCombatV6Membership(
return row ?? null;
}
export async function findCombatV6Profile(
membershipId: string,
q: DbExecutor,
) {
export async function findCombatV6Profile(membershipId: string, q: DbExecutor) {
const [row] = await q
.select()
.from(combatV6BuildProfiles)
@@ -65,7 +62,10 @@ export async function listCombatV6MethodLevels(
q: DbExecutor,
): Promise<Record<string, number>> {
const rows = await q
.select({ methodId: combatV6MethodProgress.methodId, level: combatV6MethodProgress.level })
.select({
methodId: combatV6MethodProgress.methodId,
level: combatV6MethodProgress.level,
})
.from(combatV6MethodProgress)
.where(eq(combatV6MethodProgress.profileId, profileId));
return Object.fromEntries(rows.map((row) => [row.methodId, row.level]));
@@ -76,25 +76,40 @@ export async function loadActiveCombatV6Build(
q: DbExecutor,
): Promise<CombatV6PersistedBuildV1 | null> {
const membership = await findActiveCombatV6Membership(cultivatorId, q);
if (!membership || !(membership.sectId in COMBAT_V6_SECT_DEFINITIONS_V4)) return null;
if (!membership || !(membership.sectId in COMBAT_V6_SECT_DEFINITIONS_V4))
return null;
const profile = await findCombatV6Profile(membership.membershipId, q);
if (!profile || profile.status !== 'active' || !profile.activePathId) return null;
if (!profile || profile.status !== 'active' || !profile.activePathId)
return null;
const sectId = membership.sectId as CombatV6SectId;
const methods = await listCombatV6MethodLevels(profile.id, q);
const loadouts = await q
.select({ id: combatV6MeridianLoadouts.id, pathId: combatV6MeridianLoadouts.pathId, revision: combatV6MeridianLoadouts.revision })
.select({
id: combatV6MeridianLoadouts.id,
pathId: combatV6MeridianLoadouts.pathId,
revision: combatV6MeridianLoadouts.revision,
})
.from(combatV6MeridianLoadouts)
.where(eq(combatV6MeridianLoadouts.profileId, profile.id));
const meridianLoadouts = [] as SectCombatProgressV6['meridianLoadouts'][number][];
const meridianLoadouts =
[] as SectCombatProgressV6['meridianLoadouts'][number][];
for (const loadout of loadouts) {
const nodes = await q
.select({ nodeId: combatV6MeridianNodes.nodeId, layer: combatV6MeridianNodes.layer })
.select({
nodeId: combatV6MeridianNodes.nodeId,
layer: combatV6MeridianNodes.layer,
})
.from(combatV6MeridianNodes)
.where(eq(combatV6MeridianNodes.loadoutId, loadout.id));
meridianLoadouts.push({
pathId: loadout.pathId,
revision: loadout.revision,
nodeIds: nodes.sort((left, right) => left.layer - right.layer || left.nodeId.localeCompare(right.nodeId)).map((node) => node.nodeId),
nodeIds: nodes
.sort(
(left, right) =>
left.layer - right.layer || left.nodeId.localeCompare(right.nodeId),
)
.map((node) => node.nodeId),
});
}
@@ -105,28 +120,46 @@ export async function loadActiveCombatV6Build(
.limit(1);
if (!manualState) return null;
const slots = await q
.select({ slot: combatV6ManualSlots.slot, manualId: combatV6ManualSlots.manualId })
.select({
slot: combatV6ManualSlots.slot,
manualId: combatV6ManualSlots.manualId,
})
.from(combatV6ManualSlots)
.where(eq(combatV6ManualSlots.stateId, manualState.id));
const manuals: CultivatorManualStateV1 = {
version: 1,
revision: manualState.revision,
build: { slots: slots.map((slot) => ({ slot: slot.slot as 1 | 2 | 3 | 4 | 5 | 6, manualId: slot.manualId })) },
build: {
slots: slots.map((slot) => ({
slot: slot.slot as 1 | 2 | 3 | 4 | 5 | 6,
manualId: slot.manualId,
})),
},
};
const equipmentRows = await q
.select({
slot: combatV6EquipmentLoadouts.slot,
instance: combatV6EquipmentInstances.instance,
ownerId: combatV6EquipmentInstances.cultivatorId,
instance: inventoryItems.instanceData,
ownerId: inventoryItems.cultivatorId,
location: inventoryItems.location,
definitionId: inventoryItems.definitionId,
})
.from(combatV6EquipmentLoadouts)
.innerJoin(
combatV6EquipmentInstances,
eq(combatV6EquipmentLoadouts.equipmentInstanceId, combatV6EquipmentInstances.id),
inventoryItems,
eq(combatV6EquipmentLoadouts.equipmentInstanceId, inventoryItems.id),
)
.where(eq(combatV6EquipmentLoadouts.profileId, profile.id));
if (equipmentRows.some((row) => row.ownerId !== cultivatorId)) return null;
if (
equipmentRows.some(
(row) =>
row.ownerId !== cultivatorId ||
row.location !== 'bag' ||
row.definitionId !== 'equipment.v6',
)
)
return null;
const equipment = Object.fromEntries(
equipmentRows.map((row) => [row.slot, structuredClone(row.instance)]),
) as DaoEquipmentLoadoutV1;
@@ -142,9 +175,11 @@ export async function loadActiveCombatV6Build(
version: 1,
sectId,
methods,
meridianDepth: profile.meridianDepth as SectCombatProgressV6['meridianDepth'],
meridianDepth:
profile.meridianDepth as SectCombatProgressV6['meridianDepth'],
activePathId: profile.activePathId,
meridianLoadouts: meridianLoadouts as SectCombatProgressV6['meridianLoadouts'],
meridianLoadouts:
meridianLoadouts as SectCombatProgressV6['meridianLoadouts'],
},
manuals,
equipment,
@@ -173,11 +208,16 @@ export async function lockActiveMembership(
return row ?? null;
}
export async function characterIdentityRow(cultivatorId: string, q: DbExecutor) {
export async function characterIdentityRow(
cultivatorId: string,
q: DbExecutor,
) {
const [row] = await q
.select()
.from(cultivators)
.where(and(eq(cultivators.id, cultivatorId), eq(cultivators.status, 'active')))
.where(
and(eq(cultivators.id, cultivatorId), eq(cultivators.status, 'active')),
)
.limit(1);
return row ?? null;
}
+513
View File
@@ -0,0 +1,513 @@
import type {
InventoryAction,
InventoryQuerySchema,
InventoryView,
} from '@shared/contracts/inventory';
import {
compileDaoEquipmentSpecialLoadoutV1,
type DaoEquipmentInstanceV1,
} from '@shared/engine/combat-v6/equipment';
import {
addItems,
BAG_CAPACITY,
BOOKS,
emptySlot,
InventoryItemSchema,
itemDefinition,
learnBeastSkill,
sameStack,
sortBag,
type InventoryItem,
type ItemGrant,
} from '@shared/inventory';
import { and, asc, count, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { randomInt, randomUUID } from 'node:crypto';
import type { z } from 'zod';
import { db, type DbTransaction } from '../drizzle/db';
import {
combatV6Beasts,
combatV6BuildProfiles,
combatV6EquipmentLoadouts,
inventoryItems,
} from '../drizzle/schema';
import { redis } from '../redis';
import { redisLockKeys, withRedisLock } from '../redis/lock';
import {
readBeastOwner,
readBeastRoster,
} from '../repositories/combatV6BeastRepository';
import {
findActiveCombatV6Membership,
findCombatV6Profile,
loadActiveCombatV6Build,
} from '../repositories/combatV6BuildRepository';
import { lockCultivatorForStateMutation } from '../repositories/playerStateRepository';
import { arenaOccupancyKey } from './combat-v6/CombatV6ArenaStore';
import { CombatV6RuntimeStore } from './combat-v6/CombatV6RuntimeStore';
import { CombatV6WildStore } from './combat-v6/CombatV6WildStore';
import { ResourceEventCommitter } from './ResourceEventCommitter';
export class InventoryError extends Error {}
function itemOf(row: typeof inventoryItems.$inferSelect): InventoryItem {
return InventoryItemSchema.parse({
id: row.id,
location: row.location,
slotIndex: row.slotIndex,
definitionId: row.definitionId,
quantity: row.quantity,
instanceData: row.instanceData,
revision: row.revision,
});
}
export async function readInventory(
owner: string,
query: z.infer<typeof InventoryQuerySchema>,
): Promise<InventoryView> {
const ownerFilter = eq(inventoryItems.cultivatorId, owner);
const matches = BOOKS.filter((i) => i.name.includes(query.search)).map(
(i) => i.id,
);
const filter = and(
ownerFilter,
eq(inventoryItems.location, query.location),
query.kind === 'all'
? undefined
: query.kind === 'equipment'
? eq(inventoryItems.definitionId, 'equipment.v6')
: inArray(
inventoryItems.definitionId,
BOOKS.map((i) => i.id),
),
query.search
? or(
inArray(inventoryItems.definitionId, matches),
ilike(
sql`${inventoryItems.instanceData}->>'name'`,
`%${query.search.replace(/[\\%_]/g, '\\$&')}%`,
),
)
: undefined,
);
const [requestedRows, totals, usage] = await Promise.all([
db
.select()
.from(inventoryItems)
.where(filter)
.orderBy(asc(inventoryItems.slotIndex), asc(inventoryItems.id))
.limit(query.location === 'bag' ? BAG_CAPACITY : 40)
.offset(query.location === 'bag' ? 0 : query.page * 40),
db.select({ value: count() }).from(inventoryItems).where(filter),
db
.select({ value: count() })
.from(inventoryItems)
.where(and(ownerFilter, eq(inventoryItems.location, 'bag'))),
]);
const page =
query.location === 'bag'
? 0
: Math.min(query.page, Math.max(0, Math.ceil(totals[0].value / 40) - 1));
const rows =
page === query.page || query.location === 'bag'
? requestedRows
: await db
.select()
.from(inventoryItems)
.where(filter)
.orderBy(asc(inventoryItems.slotIndex), asc(inventoryItems.id))
.limit(40)
.offset(page * 40);
const equipped = rows.length
? await db
.select({ id: combatV6EquipmentLoadouts.equipmentInstanceId })
.from(combatV6EquipmentLoadouts)
.where(
inArray(
combatV6EquipmentLoadouts.equipmentInstanceId,
rows.map((r) => r.id),
),
)
: [];
const ids = new Set(equipped.map((i) => i.id));
return {
items: rows.map((row) => ({
...itemOf(row),
name:
row.definitionId === 'equipment.v6'
? (row.instanceData as DaoEquipmentInstanceV1).name
: itemDefinition(row.definitionId).name,
equipped: ids.has(row.id),
})),
total: totals[0].value,
used: usage[0].value,
capacity: BAG_CAPACITY,
page,
};
}
/** Caller holds the character SQL lock. Apply only changed rows, with stale-write guards. */
async function save(
owner: string,
before: InventoryItem[],
after: InventoryItem[],
tx: DbTransaction,
) {
const nextIds = new Set(after.map((i) => i.id));
for (const old of before) {
const next = after.find((i) => i.id === old.id);
if (next && JSON.stringify(next) === JSON.stringify(old)) continue;
const filter = and(
eq(inventoryItems.id, old.id),
eq(inventoryItems.cultivatorId, owner),
eq(inventoryItems.revision, old.revision),
);
// Release bag slots before swaps; the transaction never exposes temporary positions.
const rows = nextIds.has(old.id)
? await tx
.update(inventoryItems)
.set({ location: 'storage', slotIndex: null })
.where(filter)
.returning({ id: inventoryItems.id })
: await tx
.delete(inventoryItems)
.where(filter)
.returning({ id: inventoryItems.id });
if (!rows.length) throw new InventoryError('物品已变化,请刷新');
}
const oldMap = new Map(before.map((i) => [i.id, i]));
for (const next of after) {
const old = oldMap.get(next.id);
if (old && JSON.stringify(old) === JSON.stringify(next)) continue;
if (old)
await tx
.update(inventoryItems)
.set({ ...next, updatedAt: new Date() })
.where(
and(
eq(inventoryItems.id, next.id),
eq(inventoryItems.cultivatorId, owner),
),
);
else
await tx.insert(inventoryItems).values({ ...next, cultivatorId: owner });
}
}
export async function grantInventory(
owner: string,
grants: ItemGrant[],
tx: DbTransaction,
) {
if (!grants.length) return;
// Only relevant stacks and the bounded bag are needed, even with an unlimited store.
const before = (
await tx
.select()
.from(inventoryItems)
.where(
and(
eq(inventoryItems.cultivatorId, owner),
or(
eq(inventoryItems.location, 'bag'),
or(
...grants.map((g) =>
and(
eq(inventoryItems.definitionId, g.definitionId),
sql`${inventoryItems.quantity} < ${itemDefinition(g.definitionId).stackLimit}`,
),
),
),
),
),
)
).map(itemOf);
let next = before;
for (const grant of grants)
next = addItems(next, grant, 'bag', true, randomUUID);
await save(owner, before, next, tx);
}
export async function mutateInventory(owner: string, input: InventoryAction) {
return withRedisLock(
{
key: redisLockKeys.cultivatorMutation(owner),
context: 'inventory',
timeoutMs: 30000,
retries: 0,
},
async (lease) =>
db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, owner);
if (
(await new CombatV6WildStore().lock(owner)) ||
(await new CombatV6RuntimeStore().currentId(owner)) ||
(await redis.get(arenaOccupancyKey(owner)))
)
throw new InventoryError('请先结束战斗与结算,再调整物品');
const before = (
await tx
.select()
.from(inventoryItems)
.where(
and(
eq(inventoryItems.cultivatorId, owner),
or(
eq(inventoryItems.location, 'bag'),
input.action === 'sort'
? undefined
: eq(inventoryItems.id, input.id),
),
),
)
).map(itemOf);
if (input.action === 'transfer' && input.location === 'storage') {
const source = before.find((i) => i.id === input.id);
if (source) {
const stacks = await tx
.select()
.from(inventoryItems)
.where(
and(
eq(inventoryItems.cultivatorId, owner),
eq(inventoryItems.location, 'storage'),
eq(inventoryItems.definitionId, source.definitionId),
sql`${inventoryItems.quantity} < ${itemDefinition(source.definitionId).stackLimit}`,
),
)
.orderBy(asc(inventoryItems.id))
.limit(1);
before.push(...stacks.map(itemOf));
}
}
let next = before.map((i) => ({ ...i }));
const item =
input.action === 'sort'
? undefined
: next.find(
(i) => i.id === input.id && i.revision === input.revision,
);
if (input.action !== 'sort' && !item)
throw new InventoryError('物品已变化,请刷新后重试');
let result: { oldSkill?: string; newSkill?: string } = {};
if (input.action === 'sort') {
const bag = next.filter((i) => i.location === 'bag');
if (
bag.length !== input.items.length ||
new Set(input.items.map((i) => i.id)).size !== input.items.length ||
input.items.some(
(ref) =>
!bag.some(
(i) => i.id === ref.id && i.revision === ref.revision,
),
)
)
throw new InventoryError('背包已变化,请刷新');
next = sortBag(next);
} else if (item) {
const equipped = await tx
.select()
.from(combatV6EquipmentLoadouts)
.where(eq(combatV6EquipmentLoadouts.equipmentInstanceId, item.id));
if (input.action === 'transfer') {
if (equipped.length) throw new InventoryError('请先卸下装备');
if (item.location === input.location)
throw new InventoryError('物品已在该位置');
next = next.filter((i) => i.id !== item.id);
if (
item.instanceData == null &&
itemDefinition(item.definitionId).stackLimit > 1
)
next = addItems(
next,
{ definitionId: item.definitionId, quantity: item.quantity },
input.location,
false,
() => item.id,
).map((entry) =>
entry.id === item.id
? { ...entry, revision: item.revision + 1 }
: entry,
);
else {
const slotIndex =
input.location === 'bag' ? emptySlot(next) : null;
if (input.location === 'bag' && slotIndex === null)
throw new InventoryError('背包格子不足');
next.push({
...item,
location: input.location,
slotIndex,
revision: item.revision + 1,
});
}
} else if (input.action === 'move') {
if (item.location !== 'bag')
throw new InventoryError('请先取出物品');
const target = next.find(
(i) => i.location === 'bag' && i.slotIndex === input.slot,
);
if (
(target?.id ?? null) !== input.targetId ||
(target?.revision ?? null) !== input.targetRevision
)
throw new InventoryError('目标格位已变化,请刷新');
if (target?.id === item.id) {
lease.assertHeld();
return result;
}
if (target && sameStack(item, target)) {
const amount = Math.min(
item.quantity,
itemDefinition(item.definitionId).stackLimit - target.quantity,
);
if (!amount) throw new InventoryError('目标堆叠已满');
item.quantity -= amount;
target.quantity += amount;
target.revision++;
if (!item.quantity) next = next.filter((i) => i.id !== item.id);
} else {
if (target) {
target.slotIndex = item.slotIndex;
target.revision++;
}
item.slotIndex = input.slot;
}
item.revision++;
} else if (input.action === 'split') {
if (
item.location !== 'bag' ||
item.instanceData != null ||
input.quantity >= item.quantity
)
throw new InventoryError('拆分数量无效');
const slotIndex = emptySlot(next);
if (slotIndex === null) throw new InventoryError('背包格子不足');
item.quantity -= input.quantity;
item.revision++;
next.push({
...item,
id: randomUUID(),
quantity: input.quantity,
slotIndex,
revision: 0,
});
} else if (input.action === 'learn') {
if (item.location !== 'bag')
throw new InventoryError('请先从储藏室取出兽诀');
const roster = await readBeastRoster(owner, tx);
const beast = roster.beasts.find(
(b) =>
b.id === input.beastId && b.revision === input.beastRevision,
);
if (!beast || !beast.skillSlotCapacity)
throw new InventoryError('灵兽已变化或没有技能格');
const slot = randomInt(beast.skillSlotCapacity);
const learned = learnBeastSkill(
beast,
item.definitionId,
roster.ownerLevel,
slot,
);
await tx
.update(combatV6Beasts)
.set({ individual: learned })
.where(
and(
eq(combatV6Beasts.id, beast.id),
eq(combatV6Beasts.cultivatorId, owner),
),
);
result = {
oldSkill: beast.skills[slot],
newSkill: learned.skills[slot],
};
item.quantity--;
item.revision++;
if (!item.quantity) next = next.filter((i) => i.id !== item.id);
} else if (input.action === 'equip') {
if (item.location !== 'bag' || item.definitionId !== 'equipment.v6')
throw new InventoryError('请先将道装取入背包');
const membership = await findActiveCombatV6Membership(owner, tx);
const profile = membership
? await findCombatV6Profile(membership.membershipId, tx)
: null;
if (!profile || profile.status !== 'active')
throw new InventoryError('请先完成战斗构筑');
const equipment = item.instanceData as DaoEquipmentInstanceV1;
const [previous] = await tx
.select()
.from(combatV6EquipmentLoadouts)
.where(
and(
eq(combatV6EquipmentLoadouts.profileId, profile.id),
eq(combatV6EquipmentLoadouts.slot, equipment.slot),
),
);
if (input.equipped && previous?.equipmentInstanceId === item.id)
throw new InventoryError('该物品已装备');
if (!input.equipped && previous?.equipmentInstanceId !== item.id)
throw new InventoryError('装备状态已变化,请刷新');
const replaced = next.find(
(entry) =>
entry.id === previous?.equipmentInstanceId &&
entry.id !== item.id,
);
if (input.equipped && replaced) replaced.revision++;
if (input.equipped) {
await tx
.insert(combatV6EquipmentLoadouts)
.values({
profileId: profile.id,
slot: equipment.slot,
equipmentInstanceId: item.id,
})
.onConflictDoUpdate({
target: [
combatV6EquipmentLoadouts.profileId,
combatV6EquipmentLoadouts.slot,
],
set: { equipmentInstanceId: item.id },
});
} else
await tx
.delete(combatV6EquipmentLoadouts)
.where(
and(
eq(combatV6EquipmentLoadouts.profileId, profile.id),
eq(combatV6EquipmentLoadouts.equipmentInstanceId, item.id),
),
);
item.revision++;
const build = await loadActiveCombatV6Build(owner, tx);
const character = await readBeastOwner(owner, tx);
if (!build) throw new InventoryError('战斗构筑不可用');
const compiled = compileDaoEquipmentSpecialLoadoutV1(
build.equipment,
character.ownerLevel,
);
if (!compiled.ok)
throw new InventoryError(
compiled.diagnostics.find((d) => d.severity === 'error')
?.message ?? '装配无效',
);
await tx
.update(combatV6BuildProfiles)
.set({ revision: profile.revision + 1 })
.where(eq(combatV6BuildProfiles.id, profile.id));
await new ResourceEventCommitter().commit(tx, {
actor: { userId: character.userId, cultivatorId: owner },
source: 'inventory-equipment',
scopeDefaults: { cultivatorId: owner },
changes: [
{
resourceTopic: 'player.combat-v6-build',
operation: 'invalidate',
eventType: 'combat_v6.equipment.changed',
},
],
});
}
}
await save(owner, before, next, tx);
lease.assertHeld();
return result;
}),
);
}
@@ -14,6 +14,7 @@ import { settleWildResources } from '@shared/engine/combat-v6/wild/rules';
import type { CultivatorCondition } from '@shared/types/condition';
import { and, eq } from 'drizzle-orm';
import { ConditionService } from '../ConditionService';
import { grantInventory } from '../InventoryService';
import { ResourceEventCommitter } from '../ResourceEventCommitter';
import { CombatV6RuntimeStore } from './CombatV6RuntimeStore';
import { CombatV6WildStore } from './CombatV6WildStore';
@@ -78,6 +79,8 @@ export async function projectCombatV6Condition(
tx,
);
await settleBeastProgress(s, tx);
if (record.reason === 'battle-ended')
await grantInventory(s.cultivatorId, s.itemRewards ?? [], tx);
}
const [row] = await tx
.select({ condition: cultivators.condition })
@@ -29,6 +29,7 @@ import {
beastVictoryExperience,
generateCapturedBeast,
} from '@shared/engine/combat-v6/beasts/progression';
import { SeededRng } from '@shared/engine/combat-v6/core';
import { projectCultivatorMultiSectV5ToCombatV6 } from '@shared/engine/combat-v6/projection';
import {
WILD_CONTENT_VERSION,
@@ -37,6 +38,7 @@ import {
} from '@shared/engine/combat-v6/wild/content';
import { createWildHost, WildHost } from '@shared/engine/combat-v6/wild/host';
import { wildDay } from '@shared/engine/combat-v6/wild/rules';
import { rollBeastBooks } from '@shared/inventory';
import { evaluateFateContext } from '@shared/lib/fates';
import { eq } from 'drizzle-orm';
import { createHash, randomInt, randomUUID } from 'node:crypto';
@@ -99,7 +101,21 @@ function summaryOf(
entry: WildSettlement['entry'],
): WildSettlement {
const p = r.host.state.units.find((u) => u.id === r.host.playerId)!;
const rewardSeed = createHash('sha256')
.update(`beast-books-v1:${r.battleId}:${r.host.input.seed}`)
.digest()
.readUInt32LE();
const rewardRng = new SeededRng(rewardSeed);
return {
itemRewards:
r.host.state.result?.winner === p.side
? rollBeastBooks(
r.host.state.units.filter(
(u) => u.side !== p.side && u.kind === 'npc' && u.flags.dead,
).length,
() => rewardRng.next(),
)
: [],
capturedBeasts: r.host.events.flatMap((event) => {
if (event.type !== 'unitCaptured' || event.unitId !== r.host.playerId)
return [];
@@ -430,13 +446,14 @@ export class CombatV6WildSessionService {
action(host, presentation.capture);
const s = await store.summary(id);
if (!s) throw new WildError('WILD_SETTLEMENT_MISSING', '结算事实缺失');
const next = {
const next: WildRuntime = {
...r,
revision: r.revision + 1,
host: host.runtimeSnapshot(),
};
next.latestEventSeq = next.host.events.length - 1;
const nextSummary = summaryOf(next, s.entry);
if (host.finished) next.itemRewards = nextSummary.itemRewards ?? [];
const trace = host.trace();
const event = host.finished
? wildTerminal(
@@ -510,6 +527,7 @@ export class CombatV6WildSessionService {
round: state.round,
phase: state.phase,
outcome: host.trace().outcome,
itemRewards: host.finished ? (r.itemRewards ?? []) : [],
settlement: host.finished
? (await store.lock(r.cultivatorId))
? 'pending'
+39
View File
@@ -10,6 +10,11 @@ import {
findOwnedCombatV6Replay,
listOwnedCombatV6Replays,
} from '@server/lib/repositories/combatV6ReplayRepository';
import {
InventoryError,
mutateInventory,
readInventory,
} from '@server/lib/services/InventoryService';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import { readResourceWithMeta } from '@server/lib/services/ResourceReadService';
import { CombatV6ArenaStore } from '@server/lib/services/combat-v6/CombatV6ArenaStore';
@@ -56,7 +61,12 @@ import {
} from '@shared/contracts/combatV6Beasts';
import { CombatV6HistoryQuerySchema } from '@shared/contracts/combatV6Replay';
import { WildExploreRequestSchema } from '@shared/contracts/combatV6Wild';
import {
InventoryActionSchema,
InventoryQuerySchema,
} from '@shared/contracts/inventory';
import { TrainingHostError } from '@shared/engine/combat-v6/encounter';
import { InventoryRuleError } from '@shared/inventory';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
@@ -78,6 +88,8 @@ function actor(c: Context<AppEnv>) {
}
function errorResponse(c: Context<AppEnv>, error: unknown) {
if (error instanceof InventoryError || error instanceof InventoryRuleError)
return c.json({ success: false, error: error.message }, 409);
if (error instanceof BeastError)
return c.json({ success: false, error: error.message }, error.status);
const coordinationError = redisLockErrorResponse(error);
@@ -124,6 +136,33 @@ function errorResponse(c: Context<AppEnv>, error: unknown) {
);
}
router.get('/inventory', async (c) => {
c.header('Cache-Control', 'no-store');
try {
return c.json({
success: true,
data: await readInventory(
actor(c).cultivatorId,
InventoryQuerySchema.parse(c.req.query()),
),
});
} catch (error) {
return errorResponse(c, error);
}
});
router.post('/inventory', async (c) => {
try {
return c.json({
success: true,
data: await mutateInventory(
actor(c).cultivatorId,
InventoryActionSchema.parse(await c.req.json()),
),
});
} catch (error) {
return errorResponse(c, error);
}
});
router.get('/beasts', async (c) => {
c.header('Cache-Control', 'no-store');
try {
@@ -1,9 +1,23 @@
import { BEAST_SKILLS } from '@shared/engine/combat-v6/beasts';
import { EffectType, TargetSide } from '@shared/engine/combat-v6/core/enums';
import { DAO_EQUIPMENT_ARTS_V1 } from '@shared/engine/combat-v6/equipment/special-content';
import { describe, expect, it } from 'vitest';
import { combatV6SkillDetails } from './skill-details';
describe('combat skill previews', () => {
it('shows combo chances from the authoritative hooks', () => {
const details = combatV6SkillDetails(BEAST_SKILLS, []);
expect(details['beast.combo'].description).toContain('25%');
expect(details['beast.advanced-combo'].description).toContain('40%');
const combo = BEAST_SKILLS.find((skill) => skill.id === 'beast.combo')!;
const adjusted = {
...combo,
hooks: combo.hooks!.map((hook) => ({ ...hook, chance: 0.3 })),
};
expect(
combatV6SkillDetails([adjusted], [])[combo.id].description,
).toContain('30%');
});
it('classifies all registered equipment arts without relying on skill names', () => {
const skills = DAO_EQUIPMENT_ARTS_V1.map((art) => art.skill);
const details = combatV6SkillDetails(skills, []);
+11 -3
View File
@@ -1,4 +1,4 @@
import { EffectType } from '@shared/engine/combat-v6/core/enums';
import { EffectType, HookName } from '@shared/engine/combat-v6/core/enums';
import type {
SkillDef,
SkillEffect,
@@ -7,6 +7,12 @@ import type {
import { DAO_EQUIPMENT_ART_SKILL_ID } from '@shared/engine/combat-v6/equipment/special-content';
const artIds = new Set<string>(Object.values(DAO_EQUIPMENT_ART_SKILL_ID));
function beastComboDescription(skill: SkillDef): string | undefined {
if (skill.id !== 'beast.combo' && skill.id !== 'beast.advanced-combo') return;
const hook = skill.hooks?.find((entry) => entry.on === HookName.AfterHit);
if (typeof hook?.chance !== 'number') return;
return `自身物理攻击命中后,有 ${Math.round(hook.chance * 100)}% 概率向该目标追加一次物理攻击。`;
}
const effectLabels: Record<SkillEffect['type'], string> = {
physicalHit: '造成物理伤害',
spellHit: '造成法术伤害',
@@ -65,14 +71,16 @@ export function combatV6SkillDetails(
category: artIds.has(skill.id) ? ('art' as const) : ('spell' as const),
description: skill.capture
? '尝试收服野生灵兽,气血越低越容易成功;执行时消耗法力,失败仍消耗。'
: [
: (beastComboDescription(skill) ??
([
...new Set([
...skill.effects.map(describe),
...(skill.successEffects ?? []).map(
(effect) => `施放成功后:${describe(effect)}`,
),
]),
].join(';') || '被动能力,依技能条件触发。',
].join(';') ||
'被动能力,依技能条件触发。')),
},
]),
);
+12 -2
View File
@@ -2,6 +2,7 @@ import type { WildRuntimeSnapshot } from '@shared/engine/combat-v6/wild/host';
import type { WildResources } from '@shared/engine/combat-v6/wild/rules';
import { z } from 'zod';
import { BeastSchema, type SummonedBeast } from '../engine/combat-v6/beasts';
import { ItemGrantSchema, type ItemGrant } from '../inventory';
import type { CombatV6TrainingSessionViewV1 } from './combatV6';
import { CombatV6ReplayTimelineSchema } from './combatV6Replay';
import type { CombatV6RedisRuntimeV1 } from './combatV6Runtime';
@@ -24,6 +25,8 @@ export type WildRuntime = Omit<CombatV6RedisRuntimeV1, 'metadata' | 'host'> & {
{ sourceType: 'wild-encounter' }
>;
host: WildRuntimeSnapshot;
/** Frozen terminal display; settlement facts may be removed after delivery. */
itemRewards?: ItemGrant[];
};
export const WildRuntimeSchema = z
.object({
@@ -38,6 +41,7 @@ export const WildRuntimeSchema = z
createdAt: z.iso.datetime(),
expiresAt: z.iso.datetime(),
latestEventSeq: z.number().int().min(-1),
itemRewards: z.array(ItemGrantSchema).max(3).optional(),
host: z
.object({
schemaVersion: z.literal(1),
@@ -51,7 +55,7 @@ export const WildRuntimeSchema = z
.object({
engineVersion: z.literal('combat-v6'),
rulesetVersion: z.literal('daoyou_rules_v8'),
contentVersion: z.literal('daoyou_wild_capture_content_v1'),
contentVersion: z.literal('daoyou_wild_inventory_content_v1'),
projectionVersion: z.literal('wild_beast_v2'),
})
.strict(),
@@ -106,6 +110,7 @@ export const WildRuntimeSchema = z
v.latestEventSeq === v.host.events.length - 1,
);
export interface WildSettlement {
itemRewards?: ItemGrant[];
capturedBeasts?: SummonedBeast[];
beastExperience?: { beastId: string; amount: number };
deadBeastIds?: string[];
@@ -125,6 +130,7 @@ export interface WildSettlement {
}
export const WildSettlementSchema = z
.object({
itemRewards: z.array(ItemGrantSchema).max(3).optional(),
capturedBeasts: z.array(BeastSchema).max(3).optional(),
beastExperience: z
.object({
@@ -160,4 +166,8 @@ export const WildSettlementSchema = z
export type WildSessionView = Omit<
CombatV6TrainingSessionViewV1,
'encounterId' | 'tier'
> & { nodeId: string; settlement: 'pending' | 'settled' | 'not-started' };
> & {
nodeId: string;
settlement: 'pending' | 'settled' | 'not-started';
itemRewards?: ItemGrant[];
};
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
import { BAG_CAPACITY, type InventoryItem } from '../inventory';
const ref = {
id: z.string().min(1).max(160),
revision: z.number().int().nonnegative(),
};
export const InventoryQuerySchema = z
.object({
location: z.enum(['bag', 'storage']).default('bag'),
page: z.coerce.number().int().min(0).max(1000000).default(0),
search: z.string().max(80).default(''),
kind: z.enum(['all', 'beast_book', 'equipment']).default('all'),
})
.strict();
export const InventoryActionSchema = z.discriminatedUnion('action', [
z
.object({
action: z.literal('transfer'),
...ref,
location: z.enum(['bag', 'storage']),
})
.strict(),
z
.object({
action: z.literal('move'),
...ref,
slot: z
.number()
.int()
.min(0)
.max(BAG_CAPACITY - 1),
targetId: z.string().max(160).nullable(),
targetRevision: z.number().int().nonnegative().nullable(),
})
.strict(),
z
.object({
action: z.literal('split'),
...ref,
quantity: z.number().int().positive().max(98),
})
.strict(),
z
.object({
action: z.literal('sort'),
items: z.array(z.object(ref).strict()).max(BAG_CAPACITY),
})
.strict(),
z
.object({
action: z.literal('learn'),
...ref,
beastId: z.uuid(),
beastRevision: z.number().int().nonnegative(),
})
.strict(),
z
.object({ action: z.literal('equip'), ...ref, equipped: z.boolean() })
.strict(),
]);
export type InventoryAction = z.infer<typeof InventoryActionSchema>;
export type InventoryView = {
items: (InventoryItem & { name: string; equipped: boolean })[];
used: number;
total: number;
page: number;
capacity: number;
};
@@ -114,6 +114,7 @@ export type CombatV6VersionStamp = {
| 'daoyou_rules_v7'
| 'daoyou_rules_v8';
contentVersion:
| 'daoyou_wild_inventory_content_v1'
| 'daoyou_wild_capture_content_v1'
| 'daoyou_arena_beast_content_v1'
| 'daoyou_training_beast_content_v1'
+6
View File
@@ -152,3 +152,9 @@ export const COMBAT_V6_PHASE_9B_WILD_VERSIONS: CombatV6VersionStamp =
contentVersion: 'daoyou_wild_capture_content_v1',
projectionVersion: 'wild_beast_v2',
});
export const COMBAT_V6_PHASE_9C_WILD_VERSIONS: CombatV6VersionStamp =
Object.freeze({
...COMBAT_V6_PHASE_9B_WILD_VERSIONS,
contentVersion: 'daoyou_wild_inventory_content_v1',
});
+2 -2
View File
@@ -18,7 +18,7 @@ import { projectCultivatorMultiSectV5ToCombatV6 } from '../projection/index.ts';
import { daoyouRulesetV6 } from '../rules-daoyou/index.ts';
import {
COMBAT_V6_PHASE_6D_VERSIONS,
COMBAT_V6_PHASE_9B_WILD_VERSIONS,
COMBAT_V6_PHASE_9C_WILD_VERSIONS,
} from '../version.ts';
import {
WILD_REGION,
@@ -28,7 +28,7 @@ import {
wildPanel,
} from './content.ts';
export const WILD_VERSIONS = COMBAT_V6_PHASE_9B_WILD_VERSIONS;
export const WILD_VERSIONS = COMBAT_V6_PHASE_9C_WILD_VERSIONS;
export type WildCombatant = {
unitId: string;
speciesId: string;
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
import { DAO_EQUIPMENT_SLOTS } from '../engine/combat-v6/equipment';
export const EQUIPMENT_ATTRIBUTE_NAMES = {
physicalAtk: '物攻',
physicalDef: '物防',
magicAtk: '法攻',
magicDef: '法防',
maxHp: '气血',
maxMp: '法力',
healPower: '治疗',
speed: '速度',
hit: '命中',
dodge: '闪避',
critRate: '暴击',
spellCritRate: '法暴',
physicalFuryRate: '物理狂暴',
sealHit: '封印命中',
sealResist: '封印抵抗',
vitality: '体质',
strength: '力量',
spirit: '灵力',
endurance: '耐力',
willpower: '意志',
};
const roll = z
.object({
attr: z.enum(
Object.keys(EQUIPMENT_ATTRIBUTE_NAMES) as [
keyof typeof EQUIPMENT_ATTRIBUTE_NAMES,
...(keyof typeof EQUIPMENT_ATTRIBUTE_NAMES)[],
],
),
value: z.number().finite(),
})
.strict();
export const InventoryEquipmentSchema = z
.object({
schemaVersion: z.literal(1),
id: z.string().min(1),
templateId: z.string().min(1),
name: z.string().min(1).max(100),
slot: z.enum(DAO_EQUIPMENT_SLOTS),
equipmentLevel: z.number().int().nonnegative(),
requiredLevel: z.number().int().nonnegative(),
baseStats: z.array(roll).max(20),
attributeBonuses: z.array(roll).max(20),
essenceIds: z.array(z.string()).max(20),
artId: z.string().optional(),
formationInscription: z
.object({ patternId: z.string(), level: z.number().int().nonnegative() })
.strict()
.optional(),
appraisalState: z.literal('appraised'),
generatorVersion: z.enum([
'dao_equipment_generator_v1',
'dao_equipment_generator_v2',
]),
createdAt: z.string(),
})
.strict();
+236
View File
@@ -0,0 +1,236 @@
import { z } from 'zod';
import {
BEAST_SKILLS,
BeastSchema,
type SummonedBeast,
} from '../engine/combat-v6/beasts';
import { InventoryEquipmentSchema } from './equipment';
export const BAG_CAPACITY = 40;
export class InventoryRuleError extends Error {}
export const BOOKS = [
'beast.spirit-flame',
'beast.stone-guard',
'beast.wind-strike',
'beast.combo',
'beast.advanced-combo',
]
.map((id) => BEAST_SKILLS.find((skill) => skill.id === id)!)
.map((skill) => ({
id: `book.${skill.id}`,
name: `${skill.name}兽诀`,
kind: 'beast_book' as const,
skillId: skill.id,
stackLimit: 99,
}));
export function itemDefinition(id: string) {
const book = BOOKS.find((item) => item.id === id);
if (book) return book;
if (id === 'equipment.v6')
return {
id,
name: '道装',
kind: 'equipment' as const,
stackLimit: 1,
skillId: undefined,
};
throw new InventoryRuleError('未知物品定义');
}
export const InventoryItemSchema = z
.object({
id: z.string().min(1).max(160),
location: z.enum(['bag', 'storage']),
slotIndex: z
.number()
.int()
.min(0)
.max(BAG_CAPACITY - 1)
.nullable(),
definitionId: z.string().min(1).max(160),
quantity: z.number().int().positive().max(2147483647),
instanceData: z.unknown().nullable(),
revision: z.number().int().nonnegative(),
})
.strict()
.superRefine((item, ctx) => {
if ((item.location === 'bag') !== (item.slotIndex !== null))
ctx.addIssue({ code: 'custom', message: '格位与位置不一致' });
if (
item.definitionId !== 'equipment.v6' &&
!BOOKS.some((book) => book.id === item.definitionId)
) {
ctx.addIssue({ code: 'custom', message: '未知物品定义' });
return;
}
const definition = itemDefinition(item.definitionId);
if (item.quantity > definition.stackLimit)
ctx.addIssue({ code: 'custom', message: '超过堆叠上限' });
if (definition.kind === 'equipment') {
const equipment = InventoryEquipmentSchema.safeParse(item.instanceData);
if (!equipment.success || equipment.data.id !== item.id)
ctx.addIssue({ code: 'custom', message: '道装个体事实无效' });
} else if (item.instanceData !== null)
ctx.addIssue({ code: 'custom', message: '固定物品不能附带个体属性' });
});
export type InventoryItem = z.infer<typeof InventoryItemSchema>;
export type ItemGrant = {
definitionId: string;
quantity: number;
instanceData?: z.infer<typeof InventoryEquipmentSchema>;
};
export const ItemGrantSchema = z
.object({
definitionId: z.string(),
quantity: z.number().int().positive().max(99),
instanceData: InventoryEquipmentSchema.optional(),
})
.strict();
export function sameStack(a: InventoryItem, b: InventoryItem) {
return (
a.definitionId === b.definitionId &&
a.instanceData == null &&
b.instanceData == null &&
itemDefinition(a.definitionId).stackLimit > 1
);
}
export function emptySlot(items: InventoryItem[]) {
const used = new Set(
items.filter((i) => i.location === 'bag').map((i) => i.slotIndex),
);
for (let slot = 0; slot < BAG_CAPACITY; slot++)
if (!used.has(slot)) return slot;
return null;
}
export function sortBag(items: InventoryItem[]) {
const bag = items
.filter((item) => item.location === 'bag')
.map((item) => ({ ...item }))
.sort(
(a, b) =>
a.definitionId.localeCompare(b.definitionId) ||
a.id.localeCompare(b.id),
);
const merged: InventoryItem[] = [];
for (const item of bag) {
for (const target of merged) {
if (!sameStack(item, target)) continue;
const amount = Math.min(
item.quantity,
itemDefinition(target.definitionId).stackLimit - target.quantity,
);
item.quantity -= amount;
target.quantity += amount;
}
if (item.quantity) merged.push(item);
}
return [
...items.filter((item) => item.location !== 'bag'),
...merged.map((item, slotIndex) => ({
...item,
slotIndex,
revision: item.revision + 1,
})),
];
}
/** Caller supplies fresh identities; no random or persistence effects in planning. */
export function addItems(
items: InventoryItem[],
grant: ItemGrant,
location: 'bag' | 'storage',
overflow: boolean,
id: () => string,
) {
if (!Number.isSafeInteger(grant.quantity) || grant.quantity <= 0)
throw new InventoryRuleError('物品数量无效');
const limit = itemDefinition(grant.definitionId).stackLimit;
if (itemDefinition(grant.definitionId).kind === 'equipment') {
const facts = InventoryEquipmentSchema.parse(grant.instanceData);
if (grant.quantity !== 1 || items.some((item) => item.id === facts.id))
throw new InventoryRuleError('独立物品数量或身份无效');
const slotIndex = location === 'bag' ? emptySlot(items) : null;
if (location === 'bag' && slotIndex === null && !overflow)
throw new InventoryRuleError('背包格子不足');
const item = InventoryItemSchema.parse({
id: facts.id,
definitionId: grant.definitionId,
instanceData: facts,
quantity: 1,
location: location === 'bag' && slotIndex === null ? 'storage' : location,
slotIndex,
revision: 0,
});
return [...items, item];
}
if (grant.instanceData !== undefined)
throw new InventoryRuleError('固定物品不能附带个体属性');
const next = items.map((i) => ({ ...i }));
let remaining = grant.quantity;
for (const destination of location === 'bag' && overflow
? (['bag', 'storage'] as const)
: [location]) {
for (const item of next) {
if (
item.location !== destination ||
item.definitionId !== grant.definitionId ||
item.instanceData != null ||
item.quantity >= limit
)
continue;
const count = Math.min(limit - item.quantity, remaining);
if (count) {
item.quantity += count;
item.revision++;
remaining -= count;
}
}
while (remaining > 0) {
const slotIndex = destination === 'bag' ? emptySlot(next) : null;
if (destination === 'bag' && slotIndex === null) break;
const quantity = Math.min(limit, remaining);
next.push({
id: id(),
location: destination,
slotIndex,
definitionId: grant.definitionId,
quantity,
instanceData: null,
revision: 0,
});
remaining -= quantity;
}
}
if (remaining) throw new InventoryRuleError('背包格子不足');
return next;
}
export function learnBeastSkill(
beast: SummonedBeast,
definitionId: string,
ownerLevel: number,
slot: number,
) {
const skillId = itemDefinition(definitionId).skillId;
if (!skillId) throw new InventoryRuleError('该物品不是兽诀');
if (beast.level > ownerLevel)
throw new InventoryRuleError('灵兽等级超过人物,不能培养');
if (beast.skills.includes(skillId))
throw new InventoryRuleError('灵兽已拥有该技能');
if (!Number.isInteger(slot) || slot < 0 || slot >= beast.skillSlotCapacity)
throw new InventoryRuleError('没有可学习的技能格');
const skills = [...beast.skills];
skills[slot] = skillId;
return BeastSchema.parse({ ...beast, skills, revision: beast.revision + 1 });
}
/** Independent reward stream supplied by Host; never consumes combat RNG. */
export function rollBeastBooks(
kills: number,
random: () => number,
): ItemGrant[] {
const result: ItemGrant[] = [];
for (let i = 0; i < kills; i++) {
if (random() >= 0.03) continue;
const roll = random();
const index = roll < 0.96 ? Math.floor(roll / 0.24) : 4;
result.push({ definitionId: BOOKS[index].id, quantity: 1 });
}
return result;
}
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it } from 'vitest';
import {
activeBeastSkills,
BEAST_SPECIES,
generateStarterBeast,
} from '../engine/combat-v6/beasts';
import {
DAO_EQUIPMENT_GENERATOR_VERSION_V2,
DAO_EQUIPMENT_TEMPLATE_ID,
generateDaoEquipmentV2,
} from '../engine/combat-v6/equipment';
import {
addItems,
BOOKS,
InventoryItemSchema,
learnBeastSkill,
rollBeastBooks,
sameStack,
sortBag,
type InventoryItem,
} from './index';
const item = (slotIndex = 0, quantity = 1): InventoryItem => ({
id: `item-${slotIndex}`,
location: 'bag',
slotIndex,
definitionId: BOOKS[0].id,
quantity,
instanceData: null,
revision: 0,
});
describe('inventory capacity and immutable facts', () => {
it('sorts and merges without losing quantity or changing stored items', () => {
const stored = {
...item(5, 5),
id: 'stored',
location: 'storage' as const,
slotIndex: null,
};
const before = [item(7, 60), item(3, 60), item(9, 60), stored];
const result = sortBag(before);
expect(
result
.filter((i) => i.location === 'bag')
.map((i) => [i.slotIndex, i.quantity]),
).toEqual([
[0, 99],
[1, 81],
]);
expect(result.find((i) => i.id === 'stored')).toBe(stored);
expect(result.reduce((s, i) => s + i.quantity, 0)).toBe(185);
expect(before.map((i) => i.quantity)).toEqual([60, 60, 60, 5]);
});
it('fills existing stacks before slots, spills only remainder, and preserves input', () => {
const before = Array.from({ length: 40 }, (_, i) => item(i, i ? 99 : 98));
let id = 0;
const next = addItems(
before,
{ definitionId: BOOKS[0].id, quantity: 101 },
'bag',
true,
() => `new-${id++}`,
);
expect(next.filter((i) => i.location === 'bag')).toHaveLength(40);
expect(
next.filter((i) => i.location === 'storage').map((i) => i.quantity),
).toEqual([99, 1]);
expect(next[0].quantity).toBe(99);
expect(next[0].revision).toBe(1);
expect(before[0].quantity).toBe(98);
expect(next.reduce((s, i) => s + i.quantity, 0)).toBe(
before.reduce((s, i) => s + i.quantity, 0) + 101,
);
expect(() =>
addItems(
before,
{ definitionId: BOOKS[0].id, quantity: 2 },
'bag',
false,
() => 'unused',
),
).toThrow('背包格子不足');
});
it('uses holes, respects distinct definitions and rejects invalid quantities', () => {
let id = 0;
const next = addItems(
[item(1, 99)],
{ definitionId: BOOKS[1].id, quantity: 100 },
'bag',
false,
() => `new-${id++}`,
);
expect(next.map((i) => i.slotIndex)).toEqual([1, 0, 2]);
expect(next.map((i) => i.quantity)).toEqual([99, 99, 1]);
expect(sameStack(next[0], next[1])).toBe(false);
for (const quantity of [0, -1, 1.5, Infinity])
expect(() =>
addItems(
[],
{ definitionId: BOOKS[0].id, quantity },
'bag',
true,
() => 'a',
),
).toThrow();
});
it('validates location, quantity and frozen equipment identity', () => {
expect(
InventoryItemSchema.safeParse({ ...item(), location: 'storage' }).success,
).toBe(false);
expect(
InventoryItemSchema.safeParse({ ...item(), quantity: 100 }).success,
).toBe(false);
expect(
InventoryItemSchema.safeParse({
...item(),
instanceData: { name: '伪造' },
}).success,
).toBe(false);
const generated = generateDaoEquipmentV2({
id: 'sword',
createdAt: '2026-09-07T00:00:00Z',
seed: 4,
templateId: DAO_EQUIPMENT_TEMPLATE_ID.Weapon,
equipmentLevel: 10,
generatorVersion: DAO_EQUIPMENT_GENERATOR_VERSION_V2,
});
if (!generated.ok) throw new Error('fixture');
const equipment = {
...item(),
id: 'sword',
definitionId: 'equipment.v6',
instanceData: generated.instance,
};
expect(InventoryItemSchema.safeParse(equipment).success).toBe(true);
expect(
InventoryItemSchema.safeParse({ ...equipment, quantity: 2 }).success,
).toBe(false);
expect(
InventoryItemSchema.safeParse({ ...equipment, id: 'different' }).success,
).toBe(false);
const granted = addItems(
[],
{
definitionId: 'equipment.v6',
quantity: 1,
instanceData: generated.instance,
},
'bag',
false,
() => 'unused',
);
expect(granted[0].id).toBe('sword');
expect(granted[0].instanceData).toEqual(generated.instance);
expect(() =>
addItems(
[],
{ definitionId: 'equipment.v6', quantity: 1 },
'bag',
false,
() => 'x',
),
).toThrow();
});
});
describe('beast books', () => {
const beast = generateStarterBeast(
'00000000-0000-4000-8000-000000000001',
'00000000-0000-4000-8000-000000000002',
BEAST_SPECIES[0].id,
1,
);
it('replaces only the selected birth slot, without changing growth or capacity', () => {
const next = learnBeastSkill(beast, BOOKS[2].id, 10, 0);
expect(next.skills).toEqual([BOOKS[2].skillId]);
expect(next.skillSlotCapacity).toBe(1);
expect(next.revision).toBe(beast.revision + 1);
expect(next.aptitudes).toEqual(beast.aptitudes);
expect(beast.skills).toEqual([BEAST_SPECIES[0].skill]);
expect(() => learnBeastSkill(beast, BOOKS[0].id, 10, 0)).toThrow('已拥有');
expect(() => learnBeastSkill(beast, BOOKS[2].id, 9, 0)).toThrow('不能培养');
expect(() => learnBeastSkill(beast, BOOKS[2].id, 10, 1)).toThrow('技能格');
});
it('retains suppressed normal skill in its original slot', () => {
const two = {
...beast,
skillSlotCapacity: 2,
skills: [BOOKS[3].skillId, BOOKS[0].skillId],
};
const next = learnBeastSkill(two, BOOKS[4].id, 10, 1);
expect(next.skills).toEqual([BOOKS[3].skillId, BOOKS[4].skillId]);
expect(activeBeastSkills(next)).toEqual([BOOKS[4].skillId]);
});
it('uses the confirmed drop boundaries and supports multiple drops', () => {
const sequence = [0.03, 0.029, 0.95, 0.01, 0.96];
let index = 0;
expect(rollBeastBooks(3, () => sequence[index++])).toEqual([
{ definitionId: BOOKS[3].id, quantity: 1 },
{ definitionId: BOOKS[4].id, quantity: 1 },
]);
expect(
rollBeastBooks(0, () => {
throw new Error('must not roll');
}),
).toEqual([]);
});
});