提交
This commit is contained in:
1 parent
601184bc82
commit
a0be7ea6a1
110 files changed
+15085
-271
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
## 已经用户逐类确认的展示
|
||||
|
||||
物品格数量大于9999时统一使用“万”并固定保留两位小数(如 `1.00万`、`1000.00万`),较长数量按格宽缩小字号,保持完整单行显示。
|
||||
|
||||
“数量”指独立预览中的精确数量;从物品格打开时,格子已有堆叠数量,预览头部不再重复。调用方可以传数量标签。未列出的冗余字段不自动补入。
|
||||
|
||||
| 类型 | 名称与头部顺序 | 正文与描述 | 禁止回退 |
|
||||
@@ -11,7 +13,7 @@
|
||||
| 传承灵印 | 所携技能名,如 `噬血`、`高级连击`;类型=普通/上品 · 传承灵印 → 数量 | 所载传承默认显示技能简述,具体效果与数值可展开查看;底部保留灵印描述 | 泛称作标题;重复技能名称;第三层缩进;简述与数值说明同时平铺 |
|
||||
| 归元灵露 | 原名;功能=灵兽洗炼 → 要求=不高于金丹(普通)/不限境界(上品)→ 数量;不显示类型 | 洗炼效果集中展示重置等级、重孕资质/成长/天生技能、寿命恢复原上限;底部描述去除重复效果和境界枚举 | 名称与类型重复;冗长境界要求;效果与描述重复 |
|
||||
| 功法玉简 | 功法名,如 `轻风诀`;类型=功法玉简 → 传承境界 → 数量 | 所载功法下面直接显示初层功法效果与功法说明;保留底部玉简描述 | 名称“玉简”后缀;传承境界埋在正文;重复功法名/第三层缩进;九层效果逐项复述初层 |
|
||||
| 丹药 | 原名;类型=品质 · 丹药 → 丹相(有则显示)→ 功能 → 数量 | 效果、实际代价/服用规则、默认收起的炼制信息;保留原描述 | 道具资料、丹评、要求;丹相放在功能下方;无丹毒、不占服用额度 |
|
||||
| 丹药 | 原名;类型=品质 · 丹药 → 丹相(有则显示)→ 功能 → 数量 | 效果、实际代价/服用规则;破境用途与目标大境界有值时默认收起;不展示无法代表整堆的风味描述和炼制批次信息 | 道具资料、丹评、要求;丹相放在功能下方;无丹毒、不占服用额度 |
|
||||
| 灵果 | 原名;类型=品质 · 灵果 → 功能 → 数量 | 效果与实际适用限制;有查看者上下文时展示有用的预计效果;保留原描述,空规则组省略 | 要求、泛化造化来源、无丹毒、不占服用额度、无上下文时的占位说明 |
|
||||
|
||||
### 语义边界
|
||||
@@ -24,7 +26,7 @@
|
||||
- 灵露要求来自 allowedRealms。当前普通范围可概括为“不高于金丹”;未来若范围不连续,不能只取最大值概括。
|
||||
- 当前灵露普通描述为 `涤去后天积累。`,上品为 `元婴及以上物种须用此露;不额外提高资质、成长或多技能概率。`。后一句区分上品真实作用,不属于泛化“无副作用”占位。
|
||||
- 丹药真实丹毒、额度占用及灵果实际境界过强/过弱限制仍需保留。隐藏“无丹毒”不改变或隐藏有丹毒的事实。当前丹药代价组名为“代价”。
|
||||
- 喂兽丹药与灵果的“用于喂养灵兽,等级不能超过主人”不属于道具代价或服用规则,不在预览重复玩法限制;实际丹毒仍保留。炼制信息不再重复头部已有的丹药类别。
|
||||
- 喂兽丹药与灵果的“用于喂养灵兽,等级不能超过主人”不属于道具代价或服用规则,不在预览重复玩法限制;实际丹毒仍保留。丹药可跨炼制批次堆叠,不把单炉来源、材料和稳度显示为整堆事实。
|
||||
|
||||
## 未逐类重新设计的现有类型
|
||||
|
||||
@@ -45,3 +47,9 @@
|
||||
## 新道具接入时的判定
|
||||
|
||||
先选已有事实类型与适配器。同类新增内容沿用该类顺序,不因单个道具名称、描述长度或 ID 添加特判。新类型只列真实且帮助识别/决策的头部字段;效果按语义分组,描述独立,默认非限制不写。新规则若不能准确映射现有契约,说明具体缺口并做最小扩展,不能仅为了“可扩展”放开递归布局或任意正文组件。
|
||||
|
||||
## 邮件附件
|
||||
|
||||
- 邮件内的灵石、声望、修为、感悟、新库存物品、旧附件和灵兽统一使用物品格的尺寸、图标、名称与数量角标,按网格排列,不混用横向资源条。
|
||||
- 新库存物品沿用正式物品预览;资源只展示附件事实,不为展示新增背包物品定义。旧附件保留名称品阶色与已有描述,领取去向「洞府宝库」放在预览中,不转换为新版物品或改变领取规则。此规则只适用于邮件,不改变旧宝库页的文字列表。
|
||||
- 格子和原生浮层交互由 `PreviewSlot` 复用;物品的详细内容继续由原适配器决定,灵兽沿用既有详情。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 结伴讨伐(首版)
|
||||
|
||||
## 玩法规则
|
||||
|
||||
- 地图出现限时讨伐目标,覆盖金丹、元婴、化神、炼虚、合体、大乘、渡劫。系统频道发布传闻,点击「前往查看」进入对应地图与组队抽屉;地图头像也可打开组队。
|
||||
- 创建队伍时指定最低与最高境界,范围必须包含队长。支持队伍列表加入、快速匹配已有队伍;快速匹配优先补齐人数较多的合适队伍。
|
||||
- 每队 2~4 名不同账号的玩家,不要求满员。全员准备后队长开战;成员变化会清除准备,队长离开后自动由剩余成员接任。
|
||||
- 出现期间各队分别挑战,战斗实例、血量互相独立。失败可重试,队伍在结算后保留并清除准备。
|
||||
- 每个角色每次出现仅首次胜利发奖。已领奖角色可作为队长或队员助战、计入开战人数,不重复获得奖励,也不影响其他角色的领奖资格。
|
||||
- 到期停止招募和新开战,旧链接展示「此处异动已平息」。到期前已开始的战斗继续完成并正常结算。
|
||||
|
||||
## 首版默认值
|
||||
|
||||
这些是本次实现的初始配置,尚未经过正式数值平衡验收。
|
||||
|
||||
- 每两小时一轮,各境界同时出现一个目标,共七个;八个 BOSS 类型随轮次轮换,每个境界八轮内遍历全部类型,地点目前固定。
|
||||
- 每个目标采用对应境界中期等级。首次胜利的修为、灵石、感悟复用 BOSS 境界中期挂机历练 6 小时的计算器,保留随机浮动,每位角色独立抽取;不消费实际挂机时长。
|
||||
- 通用掉落配置在 `src/shared/rewards/data/hunt.json`:保底 2 件 `material.v1`,品质按 BOSS 境界沿用副本分布;独立 20% 概率掉落 1 枚传承灵印、20% 概率掉落 1 瓶归元灵露。灵印池 version 2 显式配置当前开放的 67 种技能(普通 34 种、上品 33 种),每种普通权重 4、上品权重 1;有奖励资格的单次胜利掉落上品灵印的概率约 3.91%。灵露池普通/上品权重为 3:1。这些数量和概率为本次初始默认值。
|
||||
- 基础资源直接入账,遵循现有上限,结算页显示实际增量;道具通过奖励邮件送达,领取进入统一库存,背包满不阻塞战斗结算。
|
||||
- 使用满气血、满法力的隔离战斗快照,不扣持久气血、法力或灵兽寿命。
|
||||
- 每场敌方固定 8 个单位:1 个 BOSS、1 个精英、6 个小怪(4 个近战、2 个术士)。双生冥虎的另一只虎占精英位;噬灵邪修的护法占精英位。小怪气血、攻击与防御低于精英。
|
||||
- 敌方气血按人数增长,但小于等比例增长,增加队友仍有战力收益。
|
||||
- 复用已注册头像,尚未新增专属 BOSS 立绘。
|
||||
- 地图每 15 秒更新事件、组队页每 5 秒更新队伍;服务端严格检查到期时间,客户端显示可能有轮询延迟。
|
||||
|
||||
## 八类目标
|
||||
|
||||
| 目标 | 机制 | 合作应对 |
|
||||
| -------- | ---------------------------------- | ---------------------------- |
|
||||
| 噬灵邪修 | 护法为受伤的邪修恢复气血 | 封印或优先击杀护法 |
|
||||
| 六目古魔 | 魔眼标记玩家,下一回合发动物理重击 | 被标记者防御,队友保护或救援 |
|
||||
| 双生冥虎 | 交替给同伴施加物理、法术减伤护体 | 驱散或转火 |
|
||||
| 血河妖蟒 | 气血极高,防御普通 | 先清随从,集中输出,兼顾续航 |
|
||||
| 铁背玄鼋 | 物防极高,法防较低 | 法术主攻,物理清理随从 |
|
||||
| 吞霞灵蟾 | 法防极高,物防较低,擅用妖焰 | 物理主攻 |
|
||||
| 金身尸王 | 双防极高,气血较少 | 固定伤害、破防 |
|
||||
| 掠影妖貂 | 闪避极高,速度较快 | 法术或必中攻击 |
|
||||
|
||||
数值仍为待平衡的初值:BOSS/精英/小怪分别按预设遭遇预算的 1/2、1/4、1/12 取气血与攻击,全部气血乘以 `1.8 + (人数 - 2) × 0.65`;小怪双防为普通预设的 65%。血河妖蟒气血为常规 BOSS 的 3.5 倍;铁背玄鼋物防 6 倍、法防 0.6 倍,吞霞灵蟾相反;金身尸王双防 6 倍、气血 25%;掠影妖貂闪避为预设命中加等级 × 6,速度 1.3 倍。
|
||||
|
||||
这些特性遵循现有 V6 公式:高防不等于免伤,物理命中下限为 45%,法术和必中招式不受该闪避影响,固定伤害不受双防影响。小怪轮流选择玩家为目标;双虎护体只给另一只虎施加,同伴阵亡后不会转给小怪。
|
||||
|
||||
内容位于 `src/shared/engine/combat-v6/hunts/content.ts`,不修改 V6 核心。出现与招募规则位于 `src/shared/hunts`。
|
||||
|
||||
### 八类目标与八人敌阵验证(2026-09-30)
|
||||
|
||||
- 聚焦命令 `bun run test src/shared/hunts src/shared/engine/combat-v6/hunts src/shared/rewards/hunt.test.ts src/shared/combat-v6/arena.test.ts src/shared/engine/combat-v6/rules-daoyou`:70 项通过。覆盖八类目标 × 七个境界 × 三种人数的 168 种阵容、V6 指令提交和首轮结算、五类属性弱点、全部类型的快照恢复、双虎同伴阵亡后不护小怪、新旧出现 ID 及全境界轮换。
|
||||
- `bun run lint`、`bun run build`(含前后端 TypeScript 检查)、`git diff --check` 通过。构建仍提示分块大小及静态/动态导入混用,未扩展处理无关打包配置。
|
||||
- 本地 local1、local2 通过页面创建队伍、快速匹配、准备并开战,战局 `3b2f3af4-d839-4d14-b2f0-41a0a11bc632`,金丹期吞霞灵蟾。桌面和 360px 均完整显示八个敌人,首轮战报确认 BOSS、精英、四个近战小怪和两个术士均出招,目标分散到两位玩家。
|
||||
- 使用原有炼气/筑基测试角色,未临时提高属性;第二回合落败并正常结算,双方随后退出队伍。无遗留测试战斗或队伍,检查的浏览器错误日志为空。内置浏览器一度点击无响应,换新标签后完成验证。
|
||||
- 截图:`/tmp/daoyou-hunt-bosses/eight-enemies.png`、`eight-enemies-mobile.png`。未重跑全量测试,未进行同境界数值平衡、真人三/四人队、八类 BOSS 全部浏览器实战或本轮胜利发奖验收。
|
||||
|
||||
## 状态与结算边界
|
||||
|
||||
- 出现 ID 由版本、轮次与境界确定,所有实例生成相同 ID。新增八类目标使用 `hunt-v2`;`hunt-v1` 仍按原三类映射解析,避免旧传闻和已建队伍的目标被重新解释。切换版本的当轮,新旧出现各自保持独立领奖资格;已经创建的战局沿用冻结阵容,新开战使用八人敌阵。传闻使用确定性消息 ID 与 Redis 去重标记。
|
||||
- Redis 保存队伍、账号归属、事件索引,短期互斥锁配合 Lua revision 检查原子更新。当前实现的队伍互斥锁是全局锁,尚未做高并发容量验收。
|
||||
- 开战重新读取成员归属、活跃状态和境界,在现有角色变更锁内构建快照,复用 V6 Redis 战斗占用。Lua 创建战局时再次使用 Redis 时间检查到期边界。
|
||||
- 开战请求使用冻结阵容和 `startRequestId` 恢复,响应丢失不会重复创建战局;旧结算事件不会清除新一场战斗的队伍状态。
|
||||
- 开战时冻结每人资源与完整物品事实快照,随机流由出现 ID、角色 ID、池版本和组名隔离。材料库读取沿用同一数据库事务;缺少本境界品质范围内的材料时拒绝开战,不用越界品质填充保底。
|
||||
- 终局沿现有 NATS 消息设施投递,先归档可回放的 `hunt` 战绩,再由 `HuntRewardProjector` 结算。数据库消费回执、修仙日志领奖凭据、三项资源变更和道具邮件在同一事务内提交。结算重试不重抽、不多发邮件。
|
||||
- 修仙日志以 `(cultivator_id, hunt_reward:{event_id})` 唯一键承接首次胜利领奖资格,不能使用战斗 ID 作为去重键。助战资格、队伍列表和战果查询统一读取该凭据;私有 `result` 保存首次获奖战斗 ID、实际奖励快照和邮件 ID,不通过日志列表返回。即使资源受上限裁剪为零,也保留凭据;助战和失败不新增收入日志。重复消息和重复胜利均不能重复加款或寄信;事务完成后清理队伍战斗状态、释放占用并确认终局。
|
||||
- 日志记录直接到账的灵石、修为、感悟,地点和目标作为简短上下文;邮件里的物品实际领取时再计入道具收入。消息消费回执继续负责战局缓存过期后的重投识别。
|
||||
- 新接口统一位于 `/api/hunts`,沿用登录和活跃角色中间件,参数在服务端校验;奖励金额与目标由服务端生成。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
未上线的 0060~0063 已合并为 `0060_player_journal.sql`,仅创建修仙日志表、角色外键及两个索引。线上从 0059 升级时直接得到最终结构,不创建中间的 `wanjiedaoyou_hunt_reward_claims` 表,也不搬迁旧领奖数据;0059 及之前的迁移历史保持不变。
|
||||
|
||||
合并条目沿用原 0063 的时间戳。本地库已完整执行原四个版本,旧领奖表已删除,迁移器按时间戳跳过合并版本,保留已有日志和执行记录。新建环境执行合并版本一次即可;只执行过部分旧版本的开发库不能直接套用此跳过规则,须先核对结构。未操作预发布或生产数据库。
|
||||
|
||||
本地直接调用 Drizzle CLI,确保读取显式环境:
|
||||
|
||||
```bash
|
||||
bun --env-file=env/local.env node_modules/drizzle-kit/bin.cjs migrate
|
||||
```
|
||||
|
||||
上线前需先按部署流程应用业务数据库迁移;本次未部署。
|
||||
|
||||
## 首版主流程验证(2026-09-30,升级奖励前)
|
||||
|
||||
环境:`bun run dev`,前端 `127.0.0.1:5174`、API `127.0.0.1:3001`,本地 local1、local2 独立登录会话。
|
||||
|
||||
- 已通过:系统频道传闻跳转、地图列表和头像、创建队伍、单人禁止开战、快速匹配、列表入队、成员变化清除准备、双人准备开战、队长离开后转移、队伍最终退出。
|
||||
- 已通过:双方独立控制、古魔标记与重击、失败后保留队伍及领奖资格、战斗页面刷新恢复、胜利结算、重复助战不发奖、回放入口。
|
||||
- 失败重试战局:`111516c7-9a41-4d7d-b514-079cf90f1a5a`。
|
||||
- 首次胜利战局:`9607e665-2728-48be-b641-9b3591f4eb75`。事件 `hunt-v1-248716-0` 中两位角色各获得 100 灵石,数据库只读核对余额与两条领奖记录。
|
||||
- 重复助战战局:`6859680a-1366-4303-a807-6d7334f59f27`。双方均显示「助战完成,本次不重复获得奖励」,余额及两条领奖记录数量不变。
|
||||
- 已检查桌面、360px 组队布局及过期事件链接;检查的浏览器控制台没有错误。内置浏览器旧标签曾出现点击无响应,使用新标签后完成流程;Chrome 流程正常。
|
||||
- 为快速验证胜利结算,曾通过正式本地 dev 接口临时提高 local1 三项属性;结束后均恢复为 61。保留实际玩法奖励,测试队伍已退出,没有遗留测试战斗。
|
||||
- 聚焦测试命令 `bun run test src/shared/hunts src/shared/engine/combat-v6/hunts src/shared/combat-v6/arena.test.ts`:27 项通过,覆盖准入、到期边界、匹配、三类机制和快照恢复。
|
||||
- `bun run lint`、`bun run build`(含前后端 TypeScript 检查)、`git diff --check` 通过。
|
||||
- 完整 `bun run test`:2288 项通过,2 项失败,分别为未修改文件 `src/shared/forging/forging.test.ts` 的材料堆叠预期、`src/shared/engine/combat-v6/content/skill-learning.test.ts` 的内容哈希预期。未在独立基线检出中复现,因此不把它们标为已确认的基线失败。
|
||||
- 未做真人三/四人、已领奖与未领奖者混合队、跨到期仍在战斗、服务中断恢复或高并发的浏览器验收;相关分支已做代码检查,人数和到期纯规则由单测覆盖。未验证全境界数值平衡。
|
||||
|
||||
浏览器证据保存在本次任务产物 `/tmp/daoyou-hunt-proof/`,不作为仓库测试脚本或长期资源提交。
|
||||
|
||||
## 奖励升级验证(2026-09-30)
|
||||
|
||||
- 沿用上述本地双账号环境。事件 `hunt-v1-248716-1`,元婴期双生冥虎,首次胜利战局 `94cfdf02-ff3e-4f5e-a83b-cdfc0df2bb42`。
|
||||
- local1 实际获得修为 287、灵石 6141、感悟 12,以及上品归元灵露、天品九尾灵狐尾尖毫、玄品墨蛟逆鳞;local2 获得修为 266、灵石 7763、感悟 12,以及灵法会心灵印、地品太乙陨金、真品雷音竹笋。只读数据库核对资源增量、两条奖励快照和两封邮件一致。
|
||||
- local1 通过邮件页面领取成功;local2 邮件保留未领取。检查了归元灵露、灵印预览及具体效果展开,材料携带完整 `material.v1` 实例事实。
|
||||
- 重复助战战局 `d0b32d78-6b6b-459f-b918-3c803f00c06c` 胜利后显示不重复获得奖励;两位角色的资源、奖励快照、邮件 ID 均与首次胜利后相同,仍为两条领奖记录、两封奖励邮件。
|
||||
- 结算详情使用弹窗,避免战斗指令栏溢出;桌面与 360px 手机视口均已检查,浏览器错误日志为空。证据为 `rewards-desktop.png`、`rewards-mobile.png`。
|
||||
- 测试队伍已全部退出,战斗已结束。local1 临时提高的气血、力量、速度属性均恢复为 61,保留正常玩法所得资源与道具。
|
||||
- 聚焦命令 `bun run test src/shared/rewards/hunt.test.ts src/shared/hunts src/shared/engine/combat-v6/hunts src/shared/rewards/yield.test.ts src/shared/rewards/dungeon-material-quality.test.ts`:35 项通过,覆盖七个境界的六小时收益、材料品质范围、保底与概率组独立性、非法配置以及讨伐机制。
|
||||
- `bun run lint`、`bun run build` 和 `git diff --check` 通过。此次未重跑完整测试集,上一轮完整测试结果见上节。
|
||||
- 未进行混合领奖资格队伍、资源上限、满背包、消息故障重试的运行验收;对应事务、去重、邮件发放和上限路径已做代码检查。概率和数量仍为待平衡的初始配置。
|
||||
|
||||
## 文案与奖励预览(2026-09-30)
|
||||
|
||||
- 按 humanizer、游戏 UI 与物品预览规范调整 BOSS 应对提示、组队提示、出战须知、战果与传闻文案。玩家界面不展示快照、品质分布、收益算法等实现说明;人数、首次获酬、助战、到期及战斗损耗规则保留。
|
||||
- 讨伐所得采用两行四列的小物品格:第一行为修为、灵石、感悟、灵材,下方标注「必得」;第二行为普通/上品传承灵印、普通/上品归元灵露,下方标注「可能获得」。灵印只作展示,不再点击展开;灵露保留标准物品预览,材料可查看该境界品质范围。不再展示上方资源提示和随信送达说明。奖励数值与掉率未改变。
|
||||
- 本地页面检查通过:桌面与 360px 布局、普通 34 种/上品 33 种列表切换、长名称灵印、具体效果展开、Esc 关闭预览、灵露预览、材料品质、招募表单、无队伍提示与刷新清除旧提示。浏览器错误日志为空。未创建新队伍或战斗,未重测战果发奖与传闻推送。
|
||||
- `bun run lint`、`bun run build`、`git diff --check` 通过。此次为文案和展示调整,未新增单元测试或重跑全量测试。截图位于 `/tmp/daoyou-hunt-copy/desktop.png`、`mobile.png`。
|
||||
- 后续简化再次检查桌面、360px 手机布局及静态灵印入口,移除原先的范围展开。新的截图为 `/tmp/daoyou-hunt-copy/compact-desktop.png`、`compact-mobile.png`;lint、构建及差异检查通过,未重跑战斗或单元测试。
|
||||
|
||||
## 修仙日志领奖凭据验证(2026-09-30)
|
||||
|
||||
- 合并前本地应用旧版 0063 后,只读查询确认旧领奖表已不存在。Chrome/内置浏览器分别使用 local1、local2 独立会话。
|
||||
- 事件 `hunt-v1-248717-0` 首胜战局 `c003a0ec-930f-4a37-b318-958dbcfa6727`:两位角色各生成一条日志凭据和一封邮件。local1 实际灵石 +3364、修为 +159、感悟 +12;local2 实际灵石 +3839、修为 +182、感悟 +12。战果接口从日志返回完整奖励及邮件 ID,公开日志不返回私有 result。
|
||||
- 同事件助战战局 `98fa29ca-69e3-4d47-9377-9cd741208e7e`:双方均返回 assisting,首胜日志和私有奖励快照完全未变,仍只有两封奖励邮件,余额与首胜实际增量一致。队伍列表也显示已获酬/助战资格。
|
||||
- local1 领取讨伐邮件后另有一条道具日志(碧鳞蛇蜕 +1、紫晶矿 +1),胜利结算不提前记录邮件道具。双方已退出测试队伍,临时境界与属性通过本地 dev 接口恢复,保留正常奖励。
|
||||
- `bun run lint`、`bun run build`、`git diff --check` 通过;`bun run test src/shared/playerJournal.test.ts src/shared/rewards/hunt.test.ts src/shared/hunts src/shared/engine/combat-v6/hunts` 共 28 项通过。未重复跑全量测试,未实测消息故障重投、资源全部封顶及混合领奖资格队伍;这些分支完成事务和唯一键检查。
|
||||
@@ -1,5 +1,7 @@
|
||||
# Game Layout Ownership
|
||||
|
||||
`/game/journal` 为 `GameViewportLayout` 下的 archive 场景,展开导航提供「修仙日志」入口。正文仅包含分类和历史列表;日志事件与执行凭据见 [修仙日志](player-journal.md)。
|
||||
|
||||
## `/game` 路由归属
|
||||
|
||||
- `GameGenesisLayout`:`/game/create`、`/game/reincarnate`
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| `cultivator.mail-audience.observed` | `daoyou.domain.system-mail.audience-observed.v1` | `system-mail-projector-v1` |
|
||||
| `mail.created` | `daoyou.domain.communication.mail-created.v1` | `mail-notification-projector-v1` |
|
||||
| `craft.item.created` | `daoyou.domain.gameplay.craft-item-created.v1` | `world-rumor-projector-v1` |
|
||||
| `equipment.forged` | `daoyou.domain.gameplay.equipment-forged.v1` | `world-rumor-projector-v1`(带器诀或器蕴时推送装备预览) |
|
||||
| `market.material.revealed` | `daoyou.domain.gameplay.market-material-revealed.v1` | `world-rumor-projector-v1` |
|
||||
| `ranking.position.changed` | `daoyou.domain.gameplay.ranking-position-changed.v1` | `world-rumor-projector-v1` |
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 修仙日志
|
||||
|
||||
入口为展开导航中的「修仙日志」(`/game/journal`)。页面按时间倒序紧凑展示,一次结算一条,可筛选闭关/突破/其他玩法并加载更多。旧记录不回填。
|
||||
|
||||
## 存储与展示
|
||||
|
||||
数据库迁移为 `drizzle/0060_player_journal.sql`,已合并本轮未上线的讨伐与日志迁移,仅创建最终使用的日志表。
|
||||
|
||||
`wanjiedaoyou_player_journal` 同时保存业务执行凭据和玩家可见的结构化结果,不另建收支明细表。`event` 使用 `src/shared/contracts/playerJournal.ts` 的可辨识联合契约,前端按类型渲染、按资源统一颜色与数字格式。
|
||||
|
||||
- 闭关:年数、实际修为与感悟收益、实际灵气消耗、寿元耗尽结果。
|
||||
- 突破:成功/失败、前后境界、实际修为与感悟变化、灵气消耗、寿元上限和属性奖励。
|
||||
- 契约保留原结算摘要,用于重复请求返回原结果;重复请求不会重新生成突破故事。
|
||||
- `GET /api/player-journal` 只查询当前登录角色。每页最多 30 条,以毫秒时间和 UUID 组合游标分页;接口不返回幂等键或请求指纹。
|
||||
|
||||
## 执行凭据
|
||||
|
||||
`(cultivator_id, operation_key)` 唯一。闭关/突破请求必须携带 UUID `requestId`,键为 `retreat.cultivate:{requestId}` 或 `retreat.breakthrough:{requestId}`;指纹包含动作和年数。同键不同参数返回 409。
|
||||
|
||||
`PlayerCommandExecutor` 的 `journal` 选项在现有角色事务中占位,业务成功后补全 `event`,失败整体回滚。占位时的空事件不会提交;重复请求读取已提交事件并跳过结算。该选项与旧 `idempotency` 选项互斥,接入玩法不会再写 `player_mutation_requests`。
|
||||
|
||||
闭关入口在角色锁内先读取已完成凭据,再判断当前突破门槛,避免原操作改变进度后阻止正常重试。事务内仍通过唯一键保护实际写入。客户端在 sessionStorage 中保存未确认结果的请求键,收到结算结果后删除;重试回放后重新获取当前角色资源,避免把旧状态当作当前状态。
|
||||
|
||||
已接入的普通玩家请求改用日志保存执行凭据和原结果,不再同时写 `player_mutation_requests`;读取兼容旧凭据,防止上线前请求再次结算。灵气预留/提交账目、限购事实、战斗归档和独立消息消费者凭据仍由各自流程维护,不删除旧表。
|
||||
|
||||
## 保留与演进
|
||||
|
||||
组队讨伐使用 `hunt_reward:{出现ID}` 作为每个角色的唯一领奖键,复用日志的私有结果保存战斗 ID、实际奖励与邮件 ID,替代独立的 `hunt_reward_claims` 表。首次胜利提交凭据和发奖同事务;同次出现再次胜利判作助战,不能重复发奖或寄信。纯邮件奖励或资源全部达到上限时仍保留不可见凭据。
|
||||
|
||||
执行凭据不参与普通历史清理。清理一条记录会重新开放对应业务键,因此不能按日志展示时长直接删除。角色删除仍跟随角色生命周期级联清理。
|
||||
|
||||
新增玩法需定义事件契约、渲染分支,并在权威结算入口提供稳定业务键与实际结果。事件结构发生不兼容变化时使用新事件类型版本并保留旧记录解析;幂等键不随展示契约版本变化。
|
||||
|
||||
## 第一、第二批玩法
|
||||
|
||||
只展示发生 **道具、修为、灵石、感悟、声望、宗门贡献** 实际得失的结算。只有灵气、气血/法力或灵兽变化不展示。铭刻、客栈恢复、丹药使用、炼体/洗髓突破没有启用日志采集;也没有扩展到秘境、爬塔和拍卖结算。
|
||||
|
||||
| 玩法 | 已接入的结算 |
|
||||
| --- | --- |
|
||||
| 野外 | 权威战斗结束结算中的道具奖励;无道具得失不记 |
|
||||
| 组队讨伐 | 首次胜利实际到账的灵石、修为、感悟;道具随邮件领取再记 |
|
||||
| 历练 | 领取时实际到账的灵石、修为、感悟 |
|
||||
| 邮件 | 单封/批量领取新旧附件;赠送时送出道具和消耗传音符 |
|
||||
| 商店与回收 | 坊市、黑市、声望兑换、宗门兑换、批量道具回收 |
|
||||
| 炼制与参悟 | 按方/即兴炼丹、炼器、功法参悟的道具和资源得失 |
|
||||
| 灵田 | 领取初始灵种、播种、培育、收获 |
|
||||
| 宗门 | 任务操作及任务战斗结算、俸禄、贡献兑换道具、灵石建设投入 |
|
||||
|
||||
邮件发出只表示待领取,不计作收件人的道具收入;实际领取再记。历练发往邮件的道具也按这个规则处理。灵兽不作为道具计算。声望兑换与宗门兑换同时记录道具收入和货币支出;仅声望或宗门贡献得失也独立成条。贡献指当前宗门的可用余额,累计贡献不重复计入。
|
||||
|
||||
新增事件契约为 `resources.settled`:`activity` 标识玩法、可选 `detail` 保存短上下文、`changes` 保存有符号的资源/道具数量。前端统一渲染资源颜色及数字,长行在手机上自然换行。
|
||||
|
||||
### 采集与幂等
|
||||
|
||||
- `JournalSettlement.ts` 仅对已登记的业务结算开启事务级采集。复用 `saveInventoryPlan` 已有的前后数量,旧邮件宝库附件和传音符消耗在各自真实写入点补充;不额外扫描角色全部背包或储藏室。
|
||||
- 数值资源读取同一事务内结算前后的角色行及当前 active 宗门身份的贡献余额(无宗门按零处理),只保存实际差值;不使用配置中的名义奖励,避免上限裁剪后记多。同一道具多次同方向变更合并,消耗与产出保留各自方向。
|
||||
- 普通请求必须有稳定业务键;炼丹、历练领取、一键领邮件补充 UUID `requestId`,客户端在 sessionStorage 保留尚未确认的请求。炼丹与历练在材料/领取时间校验前识别已结算请求,重试不会再炼制、扣费、发奖或生成结算故事。
|
||||
- 原响应以存储专用 `result` 保存在同一行,不通过日志列表接口返回。旧的查询前置检查也能读取新日志凭据。
|
||||
- 有幂等请求但没有这些资源变更时,仅保留不可见的执行凭据。列表查询先过滤再分页,因此不会出现空白页吞掉下一页记录。无掉落的异步野外结算不新增日志行。
|
||||
- 野外结算在现有消息消费事务中记录,消费者凭据用于 Redis 终局资料过期后的重复投递识别;宗门任务战斗与战斗归档同事务。不新增独立流水表,不在异步发奖之前预记收入。
|
||||
|
||||
### 本轮验证
|
||||
|
||||
纯共享差量/契约测试覆盖已有堆叠增量、全部消耗、移动不记、同物消耗与产出、内部结果不外泄和闭关回放类型隔离。前后端通过 lint、TypeScript 与构建检查;运行验收结果见任务交付说明。
|
||||
|
||||
本地浏览器已验收新旧邮件附件、批量领信、道具回收、灵石培育、功法参悟失败和历练领取。邮件/批量领信/回收/培育/历练的重复请求均回放原结果,培育同键换参数返回 409。旧版对纯声望领取的隐藏规则已取消;旧记录不回填。即兴炼丹被现有药蕴不足规则拒绝,未扣费、未产生日志,成功炼丹链路尚未运行验收。野外及宗门战斗、其他商店和灵田完整周期本轮以接入点与事务代码检查为主,未逐个完成实际玩法。
|
||||
|
||||
特殊货币补充验收:纯声望邮件领取显示 `声望 +3`,宗门建设显示 `灵石 -10,000、宗门贡献 +1`,两者重复请求均回放且只保留一条记录。声望/贡献的正负数契约测试通过。宗门兑换因本地角色职阶不足未执行,支出路径完成事务代码检查。
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "wanjiedaoyou_player_journal" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cultivator_id" uuid NOT NULL,
|
||||
"operation_key" varchar(160) NOT NULL,
|
||||
"request_fingerprint" varchar(128),
|
||||
"event" jsonb,
|
||||
"created_at" timestamp (3) DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_player_journal" ADD CONSTRAINT "wanjiedaoyou_player_journal_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 UNIQUE INDEX "player_journal_operation_unique" ON "wanjiedaoyou_player_journal" USING btree ("cultivator_id","operation_key");--> statement-breakpoint
|
||||
CREATE INDEX "player_journal_timeline_idx" ON "wanjiedaoyou_player_journal" USING btree ("cultivator_id","created_at","id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -421,6 +421,13 @@
|
||||
"when": 1790578141727,
|
||||
"tag": "0059_cloudy_ink",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 60,
|
||||
"version": "7",
|
||||
"when": 1790763373538,
|
||||
"tag": "0060_player_journal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/** One-time character maintenance. Dry-run by default; never exposed to players. */
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { db, type DbExecutor } from '../src/server/lib/drizzle/db';
|
||||
import { inventoryItems } from '../src/server/lib/drizzle/schema';
|
||||
import { redisLockKeys, withRedisLock } from '../src/server/lib/redis/lock';
|
||||
import { lockCultivatorForStateMutation } from '../src/server/lib/repositories/playerStateRepository';
|
||||
import {
|
||||
assertInventoryIdle,
|
||||
inventoryItemOf,
|
||||
saveInventoryPlan,
|
||||
} from '../src/server/lib/services/InventoryService';
|
||||
import { inventoryStackKey } from '../src/server/lib/services/inventoryStackKey';
|
||||
import { ResourceEventCommitter } from '../src/server/lib/services/ResourceEventCommitter';
|
||||
import { compactStorage, type InventoryItem } from '../src/shared/inventory';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const owner = z.uuid().parse(args[0]);
|
||||
const apply = args.includes('--apply');
|
||||
const expectedHash = args.find((arg) => arg.startsWith('--expect='))?.slice(9);
|
||||
const backupPath = args.find((arg) => arg.startsWith('--backup='))?.slice(9);
|
||||
if (
|
||||
args
|
||||
.slice(1)
|
||||
.some(
|
||||
(arg) =>
|
||||
arg !== '--apply' &&
|
||||
!arg.startsWith('--expect=') &&
|
||||
!arg.startsWith('--backup='),
|
||||
)
|
||||
)
|
||||
throw new Error('参数无效');
|
||||
if (apply && (!expectedHash || !backupPath || !isAbsolute(backupPath)))
|
||||
throw new Error(
|
||||
'--apply 需要 dry-run 输出的 --expect=<hash> 和绝对路径 --backup=<path>',
|
||||
);
|
||||
|
||||
const filter = and(
|
||||
eq(inventoryItems.cultivatorId, owner),
|
||||
eq(inventoryItems.location, 'storage'),
|
||||
eq(inventoryItems.definitionId, 'consumable.v1'),
|
||||
eq(sql<string>`${inventoryItems.instanceData}->'spec'->>'kind'`, 'pill'),
|
||||
);
|
||||
|
||||
async function readPills(executor: DbExecutor) {
|
||||
return executor
|
||||
.select()
|
||||
.from(inventoryItems)
|
||||
.where(filter)
|
||||
.orderBy(asc(inventoryItems.id));
|
||||
}
|
||||
|
||||
function plan(rows: Awaited<ReturnType<typeof readPills>>) {
|
||||
const hash = createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
||||
const before = rows.map(inventoryItemOf);
|
||||
const normalized = before.map((item): InventoryItem => {
|
||||
const stackKey = inventoryStackKey(item.definitionId, item.instanceData);
|
||||
return stackKey === item.stackKey
|
||||
? item
|
||||
: { ...item, stackKey, revision: item.revision + 1 };
|
||||
});
|
||||
const after = compactStorage(normalized);
|
||||
const quantity = (items: InventoryItem[]) =>
|
||||
items.reduce((total, item) => total + item.quantity, 0);
|
||||
if (quantity(before) !== quantity(after))
|
||||
throw new Error('整理前后丹药数量不一致');
|
||||
return {
|
||||
hash,
|
||||
before,
|
||||
after,
|
||||
summary: {
|
||||
owner,
|
||||
hash,
|
||||
beforeStacks: before.length,
|
||||
afterStacks: after.length,
|
||||
removedStacks: before.length - after.length,
|
||||
quantity: quantity(before),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
const rows = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SET TRANSACTION READ ONLY`);
|
||||
return readPills(tx);
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({ mode: 'dry-run', ...plan(rows).summary }, null, 2),
|
||||
);
|
||||
} else {
|
||||
const committed = await withRedisLock(
|
||||
{
|
||||
key: redisLockKeys.cultivatorMutation(owner),
|
||||
context: 'pill-storage-maintenance',
|
||||
timeoutMs: 30_000,
|
||||
retries: 0,
|
||||
},
|
||||
async (lease) =>
|
||||
db.transaction(async (tx) => {
|
||||
await lockCultivatorForStateMutation(tx, owner);
|
||||
await assertInventoryIdle(owner, tx);
|
||||
const rows = await readPills(tx);
|
||||
const { hash, before, after, summary } = plan(rows);
|
||||
if (hash !== expectedHash)
|
||||
throw new Error('库存已变化,请重新 dry-run');
|
||||
await writeFile(
|
||||
backupPath!,
|
||||
JSON.stringify({ owner, hash, rows }, null, 2),
|
||||
{
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
await saveInventoryPlan(owner, before, after, tx);
|
||||
if (
|
||||
before.length &&
|
||||
before.some(
|
||||
(item, index) =>
|
||||
JSON.stringify(item) !== JSON.stringify(after[index]),
|
||||
)
|
||||
)
|
||||
await new ResourceEventCommitter().commit(tx, {
|
||||
actor: { cultivatorId: owner },
|
||||
source: 'pill-storage-maintenance',
|
||||
scopeDefaults: { cultivatorId: owner },
|
||||
changes: [
|
||||
{
|
||||
resourceTopic: 'inventory.bag',
|
||||
operation: 'invalidate',
|
||||
eventType: 'inventory.bag.changed',
|
||||
},
|
||||
],
|
||||
});
|
||||
lease.assertHeld();
|
||||
return summary;
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ mode: 'apply', ...committed, backupPath }, null, 2),
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pendingCommandRequest } from '@app/lib/pendingCommandRequest';
|
||||
import { useQiActionConfirm } from '@app/components/feature/cultivator/useQiActionConfirm';
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import { useResourceMutation } from '@app/lib/resources/mutations';
|
||||
@@ -631,13 +632,15 @@ export function useAlchemyCraftSessionState(sectContext?: AlchemySectContext) {
|
||||
1500,
|
||||
);
|
||||
try {
|
||||
const pending = pendingCommandRequest(cultivator.id, 'alchemy', submitPayload);
|
||||
const body = await mutate<CraftResult>(
|
||||
fetch('/api/craft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(submitPayload),
|
||||
body: JSON.stringify({ ...submitPayload, requestId: pending.requestId }),
|
||||
}),
|
||||
);
|
||||
pending.complete();
|
||||
if (!body.consumable) throw new Error('炉中未能凝丹');
|
||||
if (
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
@@ -15,6 +15,7 @@ export type {
|
||||
|
||||
export const combatV6HistorySources = {
|
||||
ranking: '天骄榜',
|
||||
hunt: '结伴讨伐',
|
||||
'arena-sparring': '擂台切磋',
|
||||
};
|
||||
|
||||
|
||||
@@ -628,37 +628,11 @@ function buildAlchemyInfoLines(
|
||||
): string[] {
|
||||
const { alchemyMeta } = consumable.spec;
|
||||
const breakthroughLabel = getBreakthroughPurposeLabel(consumable.spec);
|
||||
const formulaFitBandText =
|
||||
alchemyMeta.source === 'formula'
|
||||
? alchemyMeta.fitBand === 'aligned'
|
||||
? '契合成丹'
|
||||
: alchemyMeta.fitBand === 'degraded'
|
||||
? '勉强成丹'
|
||||
: '偏路成丹'
|
||||
: undefined;
|
||||
const formulaFitMultiplierText =
|
||||
alchemyMeta.source === 'formula' &&
|
||||
Number.isFinite(alchemyMeta.fitMultiplier)
|
||||
? `丹方倍率:${Math.round(alchemyMeta.fitMultiplier * 100)}%`
|
||||
: undefined;
|
||||
const fitBandText =
|
||||
alchemyMeta.source === 'formula' ? formulaFitBandText : undefined;
|
||||
|
||||
return [
|
||||
breakthroughLabel ? `破境用途:${breakthroughLabel}` : undefined,
|
||||
alchemyMeta.breakthroughTargetRealm
|
||||
? `目标大境界:${alchemyMeta.breakthroughTargetRealm}`
|
||||
: undefined,
|
||||
`炼制来源:${alchemyMeta.source === 'formula' ? '丹方炼制' : '即兴炼制'}`,
|
||||
fitBandText ? `成丹层级:${fitBandText}` : undefined,
|
||||
formulaFitMultiplierText,
|
||||
`稳度:${alchemyMeta.stability}`,
|
||||
alchemyMeta.dominantElement
|
||||
? `主元素:${alchemyMeta.dominantElement}`
|
||||
: undefined,
|
||||
alchemyMeta.sourceMaterials.length > 0
|
||||
? `炼制材料:${alchemyMeta.sourceMaterials.join('、')}`
|
||||
: undefined,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
}
|
||||
|
||||
@@ -667,6 +641,7 @@ export function toPillDisplayModel(
|
||||
options?: PillDisplayOptions,
|
||||
): PillDisplayModel {
|
||||
const trackPreviewLines = buildTrackPreviewLines(consumable.spec, options);
|
||||
const alchemyInfoLines = buildAlchemyInfoLines(consumable);
|
||||
return {
|
||||
familyLabel: getPillFamilyLabel(consumable.spec.family),
|
||||
appearance: getAppearanceDisplay(consumable.spec),
|
||||
@@ -696,15 +671,19 @@ export function toPillDisplayModel(
|
||||
title: '代价',
|
||||
lines: buildCostAndRuleLines(consumable.spec, options),
|
||||
},
|
||||
{
|
||||
key: 'alchemy-info',
|
||||
role: 'source' as const,
|
||||
collapsible: true,
|
||||
title: '炼制信息',
|
||||
lines: buildAlchemyInfoLines(consumable),
|
||||
},
|
||||
...(alchemyInfoLines.length
|
||||
? [
|
||||
{
|
||||
key: 'alchemy-info',
|
||||
role: 'source' as const,
|
||||
collapsible: true,
|
||||
title: '破境信息',
|
||||
lines: alchemyInfoLines,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
flavorText: consumable.description,
|
||||
flavorText: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hasPendingCommandRequest, pendingCommandRequest } from '@app/lib/pendingCommandRequest';
|
||||
import { usePlayerSession } from '@app/lib/resources/player';
|
||||
import { HomeUrgentRow } from '@app/components/feature/home/HomeUrgentRow';
|
||||
import { InkModal } from '@app/components/layout';
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
@@ -22,6 +24,7 @@ export function YieldCard({
|
||||
variant = 'card',
|
||||
}: YieldCardProps) {
|
||||
const { pushToast } = useInkUI();
|
||||
const owner = usePlayerSession().data?.activeCultivator?.id;
|
||||
const [timeSinceYield, setTimeSinceYield] = useState(0);
|
||||
const [yieldResult, setYieldResult] = useState<{
|
||||
amount: number;
|
||||
@@ -37,7 +40,8 @@ export function YieldCard({
|
||||
|
||||
// 历练相关
|
||||
const handleClaimYield = async () => {
|
||||
if (!cultivator) return;
|
||||
if (!cultivator || !owner) return;
|
||||
const pending = pendingCommandRequest(owner, 'yield');
|
||||
setClaiming(true);
|
||||
onInteractionActiveChange?.(true);
|
||||
|
||||
@@ -45,7 +49,7 @@ export function YieldCard({
|
||||
const response = await fetch('/api/cultivator/yield', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({ requestId: pending.requestId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -74,6 +78,7 @@ export function YieldCard({
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
if (data.type === 'result') {
|
||||
pending.complete();
|
||||
// Initial calculation result
|
||||
setYieldResult(() => ({
|
||||
amount: data.data.amount,
|
||||
@@ -154,16 +159,17 @@ export function YieldCard({
|
||||
}
|
||||
}, [cultivator?.last_yield_at]);
|
||||
|
||||
const pendingYield = owner ? hasPendingCommandRequest(owner, 'yield') : false;
|
||||
const actionButton = (
|
||||
<InkButton
|
||||
variant={timeSinceYield >= 1 ? 'primary' : 'secondary'}
|
||||
disabled={timeSinceYield < 1}
|
||||
disabled={timeSinceYield < 1 && !pendingYield}
|
||||
pending={claiming}
|
||||
pendingLabel="结算中……"
|
||||
onClick={handleClaimYield}
|
||||
className={variant === 'card' ? 'min-w-20' : undefined}
|
||||
>
|
||||
{timeSinceYield < 1 ? '历练中' : '领取'}
|
||||
{pendingYield ? '重试领取' : timeSinceYield < 1 ? '历练中' : '领取'}
|
||||
</InkButton>
|
||||
);
|
||||
const spiritStonesInfo = getGameConceptInfo('spirit_stones');
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { GameIcon } from '@app/components/ui/GameIcon';
|
||||
import { InkButton } from '@app/components/ui/InkButton';
|
||||
import { InkDetailDrawer } from '@app/components/ui/InkDetailDrawer';
|
||||
import { useCultivatorIdentity } from '@app/lib/resources/player';
|
||||
import type { HuntLobby, HuntTeam } from '@shared/contracts/hunts';
|
||||
import { HUNT_BOSSES, huntMapHref } from '@shared/hunts/config';
|
||||
import { huntRealmAllowed } from '@shared/hunts/rules';
|
||||
import { REALM_VALUES, type RealmType } from '@shared/types/constants';
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { HuntRewardPreview } from './HuntRewardPreview';
|
||||
import { huntRequest, useHunts } from './useHunts';
|
||||
export function HuntLobbyDrawer({
|
||||
eventId,
|
||||
onClose,
|
||||
}: {
|
||||
eventId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, error, actorId, refresh } = useHunts<HuntLobby>(
|
||||
`/api/hunts/${eventId}`,
|
||||
);
|
||||
const identity = useCultivatorIdentity().data?.cultivator;
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [minRealm, setMinRealm] = useState<RealmType>('炼气');
|
||||
const [maxRealm, setMaxRealm] = useState<RealmType>('渡劫');
|
||||
const navigate = useNavigate();
|
||||
const team = data?.myTeam;
|
||||
const self = team?.members.find((m) => m.cultivatorId === actorId);
|
||||
const boss = data && HUNT_BOSSES[data.event.bossId];
|
||||
const act = async (url: string, body: unknown, enter = false) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const next = await huntRequest<HuntTeam | null>(url, actorId, body);
|
||||
setCreating(false);
|
||||
refresh();
|
||||
if (enter && next?.battleId)
|
||||
navigate(`/game/combat-v6/hunt/${next.battleId}`);
|
||||
} catch (cause) {
|
||||
setActionError(
|
||||
cause instanceof Error ? cause.message : '暂时未能办妥,请稍后再试',
|
||||
);
|
||||
refresh();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
const command = (type: 'ready' | 'leave' | 'start') =>
|
||||
team &&
|
||||
void act(
|
||||
`/api/hunts/teams/${team.id}`,
|
||||
{
|
||||
type,
|
||||
revision: team.revision,
|
||||
...(type === 'ready' ? { ready: !self?.ready } : {}),
|
||||
},
|
||||
type === 'start',
|
||||
);
|
||||
return (
|
||||
<InkDetailDrawer
|
||||
isOpen
|
||||
title={boss ? `${data!.event.realm}期 · ${boss.name}` : '结伴讨伐'}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
>
|
||||
{error || actionError ? (
|
||||
<p role="alert" className="text-crimson mb-3 text-sm">
|
||||
{actionError || error}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActionError('');
|
||||
refresh();
|
||||
}}
|
||||
className="ml-2 underline"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{!data ? (
|
||||
<p className="text-sm">正在探听讨伐消息……</p>
|
||||
) : (
|
||||
<div className="space-y-5 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<GameIcon value={boss!.icon} className="text-7xl" />
|
||||
<div>
|
||||
<p>{data.event.locationName}</p>
|
||||
<p className="text-ink-secondary mt-1">
|
||||
{data.open
|
||||
? `行踪将于 ${new Date(data.event.expiresAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 隐去`
|
||||
: '此处异动已平息'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="leading-7">{boss!.hint}</p>
|
||||
{data.claimed ? (
|
||||
<p className="text-ink-secondary">
|
||||
你已领过此次讨伐的报酬,仍可助道友一战,助战不再获酬。
|
||||
</p>
|
||||
) : null}
|
||||
{team && team.event.id !== eventId ? (
|
||||
<p>
|
||||
你已有一支讨伐队伍。
|
||||
<Link
|
||||
className="text-teal underline"
|
||||
to={huntMapHref(team.event)}
|
||||
>
|
||||
查看我的队伍
|
||||
</Link>
|
||||
</p>
|
||||
) : team ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
我的队伍{' '}
|
||||
<span className="font-mono">{team.members.length}/4</span>
|
||||
</span>
|
||||
<span className="text-ink-secondary">
|
||||
{team.minRealm}至{team.maxRealm}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-ink/10 divide-y">
|
||||
{team.members.map((m) => (
|
||||
<li
|
||||
key={m.cultivatorId}
|
||||
className="flex items-center gap-2 py-3"
|
||||
>
|
||||
<GameIcon
|
||||
value="icon:cultivator-male-avatar"
|
||||
className="text-3xl"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
{m.name}
|
||||
{m.cultivatorId === team.leaderId ? ' · 队长' : ''}
|
||||
<span className="text-ink-secondary block text-xs">
|
||||
{m.realm}
|
||||
{m.assisting ? ' · 助战' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span>{m.ready ? '已准备' : '未准备'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{team.status === 'in_battle' && team.battleId ? (
|
||||
<InkButton
|
||||
onClick={() =>
|
||||
navigate(`/game/combat-v6/hunt/${team.battleId}`)
|
||||
}
|
||||
>
|
||||
进入战斗
|
||||
</InkButton>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<InkButton
|
||||
disabled={
|
||||
pending || !data.open || team.status === 'starting'
|
||||
}
|
||||
onClick={() => command('ready')}
|
||||
>
|
||||
{self?.ready ? '取消准备' : '准备'}
|
||||
</InkButton>
|
||||
{team.leaderId === actorId ? (
|
||||
<InkButton
|
||||
disabled={
|
||||
pending ||
|
||||
!data.open ||
|
||||
team.members.length < 2 ||
|
||||
team.members.some((m) => !m.ready)
|
||||
}
|
||||
onClick={() => command('start')}
|
||||
>
|
||||
{team.status === 'starting' ? '继续出战' : '开始讨伐'}
|
||||
</InkButton>
|
||||
) : null}
|
||||
<InkButton
|
||||
disabled={pending || team.status === 'starting'}
|
||||
onClick={() => command('leave')}
|
||||
>
|
||||
离开队伍
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
{team.members.length < 2 ? (
|
||||
<p className="text-ink-secondary">
|
||||
再邀一位道友,准备妥当便可出战。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : data.open ? (
|
||||
<>
|
||||
{creating ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void act('/api/hunts/teams', {
|
||||
eventId,
|
||||
minRealm,
|
||||
maxRealm,
|
||||
});
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<p>招募何等境界的道友?</p>
|
||||
<div className="flex gap-3">
|
||||
<label className="flex-1">
|
||||
最低境界
|
||||
<select
|
||||
className="border-ink/20 mt-1 block w-full border bg-transparent p-2"
|
||||
value={minRealm}
|
||||
onChange={(e) =>
|
||||
setMinRealm(e.target.value as RealmType)
|
||||
}
|
||||
>
|
||||
{REALM_VALUES.map((r) => (
|
||||
<option key={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex-1">
|
||||
最高境界
|
||||
<select
|
||||
className="border-ink/20 mt-1 block w-full border bg-transparent p-2"
|
||||
value={maxRealm}
|
||||
onChange={(e) =>
|
||||
setMaxRealm(e.target.value as RealmType)
|
||||
}
|
||||
>
|
||||
{REALM_VALUES.map((r) => (
|
||||
<option key={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<InkButton
|
||||
type="submit"
|
||||
disabled={
|
||||
pending ||
|
||||
REALM_VALUES.indexOf(minRealm) >
|
||||
REALM_VALUES.indexOf(maxRealm)
|
||||
}
|
||||
>
|
||||
确认创建
|
||||
</InkButton>{' '}
|
||||
<InkButton onClick={() => setCreating(false)}>取消</InkButton>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<InkButton
|
||||
disabled={pending}
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
创建队伍
|
||||
</InkButton>
|
||||
<InkButton
|
||||
disabled={pending}
|
||||
onClick={() => void act(`/api/hunts/${eventId}/join`, {})}
|
||||
>
|
||||
快速匹配
|
||||
</InkButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-ink/10 divide-y">
|
||||
{data.teams.length ? (
|
||||
data.teams.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-3 py-3">
|
||||
<GameIcon
|
||||
value="icon:cultivator-male-avatar"
|
||||
className="text-4xl"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>
|
||||
{
|
||||
t.members.find((m) => m.cultivatorId === t.leaderId)
|
||||
?.name
|
||||
}
|
||||
的队伍{' '}
|
||||
<span className="font-mono">
|
||||
{t.members.length}/4
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-ink-secondary mt-1 text-xs">
|
||||
{t.minRealm}至{t.maxRealm} ·{' '}
|
||||
{t.members
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.name}${m.assisting ? '(助战)' : ''}`,
|
||||
)
|
||||
.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
<InkButton
|
||||
disabled={
|
||||
pending ||
|
||||
t.members.length >= 4 ||
|
||||
!identity ||
|
||||
!huntRealmAllowed(t, identity.realm)
|
||||
}
|
||||
onClick={() =>
|
||||
void act(`/api/hunts/${eventId}/join`, {
|
||||
teamId: t.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
加入
|
||||
</InkButton>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-ink-secondary py-6 text-center">
|
||||
还没有队伍,先召集几位道友吧。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<HuntRewardPreview realm={data.event.realm} />
|
||||
<details className="text-ink-secondary">
|
||||
<summary className="cursor-pointer">出战须知</summary>
|
||||
<div className="mt-2 space-y-2 leading-7">
|
||||
<p>
|
||||
2~4 人结伴即可出战,不必等到满员。全员准备后,由队长发起讨伐。
|
||||
</p>
|
||||
<p>
|
||||
各队均可挑战,不必争抢。此次行踪消失前,每人只领一次报酬;落败可再战,领过报酬也能助战。
|
||||
</p>
|
||||
<p>
|
||||
入战时气血、法力充盈,战后不损原有气血、法力,也不折损灵兽寿元。
|
||||
</p>
|
||||
<p>行踪隐去后便无法出战,已经交手的队伍仍可战至终局。</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</InkDetailDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { GameIcon } from '@app/components/ui/GameIcon';
|
||||
import { HUNT_BOSSES, type HuntEvent } from '@shared/hunts/config';
|
||||
export type HuntMapPoint = { id: string; x: number; y: number };
|
||||
export function HuntMapMarkers({
|
||||
events,
|
||||
points,
|
||||
showList,
|
||||
onSelect,
|
||||
}: {
|
||||
events: HuntEvent[];
|
||||
points: HuntMapPoint[];
|
||||
showList: boolean;
|
||||
onSelect: (event: HuntEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showList ? (
|
||||
<details className="bg-paper/95 absolute top-[var(--atlas-toolbar-bottom)] left-3 z-20 max-h-[50dvh] max-w-[calc(100%-1.5rem)] overflow-auto p-3 shadow-sm">
|
||||
<summary className="text-crimson cursor-pointer text-sm">
|
||||
异闻讨伐 · {events.length}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{events.map((event) => (
|
||||
<li key={event.id}>
|
||||
<button
|
||||
onClick={() => onSelect(event)}
|
||||
className="hover:bg-ink/5 flex w-full items-center gap-2 p-2 text-left text-sm"
|
||||
>
|
||||
<GameIcon
|
||||
value={HUNT_BOSSES[event.bossId].icon}
|
||||
className="text-4xl"
|
||||
/>
|
||||
<span>
|
||||
{event.realm} · {HUNT_BOSSES[event.bossId].name}
|
||||
<span className="text-ink-secondary block text-xs">
|
||||
{event.locationName}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : (
|
||||
points.map((point) => {
|
||||
const event = events.find((e) => e.id === point.id);
|
||||
return event ? (
|
||||
<button
|
||||
key={event.id}
|
||||
aria-label={`讨伐${event.realm}期${HUNT_BOSSES[event.bossId].name}`}
|
||||
onClick={() => onSelect(event)}
|
||||
style={{ left: point.x, top: point.y }}
|
||||
className="bg-paper/95 border-crimson/40 absolute z-10 flex -translate-x-1/2 -translate-y-full flex-col items-center rounded border px-2 py-1 shadow-sm"
|
||||
>
|
||||
<GameIcon
|
||||
value={HUNT_BOSSES[event.bossId].icon}
|
||||
className="text-5xl"
|
||||
/>
|
||||
<span className="text-crimson text-xs whitespace-nowrap">
|
||||
{event.realm} · {HUNT_BOSSES[event.bossId].name}
|
||||
</span>
|
||||
</button>
|
||||
) : null;
|
||||
})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ItemSlot } from '@app/components/feature/items/ItemSlot';
|
||||
import { InkModal } from '@app/components/layout/InkModal';
|
||||
import type { HuntBattleReward } from '@shared/contracts/hunts';
|
||||
import { dungeonRewardItemName } from '@shared/rewards/dungeon';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useHunts } from './useHunts';
|
||||
|
||||
export function HuntResult({
|
||||
battleId,
|
||||
onClose,
|
||||
}: {
|
||||
battleId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, error, refresh } = useHunts<HuntBattleReward>(
|
||||
`/api/hunts/battles/${battleId}/reward`,
|
||||
);
|
||||
const [showRewards, setShowRewards] = useState(false);
|
||||
return (
|
||||
<div className="min-w-0 space-y-2 text-sm">
|
||||
<p role="status">
|
||||
{error
|
||||
? '暂未查到此次所得,请稍后再看'
|
||||
: !data || data.status === 'pending'
|
||||
? '正在清点此次所得……'
|
||||
: data.status === 'assisting'
|
||||
? '助战得胜。你已领过此次报酬,不再另赠。'
|
||||
: data.status === 'no-reward'
|
||||
? '此战未能得胜,行踪消失前还可再战。'
|
||||
: '此次所得已收妥'}
|
||||
</p>
|
||||
{data?.status === 'rewarded' && data.reward ? (
|
||||
<InkModal
|
||||
isOpen={showRewards}
|
||||
title="讨伐所得"
|
||||
onClose={() => setShowRewards(false)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="flex flex-wrap justify-center gap-3">
|
||||
<span>
|
||||
修为{' '}
|
||||
<span className="font-mono">+{data.reward.experience}</span>
|
||||
</span>
|
||||
<span>
|
||||
灵石{' '}
|
||||
<span className="font-mono">+{data.reward.spiritStones}</span>
|
||||
</span>
|
||||
<span>
|
||||
感悟 <span className="font-mono">+{data.reward.insight}</span>
|
||||
</span>
|
||||
</p>
|
||||
{data.reward.items.length ? (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{data.reward.items.map((item, index) => (
|
||||
<ItemSlot
|
||||
key={index}
|
||||
className="w-20"
|
||||
quantityLabel="奖励"
|
||||
item={{
|
||||
...item,
|
||||
instanceData: item.instanceData ?? null,
|
||||
name: dungeonRewardItemName(item),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-ink-secondary">
|
||||
寻得的物品已随信送来。
|
||||
<Link to="/game/mail" className="text-teal underline">
|
||||
查看邮件
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</InkModal>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{data?.status === 'rewarded' ? (
|
||||
<button onClick={() => setShowRewards(true)}>查看所得</button>
|
||||
) : null}
|
||||
{error ? <button onClick={refresh}>重试</button> : null}
|
||||
<button onClick={onClose}>返回讨伐队伍</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { itemPresentation } from '@app/components/feature/items/itemPresentation';
|
||||
import { ItemSlot } from '@app/components/feature/items/ItemSlot';
|
||||
import { GameIcon } from '@app/components/ui/GameIcon';
|
||||
import { BEAST_SUPERIOR_BOOK_SKILL_IDS } from '@shared/engine/combat-v6/beasts/content';
|
||||
import { itemDefinition } from '@shared/inventory';
|
||||
import { getGameConceptInfo } from '@shared/lib/gameConceptDisplay';
|
||||
import { DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM } from '@shared/rewards/dungeon-material-quality';
|
||||
import { HUNT_DROP_POOL } from '@shared/rewards/hunt';
|
||||
import type { RealmType } from '@shared/types/constants';
|
||||
import { useState } from 'react';
|
||||
|
||||
const bookEntries = HUNT_DROP_POOL.groups.find((g) => g.id === 'book')!.entries;
|
||||
const bookPools = [
|
||||
{ id: 'ordinary', label: '普通传承灵印', superior: false },
|
||||
{ id: 'superior', label: '上品传承灵印', superior: true },
|
||||
].map((pool) => ({
|
||||
...pool,
|
||||
entries: bookEntries.filter(
|
||||
(entry) =>
|
||||
BEAST_SUPERIOR_BOOK_SKILL_IDS.has(
|
||||
itemDefinition(entry.rewardId).skillId!,
|
||||
) === pool.superior,
|
||||
),
|
||||
}));
|
||||
const materialCount = HUNT_DROP_POOL.groups.find((g) => g.id === 'materials')!
|
||||
.entries[0].quantity.min;
|
||||
const dewEntries = HUNT_DROP_POOL.groups.find((g) => g.id === 'dew')!.entries;
|
||||
function previewItem(definitionId: string) {
|
||||
return {
|
||||
definitionId,
|
||||
name: itemDefinition(definitionId).name,
|
||||
quantity: 1,
|
||||
instanceData: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function HuntRewardPreview({ realm }: { realm: RealmType }) {
|
||||
const [showMaterials, setShowMaterials] = useState(false);
|
||||
return (
|
||||
<section aria-label="讨伐所得" className="space-y-3">
|
||||
<h3>讨伐所得</h3>
|
||||
<div className="grid max-w-[19.5rem] grid-cols-4 gap-2">
|
||||
{['cultivation_exp', 'spirit_stones', 'comprehension_insight'].map(
|
||||
(key) => {
|
||||
const resource = getGameConceptInfo(key);
|
||||
return (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<ItemSlot
|
||||
className="w-full cursor-default"
|
||||
emptyLabel={resource.label}
|
||||
emptyIcon={
|
||||
<GameIcon
|
||||
value={resource.icon}
|
||||
purpose="artwork"
|
||||
className="text-ink"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-ink-secondary text-center text-xs">必得</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<ItemSlot
|
||||
className="w-full"
|
||||
emptyLabel="灵材"
|
||||
emptyIcon="💎"
|
||||
badge={`×${materialCount}`}
|
||||
selected={showMaterials}
|
||||
onQuickAction={() => setShowMaterials((shown) => !shown)}
|
||||
/>
|
||||
<p className="text-ink-secondary text-center text-xs">必得</p>
|
||||
</div>
|
||||
{bookPools
|
||||
.filter((p) => p.entries.length)
|
||||
.map((p) => (
|
||||
<div key={p.id} className="space-y-1.5">
|
||||
<ItemSlot
|
||||
className="w-full cursor-default"
|
||||
emptyLabel={p.label}
|
||||
emptyIcon={
|
||||
<GameIcon
|
||||
value={
|
||||
itemPresentation(previewItem(p.entries[0].rewardId)).icon
|
||||
}
|
||||
purpose="artwork"
|
||||
className="text-ink"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-ink-secondary text-center text-xs">可能获得</p>
|
||||
</div>
|
||||
))}
|
||||
{dewEntries.map((entry) => (
|
||||
<div key={entry.rewardId} className="space-y-1.5">
|
||||
<ItemSlot
|
||||
className="w-full"
|
||||
item={previewItem(entry.rewardId)}
|
||||
quantityLabel="奖励"
|
||||
/>
|
||||
<p className="text-ink-secondary text-center text-xs">可能获得</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showMaterials ? (
|
||||
<div className="space-y-3" aria-label="灵材品质">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p>可得灵材品质</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMaterials(false)}
|
||||
className="text-ink-secondary shrink-0"
|
||||
>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-ink-secondary">
|
||||
{Object.entries(DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM[realm])
|
||||
.filter(([, chance]) => chance > 0)
|
||||
.map(([quality]) => quality)
|
||||
.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { fetchJsonCached } from '@app/lib/client/requestCache';
|
||||
import { useCultivatorIdentity } from '@app/lib/resources/player';
|
||||
import { useEffect, useState } from 'react';
|
||||
export async function huntRequest<T>(
|
||||
url: string,
|
||||
actorId: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetchJsonCached<{ data: T }>(url, {
|
||||
key: `hunts:${actorId}:${url}`,
|
||||
signal,
|
||||
...(body === undefined
|
||||
? {}
|
||||
: {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
export function useHunts<T>(url: string, interval = 5000) {
|
||||
const actorId = useCultivatorIdentity().data?.cultivator?.id ?? '';
|
||||
const [result, setResult] = useState<{
|
||||
data?: T;
|
||||
error?: string;
|
||||
actorId: string;
|
||||
url: string;
|
||||
}>({ actorId, url });
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!actorId) return;
|
||||
let disposed = false;
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const run = async () => {
|
||||
try {
|
||||
const data = await huntRequest<T>(
|
||||
url,
|
||||
actorId,
|
||||
undefined,
|
||||
abort.signal,
|
||||
);
|
||||
if (!disposed) setResult({ data, actorId, url });
|
||||
} catch (cause) {
|
||||
if (!disposed)
|
||||
setResult((previous) => ({
|
||||
...(previous.actorId === actorId && previous.url === url
|
||||
? previous
|
||||
: {}),
|
||||
actorId,
|
||||
url,
|
||||
error: cause instanceof Error ? cause.message : '载入失败',
|
||||
}));
|
||||
} finally {
|
||||
if (!disposed)
|
||||
timer = setTimeout(() => {
|
||||
if (document.visibilityState === 'visible') void run();
|
||||
else timer = setTimeout(run, interval);
|
||||
}, interval);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [url, interval, actorId, revision]);
|
||||
return {
|
||||
data:
|
||||
result.actorId === actorId && result.url === url
|
||||
? result.data
|
||||
: undefined,
|
||||
error:
|
||||
result.actorId === actorId && result.url === url
|
||||
? result.error
|
||||
: undefined,
|
||||
actorId,
|
||||
refresh: () => setRevision((n) => n + 1),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { InkModal } from '@app/components/layout/InkModal';
|
||||
import { InkButton } from '@app/components/ui/InkButton';
|
||||
import { InkDiscreteRange } from '@app/components/ui/InkDiscreteRange';
|
||||
import type { InventorySort } from '@shared/inventory/sorting';
|
||||
import {
|
||||
INVENTORY_MATERIAL_TYPES,
|
||||
MATERIAL_TYPE_NAMES,
|
||||
} from '@shared/items/definitions/materials';
|
||||
import { QUALITY_VALUES } from '@shared/types/constants';
|
||||
import {
|
||||
ELEMENT_VALUES,
|
||||
QUALITY_VALUES,
|
||||
type ElementType,
|
||||
} from '@shared/types/constants';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
defaultInventoryFilter,
|
||||
@@ -20,13 +25,18 @@ export function InventoryFilters({
|
||||
value,
|
||||
onChange,
|
||||
kindDisabled = false,
|
||||
sort,
|
||||
onSortChange,
|
||||
}: {
|
||||
value: InventoryFilter;
|
||||
onChange: (value: InventoryFilter) => void;
|
||||
kindDisabled?: boolean;
|
||||
sort?: InventorySort;
|
||||
onSortChange?: (sort: InventorySort | undefined) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<InventoryFilter>(value);
|
||||
const [draftSort, setDraftSort] = useState(sort);
|
||||
const min = draft.minRank ? QUALITY_VALUES.indexOf(draft.minRank) : 0;
|
||||
const max = draft.maxRank
|
||||
? QUALITY_VALUES.indexOf(draft.maxRank)
|
||||
@@ -37,6 +47,7 @@ export function InventoryFilters({
|
||||
? draft
|
||||
: { kind: kindDisabled ? value.kind : draft.kind },
|
||||
);
|
||||
onSortChange?.(draftSort);
|
||||
setOpen(false);
|
||||
}
|
||||
return (
|
||||
@@ -44,10 +55,11 @@ export function InventoryFilters({
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setDraftSort(sort);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
筛选{inventoryFilterActive(value) ? ' · 已启用' : ''}
|
||||
筛选{inventoryFilterActive(value) || sort ? ' · 已启用' : ''}
|
||||
</InkButton>
|
||||
<InkModal
|
||||
isOpen={open}
|
||||
@@ -56,11 +68,12 @@ export function InventoryFilters({
|
||||
footer={
|
||||
<div className="flex justify-between gap-3">
|
||||
<InkButton
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
setDraft(
|
||||
kindDisabled ? { kind: value.kind } : defaultInventoryFilter,
|
||||
)
|
||||
}
|
||||
);
|
||||
setDraftSort(undefined);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</InkButton>
|
||||
@@ -98,6 +111,27 @@ export function InventoryFilters({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{onSortChange ? (
|
||||
<label className="block space-y-2">
|
||||
<span>排序方式</span>
|
||||
<select
|
||||
aria-label="排序方式"
|
||||
value={draftSort ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraftSort(
|
||||
(event.target.value || undefined) as
|
||||
InventorySort | undefined,
|
||||
)
|
||||
}
|
||||
className="border-ink/20 w-full border bg-transparent p-2"
|
||||
>
|
||||
<option value="">默认顺序</option>
|
||||
<option value="updatedAt">获得时间 · 最新优先</option>
|
||||
<option value="quantity">堆叠数量 · 最多优先</option>
|
||||
<option value="kind">物品类型</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{draft.kind === 'material' ? (
|
||||
<>
|
||||
<fieldset className="space-y-2">
|
||||
@@ -141,6 +175,28 @@ export function InventoryFilters({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-2">
|
||||
<span>五行属性</span>
|
||||
<select
|
||||
aria-label="五行属性"
|
||||
value={draft.element ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
element: (event.target.value || undefined) as
|
||||
ElementType | undefined,
|
||||
}))
|
||||
}
|
||||
className="border-ink/20 w-full border bg-transparent p-2"
|
||||
>
|
||||
<option value="">全部属性</option>
|
||||
{ELEMENT_VALUES.map((element) => (
|
||||
<option key={element} value={element}>
|
||||
{element}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
@@ -24,8 +24,57 @@ export function InventoryGrid({
|
||||
);
|
||||
}
|
||||
|
||||
/** Native auto popovers share one top-layer preview, including inside bag drawers. */
|
||||
type SlotItem = {
|
||||
name: string;
|
||||
quantity: number;
|
||||
icon: string;
|
||||
color: string;
|
||||
equipped?: boolean;
|
||||
};
|
||||
|
||||
type SlotProps = {
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string;
|
||||
emptyLabel?: string;
|
||||
emptyIcon?: ReactNode;
|
||||
className?: string;
|
||||
onQuickAction?: () => void;
|
||||
quickOnTouch?: boolean;
|
||||
guideAnchor?: string;
|
||||
};
|
||||
|
||||
export function ItemSlot({
|
||||
item,
|
||||
children,
|
||||
quantityLabel = '持有',
|
||||
...props
|
||||
}: SlotProps & {
|
||||
item?: DisplayItem;
|
||||
children?: (close: () => void) => ReactNode;
|
||||
quantityLabel?: '持有' | '库存' | '奖励' | '投入' | '产出';
|
||||
}) {
|
||||
return (
|
||||
<PreviewSlot
|
||||
{...props}
|
||||
item={item ? { ...item, ...itemPresentation(item) } : undefined}
|
||||
renderPreview={(close) =>
|
||||
item && (
|
||||
<ItemPreview
|
||||
item={item}
|
||||
options={{ hideQuantity: true }}
|
||||
quantityLabel={quantityLabel}
|
||||
close={close}
|
||||
actions={children?.(close)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared slot and native popover for items and non-inventory rewards. */
|
||||
export function PreviewSlot({
|
||||
item,
|
||||
selected,
|
||||
disabled,
|
||||
@@ -35,22 +84,11 @@ export function ItemSlot({
|
||||
className,
|
||||
onQuickAction,
|
||||
quickOnTouch,
|
||||
children,
|
||||
quantityLabel = '持有',
|
||||
renderPreview,
|
||||
guideAnchor,
|
||||
}: {
|
||||
item?: DisplayItem;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string;
|
||||
emptyLabel?: string;
|
||||
emptyIcon?: ReactNode;
|
||||
className?: string;
|
||||
onQuickAction?: () => void;
|
||||
quickOnTouch?: boolean;
|
||||
children?: (close: () => void) => ReactNode;
|
||||
quantityLabel?: '持有' | '库存' | '奖励' | '投入' | '产出';
|
||||
guideAnchor?: string;
|
||||
}: SlotProps & {
|
||||
item?: SlotItem;
|
||||
renderPreview: (close: () => void) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
@@ -61,8 +99,12 @@ export function ItemSlot({
|
||||
const longPressed = useRef(false);
|
||||
const touchMoved = useRef(false);
|
||||
const pointer = useRef('');
|
||||
const presentation = item ? itemPresentation(item) : undefined;
|
||||
const slotName = item?.name ?? emptyLabel;
|
||||
const quantityText = item
|
||||
? item.quantity > 9999
|
||||
? `${(item.quantity / 10000).toFixed(2)}万`
|
||||
: String(item.quantity)
|
||||
: '';
|
||||
function cancel() {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
@@ -231,11 +273,7 @@ export function ItemSlot({
|
||||
(onQuickAction ? 'text-ink-secondary' : 'text-ink/25'),
|
||||
)}
|
||||
>
|
||||
{presentation ? (
|
||||
<GameIcon value={presentation.icon} purpose="artwork" />
|
||||
) : (
|
||||
emptyIcon
|
||||
)}
|
||||
{item ? <GameIcon value={item.icon} purpose="artwork" /> : emptyIcon}
|
||||
</span>
|
||||
{slotName ? (
|
||||
<span
|
||||
@@ -246,18 +284,26 @@ export function ItemSlot({
|
||||
: slotName.length === 5
|
||||
? 'text-[clamp(0.625rem,16cqw,0.75rem)] leading-[1.1] @max-[4rem]:text-[8px] @max-[4rem]:leading-[1.05]'
|
||||
: 'text-[clamp(0.625rem,16cqw,0.75rem)] leading-[1.1]',
|
||||
presentation?.color,
|
||||
item?.color,
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-2 w-full">{slotName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{item && item.quantity > 1 ? (
|
||||
<span className="text-ink absolute top-1 left-1 font-mono text-[clamp(0.75rem,20cqw,1rem)] leading-none font-semibold tracking-tight [text-shadow:0_1px_2px_var(--color-paper)]">
|
||||
<span
|
||||
className="text-ink absolute top-1 left-1 font-mono text-[clamp(0.75rem,20cqw,1rem)] leading-none font-semibold tracking-tight whitespace-nowrap [text-shadow:0_1px_2px_var(--color-paper)]"
|
||||
style={
|
||||
item.quantity > 9999
|
||||
? {
|
||||
// Leave room for the multiplier and 万 in narrow slots.
|
||||
fontSize: `min(1rem, 20cqw, calc((100cqw - 0.5rem) / ${quantityText.length * 0.65 + 1}))`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="text-[0.75em]">×</span>
|
||||
{item.quantity >= 10000
|
||||
? `${Math.floor(item.quantity / 1000)}k`
|
||||
: item.quantity}
|
||||
{quantityText}
|
||||
</span>
|
||||
) : null}
|
||||
{badge || item?.equipped ? (
|
||||
@@ -293,15 +339,7 @@ export function ItemSlot({
|
||||
}}
|
||||
className="bg-bgpaper text-ink border-ink/30 fixed inset-auto m-0 w-80 overflow-y-auto overscroll-contain border p-3 text-sm leading-6 [overflow-wrap:anywhere] shadow-xl"
|
||||
>
|
||||
{open && item && presentation ? (
|
||||
<ItemPreview
|
||||
item={item}
|
||||
options={{ hideQuantity: true }}
|
||||
quantityLabel={quantityLabel}
|
||||
close={close}
|
||||
actions={children?.(() => setOpen(false))}
|
||||
/>
|
||||
) : null}
|
||||
{open && item ? renderPreview(() => setOpen(false)) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import type { InventoryView } from '@shared/contracts/inventory';
|
||||
import { itemDefinition } from '@shared/inventory';
|
||||
import { INVENTORY_KINDS } from '@shared/inventory/sorting';
|
||||
import {
|
||||
INVENTORY_MATERIAL_TYPES,
|
||||
MaterialFactsSchema,
|
||||
} from '@shared/items/definitions/materials';
|
||||
import { QUALITY_VALUES, type Quality } from '@shared/types/constants';
|
||||
import {
|
||||
QUALITY_VALUES,
|
||||
type ElementType,
|
||||
type Quality,
|
||||
} from '@shared/types/constants';
|
||||
|
||||
type Item = InventoryView['items'][number];
|
||||
|
||||
export const inventoryKinds = [
|
||||
['all', '全部'],
|
||||
['beast_book', '传承灵印'],
|
||||
['beast_refinement', '归元灵露'],
|
||||
['beast_rejuvenation', '化生果'],
|
||||
['manual_jade', '功法玉简'],
|
||||
['inscription', '阵纹'],
|
||||
['equipment', '道装'],
|
||||
['blueprint', '图纸'],
|
||||
['material', '材料'],
|
||||
['seed', '灵种'],
|
||||
['consumable', '丹药与消耗品'],
|
||||
] as const;
|
||||
export const inventoryKinds = INVENTORY_KINDS;
|
||||
|
||||
export type InventoryKind = (typeof inventoryKinds)[number][0];
|
||||
export type MaterialType = (typeof INVENTORY_MATERIAL_TYPES)[number];
|
||||
@@ -29,6 +22,7 @@ export type InventoryFilter = {
|
||||
minRank?: Quality;
|
||||
maxRank?: Quality;
|
||||
materialType?: MaterialType;
|
||||
element?: ElementType;
|
||||
};
|
||||
|
||||
export const defaultInventoryFilter: InventoryFilter = { kind: 'all' };
|
||||
@@ -38,7 +32,8 @@ export function inventoryFilterActive(filter: InventoryFilter) {
|
||||
filter.kind !== 'all' ||
|
||||
!!filter.minRank ||
|
||||
!!filter.maxRank ||
|
||||
!!filter.materialType
|
||||
!!filter.materialType ||
|
||||
!!filter.element
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +47,7 @@ export function matchesInventoryFilters(item: Item, filter: InventoryFilter) {
|
||||
return (
|
||||
(!filter.minRank || rank >= QUALITY_VALUES.indexOf(filter.minRank)) &&
|
||||
(!filter.maxRank || rank <= QUALITY_VALUES.indexOf(filter.maxRank)) &&
|
||||
(!filter.materialType || facts.data.type === filter.materialType)
|
||||
(!filter.materialType || facts.data.type === filter.materialType) &&
|
||||
(!filter.element || facts.data.element === filter.element)
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export const consumableAdapter: ItemAdapter = (item) => {
|
||||
tone: groupTones[group.role],
|
||||
...(group.collapsible ? { collapsible: true as const } : {}),
|
||||
})),
|
||||
description: facts.description,
|
||||
description: facts.spec.kind === 'pill' ? undefined : facts.description,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
JOURNAL_ACTIVITIES,
|
||||
type PlayerJournalEvent,
|
||||
} from '@shared/contracts/playerJournal';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const resourceStyles = {
|
||||
spiritStones: { label: '灵石', className: 'text-amber-700' },
|
||||
exp: { label: '修为', className: 'text-teal' },
|
||||
insight: { label: '感悟', className: 'text-resource-shield' },
|
||||
reputation: { label: '声望', className: 'text-amber-700' },
|
||||
contribution: { label: '宗门贡献', className: 'text-teal' },
|
||||
qi: { label: '灵气', className: 'text-ink-secondary' },
|
||||
} as const;
|
||||
|
||||
function ResourceAmount({
|
||||
resource,
|
||||
value,
|
||||
}: {
|
||||
resource: keyof typeof resourceStyles;
|
||||
value: number;
|
||||
}) {
|
||||
const style = resourceStyles[resource];
|
||||
return (
|
||||
<span className={`whitespace-nowrap ${style.className}`}>
|
||||
{style.label}
|
||||
<span className="font-mono">
|
||||
{value > 0 ? '+' : ''}
|
||||
{(value === 0 ? 0 : value).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function JournalEventContent({ event }: { event: PlayerJournalEvent }) {
|
||||
if (event.type === 'resources.settled') {
|
||||
return (
|
||||
<>
|
||||
{JOURNAL_ACTIVITIES[event.activity]}
|
||||
{event.detail ? `(${event.detail})` : ''}:
|
||||
{event.changes.map((change, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? ',' : null}
|
||||
{change.kind === 'resource' ? (
|
||||
<ResourceAmount
|
||||
resource={change.resource}
|
||||
value={change.amount}
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
{change.name}
|
||||
<span className="font-mono whitespace-nowrap">
|
||||
{change.amount > 0 ? '+' : ''}
|
||||
{change.amount.toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
。
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (event.type === 'retreat.completed') {
|
||||
return (
|
||||
<>
|
||||
闭关<span className="font-mono">{event.years}</span>年
|
||||
{event.summary.epiphany_triggered ? '(顿悟)' : ''}:
|
||||
<ResourceAmount resource="exp" value={event.summary.exp_gained} />,
|
||||
<ResourceAmount
|
||||
resource="insight"
|
||||
value={event.summary.insight_gained}
|
||||
/>
|
||||
,
|
||||
<ResourceAmount resource="qi" value={-event.qiSpent} />
|
||||
{event.depleted ? (
|
||||
<span className="text-crimson">,寿元已尽</span>
|
||||
) : null}
|
||||
。
|
||||
</>
|
||||
);
|
||||
}
|
||||
const summary = event.summary;
|
||||
return (
|
||||
<>
|
||||
突破{summary.success ? '成功' : '失败'}({summary.fromRealm}
|
||||
{summary.fromStage}
|
||||
{summary.success ? `→${summary.toRealm}${summary.toStage}` : ''}):
|
||||
<ResourceAmount resource="exp" value={-event.expSpent} />,
|
||||
<ResourceAmount resource="insight" value={summary.insight_change} />,
|
||||
<ResourceAmount resource="qi" value={-event.qiSpent} />
|
||||
{summary.lifespanGained > 0 ? (
|
||||
<span className="whitespace-nowrap">
|
||||
,寿元上限<span className="font-mono">+{summary.lifespanGained}</span>
|
||||
年
|
||||
</span>
|
||||
) : null}
|
||||
{summary.naturalAttributeGrowth > 0 ? (
|
||||
<span className="whitespace-nowrap">
|
||||
,六维各
|
||||
<span className="font-mono">+{summary.naturalAttributeGrowth}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{summary.attributePointReward > 0 ? (
|
||||
<span className="whitespace-nowrap">
|
||||
,属性点
|
||||
<span className="font-mono">+{summary.attributePointReward}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{summary.inner_demon_triggered ? (
|
||||
<span className="text-crimson">,心魔缠身</span>
|
||||
) : null}
|
||||
。
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@app/components/feature/cultivator/useQiActionConfirm';
|
||||
import {
|
||||
useCultivatorCondition,
|
||||
useCultivatorCurrency,
|
||||
useCultivatorIdentity,
|
||||
useCultivatorProgress,
|
||||
usePlayerSession,
|
||||
@@ -23,7 +24,7 @@ import type {
|
||||
} from '@shared/contracts/retreat';
|
||||
import type { TaskInstance } from '@shared/types/task';
|
||||
import { getRetreatQiCost } from '@shared/config/qiSystem';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
type ReincarnateContextData,
|
||||
@@ -112,6 +113,7 @@ type RetreatRequestOutcome =
|
||||
export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
const profile = useCultivatorIdentity();
|
||||
const condition = useCultivatorCondition();
|
||||
const currency = useCultivatorCurrency();
|
||||
const progress = useCultivatorProgress();
|
||||
const playerSession = usePlayerSession();
|
||||
const identity = profile.data?.cultivator;
|
||||
@@ -148,6 +150,7 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
error: taskError,
|
||||
} = useTaskList(cultivator?.id);
|
||||
const [retreatYears, setRetreatYears] = useState('10');
|
||||
const requestInFlight = useRef(false);
|
||||
const [retreatResult, setRetreatResult] = useState<RetreatResultData | null>(
|
||||
null,
|
||||
);
|
||||
@@ -262,6 +265,10 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
action: RetreatAction;
|
||||
years?: number;
|
||||
}): Promise<RetreatRequestOutcome> => {
|
||||
if (!cultivator || requestInFlight.current) return { ok: true };
|
||||
requestInFlight.current = true;
|
||||
// Keep the key after a lost response, including across page reloads.
|
||||
const storageKey = `retreat-request:${cultivator.id}:${body.action}:${body.years ?? 0}`;
|
||||
const cultivatorSnapshot = cultivator
|
||||
? {
|
||||
name: cultivator.name,
|
||||
@@ -277,10 +284,12 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
setReincarnateContext(null);
|
||||
|
||||
try {
|
||||
const requestId = window.sessionStorage.getItem(storageKey) ?? crypto.randomUUID();
|
||||
window.sessionStorage.setItem(storageKey, requestId);
|
||||
const response = await fetch('/api/cultivator/retreat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ ...body, requestId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -296,6 +305,7 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
await consumeRetreatStream(response, {
|
||||
cultivatorSnapshot,
|
||||
onResult: (result) => {
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
setRetreatResult(result);
|
||||
setRetreatResultOpen(true);
|
||||
setRetreatResultStreaming(Boolean(result.storyType));
|
||||
@@ -307,6 +317,13 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
onStoryUpdate: setRetreatResult,
|
||||
onReincarnateContext: setReincarnateContext,
|
||||
onState: (state) => {
|
||||
if (state.replayed) {
|
||||
profile.invalidate();
|
||||
condition.invalidate();
|
||||
progress.invalidate();
|
||||
currency.invalidate();
|
||||
playerSession.invalidate();
|
||||
}
|
||||
if (state.changes.length === 0) return;
|
||||
consumeChanges(state);
|
||||
},
|
||||
@@ -321,11 +338,12 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
|
||||
|
||||
return { ok: true };
|
||||
} finally {
|
||||
requestInFlight.current = false;
|
||||
setRetreatResultStreaming(false);
|
||||
setRetreatLoading(false);
|
||||
}
|
||||
},
|
||||
[consumeChanges, cultivator, pushToast],
|
||||
[consumeChanges, cultivator, pushToast, profile, condition, progress, currency, playerSession],
|
||||
);
|
||||
|
||||
const handleRetreat = useCallback(async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { huntEventById, huntMapHref } from '@shared/hunts/config';
|
||||
import { BeastTradeDetails } from '@app/components/feature/beasts/BeastTradePreview';
|
||||
import { itemPresentation } from '@app/components/feature/items/itemPresentation';
|
||||
import { ItemPreview } from '@app/components/feature/items/ItemPreview';
|
||||
@@ -132,6 +133,12 @@ export function WorldChatMessageItem({
|
||||
return null;
|
||||
}
|
||||
}, [message]);
|
||||
const hunt =
|
||||
message.channel === 'system' &&
|
||||
message.messageType === 'hunt_rumor' &&
|
||||
'eventId' in message.payload
|
||||
? huntEventById(message.payload.eventId)
|
||||
: undefined;
|
||||
const battleShowcase =
|
||||
message.messageType === 'battle_showcase' &&
|
||||
isBattleShowcasePayload(message.payload)
|
||||
@@ -185,7 +192,14 @@ export function WorldChatMessageItem({
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 break-all">
|
||||
{message.messageType === 'combat_v6_replay' && 'version' in message.payload && message.payload.version === 1 && 'shareCode' in message.payload && typeof message.payload.shareCode === 'string' && 'sides' in message.payload && Array.isArray(message.payload.sides) && Array.isArray(message.payload.sides[0]) && Array.isArray(message.payload.sides[1]) ? (
|
||||
{hunt ? (
|
||||
<span>
|
||||
{renderTextMessage(message)}{' '}
|
||||
<Link className="text-teal font-semibold underline" to={huntMapHref(hunt)}>
|
||||
前往查看
|
||||
</Link>
|
||||
</span>
|
||||
) : message.messageType === 'combat_v6_replay' && 'version' in message.payload && message.payload.version === 1 && 'shareCode' in message.payload && typeof message.payload.shareCode === 'string' && 'sides' in message.payload && Array.isArray(message.payload.sides) && Array.isArray(message.payload.sides[0]) && Array.isArray(message.payload.sides[1]) ? (
|
||||
<Link className="border-ink/15 hover:border-teal block border border-dashed bg-white/55 px-3 py-2" to={`/combat-replay/${message.payload.shareCode}`}>
|
||||
<span className="text-teal font-semibold">战斗回放 · {message.payload.sides[0].join('、')} 对阵 {message.payload.sides[1].join('、')}</span>
|
||||
<span className="text-ink-secondary ml-2 text-xs">{message.payload.roundCount} 回合 · 点击查看</span>
|
||||
|
||||
@@ -188,6 +188,12 @@ export const gameDockGroups: GameNavGroup[] = [
|
||||
href: '/game/battle/history',
|
||||
expandedDockLabel: '⚔️ 全部战绩',
|
||||
},
|
||||
{
|
||||
id: 'journal',
|
||||
sceneLabel: '修仙日志',
|
||||
href: '/game/journal',
|
||||
expandedDockLabel: '📜 修仙日志',
|
||||
},
|
||||
{
|
||||
id: 'dungeon-history',
|
||||
sceneLabel: '探险札记',
|
||||
@@ -314,6 +320,10 @@ export const gameDockGroups: GameNavGroup[] = [
|
||||
href: '/game/rankings',
|
||||
expandedDockLabel: '🏆 天骄榜',
|
||||
},
|
||||
{
|
||||
id: 'hunt',
|
||||
sceneLabel: '结伴讨伐',
|
||||
},
|
||||
{
|
||||
id: 'arena-sparring',
|
||||
sceneLabel: '擂台切磋',
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BeastTradeDetails } from '@app/components/feature/beasts/BeastTradePreview';
|
||||
import { ItemPreviewView } from '@app/components/feature/items/ItemPreviewView';
|
||||
import { ItemSlot, PreviewSlot } from '@app/components/feature/items/ItemSlot';
|
||||
import type { ItemPreviewModel } from '@app/components/feature/items/presentation/types';
|
||||
import { tierColorMap } from '@app/components/ui/inkBadgeTiers';
|
||||
import { BEAST_SPECIES } from '@shared/engine/combat-v6/beasts/content';
|
||||
import { getGameConceptIcon } from '@shared/lib/gameConceptDisplay';
|
||||
import type { MailAttachment } from '@shared/types/mail';
|
||||
|
||||
export function MailAttachmentSlot({
|
||||
attachment,
|
||||
}: {
|
||||
attachment: MailAttachment;
|
||||
}) {
|
||||
if (attachment.type === 'inventory_v1' && attachment.inventory) {
|
||||
return (
|
||||
<ItemSlot
|
||||
quantityLabel="奖励"
|
||||
item={{
|
||||
name: attachment.name,
|
||||
definitionId: attachment.inventory.definitionId,
|
||||
instanceData: attachment.inventory.instanceData ?? null,
|
||||
quantity: attachment.quantity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (attachment.type === 'beast_v1' && attachment.beastPreview) {
|
||||
const beast = attachment.beastPreview;
|
||||
const species = BEAST_SPECIES.find((entry) => entry.id === beast.speciesId);
|
||||
return (
|
||||
<PreviewSlot
|
||||
item={{
|
||||
name: attachment.name,
|
||||
quantity: attachment.quantity,
|
||||
icon: species?.icon ?? '🐾',
|
||||
color: 'text-ink',
|
||||
}}
|
||||
badge={beast.isMutant ? '变异' : undefined}
|
||||
renderPreview={() => <BeastTradeDetails beast={beast} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isVault = ['material', 'consumable', 'artifact'].includes(
|
||||
attachment.type,
|
||||
);
|
||||
const facts = isVault ? attachment.data : undefined;
|
||||
const tier = facts && ('rank' in facts ? facts.rank : facts.quality);
|
||||
const icon = getGameConceptIcon(attachment.type) || '🎁';
|
||||
const color = tier ? tierColorMap[tier] : 'text-ink';
|
||||
const model: ItemPreviewModel = {
|
||||
title: attachment.name,
|
||||
icon,
|
||||
titleColor: color,
|
||||
header: isVault
|
||||
? [{ kind: 'field', label: '领取去向', value: '洞府宝库' }]
|
||||
: [],
|
||||
sections: [],
|
||||
description: facts?.description,
|
||||
};
|
||||
|
||||
return (
|
||||
<PreviewSlot
|
||||
item={{
|
||||
name: attachment.name,
|
||||
quantity: attachment.quantity,
|
||||
icon,
|
||||
color,
|
||||
}}
|
||||
renderPreview={(close) => <ItemPreviewView model={model} close={close} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { BeastTradeSlot } from '@app/components/feature/beasts/BeastTradePreview';
|
||||
import { ItemSlot } from '@app/components/feature/items/ItemSlot';
|
||||
import { InkModal } from '@app/components/layout';
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import { InkBadge } from '@app/components/ui/InkBadge';
|
||||
import { InkButton } from '@app/components/ui/InkButton';
|
||||
import { InkNotice } from '@app/components/ui/InkNotice';
|
||||
import { useResourceMutation } from '@app/lib/resources/mutations';
|
||||
import { mailLocationText } from '@shared/contracts/mail';
|
||||
import { getGameConceptIcon } from '@shared/lib/gameConceptDisplay';
|
||||
import { Artifact, Consumable, Material } from '@shared/types/cultivator';
|
||||
import { useState } from 'react';
|
||||
import { MailAttachmentSlot } from './MailAttachmentSlot';
|
||||
import { Mail } from './MailList';
|
||||
|
||||
interface MailDetailModalProps {
|
||||
@@ -81,84 +77,10 @@ export function MailDetailModal({
|
||||
<h4 className="text-ink-secondary text-sm font-bold">
|
||||
🎁 附赠物品
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{mail.attachments?.map((item, idx) =>
|
||||
item.type === 'beast_v1' && item.beastPreview ? (
|
||||
<div key={idx}>
|
||||
<div className="w-20">
|
||||
<BeastTradeSlot beast={item.beastPreview} />
|
||||
</div>
|
||||
<p>{item.name} · 灵兽</p>
|
||||
</div>
|
||||
) : item.type === 'inventory_v1' && item.inventory ? (
|
||||
<div key={idx} className="w-20">
|
||||
<ItemSlot
|
||||
className="w-full"
|
||||
item={{
|
||||
name: item.name,
|
||||
definitionId: item.inventory.definitionId,
|
||||
instanceData: item.inventory.instanceData ?? null,
|
||||
quantity: item.quantity,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-paper-2 border-ink/10 flex items-center justify-between border border-dashed p-2 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true">
|
||||
{getGameConceptIcon(item.type) || '🎁'}
|
||||
</span>
|
||||
{item.type === 'spirit_stones' && (
|
||||
<span className="text-ink">{item.name}</span>
|
||||
)}
|
||||
{item.type === 'reputation' && (
|
||||
<span className="text-ink">{item.name}</span>
|
||||
)}
|
||||
{item.type === 'cultivation_exp' && (
|
||||
<span className="text-ink">{item.name}</span>
|
||||
)}
|
||||
{item.type === 'comprehension_insight' && (
|
||||
<span className="text-ink">{item.name}</span>
|
||||
)}
|
||||
{item.type === 'material' && (
|
||||
<InkBadge
|
||||
tier={(item.data as Material)?.rank}
|
||||
hideTierText
|
||||
>
|
||||
{item.name}
|
||||
</InkBadge>
|
||||
)}
|
||||
{item.type === 'consumable' && (
|
||||
<InkBadge
|
||||
tier={(item.data as Consumable)?.quality}
|
||||
hideTierText
|
||||
>
|
||||
{item.name}
|
||||
</InkBadge>
|
||||
)}
|
||||
{item.type === 'artifact' && (
|
||||
<InkBadge
|
||||
tier={(item.data as Artifact)?.quality}
|
||||
hideTierText
|
||||
>
|
||||
{item.name}
|
||||
</InkBadge>
|
||||
)}
|
||||
</div>
|
||||
<span className="opacity-70">
|
||||
<span className="font-mono">×{item.quantity}</span>
|
||||
{['material', 'consumable', 'artifact'].includes(
|
||||
item.type,
|
||||
) ? (
|
||||
<span className="block text-xs">洞府宝库</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5">
|
||||
{mail.attachments?.map((item, idx) => (
|
||||
<MailAttachmentSlot key={idx} attachment={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Keep the same operation key until its committed result is received. */
|
||||
export function pendingCommandRequest(
|
||||
owner: string,
|
||||
action: string,
|
||||
payload: unknown = null,
|
||||
) {
|
||||
const key = `pending-command:${owner}:${action}:${JSON.stringify(payload)}`;
|
||||
const requestId = window.sessionStorage.getItem(key) ?? crypto.randomUUID();
|
||||
window.sessionStorage.setItem(key, requestId);
|
||||
return {
|
||||
requestId,
|
||||
complete() {
|
||||
if (window.sessionStorage.getItem(key) === requestId)
|
||||
window.sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPendingCommandRequest(owner: string, action: string) {
|
||||
return (
|
||||
window.sessionStorage.getItem(`pending-command:${owner}:${action}:null`) !==
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export function useCraftStorage(filter: InventoryFilter, enabled: boolean) {
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [result, setResult] = useState<{ key: string; view: InventoryView }>();
|
||||
const [failure, setFailure] = useState<{ key: string; message: string }>();
|
||||
const { kind, minRank, maxRank, materialType } = filter;
|
||||
const key = `${owner}:${kind}:${minRank ?? ''}:${maxRank ?? ''}:${materialType ?? ''}:${page}`;
|
||||
const { kind, minRank, maxRank, materialType, element } = filter;
|
||||
const key = `${owner}:${kind}:${minRank ?? ''}:${maxRank ?? ''}:${materialType ?? ''}:${element ?? ''}:${page}`;
|
||||
const view = result?.key === key ? result.view : undefined;
|
||||
const error = failure?.key === key ? failure.message : '';
|
||||
useEffect(() => {
|
||||
@@ -26,6 +26,7 @@ export function useCraftStorage(filter: InventoryFilter, enabled: boolean) {
|
||||
if (minRank) query.set('minRank', minRank);
|
||||
if (maxRank) query.set('maxRank', maxRank);
|
||||
if (materialType) query.set('materialType', materialType);
|
||||
if (element) query.set('element', element);
|
||||
}
|
||||
void combatV6Request<InventoryView>(`/api/combat-v6/inventory?${query}`, {
|
||||
signal: controller.signal,
|
||||
@@ -51,6 +52,7 @@ export function useCraftStorage(filter: InventoryFilter, enabled: boolean) {
|
||||
minRank,
|
||||
maxRank,
|
||||
materialType,
|
||||
element,
|
||||
owner,
|
||||
page,
|
||||
refresh,
|
||||
|
||||
@@ -30,6 +30,7 @@ export function resolveGameShellKind(pathname: string): GameShellKind | null {
|
||||
}
|
||||
|
||||
if (
|
||||
/^\/game\/combat-v6\/hunt\/[^/]+$/.test(pathname) ||
|
||||
pathname === '/game/battle/challenge' ||
|
||||
/^\/game\/battle\/live\/[^/]+$/.test(pathname) ||
|
||||
/^\/game\/battle\/[^/]+$/.test(pathname) ||
|
||||
|
||||
@@ -916,6 +916,18 @@ export const router = createBrowserRouter(
|
||||
'【全部战绩】',
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="journal"
|
||||
lazy={lazyRoute(() => import('@app/routes/game/journal/route'))}
|
||||
handle={scene(
|
||||
{
|
||||
id: 'journal',
|
||||
presentation: 'archive',
|
||||
summary: '翻阅道具、修为、感悟与各类货币的得失。',
|
||||
},
|
||||
'修仙日志',
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="rankings"
|
||||
lazy={lazyRoute(() => import('@app/routes/game/rankings/route'))}
|
||||
@@ -1118,6 +1130,7 @@ export const router = createBrowserRouter(
|
||||
</Route>
|
||||
|
||||
<Route element={<CombatV6Layout />}>
|
||||
<Route path="combat-v6/hunt/:battleId" lazy={lazyRoute(() => import('@app/routes/game/combat-v6/arena/route'))} handle={scene({ id: 'hunt', chrome: 'immersive', dock: 'hidden' }, '结伴讨伐')} />
|
||||
<Route
|
||||
path="combat-v6/arena/:battleId"
|
||||
lazy={lazyRoute(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CombatV6Battle } from '@app/components/feature/combat-v6/CombatV6Battle';
|
||||
import { CombatV6Page } from '@app/components/feature/combat-v6/CombatV6Page';
|
||||
import { useArenaV6Session } from '@app/components/feature/combat-v6/useArenaV6Session';
|
||||
import { HuntResult } from '@app/components/feature/hunts/HuntResult';
|
||||
import { HUNT_BOSSES, huntMapHref } from '@shared/hunts/config';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
|
||||
@@ -45,7 +47,9 @@ function ArenaBattle({
|
||||
if (!response.ok && response.status !== 404)
|
||||
throw new Error('退出观战失败,请重试');
|
||||
}
|
||||
navigate('/game/arena');
|
||||
navigate(
|
||||
state.session?.hunt ? huntMapHref(state.session.hunt) : '/game/arena',
|
||||
);
|
||||
} catch (cause) {
|
||||
setLeaveError(cause instanceof Error ? cause.message : '退出观战失败');
|
||||
} finally {
|
||||
@@ -54,20 +58,27 @@ function ArenaBattle({
|
||||
};
|
||||
const session = state.session;
|
||||
const playing = state.queue.length > 0;
|
||||
const title = session?.hunt
|
||||
? `结伴讨伐 · ${HUNT_BOSSES[session.hunt.bossId].name}`
|
||||
: spectator
|
||||
? '擂台观战'
|
||||
: '擂台切磋';
|
||||
const back = session?.hunt ? huntMapHref(session.hunt) : '/game/arena';
|
||||
const backLabel = session?.hunt ? '返回讨伐队伍' : '返回擂台';
|
||||
return (
|
||||
<CombatV6Page
|
||||
title={spectator ? '擂台观战' : '擂台切磋'}
|
||||
title={title}
|
||||
active={!!session}
|
||||
loading={!session && !error}
|
||||
error={leaveError ?? error}
|
||||
onRetry={controller.refresh}
|
||||
back="/game/arena"
|
||||
backLabel="返回擂台"
|
||||
back={back}
|
||||
backLabel={backLabel}
|
||||
>
|
||||
{session ? (
|
||||
<>
|
||||
<CombatV6Battle
|
||||
title={spectator ? '擂台观战' : '擂台切磋'}
|
||||
title={title}
|
||||
session={session}
|
||||
online={session}
|
||||
connected={connected}
|
||||
@@ -83,8 +94,13 @@ function ArenaBattle({
|
||||
onResolve={noResolve}
|
||||
onAuto={controller.submitAuto}
|
||||
onClose={() => void leave()}
|
||||
back="/game/arena"
|
||||
backLabel="返回擂台"
|
||||
endAction={
|
||||
session.hunt && session.outcome ? (
|
||||
<HuntResult battleId={battleId} onClose={() => void leave()} />
|
||||
) : undefined
|
||||
}
|
||||
back={back}
|
||||
backLabel={backLabel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -34,6 +34,10 @@ import type {
|
||||
} from '@shared/engine/combat-v6/equipment/types';
|
||||
import { combatCharacterLevel } from '@shared/engine/combat-v6/projection/character-level';
|
||||
import { BAG_CAPACITY, itemDefinition } from '@shared/inventory';
|
||||
import {
|
||||
sortInventoryItems,
|
||||
type InventorySort,
|
||||
} from '@shared/inventory/sorting';
|
||||
import { ConsumableFactsSchema } from '@shared/items/definitions/consumables';
|
||||
import { EQUIPMENT_SLOT_NAMES } from '@shared/items/definitions/equipment-blueprints';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
@@ -62,6 +66,7 @@ export default function InventoryV6() {
|
||||
: 'bag';
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState<InventoryFilter>(defaultInventoryFilter);
|
||||
const [sort, setSort] = useState<InventorySort>();
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [storage, setData] = useState<InventoryView>();
|
||||
@@ -92,10 +97,12 @@ export default function InventoryV6() {
|
||||
page: String(page),
|
||||
kind: filter.kind,
|
||||
});
|
||||
if (sort) query.set('sort', sort);
|
||||
if (filter.kind === 'material') {
|
||||
if (filter.minRank) query.set('minRank', filter.minRank);
|
||||
if (filter.maxRank) query.set('maxRank', filter.maxRank);
|
||||
if (filter.materialType) query.set('materialType', filter.materialType);
|
||||
if (filter.element) query.set('element', filter.element);
|
||||
}
|
||||
void combatV6Request<InventoryView>(`${endpoint}?${query}`, {
|
||||
signal: controller.signal,
|
||||
@@ -119,7 +126,7 @@ export default function InventoryV6() {
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [location, page, filter, refresh, pushToast]);
|
||||
}, [location, page, filter, sort, refresh, pushToast]);
|
||||
async function act(action: BagAction) {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
@@ -191,11 +198,15 @@ export default function InventoryV6() {
|
||||
(item.instanceData as DaoEquipmentInstanceV1).slot === slotFilter))
|
||||
);
|
||||
}
|
||||
const visibleItems = visibleData
|
||||
const filteredItems = visibleData
|
||||
? location === 'bag' && filtered
|
||||
? visibleData.items.filter(matches)
|
||||
: visibleData.items
|
||||
: [];
|
||||
const visibleItems =
|
||||
location === 'bag' && sort
|
||||
? sortInventoryItems(filteredItems, sort)
|
||||
: filteredItems;
|
||||
const selectedItems = visibleItems.filter((item) => selectedIds.has(item.id));
|
||||
function clearSelection() {
|
||||
setSelectedIds(new Set());
|
||||
@@ -294,6 +305,8 @@ export default function InventoryV6() {
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<InventoryFilters
|
||||
value={filter}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onChange={(value) => {
|
||||
clearSelection();
|
||||
setFilter(value);
|
||||
@@ -385,7 +398,7 @@ export default function InventoryV6() {
|
||||
</InkButton>
|
||||
{location === 'bag' ? (
|
||||
<InkButton
|
||||
disabled={unavailable || filtered}
|
||||
disabled={unavailable || filtered || !!sort}
|
||||
onClick={() =>
|
||||
void act({
|
||||
action: 'sort',
|
||||
@@ -418,7 +431,7 @@ export default function InventoryV6() {
|
||||
<InventoryItems
|
||||
items={visibleItems}
|
||||
location={location}
|
||||
compact={location === 'bag' && filtered}
|
||||
compact={location === 'bag' && (filtered || !!sort)}
|
||||
className="w-full grid-cols-5 gap-1.5 md:grid-cols-8 md:gap-1"
|
||||
quickTouchHint={selecting}
|
||||
slotProps={(entry) => ({
|
||||
@@ -456,7 +469,7 @@ export default function InventoryV6() {
|
||||
)}
|
||||
{visibleData &&
|
||||
visibleItems.length === 0 &&
|
||||
(location === 'storage' || filtered) ? (
|
||||
(location === 'storage' || filtered || !!sort) ? (
|
||||
<p className="text-ink-secondary text-sm">
|
||||
{filtered ? '暂无符合筛选条件的物品' : '暂无物品'}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { JournalEventContent } from '@app/components/feature/journal/JournalEventContent';
|
||||
import {
|
||||
GameLoadingState,
|
||||
GameSceneFrame,
|
||||
GameSceneTabs,
|
||||
} from '@app/components/game-shell';
|
||||
import { InkButton, InkNotice } from '@app/components/ui';
|
||||
import { fetchJsonCached } from '@app/lib/client/requestCache';
|
||||
import { usePlayerSession } from '@app/lib/resources/player';
|
||||
import type {
|
||||
JournalCursor,
|
||||
PlayerJournalPage,
|
||||
} from '@shared/contracts/playerJournal';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export default function JournalRoute() {
|
||||
const cultivatorId = usePlayerSession().data?.activeCultivator?.id;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const selected = params.get('type');
|
||||
const type =
|
||||
selected === 'retreat.completed' ||
|
||||
selected === 'breakthrough.completed' ||
|
||||
selected === 'resources.settled'
|
||||
? selected
|
||||
: '';
|
||||
return (
|
||||
<GameSceneFrame variant="lite">
|
||||
<GameSceneTabs
|
||||
activeValue={type}
|
||||
onChange={(value) => setParams(value ? { type: value } : {})}
|
||||
items={[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '闭关', value: 'retreat.completed' },
|
||||
{ label: '突破', value: 'breakthrough.completed' },
|
||||
{ label: '其他玩法', value: 'resources.settled' },
|
||||
]}
|
||||
/>
|
||||
{cultivatorId ? (
|
||||
<JournalList
|
||||
key={`${cultivatorId}:${type}`}
|
||||
cultivatorId={cultivatorId}
|
||||
type={type}
|
||||
/>
|
||||
) : null}
|
||||
</GameSceneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalList({
|
||||
cultivatorId,
|
||||
type,
|
||||
}: {
|
||||
cultivatorId: string;
|
||||
type: string;
|
||||
}) {
|
||||
const [items, setItems] = useState<PlayerJournalPage['items']>([]);
|
||||
const [cursor, setCursor] = useState<JournalCursor | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<JournalCursor | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
const query = new URLSearchParams(type ? { type } : {});
|
||||
if (cursor) {
|
||||
query.set('before', cursor.createdAt);
|
||||
query.set('beforeId', cursor.id);
|
||||
}
|
||||
void fetchJsonCached<{ data: PlayerJournalPage }>(
|
||||
`/api/player-journal?${query}`,
|
||||
{
|
||||
key: `journal:${cultivatorId}:${query}`,
|
||||
signal: abort.signal,
|
||||
cache: 'no-store',
|
||||
},
|
||||
)
|
||||
.then(({ data }) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setItems((previous) =>
|
||||
cursor ? [...previous, ...data.items] : data.items,
|
||||
);
|
||||
setNextCursor(data.nextCursor);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!abort.signal.aborted) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!abort.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [cultivatorId, type, cursor, attempt]);
|
||||
return (
|
||||
<>
|
||||
<ol className="text-sm leading-6">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="py-1">
|
||||
<time
|
||||
dateTime={item.createdAt}
|
||||
title={new Date(item.createdAt).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
})}
|
||||
className="text-ink-muted mr-2 font-mono text-xs whitespace-nowrap"
|
||||
>
|
||||
{new Date(item.createdAt).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</time>
|
||||
<JournalEventContent event={item.event} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{loading ? (
|
||||
<GameLoadingState message="正在翻阅修仙日志……" variant="inline" />
|
||||
) : null}
|
||||
{error ? (
|
||||
<div role="alert">
|
||||
<InkNotice>{error}</InkNotice>
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</InkButton>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error && !items.length ? (
|
||||
<InkNotice>
|
||||
尚无记录。道具、修为、感悟与各类货币的得失,会记在这里。
|
||||
</InkNotice>
|
||||
) : null}
|
||||
{!loading && !error && nextCursor ? (
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setCursor(nextCursor);
|
||||
}}
|
||||
>
|
||||
加载更多
|
||||
</InkButton>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pendingCommandRequest } from '@app/lib/pendingCommandRequest';
|
||||
import { FriendTargetModal } from '@app/components/feature/friends';
|
||||
import {
|
||||
GameLoadingState,
|
||||
@@ -275,6 +276,8 @@ export default function MailPage() {
|
||||
};
|
||||
|
||||
const handleClaimAll = async () => {
|
||||
if (!cultivator) return;
|
||||
const pending = pendingCommandRequest(cultivator.id, 'mail-claim-all');
|
||||
try {
|
||||
setBatchClaiming(true);
|
||||
const data = await mutate<{
|
||||
@@ -286,9 +289,12 @@ export default function MailPage() {
|
||||
}>(
|
||||
fetch('/api/cultivator/mail/claim-all', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId: pending.requestId }),
|
||||
}),
|
||||
);
|
||||
|
||||
pending.complete();
|
||||
const claimedMailIds = data.claimedMailIds || [];
|
||||
if (claimedMailIds.length > 0) {
|
||||
setMails((prev) =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HuntEvent } from '@shared/hunts/config';
|
||||
import type { HuntMapPoint } from '@app/components/feature/hunts/HuntMapMarkers';
|
||||
import { GameIcon } from '@app/components/ui/GameIcon';
|
||||
import {
|
||||
ATLAS_ANCHORS,
|
||||
@@ -15,6 +17,7 @@ import * as Phaser from 'phaser';
|
||||
import { ATLAS_CATEGORY_STYLE } from './atlasMarkerStyle';
|
||||
|
||||
export interface AtlasView {
|
||||
hunts?: HuntEvent[];
|
||||
region: 'world' | keyof typeof ATLAS_ANCHORS;
|
||||
selectedId: string | null;
|
||||
blocked: boolean;
|
||||
@@ -32,6 +35,7 @@ export interface AtlasController {
|
||||
interface AtlasArguments {
|
||||
root: HTMLElement;
|
||||
view: AtlasView;
|
||||
onHuntPositions?: (points: HuntMapPoint[]) => void;
|
||||
onRegion: (id: string) => void;
|
||||
onNode: (id: string) => void;
|
||||
onClear: () => void;
|
||||
@@ -645,6 +649,23 @@ export function attachAtlasPhaser(args: AtlasArguments): AtlasController {
|
||||
private layoutMarkers() {
|
||||
const camera = this.cameras.main;
|
||||
camera.preRender();
|
||||
const huntPoints: HuntMapPoint[] = [];
|
||||
if (view.region !== 'world') {
|
||||
for (const event of view.hunts ?? []) {
|
||||
const anchor = ATLAS_ANCHORS[view.region][event.nodeId];
|
||||
if (!anchor) continue;
|
||||
const x =
|
||||
((anchor[0] * WIDTH - camera.midPoint.x) * camera.zoom + camera.width / 2) / pixelRatio;
|
||||
const y =
|
||||
((anchor[1] * HEIGHT - camera.midPoint.y) * camera.zoom + camera.height / 2) / pixelRatio - 36;
|
||||
if (
|
||||
x > 40 && x < camera.width / pixelRatio - 40 &&
|
||||
y > 100 && y < camera.height / pixelRatio
|
||||
)
|
||||
huntPoints.push({ id: event.id, x: Math.round(x), y: Math.round(y) });
|
||||
}
|
||||
}
|
||||
args.onHuntPositions?.(huntPoints);
|
||||
const obstacles = view.occlusions.map(
|
||||
(rect) =>
|
||||
new Phaser.Geom.Rectangle(rect.x, rect.y, rect.width, rect.height),
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { HuntLobbyDrawer } from '@app/components/feature/hunts/HuntLobbyDrawer';
|
||||
import {
|
||||
HuntMapMarkers,
|
||||
type HuntMapPoint,
|
||||
} from '@app/components/feature/hunts/HuntMapMarkers';
|
||||
import { useHunts } from '@app/components/feature/hunts/useHunts';
|
||||
import { huntMapHref, type HuntEvent } from '@shared/hunts/config';
|
||||
import {
|
||||
buildNodeActions,
|
||||
buildSectLandmarkActions,
|
||||
@@ -45,6 +52,14 @@ export default function AtlasPage() {
|
||||
const controller = useRef<AtlasController | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const huntFeed = useHunts<{ events: HuntEvent[]; serverNow: number }>(
|
||||
'/api/hunts',
|
||||
15000,
|
||||
);
|
||||
const huntEvents = huntFeed.data?.events ?? [];
|
||||
const huntEventId = params.get('hunt');
|
||||
const [huntPoints, setHuntPoints] = useState<HuntMapPoint[]>([]);
|
||||
const huntKey = huntEvents.map((e) => e.id).join(',');
|
||||
const teachingMap = params.get('guide') === 'map-qingxi';
|
||||
const isAtlas = mapMode === 'atlas' && !teachingMap;
|
||||
const player = usePlayerSession();
|
||||
@@ -93,6 +108,7 @@ export default function AtlasPage() {
|
||||
const mapParams = new URLSearchParams(params);
|
||||
if (revealingFilteredNode) mapParams.set('types', 'all');
|
||||
const view: AtlasView = {
|
||||
hunts: huntEvents,
|
||||
region: availableRegion ?? 'world',
|
||||
selectedId: selected?.id ?? null,
|
||||
blocked: !!unavailable || !!error || overlapIds.length > 0,
|
||||
@@ -109,6 +125,7 @@ export default function AtlasPage() {
|
||||
)
|
||||
return;
|
||||
const next = new URLSearchParams(mapParams);
|
||||
next.delete('hunt');
|
||||
next.delete('nodeId');
|
||||
if (id) next.set('region', id);
|
||||
else next.delete('region');
|
||||
@@ -125,6 +142,7 @@ export default function AtlasPage() {
|
||||
if (!targetRegion || !hasAtlasMap(targetRegion.id)) return;
|
||||
const next = new URLSearchParams(mapParams);
|
||||
if (!matchesAtlasCategories(location, categories)) next.set('types', 'all');
|
||||
next.delete('hunt');
|
||||
next.set('nodeId', id);
|
||||
next.set('region', targetRegion.id);
|
||||
setParams(next);
|
||||
@@ -136,6 +154,7 @@ export default function AtlasPage() {
|
||||
};
|
||||
const closeNode = () => {
|
||||
const next = new URLSearchParams(mapParams);
|
||||
next.delete('hunt');
|
||||
next.delete('nodeId');
|
||||
setFocusId(null);
|
||||
setFilterOpen(false);
|
||||
@@ -150,6 +169,7 @@ export default function AtlasPage() {
|
||||
const next = new URLSearchParams(mapParams);
|
||||
next.set('types', nextCategories.join(',') || 'all');
|
||||
if (selected && !matchesAtlasCategories(selected, nextCategories)) {
|
||||
next.delete('hunt');
|
||||
next.delete('nodeId');
|
||||
if (region) next.set('region', region.id);
|
||||
setFocusId(null);
|
||||
@@ -189,6 +209,12 @@ export default function AtlasPage() {
|
||||
instance = runtime.attachAtlasPhaser({
|
||||
root: root.current,
|
||||
view: getView(),
|
||||
onHuntPositions: (points) =>
|
||||
setHuntPoints((previous) =>
|
||||
JSON.stringify(previous) === JSON.stringify(points)
|
||||
? previous
|
||||
: points,
|
||||
),
|
||||
onRegion: (id) => onRegion(id),
|
||||
onNode: (id) => onNode(id),
|
||||
onClear: () => onClear(),
|
||||
@@ -226,6 +252,7 @@ export default function AtlasPage() {
|
||||
controller.current?.setView(getView());
|
||||
}, [
|
||||
view.region,
|
||||
huntKey,
|
||||
view.selectedId,
|
||||
view.blocked,
|
||||
categoryKey,
|
||||
@@ -322,6 +349,37 @@ export default function AtlasPage() {
|
||||
>
|
||||
<div ref={root} className="absolute inset-0 overflow-hidden" />
|
||||
|
||||
{!teachingMap && !error && (!isAtlas || !loading) ? (
|
||||
<HuntMapMarkers
|
||||
events={huntEvents}
|
||||
points={huntPoints}
|
||||
showList={!isAtlas || !availableRegion}
|
||||
onSelect={(event) => {
|
||||
setFocusId(event.nodeId);
|
||||
setFocusRequest((n) => n + 1);
|
||||
setSearchOpen(false);
|
||||
setFilterOpen(false);
|
||||
navigate(huntMapHref(event));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{huntEventId ? (
|
||||
<HuntLobbyDrawer
|
||||
key={huntEventId}
|
||||
eventId={huntEventId}
|
||||
onClose={() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete('hunt');
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{huntFeed.error ? (
|
||||
<p role="status" className="bg-paper/95 absolute bottom-3 left-3 z-20 p-2 text-xs">
|
||||
讨伐消息暂不可用{' '}
|
||||
<button onClick={huntFeed.refresh} className="underline">重试</button>
|
||||
</p>
|
||||
) : null}
|
||||
{isAtlas && loading && !error ? (
|
||||
<div className="bg-paper/90 absolute inset-0 z-10">
|
||||
<GameLoadingState variant="scene" message="正在展开山河画卷……" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlayerJournalEvent } from '@shared/contracts/playerJournal';
|
||||
import type { StoryStatus, StoryTrack } from '@shared/story/schema';
|
||||
import type { SystemMailConditions } from '@shared/contracts/systemMail';
|
||||
import type { RewardSelection } from '@shared/contracts/adminRewards';
|
||||
@@ -715,6 +716,25 @@ export const resourceEvents = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Journal rows are also execution receipts; do not age them out with replay events.
|
||||
export const playerJournal = pgTable(
|
||||
'wanjiedaoyou_player_journal',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
cultivatorId: uuid('cultivator_id').notNull()
|
||||
.references(() => cultivators.id, { onDelete: 'cascade' }),
|
||||
operationKey: varchar('operation_key', { length: 160 }).notNull(),
|
||||
requestFingerprint: varchar('request_fingerprint', { length: 128 }),
|
||||
// Null only while the owning transaction is executing; never committed empty.
|
||||
event: jsonb('event').$type<PlayerJournalEvent>(),
|
||||
createdAt: timestamp('created_at', { precision: 3 }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('player_journal_operation_unique').on(table.cultivatorId, table.operationKey),
|
||||
index('player_journal_timeline_idx').on(table.cultivatorId, table.createdAt, table.id),
|
||||
],
|
||||
);
|
||||
|
||||
export const playerMutationRequests = pgTable(
|
||||
'wanjiedaoyou_player_mutation_requests',
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { startHuntWorld, stopHuntWorld } from '../services/hunts/HuntWorldService';
|
||||
import { getJetStreamClient } from '@server/lib/nats';
|
||||
import {
|
||||
archiveCombatV6Replay,
|
||||
@@ -114,6 +115,7 @@ async function processReplay(message: JsMsg): Promise<void> {
|
||||
|
||||
export async function startCombatV6Messaging(): Promise<void> {
|
||||
startArenaV6Coordinator();
|
||||
startHuntWorld();
|
||||
if (consumerTask) return;
|
||||
stopping = false;
|
||||
const jetStream = await getJetStreamClient();
|
||||
@@ -160,6 +162,7 @@ export async function startCombatV6Messaging(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function stopCombatV6Messaging(): Promise<void> {
|
||||
await stopHuntWorld();
|
||||
await stopArenaV6Coordinator();
|
||||
stopping = true;
|
||||
if (timer) clearInterval(timer);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectHuntReward } from '../services/hunts/HuntRewardProjector';
|
||||
import { db } from '@server/lib/drizzle/db';
|
||||
import { closeNatsConnection, getNatsConnection } from '@server/lib/nats';
|
||||
import { claimMessageForConsumer } from '@server/lib/repositories/messageConsumptionRepository';
|
||||
@@ -78,7 +79,9 @@ export async function registerMessageInfrastructure(): Promise<void> {
|
||||
const data = (
|
||||
event as DomainEventEnvelope<'combat.v6.battle.finished'>
|
||||
).data;
|
||||
if (data.sourceType !== 'arena-sparring') {
|
||||
if (data.sourceType === 'hunt') {
|
||||
await projectHuntReward(data.battleId);
|
||||
} else if (data.sourceType !== 'arena-sparring') {
|
||||
await projectCombatV6Condition(data.battleId);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@ export async function registerMessageInfrastructure(): Promise<void> {
|
||||
acceptedTypes: [
|
||||
'cultivator.realm.changed',
|
||||
'craft.item.created',
|
||||
'equipment.forged',
|
||||
'market.material.revealed',
|
||||
'ranking.position.changed',
|
||||
'beast.exceptional.acquired',
|
||||
|
||||
@@ -33,6 +33,7 @@ export async function pruneMessageConsumptions(
|
||||
and(
|
||||
lt(messageConsumptions.processedAt, cutoff),
|
||||
ne(messageConsumptions.consumerName, COMBAT_V6_CONDITION_CONSUMER),
|
||||
ne(messageConsumptions.consumerName, 'hunt-reward-v1'),
|
||||
),
|
||||
)
|
||||
.returning({ messageId: messageConsumptions.messageId });
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
getExecutor,
|
||||
type DbExecutor,
|
||||
type DbTransaction,
|
||||
} from '@server/lib/drizzle/db';
|
||||
import { playerJournal } from '@server/lib/drizzle/schema';
|
||||
import {
|
||||
JOURNAL_ACTIVITIES,
|
||||
PlayerJournalEventSchema,
|
||||
StoredJournalEventSchema,
|
||||
type JournalActivity,
|
||||
type PlayerJournalEvent,
|
||||
type PlayerJournalPage,
|
||||
type PlayerJournalQuerySchema,
|
||||
type StoredJournalEvent,
|
||||
} from '@shared/contracts/playerJournal';
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export class JournalIdempotencyError extends Error {
|
||||
readonly status = 409;
|
||||
}
|
||||
|
||||
export async function findJournalOperation(
|
||||
cultivatorId: string,
|
||||
operationKey: string,
|
||||
fingerprint: string | null,
|
||||
tx?: DbTransaction,
|
||||
) {
|
||||
const [row] = await getExecutor(tx)
|
||||
.select()
|
||||
.from(playerJournal)
|
||||
.where(
|
||||
and(
|
||||
eq(playerJournal.cultivatorId, cultivatorId),
|
||||
eq(playerJournal.operationKey, operationKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
if (row.requestFingerprint !== fingerprint) {
|
||||
throw new JournalIdempotencyError('同一请求编号不能用于不同的操作参数');
|
||||
}
|
||||
return { ...row, event: StoredJournalEventSchema.parse(row.event) };
|
||||
}
|
||||
|
||||
/** Claim before any side effect. The caller must finish the row in this transaction. */
|
||||
export async function claimJournalOperation(
|
||||
tx: DbTransaction,
|
||||
input: {
|
||||
cultivatorId: string;
|
||||
operationKey: string;
|
||||
fingerprint: string | null;
|
||||
},
|
||||
): Promise<{ id: string; previous: StoredJournalEvent | null }> {
|
||||
const [row] = await tx
|
||||
.insert(playerJournal)
|
||||
.values({
|
||||
cultivatorId: input.cultivatorId,
|
||||
operationKey: input.operationKey,
|
||||
requestFingerprint: input.fingerprint,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [playerJournal.cultivatorId, playerJournal.operationKey],
|
||||
})
|
||||
.returning({ id: playerJournal.id });
|
||||
if (row) return { id: row.id, previous: null };
|
||||
const previous = await findJournalOperation(
|
||||
input.cultivatorId,
|
||||
input.operationKey,
|
||||
input.fingerprint,
|
||||
tx,
|
||||
);
|
||||
if (!previous) throw new Error('执行记录不存在');
|
||||
return { id: previous.id, previous: previous.event };
|
||||
}
|
||||
|
||||
export async function completeJournalOperation(
|
||||
tx: DbTransaction,
|
||||
id: string,
|
||||
event: StoredJournalEvent,
|
||||
) {
|
||||
await tx
|
||||
.update(playerJournal)
|
||||
.set({ event: StoredJournalEventSchema.parse(event) })
|
||||
.where(eq(playerJournal.id, id));
|
||||
}
|
||||
|
||||
export async function listPlayerJournal(
|
||||
cultivatorId: string,
|
||||
query: z.infer<typeof PlayerJournalQuerySchema>,
|
||||
): Promise<PlayerJournalPage> {
|
||||
const rows = await getExecutor()
|
||||
.select({
|
||||
id: playerJournal.id,
|
||||
event: sql<PlayerJournalEvent>`${playerJournal.event} - 'result'`,
|
||||
createdAt: playerJournal.createdAt,
|
||||
})
|
||||
.from(playerJournal)
|
||||
.where(
|
||||
and(
|
||||
eq(playerJournal.cultivatorId, cultivatorId),
|
||||
sql`(
|
||||
(${playerJournal.event}->>'type' = 'resources.settled' AND jsonb_array_length(${playerJournal.event}->'changes') > 0)
|
||||
OR (${playerJournal.event}->>'type' = 'retreat.completed' AND (
|
||||
(${playerJournal.event}->'summary'->>'exp_gained')::numeric <> 0 OR
|
||||
(${playerJournal.event}->'summary'->>'insight_gained')::numeric <> 0))
|
||||
OR (${playerJournal.event}->>'type' = 'breakthrough.completed' AND (
|
||||
(${playerJournal.event}->>'expSpent')::numeric <> 0 OR
|
||||
(${playerJournal.event}->'summary'->>'insight_change')::numeric <> 0))
|
||||
)`,
|
||||
query.activity
|
||||
? sql`${playerJournal.event}->>'activity' = ${query.activity}`
|
||||
: undefined,
|
||||
query.type
|
||||
? sql`${playerJournal.event}->>'type' = ${query.type}`
|
||||
: undefined,
|
||||
query.before && query.beforeId
|
||||
? sql`(${playerJournal.createdAt}, ${playerJournal.id}) < (${new Date(query.before)}, ${query.beforeId}::uuid)`
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(playerJournal.createdAt), desc(playerJournal.id))
|
||||
.limit(31);
|
||||
const items = rows.slice(0, 30).map((row) => ({
|
||||
id: row.id,
|
||||
event: PlayerJournalEventSchema.parse(row.event),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
items,
|
||||
nextCursor:
|
||||
rows.length > 30 && last
|
||||
? { id: last.id, createdAt: last.createdAt }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isJournalActivity(source: string): source is JournalActivity {
|
||||
return Object.hasOwn(JOURNAL_ACTIVITIES, source);
|
||||
}
|
||||
|
||||
export function journalOperationKey(source: string, requestId: string) {
|
||||
const key = `${source}:${requestId}`;
|
||||
return Buffer.byteLength(key, 'utf8') <= 160
|
||||
? key
|
||||
: `sha256:${createHash('sha256').update(key).digest('hex')}`;
|
||||
}
|
||||
|
||||
/** Read-through for preflight replay checks while old receipts remain valid. */
|
||||
export async function findJournalMutationRequest(
|
||||
cultivatorId: string,
|
||||
source: string,
|
||||
requestId: string,
|
||||
q: DbExecutor,
|
||||
) {
|
||||
if (!isJournalActivity(source)) return null;
|
||||
const [row] = await q
|
||||
.select()
|
||||
.from(playerJournal)
|
||||
.where(
|
||||
and(
|
||||
eq(playerJournal.cultivatorId, cultivatorId),
|
||||
eq(playerJournal.operationKey, journalOperationKey(source, requestId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const event = StoredJournalEventSchema.parse(row.event);
|
||||
return {
|
||||
id: row.id,
|
||||
cultivatorId,
|
||||
source,
|
||||
requestId,
|
||||
requestFingerprint: row.requestFingerprint ?? '',
|
||||
result: event.result,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findJournalMutationRequest } from './playerJournalRepository';
|
||||
import {
|
||||
getExecutor,
|
||||
runDbTasks,
|
||||
@@ -280,7 +281,7 @@ export async function findPlayerMutationRequest(
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
return row ?? findJournalMutationRequest(cultivatorId, source, requestId, q);
|
||||
}
|
||||
|
||||
export async function listPlayerMutationRequestsByPrefix(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordJournalChange } from './JournalSettlement';
|
||||
import { ConsumableFactsSchema } from '@shared/items/definitions/consumables';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
@@ -93,6 +94,9 @@ export async function consumeBagConsumable(
|
||||
.update(inventoryItems)
|
||||
.set({ quantity: remainingQuantity, revision: row.revision + 1 })
|
||||
.where(filter);
|
||||
if ('rollback' in q) recordJournalChange(q, owner, {
|
||||
kind: 'item', id: row.id, name: bagConsumableOf(row).name, amount: -quantity,
|
||||
});
|
||||
return {
|
||||
remainingQuantity,
|
||||
removed: !remainingQuantity,
|
||||
|
||||
@@ -16,6 +16,9 @@ import { publishResourceEvents } from '@server/lib/services/playerStateBroadcast
|
||||
import type { PlayerResourceMutationMeta } from '@shared/contracts/player';
|
||||
import type { ResourceChangeDescriptor } from '@shared/contracts/resources';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { captureJournalSettlement } from './JournalSettlement';
|
||||
import { claimJournalOperation, completeJournalOperation, isJournalActivity, journalOperationKey } from '@server/lib/repositories/playerJournalRepository';
|
||||
import type { PlayerJournalEvent } from '@shared/contracts/playerJournal';
|
||||
import {
|
||||
baselinesFromResourceChanges,
|
||||
resourceEventCommitter,
|
||||
@@ -28,6 +31,13 @@ const SLOW_TRANSACTION_THRESHOLD_MS = 500;
|
||||
export type FeatureCommandResult<TResult> = {
|
||||
result: TResult;
|
||||
resourceChanges: ResourceChangeDescriptor[];
|
||||
journalEvent?: PlayerJournalEvent;
|
||||
};
|
||||
|
||||
type JournalExecution<TResult> = {
|
||||
operationKey: string;
|
||||
fingerprint: string;
|
||||
replay(event: PlayerJournalEvent): TResult;
|
||||
};
|
||||
|
||||
export type CommittedCommand<TResult> = {
|
||||
@@ -82,6 +92,7 @@ export class PlayerCommandExecutor {
|
||||
source: string;
|
||||
requestId?: string | null;
|
||||
idempotency?: { key: string; fingerprint: string };
|
||||
journal?: JournalExecution<TResult>;
|
||||
allowEmpty?: boolean;
|
||||
lock?: {
|
||||
context?: string;
|
||||
@@ -111,11 +122,15 @@ export class PlayerCommandExecutor {
|
||||
source: string;
|
||||
requestId?: string | null;
|
||||
idempotency?: { key: string; fingerprint: string };
|
||||
journal?: JournalExecution<TResult>;
|
||||
allowEmpty?: boolean;
|
||||
command(tx: DbTransaction): Promise<FeatureCommandResult<TResult>>;
|
||||
coordination: PlayerCommandCoordination;
|
||||
},
|
||||
): Promise<CommittedCommand<TResult>> {
|
||||
if (input.journal && input.idempotency) {
|
||||
throw new Error('日志执行凭据与旧请求幂等记录不能同时启用');
|
||||
}
|
||||
const lease =
|
||||
input.coordination.mode === 'redis'
|
||||
? input.coordination.lease
|
||||
@@ -124,6 +139,11 @@ export class PlayerCommandExecutor {
|
||||
const idempotency = input.idempotency
|
||||
? normalizeIdempotency(input.idempotency)
|
||||
: undefined;
|
||||
const activity = !input.journal && isJournalActivity(input.source) ? input.source : null;
|
||||
if (activity && !idempotency) throw new Error('日志结算缺少稳定的幂等键');
|
||||
const automaticJournalKey = activity
|
||||
? journalOperationKey(activity, idempotency!.key)
|
||||
: null;
|
||||
const eventRequestId = normalizeNullableField(
|
||||
input.requestId ?? idempotency?.key ?? null,
|
||||
);
|
||||
@@ -132,7 +152,21 @@ export class PlayerCommandExecutor {
|
||||
try {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await lockCultivatorForStateMutation(tx, input.cultivatorId);
|
||||
await assertCombatV6MutationAllowed(input.cultivatorId, input.source);
|
||||
const journal = input.journal
|
||||
? await claimJournalOperation(tx, {
|
||||
cultivatorId: input.cultivatorId,
|
||||
operationKey: input.journal.operationKey,
|
||||
fingerprint: input.journal.fingerprint,
|
||||
})
|
||||
: null;
|
||||
if (journal?.previous && input.journal) {
|
||||
lease?.assertHeld();
|
||||
return {
|
||||
result: input.journal.replay(journal.previous),
|
||||
state: { changes: [], baselines: [], replayed: true },
|
||||
};
|
||||
}
|
||||
if (!activity) await assertCombatV6MutationAllowed(input.cultivatorId, input.source);
|
||||
if (idempotency) {
|
||||
const existing = await findPlayerMutationRequest(
|
||||
input.cultivatorId,
|
||||
@@ -164,7 +198,34 @@ export class PlayerCommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
const command = await input.command(tx);
|
||||
const automaticJournal = automaticJournalKey
|
||||
? await claimJournalOperation(tx, {
|
||||
cultivatorId: input.cultivatorId,
|
||||
operationKey: automaticJournalKey,
|
||||
fingerprint: idempotency?.fingerprint ?? null,
|
||||
})
|
||||
: null;
|
||||
if (automaticJournal?.previous) {
|
||||
lease?.assertHeld();
|
||||
return {
|
||||
result: automaticJournal.previous.result as TResult,
|
||||
state: { changes: [], baselines: [], replayed: true },
|
||||
};
|
||||
}
|
||||
if (activity) await assertCombatV6MutationAllowed(input.cultivatorId, input.source);
|
||||
const captured = activity
|
||||
? await captureJournalSettlement(tx, input.cultivatorId, activity, () => input.command(tx))
|
||||
: null;
|
||||
const command = captured ? captured.value : await input.command(tx);
|
||||
if (automaticJournal && captured) {
|
||||
await completeJournalOperation(tx, automaticJournal.id, {
|
||||
...captured.event, result: command.result,
|
||||
});
|
||||
}
|
||||
if (journal) {
|
||||
if (!command.journalEvent) throw new Error('执行结果缺少修仙日志');
|
||||
await completeJournalOperation(tx, journal.id, command.journalEvent);
|
||||
}
|
||||
if (command.resourceChanges.length === 0 && !input.allowEmpty) {
|
||||
throw new Error('玩家写操作缺少资源变更描述');
|
||||
}
|
||||
@@ -201,7 +262,7 @@ export class PlayerCommandExecutor {
|
||||
cultivatorId: input.cultivatorId,
|
||||
},
|
||||
});
|
||||
if (idempotency) {
|
||||
if (idempotency && !activity) {
|
||||
await insertPlayerMutationRequest(
|
||||
{
|
||||
cultivatorId: input.cultivatorId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findPlayerMutationRequest } from '@server/lib/repositories/playerStateRepository';
|
||||
import type { DbTransaction } from '@server/lib/drizzle/db';
|
||||
import { createDomainEvent } from '@server/lib/mq/domainEventWriter';
|
||||
import { publishTransactionalMessageBestEffort } from '@server/lib/mq/transactionalMessagePublisher';
|
||||
@@ -7,7 +8,6 @@ import type { ResourceOperationSettlement } from '@shared/engine/resource/types'
|
||||
import { QUALITY_ORDER, type Quality } from '@shared/types/constants';
|
||||
import type { AlchemyMode } from '@shared/types/consumable';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { assertAlchemyMaterialVersions } from './alchemy/AlchemyInventory';
|
||||
import { prepareFormulaCraft } from './AlchemyFormulaService';
|
||||
import { prepareAlchemyCraft } from './alchemyServiceV2';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { QiService } from './QiService';
|
||||
|
||||
export type CraftCommandInput = {
|
||||
requestId: string;
|
||||
materialIds: string[];
|
||||
materialVersions: Record<string, string>;
|
||||
craftType: 'alchemy';
|
||||
@@ -40,6 +41,14 @@ export async function executeCraftCommand(args: {
|
||||
input: CraftCommandInput;
|
||||
}): Promise<CommittedCommand<unknown>> {
|
||||
const { input } = args;
|
||||
const source = `alchemy_${input.alchemyMode ?? 'improvised'}`;
|
||||
const idempotency = { key: input.requestId, fingerprint: JSON.stringify(input) };
|
||||
if (await findPlayerMutationRequest(args.cultivatorId, source, input.requestId)) {
|
||||
return playerCommandExecutor.executeWithLock({
|
||||
userId: args.userId, cultivatorId: args.cultivatorId, source, idempotency,
|
||||
command: async () => { throw new Error('炼丹执行凭据已失效'); },
|
||||
});
|
||||
}
|
||||
await assertInventoryIdle(args.cultivatorId);
|
||||
const { name: cultivatorName } = await readCultivatorName(args.cultivatorId);
|
||||
if (input.materialIds.length === 0) {
|
||||
@@ -74,11 +83,13 @@ export async function executeCraftCommand(args: {
|
||||
);
|
||||
let afterCommit: (() => Promise<void>) | undefined;
|
||||
const domainEventIds: string[] = [];
|
||||
const actionInstanceId = randomUUID();
|
||||
const actionInstanceId = input.requestId;
|
||||
const committed = await playerCommandExecutor.executeWithLock({
|
||||
userId: args.userId,
|
||||
cultivatorId: args.cultivatorId,
|
||||
source: `alchemy_${mode}`,
|
||||
requestId: actionInstanceId,
|
||||
idempotency,
|
||||
lock: {
|
||||
context: `alchemy-${mode}`,
|
||||
timeoutMs: 60_000,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describeJournal } from './JournalSettlement';
|
||||
import {
|
||||
getOrInitCultivationProgress,
|
||||
stripExpCapForStorage,
|
||||
@@ -178,6 +179,7 @@ export async function enlightenManual(
|
||||
.where(eq(cultivators.id, actor.cultivatorId));
|
||||
await saveInventoryPlan(actor.cultivatorId, before, after, tx);
|
||||
await QiService.commitReservation({ actionInstanceId, tx });
|
||||
describeJournal(tx, actor.cultivatorId, jadeDefinitionId ? '成功' : '未悟得功法');
|
||||
return {
|
||||
result: {
|
||||
requestId: input.requestId,
|
||||
|
||||
@@ -39,6 +39,8 @@ import { and, asc, count, eq, gte, ilike, inArray, or, sql } from 'drizzle-orm';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import type { z } from 'zod';
|
||||
import { db, type DbExecutor, type DbTransaction } from '../drizzle/db';
|
||||
import { createDomainEvent } from '../mq/domainEventWriter';
|
||||
import { publishTransactionalMessageBestEffort } from '../mq/transactionalMessagePublisher';
|
||||
import {
|
||||
consumables,
|
||||
cultivatorBeasts,
|
||||
@@ -251,6 +253,7 @@ export async function forgeEquipment(
|
||||
const seed = randomInt(0x100000000);
|
||||
const equipmentId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
let rumorEventId: string | undefined;
|
||||
const committed = await playerCommandExecutor.executeWithLock({
|
||||
userId,
|
||||
cultivatorId: owner,
|
||||
@@ -345,6 +348,22 @@ export async function forgeEquipment(
|
||||
const story = forged
|
||||
? await StoryService.noteFact(owner, forged, tx)
|
||||
: null;
|
||||
rumorEventId = (
|
||||
await createDomainEvent(
|
||||
{
|
||||
type: 'equipment.forged',
|
||||
aggregate: { type: 'equipment', id: equipment.id },
|
||||
deduplicationKey: `${owner}:equipment-forged:${input.requestId}`,
|
||||
data: {
|
||||
userId,
|
||||
cultivatorId: owner,
|
||||
cultivatorName: crafterName,
|
||||
equipment,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
)
|
||||
).id;
|
||||
lease.assertHeld();
|
||||
return {
|
||||
result: {
|
||||
@@ -378,6 +397,10 @@ export async function forgeEquipment(
|
||||
};
|
||||
},
|
||||
});
|
||||
publishTransactionalMessageBestEffort(rumorEventId, {
|
||||
source: 'forging',
|
||||
cultivatorId: owner,
|
||||
});
|
||||
return { data: committed.result, state: committed.state };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordJournalItems } from './JournalSettlement';
|
||||
import type {
|
||||
InventoryAction,
|
||||
InventoryQuerySchema,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type ItemGrant,
|
||||
} from '@shared/inventory';
|
||||
import { changeEquipmentLocation } from '@shared/inventory/equipment-location';
|
||||
import { INVENTORY_KINDS } from '@shared/inventory/sorting';
|
||||
import { BEAST_REJUVENATION } from '@shared/items/definitions/beast-rejuvenation';
|
||||
import { ConsumableFactsSchema } from '@shared/items/definitions/consumables';
|
||||
import { MaterialFactsSchema } from '@shared/items/definitions/materials';
|
||||
@@ -169,6 +171,12 @@ export async function readInventory(
|
||||
query.materialType,
|
||||
)
|
||||
: undefined,
|
||||
query.kind === 'material' && query.element
|
||||
? eq(
|
||||
sql<string>`${inventoryItems.instanceData}->>'element'`,
|
||||
query.element,
|
||||
)
|
||||
: undefined,
|
||||
query.recycleCategory
|
||||
? inArray(
|
||||
inventoryItems.definitionId,
|
||||
@@ -196,10 +204,24 @@ export async function readInventory(
|
||||
? inArray(recycleQuality, recycleRanks)
|
||||
: undefined,
|
||||
);
|
||||
const recentOrder = [desc(inventoryItems.updatedAt), desc(inventoryItems.id)];
|
||||
const kindOrder = sql`case ${sql.join(
|
||||
INVENTORY_KINDS.filter(([kind]) => kind !== 'all').map(([kind], index) =>
|
||||
sql`when ${inArray(
|
||||
inventoryItems.definitionId,
|
||||
ITEM_DEFINITIONS.filter((item) => item.kind === kind).map((item) => item.id),
|
||||
)} then ${index}`,
|
||||
),
|
||||
sql` `,
|
||||
)} end`;
|
||||
const order =
|
||||
query.location === 'bag'
|
||||
? [asc(inventoryItems.slotIndex), asc(inventoryItems.id)]
|
||||
: [desc(inventoryItems.updatedAt), desc(inventoryItems.id)];
|
||||
query.sort === 'quantity'
|
||||
? [desc(inventoryItems.quantity), ...recentOrder]
|
||||
: query.sort === 'kind'
|
||||
? [asc(kindOrder), ...recentOrder]
|
||||
: query.sort === 'updatedAt' || query.location === 'storage'
|
||||
? recentOrder
|
||||
: [asc(inventoryItems.slotIndex), asc(inventoryItems.id)];
|
||||
const [requestedRows, totals, usage] = await runDbTasks(executor, [
|
||||
() =>
|
||||
executor
|
||||
@@ -242,6 +264,7 @@ export async function readInventory(
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...inventoryItemOf(row),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
name:
|
||||
row.definitionId === 'equipment.v6' ||
|
||||
row.definitionId === 'material.v1' ||
|
||||
@@ -253,6 +276,7 @@ export async function readInventory(
|
||||
})),
|
||||
equippedItems: equipped.map((row) => ({
|
||||
...inventoryItemOf(row),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
name: (row.instanceData as DaoEquipmentInstanceV1).name,
|
||||
equipped: true,
|
||||
})),
|
||||
@@ -309,6 +333,7 @@ export async function saveInventoryPlan(
|
||||
else
|
||||
await tx.insert(inventoryItems).values({ ...next, cultivatorId: owner });
|
||||
}
|
||||
recordJournalItems(tx, owner, before, after);
|
||||
}
|
||||
export async function grantInventory(
|
||||
owner: string,
|
||||
@@ -382,7 +407,13 @@ function transferInventoryItem(
|
||||
location: 'bag' | 'storage',
|
||||
) {
|
||||
if (item.location === location) throw new InventoryError('物品已在该位置');
|
||||
const next = items.filter((entry) => entry.id !== item.id);
|
||||
const next = items
|
||||
.filter((entry) => entry.id !== item.id)
|
||||
.map((entry) =>
|
||||
item.definitionId === 'consumable.v1'
|
||||
? normalizeConsumableStack(entry)
|
||||
: entry,
|
||||
);
|
||||
if (itemDefinition(item.definitionId).stackLimit > 1)
|
||||
return addItems(
|
||||
next,
|
||||
@@ -400,7 +431,9 @@ function transferInventoryItem(
|
||||
location,
|
||||
false,
|
||||
() => item.id,
|
||||
item.stackKey,
|
||||
item.definitionId === 'consumable.v1'
|
||||
? inventoryStackKey(item.definitionId, item.instanceData)
|
||||
: item.stackKey,
|
||||
).map((entry) =>
|
||||
entry.id === item.id ? { ...entry, revision: item.revision + 1 } : entry,
|
||||
);
|
||||
@@ -413,6 +446,14 @@ function transferInventoryItem(
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeConsumableStack(item: InventoryItem): InventoryItem {
|
||||
if (item.definitionId !== 'consumable.v1') return item;
|
||||
const stackKey = inventoryStackKey(item.definitionId, item.instanceData);
|
||||
return stackKey === item.stackKey
|
||||
? item
|
||||
: { ...item, stackKey, revision: item.revision + 1 };
|
||||
}
|
||||
|
||||
export async function mutateInventory(owner: string, input: InventoryAction) {
|
||||
const committed = await withRedisLock(
|
||||
{
|
||||
@@ -464,7 +505,12 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
|
||||
item.stackKey
|
||||
? [and(
|
||||
eq(inventoryItems.definitionId, item.definitionId),
|
||||
eq(inventoryItems.stackKey, item.stackKey),
|
||||
eq(
|
||||
inventoryItems.stackKey,
|
||||
item.definitionId === 'consumable.v1'
|
||||
? inventoryStackKey(item.definitionId, item.instanceData)!
|
||||
: item.stackKey,
|
||||
),
|
||||
sql`${inventoryItems.quantity} < ${itemDefinition(item.definitionId).stackLimit}`,
|
||||
)]
|
||||
: [],
|
||||
@@ -541,7 +587,12 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
|
||||
eq(inventoryItems.cultivatorId, owner),
|
||||
eq(inventoryItems.location, 'storage'),
|
||||
eq(inventoryItems.definitionId, source.definitionId),
|
||||
eq(inventoryItems.stackKey, source.stackKey ?? ''),
|
||||
eq(
|
||||
inventoryItems.stackKey,
|
||||
source.definitionId === 'consumable.v1'
|
||||
? inventoryStackKey(source.definitionId, source.instanceData)!
|
||||
: source.stackKey ?? '',
|
||||
),
|
||||
sql`${inventoryItems.quantity} < ${itemDefinition(source.definitionId).stackLimit}`,
|
||||
),
|
||||
)
|
||||
@@ -582,7 +633,7 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
|
||||
)
|
||||
)
|
||||
throw new InventoryError('背包已变化,请刷新');
|
||||
next = sortBag(next);
|
||||
next = sortBag(next.map(normalizeConsumableStack));
|
||||
} else if (item) {
|
||||
const equipped = await tx
|
||||
.select()
|
||||
@@ -606,6 +657,12 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
|
||||
lease.assertHeld();
|
||||
return { data: result, state };
|
||||
}
|
||||
if (target && item.definitionId === 'consumable.v1') {
|
||||
const normalizedItem = normalizeConsumableStack(item);
|
||||
const normalizedTarget = normalizeConsumableStack(target);
|
||||
item.stackKey = normalizedItem.stackKey;
|
||||
target.stackKey = normalizedTarget.stackKey;
|
||||
}
|
||||
if (target && sameStack(item, target)) {
|
||||
const amount = Math.min(
|
||||
item.quantity,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { DbTransaction } from '@server/lib/drizzle/db';
|
||||
import {
|
||||
cultivators,
|
||||
playerJournal,
|
||||
sectMemberships,
|
||||
} from '@server/lib/drizzle/schema';
|
||||
import { journalOperationKey } from '@server/lib/repositories/playerJournalRepository';
|
||||
import {
|
||||
PlayerJournalEventSchema,
|
||||
type JournalActivity,
|
||||
type JournalChange,
|
||||
type PlayerJournalEvent,
|
||||
} from '@shared/contracts/playerJournal';
|
||||
import type { InventoryItem } from '@shared/inventory';
|
||||
import {
|
||||
compactJournalChanges,
|
||||
inventoryJournalChanges,
|
||||
} from '@shared/playerJournal';
|
||||
import type { CultivationProgress } from '@shared/types/cultivator';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
// Only explicitly enrolled settlements collect changes. The transaction owns the
|
||||
// buffer; rollback/retry gets a fresh buffer and nothing is published out of band.
|
||||
const captures = new WeakMap<
|
||||
DbTransaction,
|
||||
{
|
||||
owner: string;
|
||||
changes: JournalChange[];
|
||||
detail?: string;
|
||||
}
|
||||
>();
|
||||
|
||||
export function recordJournalItems(
|
||||
tx: DbTransaction,
|
||||
owner: string,
|
||||
before: readonly InventoryItem[],
|
||||
after: readonly InventoryItem[],
|
||||
) {
|
||||
const capture = captures.get(tx);
|
||||
if (capture?.owner === owner)
|
||||
capture.changes.push(...inventoryJournalChanges(before, after));
|
||||
}
|
||||
|
||||
export function recordJournalChange(
|
||||
tx: DbTransaction,
|
||||
owner: string,
|
||||
change: JournalChange,
|
||||
) {
|
||||
const capture = captures.get(tx);
|
||||
if (capture?.owner === owner && change.amount) capture.changes.push(change);
|
||||
}
|
||||
|
||||
export function describeJournal(
|
||||
tx: DbTransaction,
|
||||
owner: string,
|
||||
detail: string,
|
||||
) {
|
||||
const capture = captures.get(tx);
|
||||
if (capture?.owner === owner) capture.detail = detail;
|
||||
}
|
||||
|
||||
async function readResources(tx: DbTransaction, owner: string) {
|
||||
const [row] = await tx
|
||||
.select({
|
||||
spiritStones: cultivators.spirit_stones,
|
||||
reputation: cultivators.reputation,
|
||||
contribution: sectMemberships.contribution,
|
||||
progress: cultivators.cultivation_progress,
|
||||
})
|
||||
.from(cultivators)
|
||||
.leftJoin(
|
||||
sectMemberships,
|
||||
and(
|
||||
eq(sectMemberships.cultivatorId, cultivators.id),
|
||||
eq(sectMemberships.status, 'active'),
|
||||
),
|
||||
)
|
||||
.where(eq(cultivators.id, owner));
|
||||
if (!row) throw new Error('日志结算角色不存在');
|
||||
const progress = row.progress as Partial<CultivationProgress> | null;
|
||||
return {
|
||||
spiritStones: row.spiritStones,
|
||||
reputation: row.reputation,
|
||||
contribution: row.contribution ?? 0,
|
||||
exp: progress?.cultivation_exp ?? 0,
|
||||
insight: progress?.comprehension_insight ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureJournalSettlement<T>(
|
||||
tx: DbTransaction,
|
||||
owner: string,
|
||||
activity: JournalActivity,
|
||||
run: () => Promise<T>,
|
||||
): Promise<{
|
||||
value: T;
|
||||
event: Extract<PlayerJournalEvent, { type: 'resources.settled' }>;
|
||||
}> {
|
||||
if (captures.has(tx)) throw new Error('同一事务不能重复开启日志结算');
|
||||
const before = await readResources(tx, owner);
|
||||
const capture: { owner: string; changes: JournalChange[]; detail?: string } =
|
||||
{ owner, changes: [] };
|
||||
captures.set(tx, capture);
|
||||
try {
|
||||
const value = await run();
|
||||
const after = await readResources(tx, owner);
|
||||
for (const resource of [
|
||||
'spiritStones',
|
||||
'exp',
|
||||
'insight',
|
||||
'reputation',
|
||||
'contribution',
|
||||
] as const) {
|
||||
const amount = after[resource] - before[resource];
|
||||
if (amount) capture.changes.push({ kind: 'resource', resource, amount });
|
||||
}
|
||||
return {
|
||||
value,
|
||||
event: {
|
||||
type: 'resources.settled',
|
||||
activity,
|
||||
...(capture.detail ? { detail: capture.detail } : {}),
|
||||
changes: compactJournalChanges(capture.changes),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
captures.delete(tx);
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller already guards delivery with its transport receipt or battle archive. */
|
||||
export async function runJournalSettlement<T>(
|
||||
tx: DbTransaction,
|
||||
owner: string,
|
||||
activity: JournalActivity,
|
||||
operationId: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { value, event } = await captureJournalSettlement(
|
||||
tx,
|
||||
owner,
|
||||
activity,
|
||||
run,
|
||||
);
|
||||
if (event.changes.length) {
|
||||
await tx.insert(playerJournal).values({
|
||||
cultivatorId: owner,
|
||||
operationKey: journalOperationKey(activity, operationId),
|
||||
event: PlayerJournalEventSchema.parse(event),
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { describeJournal } from './JournalSettlement';
|
||||
import { findPlayerMutationRequest } from '@server/lib/repositories/playerStateRepository';
|
||||
import { getExecutor } from '@server/lib/drizzle/db';
|
||||
import { mails } from '@server/lib/drizzle/schema';
|
||||
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
|
||||
@@ -140,6 +142,7 @@ export function sendCultivatorMail(args: {
|
||||
attachment: args.attachment,
|
||||
tx,
|
||||
});
|
||||
describeJournal(tx, args.actor.cultivatorId, `寄给${result.recipientName}`);
|
||||
const resourceChanges: ResourceChangeDescriptor[] = [
|
||||
{
|
||||
resourceTopic: 'inventory.bag',
|
||||
@@ -172,7 +175,9 @@ export function claimCultivatorMail(args: {
|
||||
async (lease) => {
|
||||
const mail = await mailsQuery(args.actor.cultivatorId, args.mailId);
|
||||
if (!mail) throw new PlayerMailCommandError('Mail not found', 404);
|
||||
if (mail.isClaimed) {
|
||||
if (mail.isClaimed && !(await findPlayerMutationRequest(
|
||||
args.actor.cultivatorId, 'mail_claim', `mail-claim:${args.mailId}`,
|
||||
))) {
|
||||
throw new PlayerMailCommandError('Already claimed', 400);
|
||||
}
|
||||
const attachments = (mail.attachments as MailAttachment[]) || [];
|
||||
@@ -203,6 +208,7 @@ export function claimCultivatorMail(args: {
|
||||
});
|
||||
if (!current)
|
||||
throw new PlayerMailCommandError('邮件已领取或不存在', 400);
|
||||
describeJournal(tx, args.actor.cultivatorId, current.title);
|
||||
const attachments = (current.attachments as MailAttachment[]) || [];
|
||||
const gains = attachmentsToResourceOperations(attachments);
|
||||
if (attachments.length === 0) {
|
||||
@@ -261,7 +267,7 @@ export function claimCultivatorMail(args: {
|
||||
);
|
||||
}
|
||||
|
||||
export function claimAllCultivatorMail(args: { actor: MailActor }) {
|
||||
export function claimAllCultivatorMail(args: { actor: MailActor; requestId: string }) {
|
||||
return withRedisLock(
|
||||
{
|
||||
key: redisLockKeys.cultivatorMutation(args.actor.cultivatorId),
|
||||
@@ -281,6 +287,7 @@ export function claimAllCultivatorMail(args: { actor: MailActor }) {
|
||||
userId: args.actor.userId,
|
||||
cultivatorId: args.actor.cultivatorId,
|
||||
source: 'mail_claim_all',
|
||||
idempotency: { key: args.requestId, fingerprint: 'mail-claim-all' },
|
||||
allowEmpty: true,
|
||||
command: async (tx) => {
|
||||
const pendingMails = await tx.query.mails.findMany({
|
||||
@@ -299,6 +306,7 @@ export function claimAllCultivatorMail(args: { actor: MailActor }) {
|
||||
tx,
|
||||
);
|
||||
const mailIds = claimable.map((mail) => mail.id);
|
||||
describeJournal(tx, args.actor.cultivatorId, `${mailIds.length}封`);
|
||||
const attachments = claimable.flatMap(
|
||||
(mail) => (mail.attachments as MailAttachment[]) || [],
|
||||
);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from '@shared/engine/cultivation/CultivationEngine';
|
||||
import type { BreakthroughHistoryEntry } from '@shared/types/cultivator';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { findJournalOperation } from '@server/lib/repositories/playerJournalRepository';
|
||||
import { retreatResultFromJournal } from '@shared/contracts/playerJournal';
|
||||
import { playerCommandExecutor } from './CommandExecutors';
|
||||
import { PillOperationExecutor } from './PillOperationExecutor';
|
||||
import { QiService } from './QiService';
|
||||
@@ -67,6 +69,7 @@ export function executeRetreatCommand(args: {
|
||||
cultivatorId: string;
|
||||
action: 'cultivate' | 'breakthrough';
|
||||
years: number;
|
||||
requestId: string;
|
||||
}) {
|
||||
return withRedisLock(
|
||||
{
|
||||
@@ -76,6 +79,23 @@ export function executeRetreatCommand(args: {
|
||||
retries: 0,
|
||||
},
|
||||
async (lease) => {
|
||||
const journal = {
|
||||
operationKey: `retreat.${args.action}:${args.requestId}`,
|
||||
fingerprint: JSON.stringify({ action: args.action, years: args.years }),
|
||||
replay: retreatResultFromJournal,
|
||||
};
|
||||
// Retry before checking current progress: the original action may have changed it.
|
||||
const previous = await findJournalOperation(args.cultivatorId, journal.operationKey, journal.fingerprint);
|
||||
if (previous) {
|
||||
return {
|
||||
committed: {
|
||||
result: retreatResultFromJournal(previous.event),
|
||||
state: { changes: [], baselines: [], replayed: true },
|
||||
},
|
||||
storySource: null,
|
||||
onStoryComplete: undefined,
|
||||
};
|
||||
}
|
||||
await assertCombatV6MutationAllowed(args.cultivatorId, `retreat_${args.action}`);
|
||||
const cultivator = await loadPlayerRetreatFacts(
|
||||
args.userId,
|
||||
@@ -109,6 +129,7 @@ export function executeRetreatCommand(args: {
|
||||
years: args.years,
|
||||
result,
|
||||
lease,
|
||||
journal,
|
||||
});
|
||||
return {
|
||||
committed,
|
||||
@@ -180,6 +201,8 @@ export function executeRetreatCommand(args: {
|
||||
result,
|
||||
retreatResult,
|
||||
lease,
|
||||
journal,
|
||||
expSpent: cultivator.cultivation_progress.cultivation_exp - result.cultivator.cultivation_progress!.cultivation_exp,
|
||||
});
|
||||
publishTransactionalMessageBestEffort(domainEventId, {
|
||||
source: 'retreat_breakthrough',
|
||||
@@ -210,6 +233,7 @@ export async function commitCultivationRetreat(args: {
|
||||
years: number;
|
||||
result: ReturnType<typeof performCultivation>;
|
||||
lease: RedisLeaseContext;
|
||||
journal: { operationKey: string; fingerprint: string; replay: typeof retreatResultFromJournal };
|
||||
}) {
|
||||
const actionInstanceId = randomUUID();
|
||||
let streamResult: RetreatResultData = {
|
||||
@@ -223,6 +247,7 @@ export async function commitCultivationRetreat(args: {
|
||||
userId: args.userId,
|
||||
cultivatorId: args.cultivatorId,
|
||||
source: 'retreat_cultivate',
|
||||
journal: args.journal,
|
||||
command: async (tx) => {
|
||||
const reservation = await QiService.reserveQi({
|
||||
cultivatorId: args.cultivatorId,
|
||||
@@ -282,6 +307,13 @@ export async function commitCultivationRetreat(args: {
|
||||
}
|
||||
return {
|
||||
result: streamResult,
|
||||
journalEvent: {
|
||||
type: 'retreat.completed',
|
||||
years: args.years,
|
||||
qiSpent: reservation.consumed,
|
||||
summary: args.result.summary,
|
||||
depleted: Boolean(streamResult.depleted),
|
||||
},
|
||||
resourceChanges: retreatChanges({
|
||||
profile: {
|
||||
age: next.age,
|
||||
@@ -315,6 +347,8 @@ export async function commitBreakthroughRetreat(args: {
|
||||
result: ReturnType<typeof attemptBreakthrough>;
|
||||
retreatResult: RetreatResultData;
|
||||
lease: RedisLeaseContext;
|
||||
journal: { operationKey: string; fingerprint: string; replay: typeof retreatResultFromJournal };
|
||||
expSpent: number;
|
||||
}) {
|
||||
const actionInstanceId = randomUUID();
|
||||
let domainEventId: string | undefined;
|
||||
@@ -323,6 +357,7 @@ export async function commitBreakthroughRetreat(args: {
|
||||
userId: args.userId,
|
||||
cultivatorId: args.cultivatorId,
|
||||
source: 'retreat_breakthrough',
|
||||
journal: args.journal,
|
||||
command: async (tx) => {
|
||||
const reservation = await QiService.reserveQi({
|
||||
cultivatorId: args.cultivatorId,
|
||||
@@ -396,6 +431,12 @@ export async function commitBreakthroughRetreat(args: {
|
||||
}
|
||||
return {
|
||||
result: args.retreatResult,
|
||||
journalEvent: {
|
||||
type: 'breakthrough.completed',
|
||||
qiSpent: reservation.consumed,
|
||||
expSpent: args.expSpent,
|
||||
summary: args.result.summary,
|
||||
},
|
||||
resourceChanges: breakthroughChanges({
|
||||
profile: {
|
||||
realm: next.realm,
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
isDomainEventType,
|
||||
type DomainEventEnvelope,
|
||||
} from '@shared/contracts/domainEvents';
|
||||
import { alchemyShowcaseSnapshot } from '@shared/items/alchemyShowcase';
|
||||
import { forgingShowcaseSnapshot } from '@shared/items/forgingShowcase';
|
||||
import { QUALITY_ORDER } from '@shared/types/constants';
|
||||
import type { WorldChatPayload } from '@shared/types/world-chat';
|
||||
import type { FeatureCommandResult } from './CommandExecutors';
|
||||
@@ -36,16 +38,42 @@ export async function projectWorldRumorDomainEvent(
|
||||
}
|
||||
|
||||
if (isDomainEventType(event, 'craft.item.created')) {
|
||||
if (event.data.itemType === 'consumable') {
|
||||
const snapshot = alchemyShowcaseSnapshot(
|
||||
event.data.outputs ?? [event.data.snapshot],
|
||||
);
|
||||
if (!snapshot) return ignored();
|
||||
const text = `由${event.data.cultivatorName}炼成,品相完美,药香化霞,足令诸修侧目。`;
|
||||
return createRumor(event, event.data.userId, 'item_showcase', text, {
|
||||
version: 1,
|
||||
snapshot,
|
||||
text,
|
||||
});
|
||||
}
|
||||
if (QUALITY_ORDER[event.data.quality] < QUALITY_ORDER['天品']) {
|
||||
return ignored();
|
||||
}
|
||||
const noun = event.data.itemType === 'consumable' ? '丹品' : '品阶';
|
||||
const flourish =
|
||||
event.data.itemType === 'consumable' ? '药香化霞' : '灵韵自生';
|
||||
const text = `由${event.data.cultivatorName}炼成,${noun}已入${event.data.quality},${flourish},足令诸修侧目。`;
|
||||
const text = `由${event.data.cultivatorName}炼成,品阶已入${event.data.quality},灵韵自生,足令诸修侧目。`;
|
||||
return createRumor(event, event.data.userId, 'text', text, { text });
|
||||
}
|
||||
|
||||
if (isDomainEventType(event, 'equipment.forged')) {
|
||||
const snapshot = forgingShowcaseSnapshot(event.data.equipment);
|
||||
if (!snapshot) return ignored();
|
||||
const special = [
|
||||
event.data.equipment.artId ? '器诀' : null,
|
||||
event.data.equipment.essenceIds.length ? '器蕴' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('与');
|
||||
const text = `由${event.data.cultivatorName}炼成,天生${special},灵韵自生,足令诸修侧目。`;
|
||||
return createRumor(event, event.data.userId, 'item_showcase', text, {
|
||||
version: 1,
|
||||
snapshot,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDomainEventType(event, 'market.material.revealed')) {
|
||||
if (QUALITY_ORDER[event.data.quality] < QUALITY_ORDER['天品']) {
|
||||
return ignored();
|
||||
@@ -87,7 +115,7 @@ function ignored(): RumorProjectionResult {
|
||||
async function createRumor(
|
||||
event: DomainEventEnvelope,
|
||||
senderUserId: string,
|
||||
messageType: 'text' | 'beast_showcase',
|
||||
messageType: 'text' | 'item_showcase' | 'beast_showcase',
|
||||
text: string,
|
||||
payload: WorldChatPayload,
|
||||
): Promise<RumorProjectionResult> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { findPlayerMutationRequest } from '@server/lib/repositories/playerStateRepository';
|
||||
import { describeJournal } from './JournalSettlement';
|
||||
import { cultivators } from '@server/lib/drizzle/schema';
|
||||
import { createDomainEvent } from '@server/lib/mq/domainEventWriter';
|
||||
import { publishTransactionalMessageBestEffort } from '@server/lib/mq/transactionalMessagePublisher';
|
||||
@@ -14,7 +16,6 @@ import { planYieldRewards } from '@shared/rewards/yield';
|
||||
import type { RealmStage, RealmType } from '@shared/types/constants';
|
||||
import type { CultivationProgress } from '@shared/types/cultivator';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getExecutor } from '../drizzle/db';
|
||||
import { playerCommandExecutor } from './CommandExecutors';
|
||||
import { newRewardAttachment } from './MailInventory';
|
||||
@@ -78,11 +79,25 @@ async function loadYieldFacts(
|
||||
};
|
||||
}
|
||||
|
||||
interface YieldResult {
|
||||
cultivatorName: string;
|
||||
cultivatorRealm: RealmType;
|
||||
amount: number;
|
||||
expGain: number;
|
||||
insightGain: number;
|
||||
materials: GeneratedMaterial[];
|
||||
hours: number;
|
||||
materialCount: number;
|
||||
rewardCount: number;
|
||||
}
|
||||
|
||||
export async function executeYieldCommand(args: {
|
||||
userId: string;
|
||||
cultivatorId: string;
|
||||
requestId: string;
|
||||
}) {
|
||||
const actionInstanceId = randomUUID();
|
||||
const actionInstanceId = args.requestId;
|
||||
const idempotency = { key: args.requestId, fingerprint: 'yield' };
|
||||
let domainEventId: string | undefined;
|
||||
const prepared = await withRedisLock(
|
||||
{
|
||||
@@ -93,6 +108,15 @@ export async function executeYieldCommand(args: {
|
||||
delayMs: 50,
|
||||
},
|
||||
async (lease) => {
|
||||
if (await findPlayerMutationRequest(args.cultivatorId, 'yield_claim', args.requestId)) {
|
||||
const committed = await playerCommandExecutor.execute<YieldResult>({
|
||||
coordination: { mode: 'redis', lease },
|
||||
userId: args.userId, cultivatorId: args.cultivatorId,
|
||||
source: 'yield_claim', idempotency,
|
||||
command: async () => { throw new Error('历练执行凭据已失效'); },
|
||||
});
|
||||
return { committed, result: committed.result };
|
||||
}
|
||||
const facts = await loadYieldFacts(args.userId, args.cultivatorId);
|
||||
if (!facts) {
|
||||
throw new YieldCommandError('未找到角色信息', 404);
|
||||
@@ -140,7 +164,7 @@ export async function executeYieldCommand(args: {
|
||||
),
|
||||
...rewardPlan.items,
|
||||
];
|
||||
const result = {
|
||||
const result: YieldResult = {
|
||||
cultivatorName: facts.name,
|
||||
cultivatorRealm: facts.realm,
|
||||
amount:
|
||||
@@ -163,7 +187,10 @@ export async function executeYieldCommand(args: {
|
||||
userId: args.userId,
|
||||
cultivatorId: args.cultivatorId,
|
||||
source: 'yield_claim',
|
||||
requestId: actionInstanceId,
|
||||
idempotency,
|
||||
command: async (tx) => {
|
||||
describeJournal(tx, args.cultivatorId, `${Number(hoursElapsed.toFixed(1))}小时`);
|
||||
let spiritStones = facts.spiritStones;
|
||||
let progress = facts.progress;
|
||||
const claimedAt = new Date();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dungeonRuns } from '@server/lib/drizzle/schema';
|
||||
import { redis } from '@server/lib/redis';
|
||||
import { parseRedisJson } from '@server/lib/redis/json';
|
||||
import { activeBreakthroughBattle } from '@server/lib/services/combat-v6/CombatV6BreakthroughOccupancy';
|
||||
import { arenaOccupancyKey } from '@server/lib/services/combat-v6/CombatV6ArenaStore';
|
||||
import { arenaOccupancyKey, CombatV6ArenaStore } from '@server/lib/services/combat-v6/CombatV6ArenaStore';
|
||||
import { CombatV6RuntimeStore } from '@server/lib/services/combat-v6/CombatV6RuntimeStore';
|
||||
import { activeSectTaskBattle } from '@server/lib/services/combat-v6/CombatV6SectTaskOccupancy';
|
||||
import { CombatV6WildStore } from '@server/lib/services/combat-v6/CombatV6WildStore';
|
||||
@@ -159,11 +159,12 @@ async function arenaNotice(
|
||||
): Promise<CombatActivityNotice | null> {
|
||||
const battleId = await redis.get(arenaOccupancyKey(owner));
|
||||
if (!battleId) return null;
|
||||
const battle = await new CombatV6ArenaStore().get(battleId);
|
||||
return notice(
|
||||
'arena',
|
||||
'擂台切磋',
|
||||
false,
|
||||
`/game/combat-v6/arena/${encodeURIComponent(battleId)}`,
|
||||
battle?.hunt ? '结伴讨伐' : '擂台切磋',
|
||||
battle?.stage === 'finished',
|
||||
`/game/combat-v6/${battle?.hunt ? 'hunt' : 'arena'}/${encodeURIComponent(battleId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { HuntTeam } from '@shared/contracts/hunts';
|
||||
import { prepareHuntReward } from '../hunts/HuntRewardService';
|
||||
import { HUNT_BOSSES, huntIsOpen } from '@shared/hunts/config';
|
||||
import { huntRealmAllowed } from '@shared/hunts/rules';
|
||||
import type { RealmType } from '@shared/types/constants';
|
||||
import {
|
||||
huntEnemies,
|
||||
HUNT_SKILLS,
|
||||
HUNT_STATUSES,
|
||||
huntNpcCommand,
|
||||
} from '@shared/engine/combat-v6/hunts/content';
|
||||
import { playerAppearances } from '@shared/combat-v6/unit-appearance';
|
||||
import type { CombatV6UnitAppearance } from '@shared/contracts/combatV6';
|
||||
import { db } from '@server/lib/drizzle/db';
|
||||
@@ -77,11 +88,23 @@ function mergeDefinitions<T extends SkillDef | StatusDef>(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createArenaV6(room: ArenaRoomV1): Promise<string> {
|
||||
export async function createArenaV6(
|
||||
room: ArenaRoomV1,
|
||||
hunt?: Pick<HuntTeam, 'event' | 'minRealm' | 'maxRealm'>,
|
||||
): Promise<string> {
|
||||
const frozen = room.frozenRoster;
|
||||
if (!frozen || !room.startRequestId) throw new ArenaV6Error('缺少冻结阵容');
|
||||
const existing = await store.source(room.roomId, room.startRequestId);
|
||||
if (existing) return existing;
|
||||
if (
|
||||
hunt &&
|
||||
(!huntIsOpen(hunt.event, Date.now()) ||
|
||||
frozen.seats.length < 2 ||
|
||||
frozen.seats.length > 4 ||
|
||||
new Set(frozen.seats.map((s) => s.userId)).size !== frozen.seats.length ||
|
||||
frozen.seats.some((s) => s.teamId !== 'alpha'))
|
||||
)
|
||||
throw new ArenaV6Error('讨伐已到期或阵容无效');
|
||||
const seats = [...frozen.seats].sort((a, b) =>
|
||||
a.cultivatorId.localeCompare(b.cultivatorId),
|
||||
);
|
||||
@@ -103,6 +126,7 @@ export async function createArenaV6(room: ArenaRoomV1): Promise<string> {
|
||||
const units: ArenaRuntime['units'] = [];
|
||||
const unitAppearances: Record<string, CombatV6UnitAppearance> = {};
|
||||
const participants: ArenaRuntime['participants'] = [];
|
||||
const huntRewards: NonNullable<ArenaRuntime['huntRewards']> = {};
|
||||
const autoStrategies: NonNullable<ArenaRuntime['autoStrategies']> = {};
|
||||
for (const seat of seats) {
|
||||
if (await hasActiveCombat(seat.cultivatorId))
|
||||
@@ -115,6 +139,12 @@ export async function createArenaV6(room: ArenaRoomV1): Promise<string> {
|
||||
),
|
||||
});
|
||||
if (!identity) throw new ArenaV6Error('参战角色归属已变化');
|
||||
if (hunt && !huntRealmAllowed(hunt, identity.realm as RealmType))
|
||||
throw new ArenaV6Error('参战境界已变化,请重新准备');
|
||||
if (hunt)
|
||||
huntRewards[seat.cultivatorId] = await prepareHuntReward(
|
||||
hunt.event, seat.cultivatorId, tx,
|
||||
);
|
||||
const { player } = await assembleCombatV6TrainingPlayer(
|
||||
seat.cultivatorId,
|
||||
tx,
|
||||
@@ -150,6 +180,15 @@ export async function createArenaV6(room: ArenaRoomV1): Promise<string> {
|
||||
});
|
||||
mergeDefinitions(statuses, projection.statusDefs);
|
||||
}
|
||||
if (hunt) {
|
||||
units.push(...huntEnemies(hunt.event, seats.length));
|
||||
mergeDefinitions(skills, HUNT_SKILLS);
|
||||
mergeDefinitions(statuses, HUNT_STATUSES);
|
||||
for (const unit of units.filter((u) => u.side === 1))
|
||||
unitAppearances[unit.id!] = {
|
||||
icon: HUNT_BOSSES[hunt.event.bossId].icon,
|
||||
};
|
||||
}
|
||||
units.sort((a, b) => a.side - b.side || (a.slot ?? 0) - (b.slot ?? 0));
|
||||
const now = Date.now();
|
||||
const battleId = crypto.randomUUID();
|
||||
@@ -163,6 +202,8 @@ export async function createArenaV6(room: ArenaRoomV1): Promise<string> {
|
||||
const battle = arenaBattle(input);
|
||||
const runtime: ArenaRuntime = {
|
||||
...input,
|
||||
hunt: hunt?.event,
|
||||
huntRewards: hunt ? huntRewards : undefined,
|
||||
protocol: ARENA_V6_PROTOCOL,
|
||||
battleId,
|
||||
roomId: room.roomId,
|
||||
@@ -187,8 +228,10 @@ export async function createArenaV6(room: ArenaRoomV1): Promise<string> {
|
||||
receipts: {},
|
||||
lastResults: {},
|
||||
};
|
||||
if (runtime.hunt) fillHuntCommands(runtime);
|
||||
lease.assertHeld();
|
||||
const id = await store.create(runtime);
|
||||
if (id === 'EXPIRED') throw new ArenaV6Error('此处异动已平息');
|
||||
if (id === 'BUSY')
|
||||
throw new ArenaV6Error('有参战角色正在战斗或等待结算');
|
||||
return id;
|
||||
@@ -309,7 +352,7 @@ export async function advanceArenaV6(id: string) {
|
||||
await finalizeArenaV6(runtime);
|
||||
return;
|
||||
}
|
||||
const room = await rooms.getRoom(runtime.roomId);
|
||||
const room = runtime.hunt ? null : await rooms.getRoom(runtime.roomId);
|
||||
if (
|
||||
room?.status === 'starting' &&
|
||||
room.startRequestId === runtime.startRequestId
|
||||
@@ -352,7 +395,9 @@ export async function advanceArenaV6(id: string) {
|
||||
revision: runtime.revision + 1,
|
||||
deadlineAt: runtime.playbackEndsAt + 30000,
|
||||
};
|
||||
if (next.hunt) fillHuntCommands(next);
|
||||
for (const unit of arenaWaitingUnits(next)) {
|
||||
if (next.commands[unit.id]) continue;
|
||||
if (
|
||||
!(await store.online(
|
||||
`${runtime.battleId}:${unit.ownerId ?? unit.id}`,
|
||||
@@ -416,6 +461,10 @@ export async function advanceArenaV6(id: string) {
|
||||
}
|
||||
|
||||
async function finalizeArenaV6(runtime: ArenaRuntime) {
|
||||
if (runtime.hunt) {
|
||||
await publishHuntTerminal(runtime);
|
||||
return;
|
||||
}
|
||||
// Cleanup precedes archival so PostgreSQL outages cannot retain player locks.
|
||||
await store.release(runtime);
|
||||
const released = await rooms.forceReleaseTerminalBattle({
|
||||
@@ -529,3 +578,59 @@ export async function stopArenaV6Coordinator() {
|
||||
clearTimeout(timer);
|
||||
await task;
|
||||
}
|
||||
|
||||
function fillHuntCommands(runtime: ArenaRuntime) {
|
||||
for (const unit of arenaWaitingUnits(runtime).filter((u) => u.side === 1)) {
|
||||
runtime.commands[unit.id] = {
|
||||
requestId: `npc:${runtime.state.round}:${unit.id}`,
|
||||
command: huntNpcCommand(runtime, unit.id),
|
||||
automatic: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
async function publishHuntTerminal(runtime: ArenaRuntime) {
|
||||
await archiveCombatV6Replay(
|
||||
createCombatV6Replay({
|
||||
battleId: runtime.battleId,
|
||||
participants: runtime.participants,
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
sourceType: 'hunt',
|
||||
battleType: 'pve',
|
||||
idempotencyKey: runtime.startRequestId,
|
||||
payload: { eventId: runtime.hunt!.id, roomId: runtime.roomId },
|
||||
},
|
||||
startedAt: new Date(runtime.createdAt).toISOString(),
|
||||
finishedAt: new Date(runtime.deadlineAt).toISOString(),
|
||||
reason: runtime.terminalReason!,
|
||||
trace: {
|
||||
seed: runtime.seed,
|
||||
initialUnits: runtime.units,
|
||||
skills: runtime.skills,
|
||||
statusDefs: runtime.statusDefs,
|
||||
rounds: runtime.rounds,
|
||||
events: runtime.events,
|
||||
finalState: runtime.state,
|
||||
timeline: runtime.timeline,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const definition = DOMAIN_EVENT_DEFINITIONS['combat.v6.battle.finished'];
|
||||
const event = parseDomainEventEnvelope({
|
||||
id: runtime.battleId,
|
||||
type: 'combat.v6.battle.finished',
|
||||
version: definition.version,
|
||||
subject: definition.subject,
|
||||
occurredAt: new Date(runtime.deadlineAt).toISOString(),
|
||||
aggregate: { type: 'combat-v6-battle', id: runtime.battleId },
|
||||
correlationId: runtime.startRequestId,
|
||||
data: { battleId: runtime.battleId, sourceType: 'hunt' },
|
||||
});
|
||||
await (await getJetStreamClient()).publish(
|
||||
event.subject,
|
||||
JSONCodec().encode(event),
|
||||
{ msgID: event.id, expect: { streamName: DOMAIN_EVENT_STREAM }, timeout: 5000 },
|
||||
);
|
||||
// Keep runtime and occupancy until the reward projector commits and acknowledges.
|
||||
await redis.zadd(arenaDueKey, 'XX', Date.now() + 10000, runtime.battleId);
|
||||
}
|
||||
@@ -13,6 +13,12 @@ const sourceKey = (roomId: string, requestId: string) =>
|
||||
const CREATE = `
|
||||
local existing = redis.call('GET', KEYS[2])
|
||||
if existing then return existing end
|
||||
local runtime = cjson.decode(ARGV[1])
|
||||
if runtime.hunt then
|
||||
local time = redis.call('TIME')
|
||||
local now = tonumber(time[1])*1000 + math.floor(tonumber(time[2])/1000)
|
||||
if now >= runtime.hunt.expiresAt or now < runtime.hunt.startsAt then return 'EXPIRED' end
|
||||
end
|
||||
for i=4,#KEYS,3 do
|
||||
if redis.call('EXISTS', KEYS[i], KEYS[i+1], KEYS[i+2]) > 0 then return 'BUSY' end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runJournalSettlement, describeJournal } from '../JournalSettlement';
|
||||
import { db } from '@server/lib/drizzle/db';
|
||||
import { cultivators, messageConsumptions } from '@server/lib/drizzle/schema';
|
||||
import { createDomainEvent } from '@server/lib/mq/domainEventWriter';
|
||||
@@ -67,8 +68,7 @@ export async function projectCombatV6Condition(
|
||||
const now = new Date();
|
||||
const committed = await db.transaction(async (tx) => {
|
||||
await lockCultivatorForStateMutation(tx, s.cultivatorId);
|
||||
// A battle has one settlement. Use its UUID as the logical message key
|
||||
// for both MQ delivery and coordinator retries (which have no envelope).
|
||||
// Transport receipt still resolves redelivery after Redis terminal data expires.
|
||||
const claimed = await claimMessageForConsumer(
|
||||
{
|
||||
consumerName: COMBAT_V6_CONDITION_CONSUMER,
|
||||
@@ -78,6 +78,8 @@ export async function projectCombatV6Condition(
|
||||
tx,
|
||||
);
|
||||
if (!claimed) return;
|
||||
return runJournalSettlement(tx, s.cultivatorId, 'wild_settlement', battleId, async () => {
|
||||
describeJournal(tx, s.cultivatorId, record.reason === 'fled' ? '逃离' : record.reason === 'technical-abort' ? '中止' : '结算');
|
||||
const met =
|
||||
(record.reason === 'battle-ended' || record.reason === 'fled') &&
|
||||
s.metadata.payload.nodeId
|
||||
@@ -167,6 +169,7 @@ export async function projectCombatV6Condition(
|
||||
});
|
||||
lease.assertHeld();
|
||||
return state;
|
||||
});
|
||||
});
|
||||
if (committed) publishResourceEvents(committed.changes);
|
||||
await store.complete(s, now.getTime());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runJournalSettlement, describeJournal } from '../JournalSettlement';
|
||||
import { publicUnitAppearances } from '@shared/combat-v6/unit-appearance';
|
||||
import { isNotNull } from 'drizzle-orm';
|
||||
import { db, type DbTransaction } from '@server/lib/drizzle/db';
|
||||
@@ -366,6 +367,7 @@ export async function changeSectTaskBattle(
|
||||
.from(combatReplayArchives)
|
||||
.where(eq(combatReplayArchives.battleId, id));
|
||||
if (receipt) return;
|
||||
await runJournalSettlement(tx, actor.cultivatorId, 'sect_battle_settlement', id, async () => {
|
||||
const [row] = await tx
|
||||
.select()
|
||||
.from(sectTaskRecords)
|
||||
@@ -389,6 +391,7 @@ export async function changeSectTaskBattle(
|
||||
.require(membership.sectId)
|
||||
.tasks.get(row.taskId);
|
||||
if (!definition) invalidSectTask('任务定义缺失');
|
||||
describeJournal(tx, actor.cultivatorId, definition.presentation.title);
|
||||
let changes: import('@shared/contracts/resources').ResourceChangeDescriptor[] =
|
||||
[];
|
||||
if (host.trace().outcome === 'victory') {
|
||||
@@ -488,6 +491,7 @@ export async function changeSectTaskBattle(
|
||||
],
|
||||
});
|
||||
lease.assertHeld();
|
||||
});
|
||||
});
|
||||
await redis.expire(key(id), 86400);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { db } from '@server/lib/drizzle/db';
|
||||
import { cultivators, messageConsumptions } from '@server/lib/drizzle/schema';
|
||||
import { publishTransactionalMessageBestEffort } from '@server/lib/mq/transactionalMessagePublisher';
|
||||
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
|
||||
import { claimMessageForConsumer } from '@server/lib/repositories/messageConsumptionRepository';
|
||||
import {
|
||||
claimJournalOperation,
|
||||
completeJournalOperation,
|
||||
findJournalOperation,
|
||||
journalOperationKey,
|
||||
} from '@server/lib/repositories/playerJournalRepository';
|
||||
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
|
||||
import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils';
|
||||
import type { HuntBattleReward } from '@shared/contracts/hunts';
|
||||
import type { JournalChange } from '@shared/contracts/playerJournal';
|
||||
import { HUNT_BOSSES } from '@shared/hunts/config';
|
||||
import { dungeonRewardItemName } from '@shared/rewards/dungeon';
|
||||
import { HuntRewardSnapshotSchema } from '@shared/rewards/hunt';
|
||||
import type { RealmStage, RealmType } from '@shared/types/constants';
|
||||
import type { CultivationProgress } from '@shared/types/cultivator';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { ArenaV6Error, ownedArenaV6 } from '../combat-v6/CombatV6ArenaService';
|
||||
import { CombatV6ArenaStore } from '../combat-v6/CombatV6ArenaStore';
|
||||
import {
|
||||
updateCultivationExp,
|
||||
updateSpiritStones,
|
||||
} from '../cultivator/CultivatorStateRepository';
|
||||
import { MailService } from '../MailService';
|
||||
import { publishResourceEvents } from '../playerStateBroadcaster';
|
||||
import { ResourceEventCommitter } from '../ResourceEventCommitter';
|
||||
import type { HuntActor } from './HuntTeamService';
|
||||
import { finishHuntTeam } from './HuntTeamService';
|
||||
const store = new CombatV6ArenaStore();
|
||||
export const HUNT_REWARD_CONSUMER = 'hunt-reward-v1';
|
||||
const HuntJournalResultSchema = z.object({
|
||||
battleId: z.uuid(),
|
||||
reward: HuntRewardSnapshotSchema,
|
||||
mailId: z.uuid().optional(),
|
||||
});
|
||||
export async function projectHuntReward(battleId: string) {
|
||||
const runtime = await store.get(battleId);
|
||||
if (!runtime) {
|
||||
const receipt = await db
|
||||
.select({ id: messageConsumptions.messageId })
|
||||
.from(messageConsumptions)
|
||||
.where(
|
||||
and(
|
||||
eq(messageConsumptions.messageId, battleId),
|
||||
eq(messageConsumptions.consumerName, HUNT_REWARD_CONSUMER),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (receipt.length) return;
|
||||
throw new Error('HUNT_TERMINAL_MISSING');
|
||||
}
|
||||
if (!runtime.hunt || runtime.stage !== 'finished')
|
||||
throw new Error('HUNT_NOT_FINISHED');
|
||||
const event = runtime.hunt;
|
||||
const participants = [...runtime.participants].sort((a, b) =>
|
||||
a.cultivatorId.localeCompare(b.cultivatorId),
|
||||
);
|
||||
await withRedisLock(
|
||||
{
|
||||
keys: participants.map((p) =>
|
||||
redisLockKeys.cultivatorMutation(p.cultivatorId),
|
||||
),
|
||||
context: 'hunt-rewards',
|
||||
timeoutMs: 30000,
|
||||
retries: 0,
|
||||
},
|
||||
async (lease) => {
|
||||
const commits = await db.transaction(async (tx) => {
|
||||
for (const p of participants)
|
||||
await lockCultivatorForStateMutation(tx, p.cultivatorId);
|
||||
if (
|
||||
!(await claimMessageForConsumer(
|
||||
{
|
||||
consumerName: HUNT_REWARD_CONSUMER,
|
||||
messageId: battleId,
|
||||
messageKey: 'combat.v6.battle.finished',
|
||||
},
|
||||
tx,
|
||||
))
|
||||
)
|
||||
return { changes: [], mailEvents: [] };
|
||||
const changes = [];
|
||||
const mailEvents: string[] = [];
|
||||
if (
|
||||
runtime.terminalReason === 'battle-ended' &&
|
||||
runtime.state.result?.winner === 0
|
||||
) {
|
||||
for (const p of participants) {
|
||||
// Legacy active battles retain their original stone-only reward.
|
||||
const frozen =
|
||||
runtime.huntRewards?.[p.cultivatorId] ??
|
||||
(event.spiritStones !== undefined
|
||||
? {
|
||||
poolId: 'hunt.legacy',
|
||||
poolVersion: 1,
|
||||
spiritStones: event.spiritStones,
|
||||
experience: 0,
|
||||
insight: 0,
|
||||
items: [],
|
||||
}
|
||||
: undefined);
|
||||
const reward = HuntRewardSnapshotSchema.parse(frozen);
|
||||
const claim = await claimJournalOperation(tx, {
|
||||
cultivatorId: p.cultivatorId,
|
||||
operationKey: journalOperationKey('hunt_reward', event.id),
|
||||
fingerprint: null,
|
||||
});
|
||||
if (claim.previous) continue; // Assistants receive neither resources nor item mail.
|
||||
const before = await tx.query.cultivators.findFirst({
|
||||
where: and(
|
||||
eq(cultivators.id, p.cultivatorId),
|
||||
eq(cultivators.userId, p.userId),
|
||||
),
|
||||
});
|
||||
if (!before) throw new Error('讨伐角色不存在');
|
||||
const progress = getOrInitCultivationProgress(
|
||||
(before.cultivation_progress ?? {}) as CultivationProgress,
|
||||
before.realm as RealmType,
|
||||
before.realm_stage as RealmStage,
|
||||
);
|
||||
const stones = await updateSpiritStones(
|
||||
p.userId,
|
||||
p.cultivatorId,
|
||||
reward.spiritStones,
|
||||
tx,
|
||||
);
|
||||
const after = await updateCultivationExp(
|
||||
p.userId,
|
||||
p.cultivatorId,
|
||||
reward.experience,
|
||||
reward.insight,
|
||||
tx,
|
||||
);
|
||||
const actual = {
|
||||
...reward,
|
||||
spiritStones: stones - before.spirit_stones,
|
||||
experience: after.cultivation_exp - progress.cultivation_exp,
|
||||
insight:
|
||||
after.comprehension_insight - progress.comprehension_insight,
|
||||
};
|
||||
const mail = reward.items.length
|
||||
? await MailService.sendNewRewardMail(
|
||||
p.cultivatorId,
|
||||
'结伴讨伐·战利品',
|
||||
`道友已平定${event.locationName}的异动,此次讨伐所得道具随信送达。`,
|
||||
reward.items.map((item) => ({
|
||||
type: 'inventory_v1' as const,
|
||||
name: dungeonRewardItemName(item),
|
||||
quantity: item.quantity,
|
||||
inventory: item,
|
||||
})),
|
||||
'reward',
|
||||
tx,
|
||||
)
|
||||
: undefined;
|
||||
if (mail) mailEvents.push(mail.domainEventId);
|
||||
// Item mail is pending delivery; only its later claim records item income.
|
||||
const journalChanges: JournalChange[] = [
|
||||
{
|
||||
kind: 'resource',
|
||||
resource: 'spiritStones',
|
||||
amount: actual.spiritStones,
|
||||
},
|
||||
{ kind: 'resource', resource: 'exp', amount: actual.experience },
|
||||
{ kind: 'resource', resource: 'insight', amount: actual.insight },
|
||||
];
|
||||
await completeJournalOperation(tx, claim.id, {
|
||||
type: 'resources.settled',
|
||||
activity: 'hunt_reward',
|
||||
detail: `${event.locationName}·${HUNT_BOSSES[event.bossId].name}`,
|
||||
changes: journalChanges.filter((change) => change.amount !== 0),
|
||||
result: HuntJournalResultSchema.parse({
|
||||
battleId,
|
||||
reward: actual,
|
||||
mailId: mail?.id,
|
||||
}),
|
||||
});
|
||||
const committed = await new ResourceEventCommitter().commit(tx, {
|
||||
actor: p,
|
||||
source: 'hunt-reward',
|
||||
scopeDefaults: { cultivatorId: p.cultivatorId },
|
||||
changes: [
|
||||
{
|
||||
resourceTopic: 'player.currency',
|
||||
operation: 'invalidate',
|
||||
eventType: 'hunt.rewarded',
|
||||
},
|
||||
{
|
||||
resourceTopic: 'player.progress',
|
||||
operation: 'invalidate',
|
||||
eventType: 'hunt.rewarded',
|
||||
},
|
||||
],
|
||||
});
|
||||
changes.push(...committed.changes);
|
||||
}
|
||||
}
|
||||
lease.assertHeld();
|
||||
return { changes, mailEvents };
|
||||
});
|
||||
if (commits.changes.length) publishResourceEvents(commits.changes);
|
||||
for (const id of commits.mailEvents)
|
||||
publishTransactionalMessageBestEffort(id, { source: 'hunt-reward' });
|
||||
},
|
||||
);
|
||||
// A committed claim remains authoritative if any Redis cleanup step must retry.
|
||||
await finishHuntTeam(runtime.roomId, battleId, runtime.startRequestId);
|
||||
await store.release(runtime);
|
||||
await store.acknowledge(runtime);
|
||||
}
|
||||
|
||||
export async function huntBattleReward(
|
||||
battleId: string,
|
||||
actor: HuntActor,
|
||||
): Promise<HuntBattleReward> {
|
||||
const { runtime } = await ownedArenaV6(battleId, actor);
|
||||
if (!runtime.hunt) throw new ArenaV6Error('不是讨伐战斗', 400);
|
||||
const [receipt] = await db
|
||||
.select({ id: messageConsumptions.messageId })
|
||||
.from(messageConsumptions)
|
||||
.where(
|
||||
and(
|
||||
eq(messageConsumptions.messageId, battleId),
|
||||
eq(messageConsumptions.consumerName, HUNT_REWARD_CONSUMER),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!receipt) return { status: 'pending' };
|
||||
if (
|
||||
runtime.terminalReason !== 'battle-ended' ||
|
||||
runtime.state.result?.winner !== 0
|
||||
)
|
||||
return { status: 'no-reward' };
|
||||
const claim = await findJournalOperation(
|
||||
actor.cultivatorId,
|
||||
journalOperationKey('hunt_reward', runtime.hunt.id),
|
||||
null,
|
||||
);
|
||||
if (!claim) throw new Error('讨伐领奖凭据不存在');
|
||||
const result = HuntJournalResultSchema.parse(claim.event.result);
|
||||
if (result.battleId !== battleId) return { status: 'assisting' };
|
||||
return {
|
||||
status: 'rewarded',
|
||||
reward: result.reward,
|
||||
mailId: result.mailId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DbExecutor } from '@server/lib/drizzle/db';
|
||||
import type { HuntEvent } from '@shared/hunts/config';
|
||||
import { MaterialFactsSchema } from '@shared/items/definitions/materials';
|
||||
import { DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM } from '@shared/rewards/dungeon-material-quality';
|
||||
import { HuntRewardSnapshotSchema, planHuntReward } from '@shared/rewards/hunt';
|
||||
import { generateRealmMaterials } from '../MaterialRewardService';
|
||||
import { computeItemLibrarySampleKey } from '../itemLibrarySampleKey';
|
||||
|
||||
/** Freeze resources and library facts before combat; settlement never rerolls. */
|
||||
export async function prepareHuntReward(
|
||||
event: HuntEvent,
|
||||
cultivatorId: string,
|
||||
tx: DbExecutor,
|
||||
) {
|
||||
const seed = `${event.id}:${cultivatorId}`;
|
||||
const plan = planHuntReward(event, (stream) => {
|
||||
let index = 0;
|
||||
return () => computeItemLibrarySampleKey(`${seed}:${stream}:${index++}`);
|
||||
});
|
||||
const materials = await generateRealmMaterials(
|
||||
event.realm,
|
||||
plan.materialCount,
|
||||
`${seed}:${plan.poolId}:${plan.poolVersion}:materials`,
|
||||
true,
|
||||
tx,
|
||||
DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM[event.realm],
|
||||
);
|
||||
return HuntRewardSnapshotSchema.parse({
|
||||
poolId: plan.poolId,
|
||||
poolVersion: plan.poolVersion,
|
||||
experience: plan.experience,
|
||||
spiritStones: plan.spiritStones,
|
||||
insight: plan.insight,
|
||||
items: [
|
||||
...plan.items,
|
||||
...materials.map((material) => ({
|
||||
definitionId: 'material.v1',
|
||||
quantity: 1,
|
||||
instanceData: MaterialFactsSchema.parse({
|
||||
name: material.name,
|
||||
type: material.type,
|
||||
rank: material.rank,
|
||||
element: material.element ?? null,
|
||||
description: material.description ?? '',
|
||||
}),
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { db } from '@server/lib/drizzle/db';
|
||||
import { cultivators, playerJournal } from '@server/lib/drizzle/schema';
|
||||
import { redis } from '@server/lib/redis';
|
||||
import { withRedisLock, type RedisLeaseContext } from '@server/lib/redis/lock';
|
||||
import { journalOperationKey } from '@server/lib/repositories/playerJournalRepository';
|
||||
import {
|
||||
ARENA_SPARRING_RULES_V1,
|
||||
type ArenaRoomV1,
|
||||
} from '@shared/contracts/arena';
|
||||
import type {
|
||||
HuntLobby,
|
||||
HuntMember,
|
||||
HuntTeam,
|
||||
HuntTeamCommand,
|
||||
} from '@shared/contracts/hunts';
|
||||
import {
|
||||
huntEventById,
|
||||
huntIsOpen,
|
||||
type HuntEvent,
|
||||
} from '@shared/hunts/config';
|
||||
import {
|
||||
huntRealmAllowed,
|
||||
huntStartError,
|
||||
selectHuntTeam,
|
||||
} from '@shared/hunts/rules';
|
||||
import { REALM_VALUES, type RealmType } from '@shared/types/constants';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { hasActiveCombat } from '../combat-v6/CombatOccupancy';
|
||||
import { ArenaV6Error, createArenaV6 } from '../combat-v6/CombatV6ArenaService';
|
||||
import { CombatV6ArenaStore } from '../combat-v6/CombatV6ArenaStore';
|
||||
export type HuntActor = { userId: string; cultivatorId: string };
|
||||
const teamKey = (id: string) => `hunt:v1:team:${id}`;
|
||||
const memberKey = (id: string) => `hunt:v1:member:${id}`;
|
||||
const eventTeamsKey = (id: string) => `hunt:v1:teams:${id}`;
|
||||
const battleStore = new CombatV6ArenaStore();
|
||||
const lock = <T>(task: (lease: RedisLeaseContext) => Promise<T>) =>
|
||||
withRedisLock(
|
||||
{
|
||||
key: 'lock:hunt:v1:teams',
|
||||
context: 'hunt-team',
|
||||
timeoutMs: 60000,
|
||||
retries: 10,
|
||||
},
|
||||
task,
|
||||
);
|
||||
|
||||
async function readTeam(id: string): Promise<HuntTeam | null> {
|
||||
const raw = await redis.get(teamKey(id));
|
||||
return raw ? (JSON.parse(raw) as HuntTeam) : null;
|
||||
}
|
||||
export async function huntClaimed(eventId: string, cultivatorId: string) {
|
||||
return !!(
|
||||
await db
|
||||
.select({ id: playerJournal.cultivatorId })
|
||||
.from(playerJournal)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
playerJournal.operationKey,
|
||||
journalOperationKey('hunt_reward', eventId),
|
||||
),
|
||||
eq(playerJournal.cultivatorId, cultivatorId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
)[0];
|
||||
}
|
||||
async function member(actor: HuntActor, event: HuntEvent): Promise<HuntMember> {
|
||||
const row = await db.query.cultivators.findFirst({
|
||||
columns: { name: true, realm: true },
|
||||
where: and(
|
||||
eq(cultivators.id, actor.cultivatorId),
|
||||
eq(cultivators.userId, actor.userId),
|
||||
eq(cultivators.status, 'active'),
|
||||
),
|
||||
});
|
||||
if (!row || !REALM_VALUES.includes(row.realm as RealmType))
|
||||
throw new ArenaV6Error('这位道友暂时无法出战');
|
||||
return {
|
||||
...actor,
|
||||
name: row.name,
|
||||
realm: row.realm as RealmType,
|
||||
ready: false,
|
||||
assisting: await huntClaimed(event.id, actor.cultivatorId),
|
||||
};
|
||||
}
|
||||
// One atomic write maintains roster, membership ownership and the event index.
|
||||
async function saveTeam(
|
||||
team: HuntTeam,
|
||||
previous: HuntTeam | null,
|
||||
lease: RedisLeaseContext,
|
||||
) {
|
||||
lease.assertHeld();
|
||||
const removed = (previous?.members ?? []).filter(
|
||||
(m) => !team.members.some((n) => n.userId === m.userId),
|
||||
);
|
||||
const result = await redis.eval(
|
||||
`
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if ARGV[1] == '-1' then if raw then return 0 end
|
||||
elseif not raw or cjson.decode(raw).revision ~= tonumber(ARGV[1]) then return 0 end
|
||||
local next = cjson.decode(ARGV[2])
|
||||
for _,m in ipairs(next.members) do
|
||||
local key = 'hunt:v1:member:' .. m.userId
|
||||
local owner = redis.call('GET', key)
|
||||
if owner and owner ~= next.id then return 0 end
|
||||
end
|
||||
for _,m in ipairs(cjson.decode(ARGV[3])) do
|
||||
local key = 'hunt:v1:member:' .. m.userId
|
||||
if redis.call('GET', key) == next.id then redis.call('DEL', key) end
|
||||
end
|
||||
if #next.members == 0 then
|
||||
redis.call('DEL', KEYS[1]); redis.call('SREM', KEYS[2], next.id)
|
||||
else
|
||||
redis.call('SET', KEYS[1], ARGV[2]); redis.call('SADD', KEYS[2], next.id)
|
||||
redis.call('ZADD', KEYS[3], next.event.expiresAt, next.event.id)
|
||||
for _,m in ipairs(next.members) do redis.call('SET', 'hunt:v1:member:' .. m.userId, next.id) end
|
||||
end
|
||||
return 1`,
|
||||
3,
|
||||
teamKey(team.id),
|
||||
eventTeamsKey(team.event.id),
|
||||
'hunt:v1:team-events',
|
||||
previous?.revision ?? -1,
|
||||
JSON.stringify(team),
|
||||
JSON.stringify(removed),
|
||||
);
|
||||
if (Number(result) !== 1) throw new ArenaV6Error('队伍有变,请重新查看');
|
||||
}
|
||||
async function currentTeam(actor: HuntActor, lease: RedisLeaseContext) {
|
||||
const id = await redis.get(memberKey(actor.userId));
|
||||
if (!id) return null;
|
||||
const team = await readTeam(id);
|
||||
if (!team) {
|
||||
await redis.eval(
|
||||
"if redis.call('GET',KEYS[1]) == ARGV[1] then redis.call('DEL',KEYS[1]) end",
|
||||
1,
|
||||
memberKey(actor.userId),
|
||||
id,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (team.status === 'starting' && team.startRequestId) {
|
||||
const battleId = await battleStore.source(team.id, team.startRequestId);
|
||||
if (battleId) {
|
||||
const next = {
|
||||
...team,
|
||||
status: 'in_battle' as const,
|
||||
battleId,
|
||||
revision: team.revision + 1,
|
||||
};
|
||||
await saveTeam(next, team, lease);
|
||||
return next;
|
||||
}
|
||||
// A crashed starter has no active battle; retry with the same frozen request.
|
||||
}
|
||||
if (!huntIsOpen(team.event, Date.now()) && team.status !== 'in_battle') {
|
||||
await saveTeam(
|
||||
{ ...team, members: [], revision: team.revision + 1 },
|
||||
team,
|
||||
lease,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return team;
|
||||
}
|
||||
function requireEvent(id: string, open = true) {
|
||||
const event = huntEventById(id);
|
||||
if (!event || (open && !huntIsOpen(event, Date.now())))
|
||||
throw new ArenaV6Error('此处异动已平息', 404);
|
||||
return event;
|
||||
}
|
||||
async function eventTeams(id: string) {
|
||||
const ids = await redis.smembers(eventTeamsKey(id));
|
||||
const teams = await Promise.all(ids.map(readTeam));
|
||||
return teams.filter((t): t is HuntTeam => !!t && t.members.length > 0);
|
||||
}
|
||||
export async function huntLobby(
|
||||
id: string,
|
||||
actor: HuntActor,
|
||||
): Promise<HuntLobby> {
|
||||
const event = requireEvent(id, false);
|
||||
const myTeam = await lock((lease) => currentTeam(actor, lease));
|
||||
const open = huntIsOpen(event, Date.now());
|
||||
const [teams, claimed] = await Promise.all([
|
||||
open ? eventTeams(id) : [],
|
||||
huntClaimed(id, actor.cultivatorId),
|
||||
]);
|
||||
return {
|
||||
event,
|
||||
open,
|
||||
teams: teams.filter((t) => t.status === 'assembling'),
|
||||
myTeam,
|
||||
claimed,
|
||||
serverNow: Date.now(),
|
||||
};
|
||||
}
|
||||
export async function createHuntTeam(
|
||||
actor: HuntActor,
|
||||
input: { eventId: string; minRealm: RealmType; maxRealm: RealmType },
|
||||
) {
|
||||
return lock(async (lease) => {
|
||||
const event = requireEvent(input.eventId);
|
||||
if (await currentTeam(actor, lease))
|
||||
throw new ArenaV6Error('请先离开当前讨伐队伍');
|
||||
if (await hasActiveCombat(actor.cultivatorId))
|
||||
throw new ArenaV6Error('上一场战斗尚未结束,请稍候');
|
||||
const self = await member(actor, event);
|
||||
if (!huntRealmAllowed(input, self.realm))
|
||||
throw new ArenaV6Error('招募的境界范围也需包含你自己');
|
||||
const team: HuntTeam = {
|
||||
id: crypto.randomUUID(),
|
||||
event,
|
||||
minRealm: input.minRealm,
|
||||
maxRealm: input.maxRealm,
|
||||
leaderId: actor.cultivatorId,
|
||||
members: [self],
|
||||
status: 'assembling',
|
||||
revision: 0,
|
||||
};
|
||||
await saveTeam(team, null, lease);
|
||||
return team;
|
||||
});
|
||||
}
|
||||
export async function joinHuntTeam(
|
||||
actor: HuntActor,
|
||||
eventId: string,
|
||||
teamId?: string,
|
||||
) {
|
||||
return lock(async (lease) => {
|
||||
const event = requireEvent(eventId);
|
||||
const current = await currentTeam(actor, lease);
|
||||
if (current) {
|
||||
if (
|
||||
current.id === teamId &&
|
||||
current.members.some((m) => m.cultivatorId === actor.cultivatorId)
|
||||
)
|
||||
return current;
|
||||
throw new ArenaV6Error('请先离开当前讨伐队伍');
|
||||
}
|
||||
if (await hasActiveCombat(actor.cultivatorId))
|
||||
throw new ArenaV6Error('上一场战斗尚未结束,请稍候');
|
||||
const self = await member(actor, event);
|
||||
const team = teamId
|
||||
? await readTeam(teamId)
|
||||
: selectHuntTeam(await eventTeams(eventId), self.realm, Date.now());
|
||||
if (!team) throw new ArenaV6Error('还没有合适的队伍,可先创建队伍招募道友');
|
||||
if (
|
||||
team.event.id !== eventId ||
|
||||
team.status !== 'assembling' ||
|
||||
team.members.length >= 4 ||
|
||||
!huntRealmAllowed(team, self.realm)
|
||||
)
|
||||
throw new ArenaV6Error('这支队伍已满、已出战,或你的境界不在招募范围内');
|
||||
const next = {
|
||||
...team,
|
||||
members: [...team.members.map((m) => ({ ...m, ready: false })), self],
|
||||
revision: team.revision + 1,
|
||||
};
|
||||
await saveTeam(next, team, lease);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function arenaRoom(team: HuntTeam): ArenaRoomV1 {
|
||||
const seats = team.members.map((m, slot) => ({
|
||||
userId: m.userId,
|
||||
cultivatorId: m.cultivatorId,
|
||||
displayName: m.name,
|
||||
realm: m.realm,
|
||||
slot,
|
||||
ready: true,
|
||||
joinedAt: team.event.startsAt,
|
||||
lastSeenAt: Date.now(),
|
||||
}));
|
||||
return {
|
||||
version: 'arena_room_v1',
|
||||
roomId: team.id,
|
||||
mode: 'arena_sparring_v1',
|
||||
rules: ARENA_SPARRING_RULES_V1,
|
||||
inviteCode: '',
|
||||
hostUserId: team.members.find((m) => m.cultivatorId === team.leaderId)!
|
||||
.userId,
|
||||
revision: team.revision,
|
||||
createdAt: team.event.startsAt,
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: team.event.expiresAt,
|
||||
status: 'starting',
|
||||
startRequestId: team.startRequestId,
|
||||
teams: { alpha: seats, beta: [] },
|
||||
frozenRoster: {
|
||||
version: 'arena_frozen_roster_v1',
|
||||
startRequestId: team.startRequestId!,
|
||||
frozenAt: Date.now(),
|
||||
seats: seats.map((s) => ({ ...s, teamId: 'alpha' })),
|
||||
},
|
||||
};
|
||||
}
|
||||
export async function commandHuntTeam(
|
||||
actor: HuntActor,
|
||||
id: string,
|
||||
command: HuntTeamCommand,
|
||||
) {
|
||||
return lock(async (lease) => {
|
||||
const team = await currentTeam(actor, lease);
|
||||
if (
|
||||
!team ||
|
||||
team.id !== id ||
|
||||
!team.members.some(
|
||||
(m) =>
|
||||
m.cultivatorId === actor.cultivatorId && m.userId === actor.userId,
|
||||
)
|
||||
)
|
||||
throw new ArenaV6Error('你尚未加入这支队伍', 403);
|
||||
if (
|
||||
command.type === 'start' &&
|
||||
team.status === 'in_battle' &&
|
||||
team.leaderId === actor.cultivatorId
|
||||
)
|
||||
return team;
|
||||
if (team.revision !== command.revision)
|
||||
throw new ArenaV6Error('队员有变,请确认名单后再试');
|
||||
if (team.status === 'in_battle')
|
||||
throw new ArenaV6Error('战斗中不能更换成员');
|
||||
if (command.type === 'leave') {
|
||||
if (team.status === 'starting')
|
||||
throw new ArenaV6Error('队伍正在出战,请稍候');
|
||||
const members = team.members
|
||||
.filter((m) => m.cultivatorId !== actor.cultivatorId)
|
||||
.map((m) => ({ ...m, ready: false }));
|
||||
const next = {
|
||||
...team,
|
||||
members,
|
||||
leaderId: members.some((m) => m.cultivatorId === team.leaderId)
|
||||
? team.leaderId
|
||||
: (members[0]?.cultivatorId ?? ''),
|
||||
revision: team.revision + 1,
|
||||
};
|
||||
await saveTeam(next, team, lease);
|
||||
return null;
|
||||
}
|
||||
if (command.type === 'ready') {
|
||||
if (team.status !== 'assembling') throw new ArenaV6Error('正在开战');
|
||||
const self = await member(actor, team.event);
|
||||
if (!huntRealmAllowed(team, self.realm))
|
||||
throw new ArenaV6Error('你的境界不在这支队伍的招募范围内');
|
||||
const next = {
|
||||
...team,
|
||||
members: team.members.map((m) =>
|
||||
m.cultivatorId === actor.cultivatorId
|
||||
? { ...self, ready: command.ready }
|
||||
: m,
|
||||
),
|
||||
revision: team.revision + 1,
|
||||
};
|
||||
await saveTeam(next, team, lease);
|
||||
return next;
|
||||
}
|
||||
if (team.leaderId !== actor.cultivatorId)
|
||||
throw new ArenaV6Error('只有队长可以开战', 403);
|
||||
// Re-read every actor at admission; preparing a room never authorizes a build.
|
||||
const members: HuntMember[] = [];
|
||||
for (const m of team.members)
|
||||
members.push({ ...(await member(m, team.event)), ready: m.ready });
|
||||
const error = huntStartError(
|
||||
{ ...team, members, status: 'assembling' },
|
||||
actor.cultivatorId,
|
||||
Date.now(),
|
||||
);
|
||||
if (error) throw new ArenaV6Error(error);
|
||||
const starting: HuntTeam = {
|
||||
...team,
|
||||
members,
|
||||
status: 'starting',
|
||||
startRequestId:
|
||||
team.status === 'starting' ? team.startRequestId : crypto.randomUUID(),
|
||||
battleId: undefined,
|
||||
revision: team.revision + 1,
|
||||
};
|
||||
await saveTeam(starting, team, lease);
|
||||
try {
|
||||
const battleId = await createArenaV6(arenaRoom(starting), starting);
|
||||
const next = {
|
||||
...starting,
|
||||
status: 'in_battle' as const,
|
||||
battleId,
|
||||
revision: starting.revision + 1,
|
||||
};
|
||||
await saveTeam(next, starting, lease);
|
||||
return next;
|
||||
} catch (error) {
|
||||
// If creation succeeded but its response was lost, leave the frozen roster for recovery.
|
||||
if (!(await battleStore.source(starting.id, starting.startRequestId!)))
|
||||
await saveTeam(
|
||||
{
|
||||
...starting,
|
||||
status: 'assembling',
|
||||
members: starting.members.map((m) => ({ ...m, ready: false })),
|
||||
revision: starting.revision + 1,
|
||||
},
|
||||
starting,
|
||||
lease,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
export async function finishHuntTeam(
|
||||
id: string,
|
||||
battleId: string,
|
||||
startRequestId: string,
|
||||
) {
|
||||
return lock(async (lease) => {
|
||||
const team = await readTeam(id);
|
||||
if (
|
||||
!team ||
|
||||
team.startRequestId !== startRequestId ||
|
||||
(team.battleId && team.battleId !== battleId) ||
|
||||
team.status === 'assembling'
|
||||
)
|
||||
return;
|
||||
const members: HuntMember[] = [];
|
||||
if (huntIsOpen(team.event, Date.now()))
|
||||
for (const m of team.members)
|
||||
members.push({
|
||||
...m,
|
||||
ready: false,
|
||||
assisting: await huntClaimed(team.event.id, m.cultivatorId),
|
||||
});
|
||||
await saveTeam(
|
||||
{
|
||||
...team,
|
||||
status: 'assembling',
|
||||
members,
|
||||
battleId: undefined,
|
||||
lastBattleId: battleId,
|
||||
startRequestId: undefined,
|
||||
revision: team.revision + 1,
|
||||
},
|
||||
team,
|
||||
lease,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function cleanupHuntTeams() {
|
||||
const ids = await redis.zrangebyscore(
|
||||
'hunt:v1:team-events',
|
||||
0,
|
||||
Date.now(),
|
||||
'LIMIT',
|
||||
0,
|
||||
100,
|
||||
);
|
||||
for (const id of ids)
|
||||
await lock(async (lease) => {
|
||||
for (const team of await eventTeams(id)) {
|
||||
if (team.status === 'in_battle') continue;
|
||||
if (
|
||||
team.status === 'starting' &&
|
||||
team.startRequestId &&
|
||||
(await battleStore.source(team.id, team.startRequestId))
|
||||
)
|
||||
continue;
|
||||
await saveTeam(
|
||||
{ ...team, members: [], revision: team.revision + 1 },
|
||||
team,
|
||||
lease,
|
||||
);
|
||||
}
|
||||
if (!(await redis.scard(eventTeamsKey(id)))) {
|
||||
await redis.del(eventTeamsKey(id));
|
||||
await redis.zrem('hunt:v1:team-events', id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redis } from '@server/lib/redis';
|
||||
import { withRedisLock } from '@server/lib/redis/lock';
|
||||
import { createMessage } from '@server/lib/repositories/worldChatRepository';
|
||||
import { HUNT_BOSSES, huntEventsAt } from '@shared/hunts/config';
|
||||
import { cleanupHuntTeams } from './HuntTeamService';
|
||||
export async function refreshHuntWorld() {
|
||||
await withRedisLock(
|
||||
{
|
||||
key: 'lock:hunt:v1:refresh',
|
||||
context: 'hunt-refresh',
|
||||
timeoutMs: 30000,
|
||||
retries: 0,
|
||||
},
|
||||
async (lease) => {
|
||||
for (const event of huntEventsAt(Date.now())) {
|
||||
const key = `hunt:v1:announced:${event.id}`;
|
||||
if (await redis.exists(key)) continue;
|
||||
const text = `传闻「${HUNT_BOSSES[event.bossId].name}」现身${event.locationName},已有${event.realm}期修为。欲往讨伐的道友,切记结伴同行。`;
|
||||
lease.assertHeld();
|
||||
await createMessage({
|
||||
id: event.id,
|
||||
createdAt: new Date(event.startsAt).toISOString(),
|
||||
senderUserId: 'system',
|
||||
senderCultivatorId: null,
|
||||
senderName: '修仙界传闻',
|
||||
senderRealm: '',
|
||||
senderRealmStage: '',
|
||||
channel: 'system',
|
||||
messageType: 'hunt_rumor',
|
||||
textContent: text,
|
||||
payload: { text, eventId: event.id, nodeId: event.nodeId },
|
||||
});
|
||||
await redis.set(key, '1', 'EX', 172800);
|
||||
}
|
||||
await cleanupHuntTeams();
|
||||
},
|
||||
);
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let task: Promise<void> | undefined;
|
||||
let stopped = true;
|
||||
export function startHuntWorld() {
|
||||
if (!stopped) return;
|
||||
stopped = false;
|
||||
const run = () => {
|
||||
task = refreshHuntWorld()
|
||||
.catch((error) => console.warn('[hunt] refresh failed', error))
|
||||
.finally(() => {
|
||||
if (!stopped) {
|
||||
timer = setTimeout(run, 60000);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
run();
|
||||
}
|
||||
export async function stopHuntWorld() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
await task;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordJournalChange } from '../JournalSettlement';
|
||||
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
|
||||
import * as schema from '@server/lib/drizzle/schema';
|
||||
import {
|
||||
@@ -272,6 +273,9 @@ export class ResourceEngine {
|
||||
operation.value,
|
||||
args.tx,
|
||||
);
|
||||
recordJournalChange(args.tx, args.cultivatorId, {
|
||||
kind: 'item', id: `material:${operation.name}`, name: operation.name, amount: -operation.value,
|
||||
});
|
||||
settlement.inventoryChanges.push(
|
||||
...changes.map((change) =>
|
||||
change.operation === 'upsert'
|
||||
@@ -351,6 +355,9 @@ export class ResourceEngine {
|
||||
material,
|
||||
args.tx,
|
||||
);
|
||||
recordJournalChange(args.tx, args.cultivatorId, {
|
||||
kind: 'item', id: item.id ?? `${operation.type}:${item.name}`, name: item.name, amount: operation.value,
|
||||
});
|
||||
settlement.inventoryChanges.push({
|
||||
kind: 'materials',
|
||||
operation: 'upsert',
|
||||
@@ -372,6 +379,9 @@ export class ResourceEngine {
|
||||
artifact,
|
||||
args.tx,
|
||||
);
|
||||
recordJournalChange(args.tx, args.cultivatorId, {
|
||||
kind: 'item', id: item.id ?? `${operation.type}:${item.name}`, name: item.name, amount: 1,
|
||||
});
|
||||
settlement.inventoryChanges.push({
|
||||
kind: 'artifacts',
|
||||
operation: 'upsert',
|
||||
@@ -395,6 +405,9 @@ export class ResourceEngine {
|
||||
consumable,
|
||||
args.tx,
|
||||
);
|
||||
recordJournalChange(args.tx, args.cultivatorId, {
|
||||
kind: 'item', id: item.id ?? `${operation.type}:${item.name}`, name: item.name, amount: operation.value,
|
||||
});
|
||||
settlement.inventoryChanges.push({
|
||||
kind: 'consumables',
|
||||
operation: 'upsert',
|
||||
|
||||
@@ -267,6 +267,7 @@ export async function claimSpiritFieldStarterSeeds(actor: SpiritFieldActor) {
|
||||
userId: actor.userId,
|
||||
cultivatorId: actor.cultivatorId,
|
||||
source: 'spirit_field_starter',
|
||||
idempotency: { key: 'starter', fingerprint: 'starter' },
|
||||
command: async (tx) => {
|
||||
await loadCultivator(actor, tx);
|
||||
const field = await getOrCreateSpiritField(actor.cultivatorId, tx);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { JournalRequestSchema } from '@shared/contracts/playerJournal';
|
||||
import { PlayerCommandIdempotencyError } from '@server/lib/services/CommandExecutors';
|
||||
import {
|
||||
redisLockErrorResponse,
|
||||
requireActiveCultivatorRef,
|
||||
@@ -54,6 +56,7 @@ router.onError((error, c) => {
|
||||
if (
|
||||
error instanceof AlchemyServiceError ||
|
||||
error instanceof CraftCommandError ||
|
||||
error instanceof PlayerCommandIdempotencyError ||
|
||||
error instanceof QiServiceError
|
||||
)
|
||||
return jsonWithStatus(
|
||||
@@ -115,12 +118,7 @@ router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
if (body && retired.has(body.craftType))
|
||||
return c.json({ error: retiredMessage }, 410);
|
||||
const input = CraftSchema.parse(body);
|
||||
await assertAlchemyMaterialVersions(
|
||||
c.get('activeCultivatorRef')!.cultivatorId,
|
||||
input.materialIds,
|
||||
input.materialVersions,
|
||||
);
|
||||
const input = CraftSchema.extend(JournalRequestSchema.shape).parse(body);
|
||||
return c.json(
|
||||
toPlayerStateMutationResponse(
|
||||
await executeCraftCommand({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { JournalRequestSchema } from '@shared/contracts/playerJournal';
|
||||
import { scheduleSystemMailObservation } from '@server/lib/services/SystemMailService';
|
||||
import {
|
||||
getExecutor,
|
||||
@@ -8,6 +9,8 @@ import { mails } from '@server/lib/drizzle/schema';
|
||||
import {
|
||||
redisLockErrorResponse,
|
||||
requireActiveCultivatorRef,
|
||||
validateJson,
|
||||
getValidatedJson,
|
||||
} from '@server/lib/hono/middleware';
|
||||
import { jsonWithStatus } from '@server/lib/hono/response';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
@@ -139,12 +142,13 @@ mailRouter.post('/claim', requireActiveCultivatorRef(), async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
mailRouter.post('/claim-all', requireActiveCultivatorRef(), async (c) => {
|
||||
mailRouter.post('/claim-all', requireActiveCultivatorRef(), validateJson(JournalRequestSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
const cultivator = c.get('activeCultivatorRef');
|
||||
if (!user || !cultivator) return c.json({ error: '未授权访问' }, 401);
|
||||
try {
|
||||
const committed = await claimAllCultivatorMail({
|
||||
requestId: getValidatedJson<{ requestId: string }>(c).requestId,
|
||||
actor: {
|
||||
userId: user.id,
|
||||
cultivatorId: cultivator.cultivatorId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
redisLockErrorResponse,
|
||||
requireActiveCultivatorRef,
|
||||
validateJson,
|
||||
} from '@server/lib/hono/middleware';
|
||||
import { JournalIdempotencyError } from '@server/lib/repositories/playerJournalRepository';
|
||||
import { streamSseEvents } from '@server/lib/hono/streaming';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import {
|
||||
@@ -19,15 +21,10 @@ import {
|
||||
getLifespanExhaustedStoryPrompt,
|
||||
} from '@server/utils/prompts';
|
||||
import type { PlayerResourceMutationMeta } from '@shared/contracts/player';
|
||||
import type { RetreatResultData } from '@shared/contracts/retreat';
|
||||
import { RetreatRequestSchema, type RetreatResultData } from '@shared/contracts/retreat';
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
const RetreatSchema = z.object({
|
||||
years: z.number().optional(),
|
||||
action: z.enum(['cultivate', 'breakthrough']).default('cultivate'),
|
||||
});
|
||||
|
||||
function qiErrorResponse(c: Context<AppEnv>, error: unknown) {
|
||||
if (error instanceof QiInsufficientError) {
|
||||
return c.json(
|
||||
@@ -60,7 +57,7 @@ function createRetreatStreamResponse(
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'result', data: args.result }),
|
||||
});
|
||||
if (args.state?.changes.length) {
|
||||
if (args.state && (args.state.changes.length || args.state.replayed)) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'state', state: args.state }),
|
||||
});
|
||||
@@ -109,8 +106,13 @@ function createRetreatStreamResponse(
|
||||
}
|
||||
|
||||
const retreatRouter = new Hono<AppEnv>();
|
||||
retreatRouter.onError((error, c) => {
|
||||
if (error instanceof z.ZodError) return c.json({ error: '闭关突破参数无效' }, 400);
|
||||
console.error('闭关突破请求失败:', error);
|
||||
return c.json({ error: '闭关突破暂时无法完成' }, 500);
|
||||
});
|
||||
|
||||
retreatRouter.post('/', requireActiveCultivatorRef(), async (c) => {
|
||||
retreatRouter.post('/', requireActiveCultivatorRef(), validateJson(RetreatRequestSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
const activeCultivator = c.get('activeCultivatorRef');
|
||||
if (!user || !activeCultivator) {
|
||||
@@ -118,14 +120,13 @@ retreatRouter.post('/', requireActiveCultivatorRef(), async (c) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { years: inputYears, action } = RetreatSchema.parse(
|
||||
await c.req.json(),
|
||||
);
|
||||
const input = c.get('validatedJson') as import('@shared/contracts/retreat').RetreatRequest;
|
||||
const execution = await executeRetreatCommand({
|
||||
userId: user.id,
|
||||
cultivatorId: activeCultivator.cultivatorId,
|
||||
action,
|
||||
years: inputYears ?? 0,
|
||||
action: input.action,
|
||||
years: input.action === 'cultivate' ? input.years : 0,
|
||||
requestId: input.requestId,
|
||||
});
|
||||
return createRetreatStreamResponse(c, {
|
||||
result: execution.committed.result,
|
||||
@@ -138,6 +139,9 @@ retreatRouter.post('/', requireActiveCultivatorRef(), async (c) => {
|
||||
if (lockErrorResponse) return lockErrorResponse;
|
||||
const qiResponse = qiErrorResponse(c, error);
|
||||
if (qiResponse) return qiResponse;
|
||||
if (error instanceof JournalIdempotencyError) {
|
||||
return c.json({ error: error.message }, 409);
|
||||
}
|
||||
if (error instanceof RetreatCommandError) {
|
||||
return c.json(
|
||||
{ success: false, error: error.message, ...error.payload },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { JournalRequestSchema } from '@shared/contracts/playerJournal';
|
||||
import {
|
||||
redisLockErrorResponse,
|
||||
requireActiveCultivatorRef,
|
||||
validateJson,
|
||||
getValidatedJson,
|
||||
} from '@server/lib/hono/middleware';
|
||||
import { streamSseEvents } from '@server/lib/hono/streaming';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
@@ -15,7 +18,7 @@ import { Hono } from 'hono';
|
||||
|
||||
const yieldRouter = new Hono<AppEnv>();
|
||||
|
||||
yieldRouter.post('/', requireActiveCultivatorRef(), async (c) => {
|
||||
yieldRouter.post('/', requireActiveCultivatorRef(), validateJson(JournalRequestSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
const activeCultivator = c.get('activeCultivatorRef');
|
||||
if (!user || !activeCultivator) {
|
||||
@@ -26,17 +29,19 @@ yieldRouter.post('/', requireActiveCultivatorRef(), async (c) => {
|
||||
const { committed, result } = await executeYieldCommand({
|
||||
userId: user.id,
|
||||
cultivatorId: activeCultivator.cultivatorId,
|
||||
requestId: getValidatedJson<{ requestId: string }>(c).requestId,
|
||||
});
|
||||
return streamSseEvents(c, async (stream, _isAborted, signal) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'result', data: committed.result }),
|
||||
});
|
||||
if (committed.state.changes.length > 0) {
|
||||
if (committed.state.changes.length > 0 || committed.state.replayed) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'state', state: committed.state }),
|
||||
});
|
||||
}
|
||||
|
||||
if (committed.state.replayed) return;
|
||||
const { system, user: prompt } = renderPrompt('yield-story', {
|
||||
cultivatorRealm: result.cultivatorRealm,
|
||||
cultivatorName: result.cultivatorName,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
requireActiveCultivatorRef,
|
||||
validateJson,
|
||||
} from '@server/lib/hono/middleware';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import { isRedisLockContention } from '@server/lib/redis/lock';
|
||||
import { ArenaV6Error } from '@server/lib/services/combat-v6/CombatV6ArenaService';
|
||||
import { huntBattleReward } from '@server/lib/services/hunts/HuntRewardProjector';
|
||||
import {
|
||||
commandHuntTeam,
|
||||
createHuntTeam,
|
||||
huntLobby,
|
||||
joinHuntTeam,
|
||||
} from '@server/lib/services/hunts/HuntTeamService';
|
||||
import {
|
||||
HuntCreateTeamSchema,
|
||||
HuntEventIdSchema,
|
||||
HuntTeamCommandSchema,
|
||||
type HuntTeamCommand,
|
||||
} from '@shared/contracts/hunts';
|
||||
import { huntEventsAt } from '@shared/hunts/config';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
const router = new Hono<AppEnv>();
|
||||
router.use('*', requireActiveCultivatorRef());
|
||||
router.use('*', async (c, next) => {
|
||||
c.header('Cache-Control', 'private, no-store');
|
||||
await next();
|
||||
});
|
||||
router.onError((error, c) => {
|
||||
if (error instanceof ArenaV6Error)
|
||||
return c.json({ success: false, error: error.message }, error.status);
|
||||
if (error instanceof z.ZodError)
|
||||
return c.json(
|
||||
{ success: false, error: '未能完成操作,请刷新后再试' },
|
||||
400,
|
||||
);
|
||||
if (isRedisLockContention(error))
|
||||
return c.json(
|
||||
{ success: false, error: '队伍正忙,请稍后再试' },
|
||||
409,
|
||||
);
|
||||
console.error('[hunt] request failed', error);
|
||||
return c.json(
|
||||
{ success: false, error: '暂时联系不上讨伐队伍,请稍后再试' },
|
||||
500,
|
||||
);
|
||||
});
|
||||
router.get('/', (c) =>
|
||||
c.json({
|
||||
success: true,
|
||||
data: { events: huntEventsAt(Date.now()), serverNow: Date.now() },
|
||||
}),
|
||||
);
|
||||
router.get('/battles/:battleId/reward', async (c) =>
|
||||
c.json({
|
||||
success: true,
|
||||
data: await huntBattleReward(
|
||||
z.uuid().parse(c.req.param('battleId')),
|
||||
c.get('activeCultivatorRef')!,
|
||||
),
|
||||
}),
|
||||
);
|
||||
router.get('/:eventId', async (c) =>
|
||||
c.json({
|
||||
success: true,
|
||||
data: await huntLobby(
|
||||
HuntEventIdSchema.parse(c.req.param('eventId')),
|
||||
c.get('activeCultivatorRef')!,
|
||||
),
|
||||
}),
|
||||
);
|
||||
router.post('/teams', validateJson(HuntCreateTeamSchema), async (c) =>
|
||||
c.json({
|
||||
success: true,
|
||||
data: await createHuntTeam(
|
||||
c.get('activeCultivatorRef')!,
|
||||
c.get('validatedJson') as z.infer<typeof HuntCreateTeamSchema>,
|
||||
),
|
||||
}),
|
||||
);
|
||||
router.post(
|
||||
'/:eventId/join',
|
||||
validateJson(z.object({ teamId: z.uuid().optional() }).strict()),
|
||||
async (c) =>
|
||||
c.json({
|
||||
success: true,
|
||||
data: await joinHuntTeam(
|
||||
c.get('activeCultivatorRef')!,
|
||||
HuntEventIdSchema.parse(c.req.param('eventId')),
|
||||
(c.get('validatedJson') as { teamId?: string }).teamId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
router.post('/teams/:teamId', validateJson(HuntTeamCommandSchema), async (c) =>
|
||||
c.json({
|
||||
success: true,
|
||||
data: await commandHuntTeam(
|
||||
c.get('activeCultivatorRef')!,
|
||||
z.uuid().parse(c.req.param('teamId')),
|
||||
c.get('validatedJson') as HuntTeamCommand,
|
||||
),
|
||||
}),
|
||||
);
|
||||
export default router;
|
||||
@@ -1,3 +1,5 @@
|
||||
import huntsRouter from './hunts.router';
|
||||
import playerJournalRouter from './player-journal.router';
|
||||
import { artifactMigrationRouter } from './artifact-migration';
|
||||
import { manualMigrationRouter } from './manual-migration';
|
||||
import divinationRouter from './divination.router';
|
||||
@@ -91,11 +93,13 @@ apiRouter.get('/health-check', async (c) => {
|
||||
});
|
||||
|
||||
apiRouter.route('/player', playerRouter);
|
||||
apiRouter.route('/player-journal', playerJournalRouter);
|
||||
apiRouter.route('/account', accountRouter);
|
||||
apiRouter.route('/admin', adminRouter);
|
||||
apiRouter.route('/alchemy', alchemyFormulasRouter);
|
||||
apiRouter.route('/auction', auctionRouter);
|
||||
apiRouter.route('/arena', arenaRouter);
|
||||
apiRouter.route('/hunts', huntsRouter);
|
||||
apiRouter.route('/battle-records', battleRecordsRouter);
|
||||
apiRouter.route('/black-market', blackMarketRouter);
|
||||
apiRouter.route('/captcha', captchaRouter);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
getValidatedQuery,
|
||||
requireActiveCultivatorRef,
|
||||
validateQuery,
|
||||
} from '@server/lib/hono/middleware';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import { listPlayerJournal } from '@server/lib/repositories/playerJournalRepository';
|
||||
import { PlayerJournalQuerySchema } from '@shared/contracts/playerJournal';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
router.onError((error, c) => {
|
||||
if (error instanceof z.ZodError)
|
||||
return c.json({ error: '日志查询参数无效' }, 400);
|
||||
console.error('修仙日志查询失败:', error);
|
||||
return c.json({ error: '修仙日志暂时无法读取' }, 500);
|
||||
});
|
||||
router.get(
|
||||
'/',
|
||||
requireActiveCultivatorRef(),
|
||||
validateQuery(PlayerJournalQuerySchema),
|
||||
async (c) => {
|
||||
const { cultivatorId } = c.get('activeCultivatorRef')!;
|
||||
const query =
|
||||
getValidatedQuery<z.infer<typeof PlayerJournalQuerySchema>>(c);
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json({
|
||||
success: true,
|
||||
data: await listPlayerJournal(cultivatorId, query),
|
||||
});
|
||||
},
|
||||
);
|
||||
export default router;
|
||||
@@ -235,6 +235,7 @@ export function arenaView(
|
||||
return {
|
||||
apiVersion: COMBAT_V6_TRAINING_API_VERSION,
|
||||
protocol: ARENA_V6_PROTOCOL,
|
||||
hunt: runtime.hunt,
|
||||
sessionId: runtime.battleId,
|
||||
revision: runtime.revision,
|
||||
expiresAt: new Date(runtime.expiresAt).toISOString(),
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
StatusDef,
|
||||
} from '@shared/engine/combat-v6/core';
|
||||
import { z } from 'zod';
|
||||
import type { HuntEvent } from '../hunts/config';
|
||||
import type { HuntRewardSnapshot } from '../rewards/hunt';
|
||||
import type { CombatV6TrainingSessionViewV1 } from './combatV6';
|
||||
import { CombatV6CommandGroupSchema } from './combatV6';
|
||||
import type { CombatV6ReplayTimeline } from './combatV6Replay';
|
||||
@@ -30,6 +32,8 @@ export type ArenaParticipant = {
|
||||
slot: number;
|
||||
};
|
||||
export type ArenaRuntime = {
|
||||
hunt?: HuntEvent;
|
||||
huntRewards?: Record<string, HuntRewardSnapshot>;
|
||||
timeline: CombatV6ReplayTimeline;
|
||||
protocol: typeof ARENA_V6_PROTOCOL;
|
||||
battleId: string;
|
||||
@@ -73,6 +77,7 @@ export type ArenaSessionView = Omit<
|
||||
'tier' | 'encounterId'
|
||||
> & {
|
||||
protocol: typeof ARENA_V6_PROTOCOL;
|
||||
hunt?: HuntEvent;
|
||||
roomId: string;
|
||||
controlledUnitId: string;
|
||||
spectator?: boolean;
|
||||
|
||||
@@ -117,8 +117,8 @@ export const CombatV6ReplayTimelineSchema = z
|
||||
seq = frame.afterEventSeq;
|
||||
}
|
||||
});
|
||||
/** Only competitive battles have durable player-facing replays. */
|
||||
export const COMBAT_V6_REPLAY_SOURCES = ['ranking', 'arena-sparring'] as const;
|
||||
/** Competitive battles and cooperative hunts have durable player-facing replays. */
|
||||
export const COMBAT_V6_REPLAY_SOURCES = ['ranking', 'arena-sparring', 'hunt'] as const;
|
||||
export const CombatV6HistoryQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
source: z.enum(COMBAT_V6_REPLAY_SOURCES).optional(),
|
||||
|
||||
@@ -169,7 +169,7 @@ export type CombatV6BattleFinishedRecordV1 = z.infer<
|
||||
export const CombatV6BattleFinishedDataV1Schema = z
|
||||
.object({
|
||||
battleId: z.uuid(),
|
||||
sourceType: z.literal('arena-sparring').optional(),
|
||||
sourceType: z.enum(['arena-sparring', 'hunt']).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type CombatV6BattleFinishedDataV1 = z.infer<
|
||||
@@ -244,6 +244,13 @@ export const CombatV6RedisRuntimeV1Schema = z
|
||||
.strict();
|
||||
|
||||
export const CombatV6ReplayMetadataSchema = z.union([
|
||||
z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
sourceType: z.literal('hunt'),
|
||||
battleType: z.literal('pve'),
|
||||
idempotencyKey: z.uuid(),
|
||||
payload: z.object({ eventId: z.string(), roomId: z.uuid() }).strict(),
|
||||
}).strict(),
|
||||
CombatV6BattleMetadataV1Schema,
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { ALCHEMY_MODE_VALUES } from '@shared/types/consumable';
|
||||
import { z } from 'zod';
|
||||
import { ItemGrantSchema } from '../inventory';
|
||||
import { InventoryEquipmentSchema } from '../inventory/equipment';
|
||||
import { BeastTradePreviewSchema } from './beastTrade';
|
||||
import { CombatV6BattleFinishedDataV1Schema } from './combatV6Runtime';
|
||||
import { SystemMailAudienceSnapshotSchema } from './systemMail';
|
||||
@@ -28,6 +29,7 @@ export const DOMAIN_EVENT_TYPES = [
|
||||
'mail.created',
|
||||
'cultivator.mail-audience.observed',
|
||||
'craft.item.created',
|
||||
'equipment.forged',
|
||||
'market.material.revealed',
|
||||
'ranking.position.changed',
|
||||
'beast.exceptional.acquired',
|
||||
@@ -189,6 +191,12 @@ export const DomainEventDataSchemas = {
|
||||
outputs: z.array(z.record(z.string(), z.unknown())).max(8).optional(),
|
||||
})
|
||||
.strict(),
|
||||
'equipment.forged': z.strictObject({
|
||||
userId: z.uuid(),
|
||||
cultivatorId: z.uuid(),
|
||||
cultivatorName: z.string().min(1).max(100),
|
||||
equipment: InventoryEquipmentSchema,
|
||||
}),
|
||||
'market.material.revealed': z
|
||||
.object({
|
||||
userId: z.uuid(),
|
||||
@@ -285,6 +293,10 @@ export const DOMAIN_EVENT_DEFINITIONS = {
|
||||
version: 1,
|
||||
subject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.gameplay.craft-item-created.v1`,
|
||||
},
|
||||
'equipment.forged': {
|
||||
version: 1,
|
||||
subject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.gameplay.equipment-forged.v1`,
|
||||
},
|
||||
'market.material.revealed': {
|
||||
version: 1,
|
||||
subject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.gameplay.market-material-revealed.v1`,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
import type { HuntEvent } from '../hunts/config';
|
||||
import type { HuntRewardSnapshot } from '../rewards/hunt';
|
||||
import { REALM_VALUES, type RealmType } from '../types/constants';
|
||||
|
||||
export const HuntEventIdSchema = z
|
||||
.string()
|
||||
.regex(/^hunt-v[12]-\d{1,10}-[0-6]$/);
|
||||
export const HuntCreateTeamSchema = z
|
||||
.object({
|
||||
eventId: HuntEventIdSchema,
|
||||
minRealm: z.enum(REALM_VALUES),
|
||||
maxRealm: z.enum(REALM_VALUES),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => REALM_VALUES.indexOf(v.minRealm) <= REALM_VALUES.indexOf(v.maxRealm),
|
||||
'境界范围无效',
|
||||
);
|
||||
export const HuntTeamCommandSchema = z.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
type: z.literal('ready'),
|
||||
ready: z.boolean(),
|
||||
revision: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('leave'),
|
||||
revision: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('start'),
|
||||
revision: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
export type HuntTeamCommand = z.infer<typeof HuntTeamCommandSchema>;
|
||||
export type HuntMember = {
|
||||
userId: string;
|
||||
cultivatorId: string;
|
||||
name: string;
|
||||
realm: RealmType;
|
||||
ready: boolean;
|
||||
assisting: boolean;
|
||||
};
|
||||
export type HuntTeam = {
|
||||
id: string;
|
||||
event: HuntEvent;
|
||||
leaderId: string;
|
||||
minRealm: RealmType;
|
||||
maxRealm: RealmType;
|
||||
members: HuntMember[];
|
||||
status: 'assembling' | 'starting' | 'in_battle';
|
||||
revision: number;
|
||||
startRequestId?: string;
|
||||
battleId?: string;
|
||||
lastBattleId?: string;
|
||||
};
|
||||
export type HuntLobby = {
|
||||
event: HuntEvent;
|
||||
open: boolean;
|
||||
claimed: boolean;
|
||||
teams: HuntTeam[];
|
||||
myTeam: HuntTeam | null;
|
||||
serverNow: number;
|
||||
};
|
||||
export type HuntBattleReward = {
|
||||
status: 'pending' | 'no-reward' | 'rewarded' | 'assisting';
|
||||
reward?: HuntRewardSnapshot;
|
||||
mailId?: string;
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { BAG_CAPACITY, type InventoryItem } from '../inventory';
|
||||
import { INVENTORY_SORT_VALUES } from '../inventory/sorting';
|
||||
import { INVENTORY_MATERIAL_TYPES } from '../items/definitions/materials';
|
||||
import { QUALITY_VALUES } from '../types/constants';
|
||||
import { ELEMENT_VALUES, QUALITY_VALUES } from '../types/constants';
|
||||
const ref = {
|
||||
id: z.string().min(1).max(160),
|
||||
revision: z.number().int().nonnegative(),
|
||||
@@ -10,6 +11,7 @@ export const InventoryQuerySchema = z
|
||||
.object({
|
||||
location: z.enum(['bag', 'storage']).default('bag'),
|
||||
page: z.coerce.number().int().min(0).max(1000000).default(0),
|
||||
sort: z.enum(INVENTORY_SORT_VALUES).optional(),
|
||||
search: z.string().max(80).default(''),
|
||||
kind: z
|
||||
.enum([
|
||||
@@ -29,6 +31,7 @@ export const InventoryQuerySchema = z
|
||||
minRank: z.enum(QUALITY_VALUES).optional(),
|
||||
maxRank: z.enum(QUALITY_VALUES).optional(),
|
||||
materialType: z.enum(INVENTORY_MATERIAL_TYPES).optional(),
|
||||
element: z.enum(ELEMENT_VALUES).optional(),
|
||||
recycleCategory: z
|
||||
.enum([
|
||||
'all',
|
||||
@@ -141,8 +144,16 @@ export const InventoryActionSchema = z.discriminatedUnion('action', [
|
||||
]);
|
||||
export type InventoryAction = z.infer<typeof InventoryActionSchema>;
|
||||
export type InventoryView = {
|
||||
items: (InventoryItem & { name: string; equipped: boolean })[];
|
||||
equippedItems: (InventoryItem & { name: string; equipped: boolean })[];
|
||||
items: (InventoryItem & {
|
||||
name: string;
|
||||
equipped: boolean;
|
||||
updatedAt: string;
|
||||
})[];
|
||||
equippedItems: (InventoryItem & {
|
||||
name: string;
|
||||
equipped: boolean;
|
||||
updatedAt: string;
|
||||
})[];
|
||||
used: number;
|
||||
total: number;
|
||||
page: number;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
BreakthroughResult,
|
||||
CultivationResult,
|
||||
} from '@shared/engine/cultivation/CultivationEngine';
|
||||
import { REALM_STAGE_VALUES, REALM_VALUES } from '@shared/types/constants';
|
||||
import { z } from 'zod';
|
||||
import type { RetreatResultData } from './retreat';
|
||||
|
||||
const amount = z.number().nonnegative();
|
||||
const cultivationSummary = z.object({
|
||||
exp_gained: amount,
|
||||
exp_before: amount,
|
||||
exp_after: amount,
|
||||
insight_gained: amount,
|
||||
epiphany_triggered: z.boolean(),
|
||||
bottleneck_entered: z.boolean(),
|
||||
can_breakthrough: z.boolean(),
|
||||
progress: amount,
|
||||
}) satisfies z.ZodType<CultivationResult['summary']>;
|
||||
|
||||
const breakthroughSummary = z.object({
|
||||
success: z.boolean(),
|
||||
chance: z.number(),
|
||||
roll: z.number(),
|
||||
fromRealm: z.enum(REALM_VALUES),
|
||||
fromStage: z.enum(REALM_STAGE_VALUES),
|
||||
toRealm: z.enum(REALM_VALUES).optional(),
|
||||
toStage: z.enum(REALM_STAGE_VALUES).optional(),
|
||||
lifespanGained: amount,
|
||||
attributeGrowth: z.object({
|
||||
vitality: z.number().optional(),
|
||||
strength: z.number().optional(),
|
||||
spirit: z.number().optional(),
|
||||
endurance: z.number().optional(),
|
||||
speed: z.number().optional(),
|
||||
willpower: z.number().optional(),
|
||||
}),
|
||||
naturalAttributeGrowth: amount,
|
||||
attributePointReward: amount,
|
||||
exp_progress: amount,
|
||||
insight_value: amount,
|
||||
exp_lost: amount.optional(),
|
||||
breakthrough_type: z.enum(['forced', 'normal', 'perfect']),
|
||||
insight_change: z.number(),
|
||||
inner_demon_triggered: z.boolean(),
|
||||
modifiers: z.object({
|
||||
baseChance: z.number(),
|
||||
realmDifficulty: z.number(),
|
||||
progressMultiplier: z.number(),
|
||||
insightMultiplier: z.number(),
|
||||
demonPenalty: z.number(),
|
||||
adjustedBaseChance: z.number(),
|
||||
fateBonus: z.number(),
|
||||
pillBonus: z.number(),
|
||||
toxicityPenalty: z.number(),
|
||||
finalChance: z.number(),
|
||||
}),
|
||||
}) satisfies z.ZodType<BreakthroughResult['summary']>;
|
||||
|
||||
export const JOURNAL_ACTIVITIES = {
|
||||
wild_settlement: '野外战斗',
|
||||
hunt_reward: '组队讨伐',
|
||||
yield_claim: '领取历练收益',
|
||||
mail_claim: '领取邮件',
|
||||
mail_claim_all: '批量领取邮件',
|
||||
player_mail_send: '发送传音',
|
||||
market_purchase_v6: '坊市购买',
|
||||
black_market_purchase: '黑市购买',
|
||||
reputation_shop_buy: '声望兑换',
|
||||
bag_recycle: '回收道具',
|
||||
alchemy_formula: '按方炼丹',
|
||||
alchemy_improvised: '即兴炼丹',
|
||||
forging: '炼器',
|
||||
manual_enlightenment: '功法参悟',
|
||||
spirit_field_starter: '领取灵种',
|
||||
spirit_field_sow: '灵田播种',
|
||||
spirit_field_cultivate: '灵田培育',
|
||||
spirit_field_harvest: '灵田收获',
|
||||
sect_task_action: '宗门任务',
|
||||
sect_battle_settlement: '宗门任务战斗',
|
||||
sect_shop_purchase: '宗门兑换',
|
||||
sect_construction_donate: '宗门建设',
|
||||
sect_stipend_claim: '领取俸禄',
|
||||
} as const;
|
||||
export type JournalActivity = keyof typeof JOURNAL_ACTIVITIES;
|
||||
export const JournalActivitySchema = z.enum(
|
||||
Object.keys(JOURNAL_ACTIVITIES) as [JournalActivity, ...JournalActivity[]],
|
||||
);
|
||||
export const JournalChangeSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('resource'),
|
||||
resource: z.enum([
|
||||
'spiritStones',
|
||||
'exp',
|
||||
'insight',
|
||||
'reputation',
|
||||
'contribution',
|
||||
]),
|
||||
amount: z.number().refine((value) => value !== 0),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('item'),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
amount: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => value !== 0),
|
||||
}),
|
||||
]);
|
||||
export type JournalChange = z.infer<typeof JournalChangeSchema>;
|
||||
|
||||
export const PlayerJournalEventSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('resources.settled'),
|
||||
activity: JournalActivitySchema,
|
||||
detail: z.string().optional(),
|
||||
changes: z.array(JournalChangeSchema),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('retreat.completed'),
|
||||
years: z.number().int().min(1).max(200),
|
||||
qiSpent: amount,
|
||||
summary: cultivationSummary,
|
||||
depleted: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('breakthrough.completed'),
|
||||
qiSpent: amount,
|
||||
expSpent: amount,
|
||||
summary: breakthroughSummary,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type PlayerJournalEvent = z.infer<typeof PlayerJournalEventSchema>;
|
||||
|
||||
// Execution results stay in storage; the public event parser strips them.
|
||||
export const StoredJournalEventSchema = PlayerJournalEventSchema.and(
|
||||
z.object({ result: z.unknown().optional() }),
|
||||
);
|
||||
export type StoredJournalEvent = z.infer<typeof StoredJournalEventSchema>;
|
||||
|
||||
/** Replay the committed result without rerunning settlement or story generation. */
|
||||
export function retreatResultFromJournal(
|
||||
event: PlayerJournalEvent,
|
||||
): RetreatResultData {
|
||||
if (event.type === 'resources.settled') throw new Error('非闭关突破执行记录');
|
||||
return event.type === 'retreat.completed'
|
||||
? { action: 'cultivate', summary: event.summary, depleted: event.depleted }
|
||||
: { action: 'breakthrough', summary: event.summary };
|
||||
}
|
||||
|
||||
export const JournalCursorSchema = z.object({
|
||||
createdAt: z.iso.datetime(),
|
||||
id: z.uuid(),
|
||||
});
|
||||
export type JournalCursor = z.infer<typeof JournalCursorSchema>;
|
||||
|
||||
export const PlayerJournalQuerySchema = z
|
||||
.object({
|
||||
type: z
|
||||
.enum([
|
||||
'retreat.completed',
|
||||
'breakthrough.completed',
|
||||
'resources.settled',
|
||||
])
|
||||
.optional(),
|
||||
activity: JournalActivitySchema.optional(),
|
||||
before: z.iso.datetime().optional(),
|
||||
beforeId: z.uuid().optional(),
|
||||
})
|
||||
.refine((value) => Boolean(value.before) === Boolean(value.beforeId), {
|
||||
message: '分页游标不完整',
|
||||
});
|
||||
|
||||
export interface PlayerJournalPage {
|
||||
items: { id: string; event: PlayerJournalEvent; createdAt: string }[];
|
||||
nextCursor: JournalCursor | null;
|
||||
}
|
||||
|
||||
export const JournalRequestSchema = z.object({ requestId: z.uuid() }).strict();
|
||||
@@ -19,6 +19,7 @@ describe('完整背包资源契约', () => {
|
||||
instanceData: null,
|
||||
stackKey: null,
|
||||
name: '长春功玉简',
|
||||
updatedAt: '2026-09-30T00:00:00.000Z',
|
||||
equipped: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -21,6 +21,7 @@ export const inventoryBagSchema = z
|
||||
.min(0)
|
||||
.max(BAG_CAPACITY - 1),
|
||||
name: z.string(),
|
||||
updatedAt: z.iso.datetime(),
|
||||
equipped: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
@@ -35,6 +36,7 @@ export const inventoryBagSchema = z
|
||||
slotIndex: z.null(),
|
||||
definitionId: z.literal('equipment.v6'),
|
||||
name: z.string(),
|
||||
updatedAt: z.iso.datetime(),
|
||||
equipped: z.literal(true),
|
||||
})
|
||||
.strict(),
|
||||
|
||||
@@ -2,8 +2,20 @@ import type {
|
||||
BreakthroughResult,
|
||||
CultivationResult,
|
||||
} from '@shared/engine/cultivation/CultivationEngine';
|
||||
import { z } from 'zod';
|
||||
import type { PlayerResourceMutationMeta } from './player';
|
||||
|
||||
export const RetreatRequestSchema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('cultivate'),
|
||||
years: z.number().int().min(1).max(200),
|
||||
requestId: z.uuid(),
|
||||
}),
|
||||
z.object({ action: z.literal('breakthrough'), requestId: z.uuid() }),
|
||||
]);
|
||||
|
||||
export type RetreatRequest = z.infer<typeof RetreatRequestSchema>;
|
||||
|
||||
export type RetreatAction = 'cultivate' | 'breakthrough';
|
||||
export type RetreatStoryType = 'breakthrough' | 'lifespan';
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HUNT_BOSSES,
|
||||
huntEventsAt,
|
||||
type HuntBossId,
|
||||
} from '../../../hunts/config';
|
||||
import {
|
||||
createBattle,
|
||||
restoreBattle,
|
||||
type Command,
|
||||
type LineupUnit,
|
||||
type Unit,
|
||||
} from '../core';
|
||||
import { presetEnemyAttrs } from '../encounter/preset-enemy';
|
||||
import { createDaoyouRuleset, daoyouRulesetV6 } from '../rules-daoyou';
|
||||
import { COMBAT_V6_SEAL_CURVE_ARENA_VERSIONS } from '../version';
|
||||
import {
|
||||
HUNT_SKILLS,
|
||||
HUNT_STATUSES,
|
||||
huntEnemies,
|
||||
huntNpcCommand,
|
||||
} from './content';
|
||||
const ruleset = createDaoyouRuleset({
|
||||
formulas: {
|
||||
physicalHitChance: () => 1,
|
||||
fluctuationMin: 1,
|
||||
fluctuationMax: 1,
|
||||
},
|
||||
});
|
||||
function fixture(bossId: HuntBossId) {
|
||||
const event = { ...huntEventsAt(100000)[0], bossId };
|
||||
const players: LineupUnit[] = ['p0', 'p1'].map((id, slot) => ({
|
||||
id,
|
||||
name: id,
|
||||
side: 0,
|
||||
slot,
|
||||
kind: 'player',
|
||||
level: event.level,
|
||||
attrs: {
|
||||
hp: 100000,
|
||||
maxHp: 100000,
|
||||
mp: 1000,
|
||||
speed: 9999,
|
||||
physicalAtk: 400,
|
||||
physicalDef: 100,
|
||||
},
|
||||
}));
|
||||
const input = {
|
||||
seed: 9,
|
||||
ruleset,
|
||||
versions: COMBAT_V6_SEAL_CURVE_ARENA_VERSIONS,
|
||||
units: [...players, ...huntEnemies(event, 2)],
|
||||
skills: HUNT_SKILLS,
|
||||
statusDefs: HUNT_STATUSES,
|
||||
};
|
||||
return { input, battle: createBattle(input) };
|
||||
}
|
||||
function round(
|
||||
battle: ReturnType<typeof createBattle>,
|
||||
playerCommands: Record<string, Command> = {},
|
||||
) {
|
||||
const state = battle.snapshot();
|
||||
for (const unit of state.units)
|
||||
if (battle.queryCommands(unit.id).canSubmit)
|
||||
battle.submit(
|
||||
unit.id,
|
||||
unit.side === 1
|
||||
? huntNpcCommand({ state }, unit.id)
|
||||
: (playerCommands[unit.id] ?? { type: 'defend' }),
|
||||
);
|
||||
battle.lockAndResolve();
|
||||
}
|
||||
describe('讨伐遭遇机制', () => {
|
||||
it('古魔先标记,下一回合重击可由队友保护;恢复快照保留目标与结果', () => {
|
||||
const { battle, input } = fixture('demon');
|
||||
round(battle);
|
||||
expect(
|
||||
battle.unit('p0').statuses.some((s) => s.id === 'hunt.demon.mark'),
|
||||
).toBe(true);
|
||||
const restored = restoreBattle(input, battle.snapshot(), battle.log());
|
||||
const commands: Record<string, Command> = {
|
||||
p0: { type: 'defend' },
|
||||
p1: { type: 'protect', target: 'p0' },
|
||||
};
|
||||
round(battle, commands);
|
||||
round(restored, commands);
|
||||
expect(battle.log().some((e) => e.type === 'protectTrigger')).toBe(true);
|
||||
expect(battle.unit('p1').attrs.hp).toBeLessThan(100000);
|
||||
expect(battle.snapshot()).toEqual(restored.snapshot());
|
||||
expect(battle.log()).toEqual(restored.log());
|
||||
});
|
||||
it('邪修受伤后护法恢复其气血,护法阵亡后不再提供恢复', () => {
|
||||
const { battle } = fixture('heretic');
|
||||
const boss = battle.unit('hunt.enemy.0');
|
||||
boss.attrs.hp = Math.floor(boss.attrs.maxHp / 2);
|
||||
const before = boss.attrs.hp;
|
||||
round(battle);
|
||||
expect(battle.unit('hunt.enemy.0').attrs.hp).toBeGreaterThan(before);
|
||||
const state = battle.snapshot();
|
||||
const guard = state.units.find((u) => u.id === 'hunt.enemy.1')!;
|
||||
guard.flags.dead = true;
|
||||
guard.attrs.hp = 0;
|
||||
const restored = restoreBattle(
|
||||
fixture('heretic').input,
|
||||
state,
|
||||
battle.log(),
|
||||
);
|
||||
const hp = restored.unit('hunt.enemy.0').attrs.hp;
|
||||
round(restored);
|
||||
expect(restored.unit('hunt.enemy.0').attrs.hp).toBe(hp);
|
||||
});
|
||||
it('双生冥虎给同伴护体,状态为可驱散增益', () => {
|
||||
const { battle } = fixture('beast');
|
||||
round(battle);
|
||||
expect(
|
||||
battle
|
||||
.unit('hunt.enemy.0')
|
||||
.statuses.some((s) => s.id === 'hunt.beast.ward'),
|
||||
).toBe(true);
|
||||
const ward = HUNT_STATUSES.find((s) => s.id === 'hunt.beast.ward')!;
|
||||
expect(ward.category).toBe('buff');
|
||||
expect(ward.dispellable).not.toBe(false);
|
||||
});
|
||||
it('另一只冥虎阵亡后,不给填充的小怪施加护体', () => {
|
||||
const { battle } = fixture('beast');
|
||||
const state = battle.snapshot();
|
||||
state.round = 3;
|
||||
const mate = state.units.find((u) => u.id === 'hunt.enemy.1')!;
|
||||
mate.flags.dead = true;
|
||||
mate.attrs.hp = 0;
|
||||
expect(huntNpcCommand({ state }, 'hunt.enemy.0').type).toBe('attack');
|
||||
});
|
||||
it('人数缩放不足等比例,增加队友仍有收益;拒绝单人装配', () => {
|
||||
const event = huntEventsAt(100000)[0];
|
||||
const two = huntEnemies(event, 2)[0].attrs!.maxHp!;
|
||||
expect(huntEnemies(event, 4)[0].attrs!.maxHp!).toBeLessThan(two * 2);
|
||||
expect(() => huntEnemies(event, 1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
const bosses = Object.keys(HUNT_BOSSES) as HuntBossId[];
|
||||
describe('八人讨伐敌阵', () => {
|
||||
it.each(bosses)(
|
||||
'%s 在七个境界、2~4人挑战时均有完整且可执行的八人敌阵',
|
||||
(bossId) => {
|
||||
for (const event of huntEventsAt(100000)) {
|
||||
for (const players of [2, 3, 4]) {
|
||||
const enemies = huntEnemies({ ...event, bossId }, players);
|
||||
expect(enemies).toHaveLength(8);
|
||||
expect(enemies.map((u) => u.slot)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
expect(new Set(enemies.map((u) => u.id)).size).toBe(8);
|
||||
for (const [role, count] of [
|
||||
['boss', 1],
|
||||
['elite', 1],
|
||||
['normal', 6],
|
||||
] as const)
|
||||
expect(
|
||||
enemies.filter((u) => u.tags?.includes(`hunt.${role}`)),
|
||||
).toHaveLength(count);
|
||||
const { input } = fixture(bossId);
|
||||
const battle = createBattle({
|
||||
...input,
|
||||
ruleset: daoyouRulesetV6,
|
||||
units: [
|
||||
...Array.from({ length: players }, (_, slot) => ({
|
||||
...input.units[0],
|
||||
id: `p${slot}`,
|
||||
slot,
|
||||
level: event.level,
|
||||
})),
|
||||
...enemies,
|
||||
],
|
||||
});
|
||||
for (const enemy of enemies) {
|
||||
expect(enemy.attrs!.hp).toBeGreaterThan(0);
|
||||
expect(enemy.attrs!.hp).toBe(enemy.attrs!.maxHp);
|
||||
expect(enemy.attrs!.physicalAtk).toBeGreaterThan(0);
|
||||
expect(enemy.attrs!.magicAtk).toBeGreaterThan(0);
|
||||
const command = huntNpcCommand(
|
||||
{ state: battle.snapshot() },
|
||||
enemy.id!,
|
||||
);
|
||||
expect(() => battle.submit(enemy.id!, command)).not.toThrow();
|
||||
}
|
||||
battle.lockAndResolve();
|
||||
expect(battle.snapshot().round).toBe(2);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
it.each(bosses)('%s 补满小怪后仍可确定性恢复战斗', (bossId) => {
|
||||
const { battle, input } = fixture(bossId);
|
||||
round(battle);
|
||||
const restored = restoreBattle(input, battle.snapshot(), battle.log());
|
||||
for (let i = 0; i < 3; i++) {
|
||||
round(battle);
|
||||
round(restored);
|
||||
}
|
||||
expect(restored.snapshot()).toEqual(battle.snapshot());
|
||||
expect(restored.log()).toEqual(battle.log());
|
||||
});
|
||||
it('七个境界均保留五种属性弱点,物理与法术小怪均有较低气血和输出', () => {
|
||||
const formulas = daoyouRulesetV6.formulas;
|
||||
for (const event of huntEventsAt(100000)) {
|
||||
const { input } = fixture('heretic');
|
||||
const unit = (bossId: HuntBossId) =>
|
||||
createBattle({
|
||||
...input,
|
||||
units: [input.units[0], ...huntEnemies({ ...event, bossId }, 2)],
|
||||
}).unit('hunt.enemy.0');
|
||||
const ordinary = unit('heretic');
|
||||
const source = {
|
||||
...ordinary,
|
||||
kind: 'player' as const,
|
||||
attrs: {
|
||||
...ordinary.attrs,
|
||||
...presetEnemyAttrs(event.level, 'normal'),
|
||||
},
|
||||
};
|
||||
const damage = (target: Unit, kind: 'physical' | 'spell' | 'fixed') =>
|
||||
formulas.baseDamage({
|
||||
source,
|
||||
target,
|
||||
kind,
|
||||
family: kind,
|
||||
coeff: 1,
|
||||
power: kind === 'fixed' ? 500 : 0,
|
||||
fury: false,
|
||||
});
|
||||
const turtle = unit('ironTurtle'),
|
||||
toad = unit('mistToad'),
|
||||
corpse = unit('gildedCorpse');
|
||||
expect(unit('bloodPython').attrs.maxHp).toBeGreaterThan(
|
||||
ordinary.attrs.maxHp * 3,
|
||||
);
|
||||
expect(damage(turtle, 'physical')).toBeLessThan(
|
||||
damage(ordinary, 'physical') * 0.3,
|
||||
);
|
||||
expect(damage(turtle, 'spell')).toBeGreaterThan(
|
||||
damage(ordinary, 'spell'),
|
||||
);
|
||||
expect(damage(toad, 'spell')).toBeLessThan(
|
||||
damage(ordinary, 'spell') * 0.3,
|
||||
);
|
||||
expect(damage(toad, 'physical')).toBeGreaterThan(
|
||||
damage(ordinary, 'physical'),
|
||||
);
|
||||
expect(corpse.attrs.maxHp).toBeLessThan(ordinary.attrs.maxHp * 0.3);
|
||||
expect(damage(corpse, 'physical')).toBeLessThan(
|
||||
damage(ordinary, 'physical') * 0.3,
|
||||
);
|
||||
expect(damage(corpse, 'spell')).toBeLessThan(
|
||||
damage(ordinary, 'spell') * 0.3,
|
||||
);
|
||||
expect(damage(corpse, 'fixed')).toBe(damage(ordinary, 'fixed'));
|
||||
const marten = unit('shadowMarten');
|
||||
expect(formulas.physicalHitChance(source, marten)).toBe(0.45);
|
||||
expect(
|
||||
formulas.physicalHitChance(source, ordinary),
|
||||
).toBeGreaterThanOrEqual(0.9);
|
||||
expect(formulas.spellHitChance(source, marten)).toBe(1);
|
||||
const enemies = huntEnemies({ ...event, bossId: 'heretic' }, 2);
|
||||
for (const minion of enemies.slice(2)) {
|
||||
expect(minion.attrs!.maxHp!).toBeLessThan(enemies[1].attrs!.maxHp!);
|
||||
expect(minion.attrs!.physicalAtk!).toBeLessThan(
|
||||
enemies[1].attrs!.physicalAtk!,
|
||||
);
|
||||
expect(minion.attrs!.magicAtk!).toBeLessThan(
|
||||
enemies[1].attrs!.magicAtk!,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ArenaRuntime } from '../../../contracts/combatV6Arena';
|
||||
import type { HuntBossId, HuntEvent } from '../../../hunts/config';
|
||||
import { HUNT_BOSSES } from '../../../hunts/config';
|
||||
import type { Command, LineupUnit, SkillDef, StatusDef } from '../core';
|
||||
import { presetEnemyAttrs } from '../encounter/preset-enemy';
|
||||
const mark = 'hunt.demon.mark';
|
||||
const ward = 'hunt.beast.ward';
|
||||
export const HUNT_SKILLS: SkillDef[] = [
|
||||
{
|
||||
id: 'hunt.bolt',
|
||||
name: '妖焰',
|
||||
tags: ['spell'],
|
||||
targeting: { side: 'enemy' },
|
||||
effects: [{ type: 'spellHit', coeff: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'hunt.heal',
|
||||
name: '噬灵回元',
|
||||
tags: ['support'],
|
||||
targeting: { side: 'ally' },
|
||||
effects: [{ type: 'heal', power: 'target.maxHp * 0.12', fixedBase: true }],
|
||||
},
|
||||
{
|
||||
id: 'hunt.mark',
|
||||
name: '魔眼凝视',
|
||||
tags: ['support'],
|
||||
targeting: { side: 'enemy' },
|
||||
effects: [{ type: 'applyStatus', statusId: mark, duration: 2 }],
|
||||
},
|
||||
{
|
||||
id: 'hunt.slam',
|
||||
name: '摧山重击',
|
||||
tags: ['physical'],
|
||||
targeting: { side: 'enemy' },
|
||||
effects: [
|
||||
{ type: 'physicalHit', coeff: 2.2 },
|
||||
{ type: 'removeStatus', statusIds: [mark] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hunt.ward',
|
||||
name: '双生护体',
|
||||
tags: ['support'],
|
||||
targeting: { side: 'ally' },
|
||||
effects: [{ type: 'applyStatus', statusId: ward, duration: 2 }],
|
||||
},
|
||||
];
|
||||
export const HUNT_STATUSES: StatusDef[] = [
|
||||
{
|
||||
id: mark,
|
||||
name: '魔眼标记·下回合重击',
|
||||
kind: mark,
|
||||
category: 'debuff',
|
||||
dispellable: false,
|
||||
},
|
||||
{
|
||||
id: ward,
|
||||
name: '双生护体',
|
||||
kind: ward,
|
||||
category: 'buff',
|
||||
damageTakenPhysical: 0.45,
|
||||
damageTakenSpell: 0.45,
|
||||
},
|
||||
];
|
||||
// Each formation reserves one boss and one elite; the remaining six are fodder.
|
||||
const formations: Record<
|
||||
HuntBossId,
|
||||
{ elite: string; minions: [string, string] }
|
||||
> = {
|
||||
heretic: { elite: '噬灵护法', minions: ['邪修刀客', '邪修术士'] },
|
||||
demon: { elite: '魔眼侍卫', minions: ['古魔爪牙', '魔焰侍从'] },
|
||||
beast: { elite: '伴生冥虎', minions: ['冥虎幼兽', '幽火虎妖'] },
|
||||
bloodPython: { elite: '血鳞蟒卫', minions: ['血鳞幼蟒', '赤焰蛇妖'] },
|
||||
ironTurtle: { elite: '负山鼋卫', minions: ['铁甲幼鼋', '寒水鼋妖'] },
|
||||
mistToad: { elite: '吐霞蟾将', minions: ['青皮蟾妖', '霞雾蟾妖'] },
|
||||
gildedCorpse: { elite: '守陵尸将', minions: ['披甲行尸', '幽火尸卒'] },
|
||||
shadowMarten: { elite: '逐风貂卫', minions: ['掠影幼貂', '月火貂妖'] },
|
||||
};
|
||||
export const HUNT_ENEMY_COUNT = 8;
|
||||
export function huntEnemies(event: HuntEvent, players: number): LineupUnit[] {
|
||||
if (!Number.isInteger(players) || players < 2 || players > 4)
|
||||
throw new Error('讨伐需要2~4人');
|
||||
const formation = formations[event.bossId];
|
||||
const scale = 1.8 + (players - 2) * 0.65;
|
||||
return Array.from({ length: HUNT_ENEMY_COUNT }, (_, slot) => {
|
||||
const role = slot === 0 ? 'boss' : slot === 1 ? 'elite' : 'normal';
|
||||
// Split the encounter budget by role, rather than multiplying a boss by eight.
|
||||
const base = presetEnemyAttrs(
|
||||
event.level,
|
||||
role,
|
||||
role === 'boss' ? 2 : role === 'elite' ? 4 : 12,
|
||||
);
|
||||
const attrs = {
|
||||
...base,
|
||||
hp: Math.round(base.hp * scale),
|
||||
maxHp: Math.round(base.maxHp * scale),
|
||||
mp: 99999,
|
||||
maxMp: 99999,
|
||||
physicalDef: Math.round(
|
||||
base.physicalDef * (role === 'normal' ? 0.65 : 1),
|
||||
),
|
||||
magicDef: Math.round(base.magicDef * (role === 'normal' ? 0.65 : 1)),
|
||||
};
|
||||
if (role === 'boss') {
|
||||
switch (event.bossId) {
|
||||
case 'bloodPython':
|
||||
attrs.maxHp = Math.round(attrs.maxHp * 3.5);
|
||||
break;
|
||||
case 'ironTurtle':
|
||||
attrs.physicalDef *= 6;
|
||||
attrs.magicDef = Math.round(attrs.magicDef * 0.6);
|
||||
break;
|
||||
case 'mistToad':
|
||||
attrs.magicDef *= 6;
|
||||
attrs.physicalDef = Math.round(attrs.physicalDef * 0.6);
|
||||
break;
|
||||
case 'gildedCorpse':
|
||||
attrs.maxHp = Math.round(attrs.maxHp * 0.25);
|
||||
attrs.physicalDef *= 6;
|
||||
attrs.magicDef *= 6;
|
||||
break;
|
||||
case 'shadowMarten':
|
||||
attrs.dodge = base.hit + event.level * 6;
|
||||
attrs.speed = Math.round(base.speed * 1.3);
|
||||
break;
|
||||
}
|
||||
attrs.hp = attrs.maxHp;
|
||||
}
|
||||
let skills: string[] = [];
|
||||
if (slot === 0 && event.bossId === 'demon')
|
||||
skills = ['hunt.mark', 'hunt.slam'];
|
||||
else if (slot === 1 && event.bossId === 'heretic') skills = ['hunt.heal'];
|
||||
else if (slot < 2 && event.bossId === 'beast') skills = ['hunt.ward'];
|
||||
else if ((slot < 2 && event.bossId === 'mistToad') || slot >= 6)
|
||||
skills = ['hunt.bolt'];
|
||||
return {
|
||||
id: `hunt.enemy.${slot}`,
|
||||
name:
|
||||
slot === 0
|
||||
? HUNT_BOSSES[event.bossId].name
|
||||
: slot === 1
|
||||
? formation.elite
|
||||
: `${formation.minions[slot >= 6 ? 1 : 0]}·${slot - 1}`,
|
||||
kind: 'npc' as const,
|
||||
side: 1 as const,
|
||||
slot,
|
||||
level: event.level,
|
||||
attrs,
|
||||
skills,
|
||||
passives: [],
|
||||
tags: [`hunt.${role}`],
|
||||
};
|
||||
});
|
||||
}
|
||||
/** Commands depend only on the frozen round state; no clocks or network in content. */
|
||||
export function huntNpcCommand(
|
||||
runtime: Pick<ArenaRuntime, 'state'>,
|
||||
unitId: string,
|
||||
): Command {
|
||||
const state = runtime.state;
|
||||
const unit = state.units.find((u) => u.id === unitId)!;
|
||||
const standing = state.units.filter(
|
||||
(u) =>
|
||||
!u.flags.dead && !u.flags.downed && !u.flags.escaped && !u.flags.benched,
|
||||
);
|
||||
const enemies = standing.filter((u) => u.side === 0);
|
||||
const players = enemies.filter((u) => u.kind === 'player');
|
||||
const target = (players.length ? players : enemies)[
|
||||
(state.round - 1 + unit.slot) %
|
||||
Math.max(1, players.length || enemies.length)
|
||||
];
|
||||
if (!target) return { type: 'defend' };
|
||||
if (unit.skills.includes('hunt.heal')) {
|
||||
const boss = standing.find((u) => u.id === 'hunt.enemy.0');
|
||||
if (boss && boss.attrs.hp < boss.attrs.maxHp)
|
||||
return { type: 'skill', skillId: 'hunt.heal', targets: [boss.id] };
|
||||
}
|
||||
if (unit.skills.includes('hunt.mark')) {
|
||||
const marked = enemies.find((u) => u.statuses.some((s) => s.id === mark));
|
||||
if (marked)
|
||||
return { type: 'skill', skillId: 'hunt.slam', targets: [marked.id] };
|
||||
if (state.round % 2 === 1)
|
||||
return { type: 'skill', skillId: 'hunt.mark', targets: [target.id] };
|
||||
}
|
||||
if (unit.skills.includes('hunt.ward') && state.round % 3 === unit.slot % 3) {
|
||||
const ally = standing.find(
|
||||
(u) => u.side === 1 && u.id !== unitId && u.skills.includes('hunt.ward'),
|
||||
);
|
||||
if (ally)
|
||||
return { type: 'skill', skillId: 'hunt.ward', targets: [ally.id] };
|
||||
}
|
||||
if (unit.skills.includes('hunt.bolt'))
|
||||
return { type: 'skill', skillId: 'hunt.bolt', targets: [target.id] };
|
||||
return { type: 'attack', target: target.id };
|
||||
}
|
||||
@@ -183,7 +183,8 @@ describe('CultivationEngine insight cap', () => {
|
||||
|
||||
const result = performCultivation(cultivator, 100, () => 0.9);
|
||||
|
||||
expect(result.summary.insight_gained).toBeGreaterThan(5);
|
||||
expect(result.summary.insight_gained).toBe(5);
|
||||
expect(result.record.insight_gained).toBe(5);
|
||||
expect(result.cultivator.cultivation_progress?.comprehension_insight).toBe(
|
||||
COMPREHENSION_INSIGHT_CAP,
|
||||
);
|
||||
@@ -197,10 +198,20 @@ describe('CultivationEngine insight cap', () => {
|
||||
const result = attemptBreakthrough(cultivator, () => 0);
|
||||
|
||||
expect(result.summary.success).toBe(true);
|
||||
expect(result.summary.insight_change).toBe(2);
|
||||
expect(result.cultivator.cultivation_progress?.comprehension_insight).toBe(
|
||||
COMPREHENSION_INSIGHT_CAP,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports only the insight actually lost on a failed breakthrough', () => {
|
||||
const cultivator = createCultivator();
|
||||
cultivator.cultivation_progress!.cultivation_exp = 100_000;
|
||||
cultivator.cultivation_progress!.comprehension_insight = 3;
|
||||
const result = attemptBreakthrough(cultivator, () => 0.99);
|
||||
expect(result.summary.success).toBe(false);
|
||||
expect(result.summary.insight_change).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CultivationEngine cultivation boost', () => {
|
||||
|
||||
@@ -186,6 +186,7 @@ export function performCultivation(
|
||||
|
||||
// 记录闭关前修为
|
||||
const exp_before = progress.cultivation_exp;
|
||||
const insightBefore = progress.comprehension_insight;
|
||||
const wasBottleneckActive = isBottleneckReached(progress);
|
||||
const fateContext = evaluateFateContext(cultivator.pre_heaven_fates ?? []);
|
||||
|
||||
@@ -247,7 +248,7 @@ export function performCultivation(
|
||||
exp_gained: finalExpGain,
|
||||
exp_before,
|
||||
exp_after: progress.cultivation_exp,
|
||||
insight_gained: finalInsightGain,
|
||||
insight_gained: progress.comprehension_insight - insightBefore,
|
||||
epiphany_triggered: expResult.epiphany_triggered,
|
||||
};
|
||||
|
||||
@@ -257,7 +258,7 @@ export function performCultivation(
|
||||
exp_gained: finalExpGain,
|
||||
exp_before,
|
||||
exp_after: progress.cultivation_exp,
|
||||
insight_gained: finalInsightGain,
|
||||
insight_gained: progress.comprehension_insight - insightBefore,
|
||||
epiphany_triggered: expResult.epiphany_triggered,
|
||||
bottleneck_entered,
|
||||
can_breakthrough: canAttemptBreakthrough(progress),
|
||||
@@ -409,7 +410,6 @@ export function attemptBreakthrough(
|
||||
isMajorBreakthrough && clearMindStatus
|
||||
? Math.max(4, Math.floor(insightLoss * 0.7))
|
||||
: insightLoss;
|
||||
insight_change = -finalInsightLoss;
|
||||
progress.comprehension_insight = Math.max(
|
||||
0,
|
||||
progress.comprehension_insight - finalInsightLoss,
|
||||
@@ -464,7 +464,7 @@ export function attemptBreakthrough(
|
||||
insight_value,
|
||||
exp_lost: success ? undefined : exp_lost,
|
||||
breakthrough_type,
|
||||
insight_change,
|
||||
insight_change: progress.comprehension_insight - insight_value,
|
||||
inner_demon_triggered: progress.inner_demon,
|
||||
modifiers,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getRealmStageLevel } from '../config/realmProgression';
|
||||
import { getWorldMapLocation } from '../lib/game/mapSystem';
|
||||
import { REALM_VALUES } from '../types/constants';
|
||||
|
||||
export const HUNT_CYCLE_MS = 2 * 60 * 60 * 1000;
|
||||
export const HUNT_REALMS = REALM_VALUES.slice(2);
|
||||
export const HUNT_BOSSES = {
|
||||
heretic: {
|
||||
name: '噬灵邪修',
|
||||
icon: 'icon:cultivator-male-avatar',
|
||||
hint: '护法每回合都会替邪修疗伤。先封住护法的招式,或合力除去护法,便能断了他的援手。',
|
||||
},
|
||||
demon: {
|
||||
name: '六目古魔',
|
||||
icon: 'icon:beast-six-eyed-ape',
|
||||
hint: '魔眼盯上谁,古魔下一回合便会向谁挥出重击。被盯上的道友宜先防御,同伴可用保护或救援接应。',
|
||||
},
|
||||
beast: {
|
||||
name: '双生冥虎',
|
||||
icon: 'icon:beast-nether-tiger',
|
||||
hint: '双虎轮流为彼此施加护体,刀剑术法都难伤其身。可先驱散护体,或转攻另一只冥虎。',
|
||||
},
|
||||
bloodPython: {
|
||||
name: '血河妖蟒',
|
||||
icon: 'icon:beast-ink-jiao',
|
||||
hint: '妖蟒气血绵长,却无坚甲护身。先清除随从,再合力猛攻,留意长战中的疗伤与回灵。',
|
||||
},
|
||||
ironTurtle: {
|
||||
name: '铁背玄鼋',
|
||||
icon: 'icon:beast-snake-neck-turtle',
|
||||
hint: '铁背坚壳难受刀剑,却挡不住术法。擅长法术的道友主攻玄鼋,其余人先清理随从。',
|
||||
},
|
||||
mistToad: {
|
||||
name: '吞霞灵蟾',
|
||||
icon: 'icon:beast-golden-toad',
|
||||
hint: '灵蟾吞霞炼气,寻常术法难以奏效,肉身却不甚坚固。宜以刀剑近身破敌。',
|
||||
},
|
||||
gildedCorpse: {
|
||||
name: '金身尸王',
|
||||
icon: 'icon:cultivator-male-avatar',
|
||||
hint: '尸王金身不惧刀剑术法,内里气血却已枯竭。能直伤其身的固定伤害最为奏效。',
|
||||
},
|
||||
shadowMarten: {
|
||||
name: '掠影妖貂',
|
||||
icon: 'icon:beast-moon-marten',
|
||||
hint: '妖貂身法奇快,刀剑常落空。以法术攻它更稳,擅长必中招式的道友也可出手。',
|
||||
},
|
||||
} as const;
|
||||
export type HuntBossId = keyof typeof HUNT_BOSSES;
|
||||
const NODES = [
|
||||
'SAT_TN_04',
|
||||
'SAT_TN_02',
|
||||
'DJ_CENTRAL_01',
|
||||
'DJ_RIFT_01',
|
||||
'DJ_KW_01',
|
||||
'DJ_SKY_01',
|
||||
'DJ_TRIB_01',
|
||||
];
|
||||
export type HuntEvent = {
|
||||
id: string;
|
||||
bossId: HuntBossId;
|
||||
realm: (typeof REALM_VALUES)[number];
|
||||
level: number;
|
||||
nodeId: string;
|
||||
locationName: string;
|
||||
startsAt: number;
|
||||
expiresAt: number;
|
||||
/** Only present in battles created before the multi-resource reward rollout. */
|
||||
spiritStones?: number;
|
||||
};
|
||||
export function huntEventsAt(now: number): HuntEvent[] {
|
||||
const cycle = Math.floor(now / HUNT_CYCLE_MS);
|
||||
return eventsForCycle(cycle, 2);
|
||||
}
|
||||
// Keep old rumor links and frozen teams bound to their original three bosses.
|
||||
function eventsForCycle(cycle: number, version: 1 | 2): HuntEvent[] {
|
||||
const bosses: HuntBossId[] =
|
||||
version === 1
|
||||
? ['heretic', 'demon', 'beast']
|
||||
: (Object.keys(HUNT_BOSSES) as HuntBossId[]);
|
||||
return HUNT_REALMS.map((realm, index) => ({
|
||||
id: `hunt-v${version}-${cycle}-${index}`,
|
||||
bossId: bosses[(cycle + index) % bosses.length],
|
||||
realm,
|
||||
level: getRealmStageLevel(realm, '中期'),
|
||||
nodeId: NODES[index],
|
||||
locationName: getWorldMapLocation(NODES[index])!.name,
|
||||
startsAt: cycle * HUNT_CYCLE_MS,
|
||||
expiresAt: (cycle + 1) * HUNT_CYCLE_MS,
|
||||
}));
|
||||
}
|
||||
export function huntEventById(id: string): HuntEvent | undefined {
|
||||
const match = /^hunt-v([12])-(\d{1,10})-([0-6])$/.exec(id);
|
||||
return match
|
||||
? eventsForCycle(Number(match[2]), Number(match[1]) as 1 | 2)[
|
||||
Number(match[3])
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
export function huntIsOpen(event: HuntEvent, now: number) {
|
||||
return now >= event.startsAt && now < event.expiresAt;
|
||||
}
|
||||
export function huntMapHref(event: HuntEvent) {
|
||||
return `/game/map-v2?nodeId=${encodeURIComponent(event.nodeId)}&hunt=${encodeURIComponent(event.id)}`;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HuntCreateTeamSchema,
|
||||
HuntEventIdSchema,
|
||||
type HuntTeam,
|
||||
} from '../contracts/hunts';
|
||||
import { getAtlasRegion, hasAtlasMap } from '../lib/game/mapAtlas';
|
||||
import { getWorldMapLocation } from '../lib/game/mapSystem';
|
||||
import {
|
||||
HUNT_BOSSES,
|
||||
HUNT_CYCLE_MS,
|
||||
huntEventById,
|
||||
huntEventsAt,
|
||||
huntIsOpen,
|
||||
} from './config';
|
||||
import { huntStartError, selectHuntTeam } from './rules';
|
||||
const now = HUNT_CYCLE_MS * 100 + 10;
|
||||
function team(count: number, assisting = false): HuntTeam {
|
||||
return {
|
||||
id: 'a',
|
||||
event: huntEventsAt(now)[0],
|
||||
leaderId: 'c0',
|
||||
minRealm: '金丹',
|
||||
maxRealm: '元婴',
|
||||
status: 'assembling',
|
||||
revision: 0,
|
||||
members: Array.from({ length: count }, (_, i) => ({
|
||||
userId: `u${i}`,
|
||||
cultivatorId: `c${i}`,
|
||||
name: `道友${i}`,
|
||||
realm: '金丹',
|
||||
ready: true,
|
||||
assisting,
|
||||
})),
|
||||
};
|
||||
}
|
||||
describe('世界讨伐准入与刷新', () => {
|
||||
it('八类目标均轮换到每个境界,新旧传闻链接保持各自的目标', () => {
|
||||
const seen = Array.from({ length: 7 }, () => new Set<string>());
|
||||
for (let cycle = 100; cycle < 108; cycle++) {
|
||||
for (const [index, event] of huntEventsAt(
|
||||
HUNT_CYCLE_MS * cycle,
|
||||
).entries()) {
|
||||
expect(HuntEventIdSchema.safeParse(event.id).success).toBe(true);
|
||||
expect(huntEventById(event.id)).toEqual(event);
|
||||
seen[index].add(event.bossId);
|
||||
const oldId = `hunt-v1-${cycle}-${index}`;
|
||||
expect(HuntEventIdSchema.safeParse(oldId).success).toBe(true);
|
||||
expect(huntEventById(oldId)?.bossId).toBe(
|
||||
['heretic', 'demon', 'beast'][(cycle + index) % 3],
|
||||
);
|
||||
expect(event.id).not.toBe(oldId);
|
||||
}
|
||||
}
|
||||
for (const pool of seen)
|
||||
expect(pool).toEqual(new Set(Object.keys(HUNT_BOSSES)));
|
||||
expect(huntEventById('hunt-v3-100-0')).toBeUndefined();
|
||||
expect(HuntEventIdSchema.safeParse('hunt-v3-100-0').success).toBe(false);
|
||||
});
|
||||
it('各实例对同一时间生成相同出现事件,刷新后换领奖身份', () => {
|
||||
expect(huntEventsAt(now)).toEqual(huntEventsAt(now + 1));
|
||||
const events = huntEventsAt(now);
|
||||
expect(events.map((e) => e.realm)).toEqual([
|
||||
'金丹',
|
||||
'元婴',
|
||||
'化神',
|
||||
'炼虚',
|
||||
'合体',
|
||||
'大乘',
|
||||
'渡劫',
|
||||
]);
|
||||
for (const event of events) {
|
||||
expect(huntEventById(event.id)).toEqual(event);
|
||||
expect(
|
||||
hasAtlasMap(getAtlasRegion(getWorldMapLocation(event.nodeId)!)!.id),
|
||||
).toBe(true);
|
||||
expect(huntIsOpen(event, event.expiresAt - 1)).toBe(true);
|
||||
expect(huntIsOpen(event, event.expiresAt)).toBe(false);
|
||||
expect(huntIsOpen(event, event.startsAt - 1)).toBe(false);
|
||||
}
|
||||
expect(
|
||||
huntEventsAt(now + HUNT_CYCLE_MS).some((e) => e.id === events[0].id),
|
||||
).toBe(false);
|
||||
expect(huntEventById('hunt-v1-1-99')).toBeUndefined();
|
||||
});
|
||||
it.each([2, 3, 4])('%i人可开战,全部为助战成员也不限制开战', (count) => {
|
||||
expect(huntStartError(team(count, true), 'c0', now)).toBeNull();
|
||||
});
|
||||
it('拒绝单人、超员、未准备、非队长、同账号分身与过期事件', () => {
|
||||
expect(huntStartError(team(1), 'c0', now)).toMatch('2~4');
|
||||
expect(huntStartError(team(5), 'c0', now)).toMatch('2~4');
|
||||
expect(huntStartError(team(2), 'c1', now)).toMatch('队长');
|
||||
const t = team(2);
|
||||
t.members[1].ready = false;
|
||||
expect(huntStartError(t, 'c0', now)).toMatch('全员准备');
|
||||
t.members[1].ready = true;
|
||||
t.members[1].userId = 'u0';
|
||||
expect(huntStartError(t, 'c0', now)).toMatch('同一账号');
|
||||
expect(huntStartError(team(2), 'c0', team(2).event.expiresAt)).toMatch(
|
||||
'平息',
|
||||
);
|
||||
});
|
||||
it('快速匹配优先补齐符合境界的队伍,排除已开战与满队', () => {
|
||||
const a = team(1),
|
||||
b = { ...team(3), id: 'b' },
|
||||
full = { ...team(4), id: 'full' };
|
||||
expect(selectHuntTeam([a, b, full], '金丹', now)?.id).toBe('b');
|
||||
expect(selectHuntTeam([a, b], '渡劫', now)).toBeUndefined();
|
||||
b.status = 'starting';
|
||||
expect(selectHuntTeam([b, a], '金丹', now)?.id).toBe('a');
|
||||
expect(
|
||||
HuntCreateTeamSchema.safeParse({
|
||||
eventId: a.event.id,
|
||||
minRealm: '渡劫',
|
||||
maxRealm: '金丹',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HuntTeam } from '../contracts/hunts';
|
||||
import { REALM_VALUES, type RealmType } from '../types/constants';
|
||||
import { huntIsOpen } from './config';
|
||||
export function huntRealmAllowed(
|
||||
team: Pick<HuntTeam, 'minRealm' | 'maxRealm'>,
|
||||
realm: RealmType,
|
||||
) {
|
||||
const rank = REALM_VALUES.indexOf(realm);
|
||||
return (
|
||||
rank >= REALM_VALUES.indexOf(team.minRealm) &&
|
||||
rank <= REALM_VALUES.indexOf(team.maxRealm)
|
||||
);
|
||||
}
|
||||
export function huntStartError(
|
||||
team: HuntTeam,
|
||||
actorId: string,
|
||||
now: number,
|
||||
): string | null {
|
||||
if (!huntIsOpen(team.event, now)) return '此处异动已平息';
|
||||
if (team.leaderId !== actorId) return '只有队长可以开战';
|
||||
if (team.status !== 'assembling') return '队伍已经开始挑战';
|
||||
if (team.members.length < 2 || team.members.length > 4)
|
||||
return '需要 2~4 位道友结伴';
|
||||
if (new Set(team.members.map((m) => m.userId)).size !== team.members.length)
|
||||
return '同一账号不能重复参战';
|
||||
if (team.members.some((m) => !huntRealmAllowed(team, m.realm)))
|
||||
return '有成员不符合招募境界';
|
||||
if (team.members.some((m) => !m.ready)) return '请等待全员准备';
|
||||
return null;
|
||||
}
|
||||
export function selectHuntTeam(
|
||||
teams: HuntTeam[],
|
||||
realm: RealmType,
|
||||
now: number,
|
||||
) {
|
||||
return teams
|
||||
.filter(
|
||||
(team) =>
|
||||
team.status === 'assembling' &&
|
||||
team.members.length < 4 &&
|
||||
huntIsOpen(team.event, now) &&
|
||||
huntRealmAllowed(team, realm),
|
||||
)
|
||||
.sort(
|
||||
(a, b) => b.members.length - a.members.length || a.id.localeCompare(b.id),
|
||||
)[0];
|
||||
}
|
||||
@@ -134,7 +134,7 @@ describe('alchemy inventory migration', () => {
|
||||
InventoryItemSchema.safeParse({ ...items[0], quantity: 1000 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it('preserves complete pill facts through stacking and sorting', () => {
|
||||
it('preserves visible pill facts through stacking and sorting', () => {
|
||||
let id = 0;
|
||||
const grant = {
|
||||
definitionId: 'consumable.v1',
|
||||
@@ -156,6 +156,11 @@ describe('alchemy inventory migration', () => {
|
||||
...pill,
|
||||
description: '另一段描述',
|
||||
prompt: '另一段提示词',
|
||||
}),
|
||||
);
|
||||
expect(inventoryStackIdentity('consumable.v1', pill)).not.toBe(
|
||||
inventoryStackIdentity('consumable.v1', {
|
||||
...pill,
|
||||
score: pill.score + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -134,6 +134,28 @@ export function sortBag(items: InventoryItem[]) {
|
||||
})),
|
||||
];
|
||||
}
|
||||
export function compactStorage(items: InventoryItem[]) {
|
||||
const merged: InventoryItem[] = [];
|
||||
for (const source of items) {
|
||||
const item = { ...source };
|
||||
for (const target of merged) {
|
||||
if (!sameStack(item, target)) continue;
|
||||
const amount = Math.min(
|
||||
item.quantity,
|
||||
itemDefinition(target.definitionId).stackLimit - target.quantity,
|
||||
);
|
||||
if (!amount) continue;
|
||||
item.quantity -= amount;
|
||||
target.quantity += amount;
|
||||
target.revision++;
|
||||
}
|
||||
if (item.quantity) {
|
||||
if (item.quantity !== source.quantity) item.revision++;
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
/** Caller supplies fresh identities; no random or persistence effects in planning. */
|
||||
export function addItems(
|
||||
items: InventoryItem[],
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../engine/combat-v6/equipment';
|
||||
import {
|
||||
BOOKS,
|
||||
compactStorage,
|
||||
InventoryItemSchema,
|
||||
learnBeastSkill,
|
||||
sameStack,
|
||||
@@ -29,6 +30,17 @@ const item = (slotIndex = 0, quantity = 1): InventoryItem => ({
|
||||
revision: 0,
|
||||
});
|
||||
describe('inventory capacity and immutable facts', () => {
|
||||
it('compacts storage stacks up to their limit without changing the source array', () => {
|
||||
const before = [
|
||||
{ ...item(0, 80), location: 'storage' as const, slotIndex: null },
|
||||
{ ...item(1, 30), location: 'storage' as const, slotIndex: null },
|
||||
];
|
||||
const after = compactStorage(before);
|
||||
expect(after.map((entry) => entry.quantity)).toEqual([99, 11]);
|
||||
expect(after[0].revision).toBe(1);
|
||||
expect(after[1].revision).toBe(1);
|
||||
expect(before.map((entry) => entry.quantity)).toEqual([80, 30]);
|
||||
});
|
||||
it('灵印以技能命名并按传承分别堆叠,普通与上品不会合并', () => {
|
||||
const definitions = [
|
||||
'book.beast.combo',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InventoryQuerySchema } from '../contracts/inventory';
|
||||
import { ITEM_DEFINITIONS } from '../items/registry';
|
||||
import { INVENTORY_KINDS, sortInventoryItems } from './sorting';
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 'a',
|
||||
definitionId: 'material.v1',
|
||||
quantity: 9,
|
||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
||||
slotIndex: 7,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
definitionId: 'consumable.v1',
|
||||
quantity: 2,
|
||||
updatedAt: '2026-09-03T00:00:00.000Z',
|
||||
slotIndex: 3,
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
definitionId: 'equipment.v6',
|
||||
quantity: 1,
|
||||
updatedAt: '2026-09-02T00:00:00.000Z',
|
||||
slotIndex: 5,
|
||||
},
|
||||
];
|
||||
|
||||
describe('通用物品展示排序', () => {
|
||||
it('按更新时间从新到旧排列所有类型,保留原数组与格位', () => {
|
||||
const before = structuredClone(items);
|
||||
const sorted = sortInventoryItems(items, 'updatedAt');
|
||||
expect(sorted.map((item) => item.id)).toEqual(['b', 'c', 'a']);
|
||||
expect(sorted.map((item) => item.slotIndex)).toEqual([3, 5, 7]);
|
||||
expect(items).toEqual(before);
|
||||
});
|
||||
|
||||
it('堆叠数量最多优先,数量相同按更新时间与 ID 稳定排列', () => {
|
||||
const tied = { ...items[0], id: 'd', updatedAt: items[1].updatedAt };
|
||||
const sameTime = { ...tied, id: 'e' };
|
||||
expect(
|
||||
sortInventoryItems([...items, tied, sameTime], 'quantity').map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual(['e', 'd', 'a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('按现有物品分类顺序排列,同类按最近更新排列', () => {
|
||||
const recentMaterial = {
|
||||
...items[0],
|
||||
id: 'd',
|
||||
updatedAt: items[1].updatedAt,
|
||||
};
|
||||
expect(
|
||||
sortInventoryItems([...items, recentMaterial], 'kind').map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual(['c', 'd', 'a', 'b']);
|
||||
expect(
|
||||
new Set(
|
||||
INVENTORY_KINDS.filter(([kind]) => kind !== 'all').map(
|
||||
([kind]) => kind,
|
||||
),
|
||||
),
|
||||
).toEqual(new Set(ITEM_DEFINITIONS.map((item) => item.kind)));
|
||||
});
|
||||
|
||||
it('查询接受三种排序,拒绝未知排序且保留未指定的默认顺序', () => {
|
||||
for (const sort of ['updatedAt', 'quantity', 'kind']) {
|
||||
expect(InventoryQuerySchema.parse({ sort }).sort).toBe(sort);
|
||||
}
|
||||
expect(InventoryQuerySchema.parse({}).sort).toBeUndefined();
|
||||
expect(InventoryQuerySchema.safeParse({ sort: 'name' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { itemDefinition } from './index';
|
||||
|
||||
export const INVENTORY_KINDS = [
|
||||
['all', '全部'],
|
||||
['beast_book', '传承灵印'],
|
||||
['beast_refinement', '归元灵露'],
|
||||
['beast_rejuvenation', '化生果'],
|
||||
['manual_jade', '功法玉简'],
|
||||
['inscription', '阵纹'],
|
||||
['equipment', '道装'],
|
||||
['blueprint', '图纸'],
|
||||
['material', '材料'],
|
||||
['seed', '灵种'],
|
||||
['consumable', '丹药与消耗品'],
|
||||
] as const;
|
||||
|
||||
export const INVENTORY_SORT_VALUES = ['updatedAt', 'quantity', 'kind'] as const;
|
||||
export type InventorySort = (typeof INVENTORY_SORT_VALUES)[number];
|
||||
|
||||
type SortableItem = {
|
||||
id: string;
|
||||
definitionId: string;
|
||||
quantity: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** Display order only; never changes bag slots or the shared resource snapshot. */
|
||||
export function sortInventoryItems<T extends SortableItem>(
|
||||
items: T[],
|
||||
sort: InventorySort,
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === 'quantity' && a.quantity !== b.quantity)
|
||||
return b.quantity - a.quantity;
|
||||
if (sort === 'kind') {
|
||||
const kindOrder =
|
||||
INVENTORY_KINDS.findIndex(
|
||||
([kind]) => kind === itemDefinition(a.definitionId).kind,
|
||||
) -
|
||||
INVENTORY_KINDS.findIndex(
|
||||
([kind]) => kind === itemDefinition(b.definitionId).kind,
|
||||
);
|
||||
if (kindOrder) return kindOrder;
|
||||
}
|
||||
return (
|
||||
Date.parse(b.updatedAt) - Date.parse(a.updatedAt) ||
|
||||
(a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -28,3 +28,80 @@ it('normalizes key order and defaults while preserving every material fact', ()
|
||||
);
|
||||
expect(inventoryStackIdentity('equipment.v6', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('stacks pills across batch details but keeps effects and value separate', () => {
|
||||
const pill = {
|
||||
name: '厚土蕴灵丹',
|
||||
type: '丹药' as const,
|
||||
quality: '玄品' as const,
|
||||
score: 401,
|
||||
spec: {
|
||||
kind: 'pill' as const,
|
||||
family: 'cultivation' as const,
|
||||
operations: [
|
||||
{
|
||||
type: 'gain_progress' as const,
|
||||
target: 'cultivation_exp' as const,
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
consumeRules: {
|
||||
scene: 'out_of_battle_only' as const,
|
||||
quotaCategory: 'none' as const,
|
||||
},
|
||||
alchemyMeta: {
|
||||
source: 'formula' as const,
|
||||
sourceMaterials: ['灵草甲'],
|
||||
stability: 95,
|
||||
toxicityRating: 0,
|
||||
tags: ['cultivation'],
|
||||
version: 4 as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
const key = inventoryStackIdentity('consumable.v1', pill);
|
||||
expect(
|
||||
inventoryStackIdentity('consumable.v1', {
|
||||
...pill,
|
||||
spec: {
|
||||
...pill.spec,
|
||||
alchemyMeta: {
|
||||
...pill.spec.alchemyMeta,
|
||||
sourceMaterials: ['灵草乙'],
|
||||
batch: { lotQuantity: 7 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(key);
|
||||
expect(inventoryStackIdentity('consumable.v1', { ...pill, score: 402 })).not.toBe(key);
|
||||
expect(
|
||||
inventoryStackIdentity('consumable.v1', {
|
||||
...pill,
|
||||
spec: {
|
||||
...pill.spec,
|
||||
operations: [{ ...pill.spec.operations[0], value: 101 }],
|
||||
},
|
||||
}),
|
||||
).not.toBe(key);
|
||||
expect(
|
||||
inventoryStackIdentity('consumable.v1', {
|
||||
...pill,
|
||||
spec: {
|
||||
...pill.spec,
|
||||
alchemyMeta: { ...pill.spec.alchemyMeta, appearance: 'perfect' },
|
||||
},
|
||||
}),
|
||||
).not.toBe(key);
|
||||
expect(
|
||||
inventoryStackIdentity('consumable.v1', {
|
||||
...pill,
|
||||
spec: {
|
||||
...pill.spec,
|
||||
alchemyMeta: {
|
||||
...pill.spec.alchemyMeta,
|
||||
breakthroughTargetRealm: '元婴',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).not.toBe(key);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { ConsumableFactsSchema } from '../items/definitions/consumables';
|
||||
import { MaterialFactsSchema } from '../items/definitions/materials';
|
||||
import { SeedFactsSchema } from '../items/definitions/seeds';
|
||||
import { stableSerializeConsumableSpec } from '../lib/consumables';
|
||||
import { calculatePillScore } from '../lib/pillScore';
|
||||
|
||||
/** Versioned, length-prefixed UTF-8 facts; SQL backfill uses the same encoding. */
|
||||
export function inventoryStackIdentity(
|
||||
@@ -13,7 +14,22 @@ export function inventoryStackIdentity(
|
||||
if (definitionId === 'equipment.v6') return null;
|
||||
if (definitionId === 'consumable.v1') {
|
||||
const facts = ConsumableFactsSchema.parse(data);
|
||||
return `consumable.v2:${JSON.stringify([facts.name, facts.type, facts.quality, stableSerializeConsumableSpec(facts.spec)])}`;
|
||||
const spec = facts.spec;
|
||||
if (spec.kind !== 'pill')
|
||||
return `consumable.v2:${JSON.stringify([facts.name, facts.type, facts.quality, stableSerializeConsumableSpec(spec)])}`;
|
||||
const stackableSpec = {
|
||||
kind: spec.kind,
|
||||
family: spec.family,
|
||||
operations: spec.operations,
|
||||
consumeRules: spec.consumeRules,
|
||||
...(spec.alchemyMeta.version === 4 ? { protocol: 'pill:v4' } : {}),
|
||||
appearance: spec.alchemyMeta.appearance ?? null,
|
||||
breakthroughTargetRealm:
|
||||
spec.alchemyMeta.breakthroughTargetRealm ?? null,
|
||||
breakthroughLabel: spec.alchemyMeta.breakthroughLabel ?? null,
|
||||
recycleScore: calculatePillScore(facts),
|
||||
};
|
||||
return `consumable.v3:${JSON.stringify([facts.name, facts.type, facts.quality, facts.score, stableSerializeConsumableSpec(stackableSpec)])}`;
|
||||
}
|
||||
if (definitionId !== 'material.v1') return `definition.v1:${definitionId}`;
|
||||
const facts = MaterialFactsSchema.parse(data);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QUALITY_VALUES, type Quality } from '../types/constants';
|
||||
import type { PillAppearanceGrade } from '../types/consumable';
|
||||
import { alchemyShowcaseSnapshot } from './alchemyShowcase';
|
||||
import { isInventoryShowcase } from './showcase';
|
||||
|
||||
function pill(quality: Quality, appearance?: PillAppearanceGrade) {
|
||||
return {
|
||||
id: 'crafted-item',
|
||||
name: '回春丹',
|
||||
type: '丹药',
|
||||
quality,
|
||||
quantity: 3,
|
||||
spec: {
|
||||
kind: 'pill',
|
||||
family: 'healing',
|
||||
operations: [
|
||||
{ type: 'restore_resource', resource: 'hp', mode: 'flat', value: 100 },
|
||||
],
|
||||
consumeRules: { scene: 'out_of_battle_only', quotaCategory: 'none' },
|
||||
alchemyMeta: {
|
||||
source: 'improvised',
|
||||
sourceMaterials: [],
|
||||
stability: 100,
|
||||
toxicityRating: 0,
|
||||
tags: [],
|
||||
version: 4,
|
||||
appearance,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('alchemy showcase', () => {
|
||||
it.each(QUALITY_VALUES)(
|
||||
'showcases perfect pills of any quality: %s',
|
||||
(quality) => {
|
||||
const output = pill(quality, 'perfect');
|
||||
const snapshot = alchemyShowcaseSnapshot([output]);
|
||||
expect(snapshot).toMatchObject({
|
||||
definitionId: 'consumable.v1',
|
||||
name: output.name,
|
||||
quantity: 3,
|
||||
instanceData: { quality, spec: output.spec },
|
||||
});
|
||||
expect(isInventoryShowcase({ version: 1, snapshot })).toBe(true);
|
||||
expect(snapshot?.instanceData).not.toHaveProperty('id');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['low', 'middle', 'high', undefined] as const)(
|
||||
'does not showcase non-perfect pills even at the highest quality: %s',
|
||||
(appearance) => {
|
||||
expect(
|
||||
alchemyShowcaseSnapshot([pill('神品', appearance)]),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('checks every batch and selects the highest-quality perfect output', () => {
|
||||
const snapshot = alchemyShowcaseSnapshot([
|
||||
pill('神品', 'high'),
|
||||
pill('凡品', 'perfect'),
|
||||
pill('玄品', 'perfect'),
|
||||
]);
|
||||
expect(snapshot?.instanceData).toMatchObject({ quality: '玄品' });
|
||||
});
|
||||
|
||||
it('ignores empty or invalid output facts', () => {
|
||||
expect(alchemyShowcaseSnapshot([])).toBeUndefined();
|
||||
expect(
|
||||
alchemyShowcaseSnapshot([{ ...pill('凡品', 'perfect'), spec: null }]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
alchemyShowcaseSnapshot([{ ...pill('凡品', 'perfect'), quantity: 0 }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
import { QUALITY_ORDER } from '../types/constants';
|
||||
import {
|
||||
CONSUMABLE_ITEM,
|
||||
ConsumableFactsSchema,
|
||||
} from './definitions/consumables';
|
||||
import type { InventoryShowcaseSnapshot } from './showcase';
|
||||
|
||||
const AlchemyOutputSchema = ConsumableFactsSchema.strip().extend({
|
||||
quantity: z.number().int().positive(),
|
||||
});
|
||||
|
||||
/** 每炉只展示品质最高的完美成丹,使用炼成时的事实而非当前库存。 */
|
||||
export function alchemyShowcaseSnapshot(
|
||||
outputs: readonly unknown[],
|
||||
): InventoryShowcaseSnapshot | undefined {
|
||||
let selected: z.infer<typeof AlchemyOutputSchema> | undefined;
|
||||
for (const output of outputs) {
|
||||
const parsed = AlchemyOutputSchema.safeParse(output);
|
||||
if (!parsed.success) continue;
|
||||
const pill = parsed.data;
|
||||
if (
|
||||
pill.type !== '丹药' ||
|
||||
pill.spec.kind !== 'pill' ||
|
||||
pill.spec.alchemyMeta.appearance !== 'perfect'
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
!selected ||
|
||||
QUALITY_ORDER[pill.quality] > QUALITY_ORDER[selected.quality]
|
||||
) {
|
||||
selected = pill;
|
||||
}
|
||||
}
|
||||
if (!selected) return undefined;
|
||||
const { quantity, ...instanceData } = selected;
|
||||
return {
|
||||
definitionId: CONSUMABLE_ITEM.id,
|
||||
name: selected.name,
|
||||
quantity,
|
||||
instanceData,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user