功能:兽决跑通
This commit is contained in:
1 parent
9194f6b3f8
commit
3c73b39da2
31 files changed
+19842
-51
No files matched your search
@@ -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,不新增消费操作凭据表,响应不明通过刷新权威状态恢复。
|
||||
|
||||
后续再按需求增加:
|
||||
|
||||
|
||||
@@ -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 同时开放捕捉、升级加点和打书入口。
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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: '道身',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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(';') ||
|
||||
'被动能力,依技能条件触发。')),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user