This commit is contained in:
桃树夭 committed 2026-09-30 18:37:53 +08:00
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` 复用;物品的详细内容继续由原适配器决定,灵兽沿用既有详情。
+123
View File
@@ -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 项通过。未重复跑全量测试,未实测消息故障重投、资源全部封顶及混合领奖资格队伍;这些分支完成事务和唯一键检查。
+2
View File
@@ -1,5 +1,7 @@
# Game Layout Ownership
`/game/journal` 为 `GameViewportLayout` 下的 archive 场景,展开导航提供「修仙日志」入口。正文仅包含分类和历史列表;日志事件与执行凭据见 [修仙日志](player-journal.md)。
## `/game` 路由归属
- `GameGenesisLayout`:`/game/create`、`/game/reincarnate`
+1
View File
@@ -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` |
+68
View File
@@ -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`,两者重复请求均回放且只保留一条记录。声望/贡献的正负数契约测试通过。宗门兑换因本地角色职阶不足未执行,支出路径完成事务代码检查。
+12
View File
@@ -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
+8 -1
View File
@@ -421,6 +421,13 @@
"when": 1790578141727,
"tag": "0059_cloudy_ink",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1790763373538,
"tag": "0060_player_journal",
"breakpoints": true
}
]
}
}
+145
View File
@@ -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
);
}
+4 -2
View File
@@ -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) ||
+13
View File
@@ -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>
+152
View File
@@ -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}
</>
);
}
+6
View File
@@ -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="正在展开山河画卷……" />
+20
View File
@@ -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',
{
+3
View File
@@ -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);
+5 -1
View File
@@ -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,
+64 -3
View File
@@ -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,
+23
View File
@@ -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 };
},
);
+65 -8
View File
@@ -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);
+4 -6
View File
@@ -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,
+105
View File
@@ -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;
+4
View File
@@ -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;
+1
View File
@@ -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(),
+5
View File
@@ -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;
+2 -2
View File
@@ -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(),
+8 -1
View File
@@ -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({
+12
View File
@@ -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`,
+75
View File
@@ -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;
};
+14 -3
View File
@@ -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;
+181
View File
@@ -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,
},
],
+2
View File
@@ -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(),
+12
View File
@@ -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,
},
+105
View File
@@ -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)}`;
}
+119
View File
@@ -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);
});
});
+47
View File
@@ -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];
}
+6 -1
View File
@@ -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,
}),
);
+22
View File
@@ -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[],
+12
View File
@@ -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',
+78
View File
@@ -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,
);
});
});
+50
View File
@@ -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)
);
});
}
+77
View File
@@ -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);
});
+17 -1
View File
@@ -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);
+78
View File
@@ -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();
});
});
+43
View File
@@ -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