diff --git a/.agents/skills/daoyou-item-preview/references/type-baselines.md b/.agents/skills/daoyou-item-preview/references/type-baselines.md index a0a24e04..cd5c2095 100644 --- a/.agents/skills/daoyou-item-preview/references/type-baselines.md +++ b/.agents/skills/daoyou-item-preview/references/type-baselines.md @@ -2,6 +2,8 @@ ## 已经用户逐类确认的展示 +物品格数量大于9999时统一使用“万”并固定保留两位小数(如 `1.00万`、`1000.00万`),较长数量按格宽缩小字号,保持完整单行显示。 + “数量”指独立预览中的精确数量;从物品格打开时,格子已有堆叠数量,预览头部不再重复。调用方可以传数量标签。未列出的冗余字段不自动补入。 | 类型 | 名称与头部顺序 | 正文与描述 | 禁止回退 | @@ -11,7 +13,7 @@ | 传承灵印 | 所携技能名,如 `噬血`、`高级连击`;类型=普通/上品 · 传承灵印 → 数量 | 所载传承默认显示技能简述,具体效果与数值可展开查看;底部保留灵印描述 | 泛称作标题;重复技能名称;第三层缩进;简述与数值说明同时平铺 | | 归元灵露 | 原名;功能=灵兽洗炼 → 要求=不高于金丹(普通)/不限境界(上品)→ 数量;不显示类型 | 洗炼效果集中展示重置等级、重孕资质/成长/天生技能、寿命恢复原上限;底部描述去除重复效果和境界枚举 | 名称与类型重复;冗长境界要求;效果与描述重复 | | 功法玉简 | 功法名,如 `轻风诀`;类型=功法玉简 → 传承境界 → 数量 | 所载功法下面直接显示初层功法效果与功法说明;保留底部玉简描述 | 名称“玉简”后缀;传承境界埋在正文;重复功法名/第三层缩进;九层效果逐项复述初层 | -| 丹药 | 原名;类型=品质 · 丹药 → 丹相(有则显示)→ 功能 → 数量 | 效果、实际代价/服用规则、默认收起的炼制信息;保留原描述 | 道具资料、丹评、要求;丹相放在功能下方;无丹毒、不占服用额度 | +| 丹药 | 原名;类型=品质 · 丹药 → 丹相(有则显示)→ 功能 → 数量 | 效果、实际代价/服用规则;破境用途与目标大境界有值时默认收起;不展示无法代表整堆的风味描述和炼制批次信息 | 道具资料、丹评、要求;丹相放在功能下方;无丹毒、不占服用额度 | | 灵果 | 原名;类型=品质 · 灵果 → 功能 → 数量 | 效果与实际适用限制;有查看者上下文时展示有用的预计效果;保留原描述,空规则组省略 | 要求、泛化造化来源、无丹毒、不占服用额度、无上下文时的占位说明 | ### 语义边界 @@ -24,7 +26,7 @@ - 灵露要求来自 allowedRealms。当前普通范围可概括为“不高于金丹”;未来若范围不连续,不能只取最大值概括。 - 当前灵露普通描述为 `涤去后天积累。`,上品为 `元婴及以上物种须用此露;不额外提高资质、成长或多技能概率。`。后一句区分上品真实作用,不属于泛化“无副作用”占位。 - 丹药真实丹毒、额度占用及灵果实际境界过强/过弱限制仍需保留。隐藏“无丹毒”不改变或隐藏有丹毒的事实。当前丹药代价组名为“代价”。 -- 喂兽丹药与灵果的“用于喂养灵兽,等级不能超过主人”不属于道具代价或服用规则,不在预览重复玩法限制;实际丹毒仍保留。炼制信息不再重复头部已有的丹药类别。 +- 喂兽丹药与灵果的“用于喂养灵兽,等级不能超过主人”不属于道具代价或服用规则,不在预览重复玩法限制;实际丹毒仍保留。丹药可跨炼制批次堆叠,不把单炉来源、材料和稳度显示为整堆事实。 ## 未逐类重新设计的现有类型 @@ -45,3 +47,9 @@ ## 新道具接入时的判定 先选已有事实类型与适配器。同类新增内容沿用该类顺序,不因单个道具名称、描述长度或 ID 添加特判。新类型只列真实且帮助识别/决策的头部字段;效果按语义分组,描述独立,默认非限制不写。新规则若不能准确映射现有契约,说明具体缺口并做最小扩展,不能仅为了“可扩展”放开递归布局或任意正文组件。 + +## 邮件附件 + +- 邮件内的灵石、声望、修为、感悟、新库存物品、旧附件和灵兽统一使用物品格的尺寸、图标、名称与数量角标,按网格排列,不混用横向资源条。 +- 新库存物品沿用正式物品预览;资源只展示附件事实,不为展示新增背包物品定义。旧附件保留名称品阶色与已有描述,领取去向「洞府宝库」放在预览中,不转换为新版物品或改变领取规则。此规则只适用于邮件,不改变旧宝库页的文字列表。 +- 格子和原生浮层交互由 `PreviewSlot` 复用;物品的详细内容继续由原适配器决定,灵兽沿用既有详情。 diff --git a/docs/cooperative-hunts.md b/docs/cooperative-hunts.md new file mode 100644 index 00000000..55115ba6 --- /dev/null +++ b/docs/cooperative-hunts.md @@ -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 项通过。未重复跑全量测试,未实测消息故障重投、资源全部封顶及混合领奖资格队伍;这些分支完成事务和唯一键检查。 diff --git a/docs/game-layout-ownership.md b/docs/game-layout-ownership.md index e68f26ca..83251448 100644 --- a/docs/game-layout-ownership.md +++ b/docs/game-layout-ownership.md @@ -1,5 +1,7 @@ # Game Layout Ownership +`/game/journal` 为 `GameViewportLayout` 下的 archive 场景,展开导航提供「修仙日志」入口。正文仅包含分类和历史列表;日志事件与执行凭据见 [修仙日志](player-journal.md)。 + ## `/game` 路由归属 - `GameGenesisLayout`:`/game/create`、`/game/reincarnate` diff --git a/docs/nats-domain-events.md b/docs/nats-domain-events.md index d8eaa6d6..7267742e 100644 --- a/docs/nats-domain-events.md +++ b/docs/nats-domain-events.md @@ -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` | diff --git a/docs/player-journal.md b/docs/player-journal.md new file mode 100644 index 00000000..72f572c0 --- /dev/null +++ b/docs/player-journal.md @@ -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`,两者重复请求均回放且只保留一条记录。声望/贡献的正负数契约测试通过。宗门兑换因本地角色职阶不足未执行,支出路径完成事务代码检查。 diff --git a/drizzle/0060_player_journal.sql b/drizzle/0060_player_journal.sql new file mode 100644 index 00000000..696f65de --- /dev/null +++ b/drizzle/0060_player_journal.sql @@ -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"); diff --git a/drizzle/meta/0060_snapshot.json b/drizzle/meta/0060_snapshot.json new file mode 100644 index 00000000..06b8bae7 --- /dev/null +++ b/drizzle/meta/0060_snapshot.json @@ -0,0 +1,9249 @@ +{ + "id": "02637d8c-16a6-48ce-afa2-421363dba314", + "prevId": "c238ace8-4962-47b5-a4d5-6cdfc25d4831", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.wanjiedaoyou_account_deletion_records": { + "name": "wanjiedaoyou_account_deletion_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_ids": { + "name": "cultivator_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "account_deletion_records_user_uidx": { + "name": "account_deletion_records_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_records_status_requested_idx": { + "name": "account_deletion_records_status_requested_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_admin_message_templates": { + "name": "wanjiedaoyou_admin_message_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel": { + "name": "channel", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "subject_template": { + "name": "subject_template", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false + }, + "content_template": { + "name": "content_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_payload": { + "name": "default_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_templates_channel_status_created_idx": { + "name": "admin_templates_channel_status_created_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_alchemy_formulas": { + "name": "wanjiedaoyou_alchemy_formulas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "family": { + "name": "family", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "blueprint": { + "name": "blueprint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "mastery": { + "name": "mastery", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"level\":0,\"exp\":0}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alchemy_formulas_cultivator_updated_idx": { + "name": "alchemy_formulas_cultivator_updated_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alchemy_formulas_cultivator_family_idx": { + "name": "alchemy_formulas_cultivator_family_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "family", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_alchemy_formulas_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_alchemy_formulas_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_alchemy_formulas", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_app_settings": { + "name": "wanjiedaoyou_app_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_auction_listings": { + "name": "wanjiedaoyou_auction_listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seller_id": { + "name": "seller_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seller_name": { + "name": "seller_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_name": { + "name": "item_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_quality": { + "name": "item_quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_category": { + "name": "item_category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_snapshot": { + "name": "item_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "initial_quantity": { + "name": "initial_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "remaining_quantity": { + "name": "remaining_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "target_cultivator_id": { + "name": "target_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_cultivator_name": { + "name": "target_cultivator_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sold_at": { + "name": "sold_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auction_status_expires_created_idx": { + "name": "auction_status_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_seller_status_idx": { + "name": "auction_seller_status_idx", + "columns": [ + { + "expression": "seller_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_expires_price_idx": { + "name": "auction_status_expires_price_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "price", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_type_expires_created_idx": { + "name": "auction_status_type_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_expires_item_type_idx": { + "name": "auction_status_expires_item_type_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_type_category_expires_created_idx": { + "name": "auction_status_type_category_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_type_quality_expires_created_idx": { + "name": "auction_status_type_quality_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_item_name_expires_created_idx": { + "name": "auction_status_item_name_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_seller_name_expires_created_idx": { + "name": "auction_status_seller_name_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seller_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_visibility_target_status_idx": { + "name": "auction_visibility_target_status_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_auction_listings_seller_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_auction_listings_seller_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_auction_listings", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "seller_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_auction_listings_target_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_auction_listings_target_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_auction_listings", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "target_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_battle_records_v3": { + "name": "wanjiedaoyou_battle_records_v3", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opponent_cultivator_id": { + "name": "opponent_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_type": { + "name": "battle_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "battle_result": { + "name": "battle_result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "share_code": { + "name": "share_code", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "battle_records_v3_cultivator_created_idx": { + "name": "battle_records_v3_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_records_v3_opponent_created_idx": { + "name": "battle_records_v3_opponent_created_idx", + "columns": [ + { + "expression": "opponent_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_records_v3_user_created_idx": { + "name": "battle_records_v3_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_records_v3_share_code_uidx": { + "name": "battle_records_v3_share_code_uidx", + "columns": [ + { + "expression": "share_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_battle_records_v3_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_battle_records_v3_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_battle_records_v3", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_battle_records_v3_opponent_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_battle_records_v3_opponent_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_battle_records_v3", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "opponent_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_battle_replay_archives": { + "name": "wanjiedaoyou_battle_replay_archives", + "schema": "", + "columns": { + "match_id": { + "name": "match_id", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "replay_version": { + "name": "replay_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "engine_version": { + "name": "engine_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ruleset_version": { + "name": "ruleset_version", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "participants": { + "name": "participants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "replay": { + "name": "replay", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "battle_replay_archives_finished_idx": { + "name": "battle_replay_archives_finished_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_replay_archives_participants_gin_idx": { + "name": "battle_replay_archives_participants_gin_idx", + "columns": [ + { + "expression": "participants", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_bet_battles": { + "name": "wanjiedaoyou_bet_battles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creator_name": { + "name": "creator_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "min_realm": { + "name": "min_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "max_realm": { + "name": "max_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "taunt": { + "name": "taunt", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "creator_stake_snapshot": { + "name": "creator_stake_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "challenger_stake_snapshot": { + "name": "challenger_stake_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "challenger_id": { + "name": "challenger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "challenger_name": { + "name": "challenger_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "winner_cultivator_id": { + "name": "winner_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_record_v3_id": { + "name": "battle_record_v3_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "matched_at": { + "name": "matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bet_battles_status_expires_idx": { + "name": "bet_battles_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bet_battles_creator_status_idx": { + "name": "bet_battles_creator_status_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bet_battles_status_created_idx": { + "name": "bet_battles_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_bet_battles_creator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_bet_battles_creator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_challenger_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_bet_battles_challenger_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "challenger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_winner_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_bet_battles_winner_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "winner_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_battle_record_v3_id_wanjiedaoyou_battle_records_v3_id_fk": { + "name": "wanjiedaoyou_bet_battles_battle_record_v3_id_wanjiedaoyou_battle_records_v3_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_battle_records_v3", + "columnsFrom": [ + "battle_record_v3_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_breakthrough_history": { + "name": "wanjiedaoyou_breakthrough_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_realm": { + "name": "from_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "from_stage": { + "name": "from_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "to_realm": { + "name": "to_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "to_stage": { + "name": "to_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_spent": { + "name": "years_spent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "story": { + "name": "story", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "breakthrough_history_cultivator_created_idx": { + "name": "breakthrough_history_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_breakthrough_history_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_breakthrough_history_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_breakthrough_history", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_combat_replay_archives": { + "name": "wanjiedaoyou_combat_replay_archives", + "schema": "", + "columns": { + "battle_id": { + "name": "battle_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "metadata_version": { + "name": "metadata_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "battle_type": { + "name": "battle_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "engine_version": { + "name": "engine_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ruleset_version": { + "name": "ruleset_version", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "replay": { + "name": "replay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "share_code": { + "name": "share_code", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "share_viewer_cultivator_id": { + "name": "share_viewer_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "round_count": { + "name": "round_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sides": { + "name": "sides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[[],[]]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "combat_replay_source_idempotency_uidx": { + "name": "combat_replay_source_idempotency_uidx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "combat_replay_finished_idx": { + "name": "combat_replay_finished_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "combat_replay_share_code_uidx": { + "name": "combat_replay_share_code_uidx", + "columns": [ + { + "expression": "share_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_combat_replay_participants": { + "name": "wanjiedaoyou_combat_replay_participants", + "schema": "", + "columns": { + "battle_id": { + "name": "battle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "combat_replay_participant_cultivator_idx": { + "name": "combat_replay_participant_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "battle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_combat_replay_participants_battle_id_wanjiedaoyou_combat_replay_archives_battle_id_fk": { + "name": "wanjiedaoyou_combat_replay_participants_battle_id_wanjiedaoyou_combat_replay_archives_battle_id_fk", + "tableFrom": "wanjiedaoyou_combat_replay_participants", + "tableTo": "wanjiedaoyou_combat_replay_archives", + "columnsFrom": [ + "battle_id" + ], + "columnsTo": [ + "battle_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wanjiedaoyou_combat_replay_participants_battle_id_cultivator_id_pk": { + "name": "wanjiedaoyou_combat_replay_participants_battle_id_cultivator_id_pk", + "columns": [ + "battle_id", + "cultivator_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_consumables": { + "name": "wanjiedaoyou_consumables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quality": { + "name": "quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'凡品'" + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "stack_key": { + "name": "stack_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "consumables_cultivator_idx": { + "name": "consumables_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consumables_cultivator_name_quality_idx": { + "name": "consumables_cultivator_name_quality_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consumables_cultivator_stack_key_idx": { + "name": "consumables_cultivator_stack_key_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stack_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consumables_cultivator_stack_unique": { + "name": "consumables_cultivator_stack_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stack_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wanjiedaoyou_consumables\".\"stack_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "consumables_score_idx": { + "name": "consumables_score_idx", + "columns": [ + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_consumables_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_consumables_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_consumables", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_creation_products": { + "name": "wanjiedaoyou_creation_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "slot": { + "name": "slot", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_equipped": { + "name": "is_equipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "product_model": { + "name": "product_model", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "creation_products_cultivator_type_idx": { + "name": "creation_products_cultivator_type_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "creation_products_type_score_idx": { + "name": "creation_products_type_score_idx", + "columns": [ + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "creation_products_equipped_idx": { + "name": "creation_products_equipped_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_equipped", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_creation_products_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_creation_products_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_creation_products", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_auto_strategies": { + "name": "wanjiedaoyou_cultivator_auto_strategies", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path_id": { + "name": "path_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_cultivator_auto_strategies_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_auto_strategies_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_auto_strategies", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wanjiedaoyou_cultivator_auto_strategies_cultivator_id_path_id_pk": { + "name": "wanjiedaoyou_cultivator_auto_strategies_cultivator_id_path_id_pk", + "columns": [ + "cultivator_id", + "path_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_beast_fusions": { + "name": "wanjiedaoyou_cultivator_beast_fusions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parents": { + "name": "parents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cultivator_beast_fusions_request_unique": { + "name": "cultivator_beast_fusions_request_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_beast_fusions_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_beast_fusions_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_beast_fusions", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_beast_lineups": { + "name": "wanjiedaoyou_cultivator_beast_lineups", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "lineup": { + "name": "lineup", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "starter_claimed_at": { + "name": "starter_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_cultivator_beast_lineups_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_beast_lineups_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_beast_lineups", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_beasts": { + "name": "wanjiedaoyou_cultivator_beasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "individual": { + "name": "individual", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cultivator_beasts_owner_idx": { + "name": "cultivator_beasts_owner_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_beasts_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_beasts_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_beasts", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_equipment_slots": { + "name": "wanjiedaoyou_cultivator_equipment_slots", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "equipment_instance_id": { + "name": "equipment_instance_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cultivator_equipment_instance_unique": { + "name": "cultivator_equipment_instance_unique", + "columns": [ + { + "expression": "equipment_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_equipment_slots", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_equipment_instance_id_wanjiedaoyou_inventory_items_cultivator_id_id_fk": { + "name": "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_equipment_instance_id_wanjiedaoyou_inventory_items_cultivator_id_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_equipment_slots", + "tableTo": "wanjiedaoyou_inventory_items", + "columnsFrom": [ + "cultivator_id", + "equipment_instance_id" + ], + "columnsTo": [ + "cultivator_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_slot_pk": { + "name": "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_slot_pk", + "columns": [ + "cultivator_id", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_friends": { + "name": "wanjiedaoyou_cultivator_friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "friend_cultivator_id": { + "name": "friend_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cultivator_friends_pair_uidx": { + "name": "cultivator_friends_pair_uidx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "friend_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivator_friends_friend_idx": { + "name": "cultivator_friends_friend_idx", + "columns": [ + { + "expression": "friend_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_friends_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_friends_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_friends", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_cultivator_friends_friend_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_friends_friend_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_friends", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "friend_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_manual_slots": { + "name": "wanjiedaoyou_cultivator_manual_slots", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "manual_id": { + "name": "manual_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cultivator_manual_unique": { + "name": "cultivator_manual_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manual_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_manual_slots_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_manual_slots_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_manual_slots", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wanjiedaoyou_cultivator_manual_slots_cultivator_id_slot_pk": { + "name": "wanjiedaoyou_cultivator_manual_slots_cultivator_id_slot_pk", + "columns": [ + "cultivator_id", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cultivator_manual_slot_valid": { + "name": "cultivator_manual_slot_valid", + "value": "\"wanjiedaoyou_cultivator_manual_slots\".\"slot\" BETWEEN 1 AND 4" + } + }, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_manual_states": { + "name": "wanjiedaoyou_cultivator_manual_states", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learned": { + "name": "learned", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_cultivator_manual_states_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_manual_states_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_manual_states", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cultivator_manual_revision_valid": { + "name": "cultivator_manual_revision_valid", + "value": "\"wanjiedaoyou_cultivator_manual_states\".\"revision\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_stories": { + "name": "wanjiedaoyou_cultivator_stories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track": { + "name": "track", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "story_id": { + "name": "story_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "beat_id": { + "name": "beat_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "acks": { + "name": "acks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "grants": { + "name": "grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "marks": { + "name": "marks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cultivator_stories_owner_track_story_unique": { + "name": "cultivator_stories_owner_track_story_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "track", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "story_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivator_stories_owner_track_status_idx": { + "name": "cultivator_stories_owner_track_status_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "track", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_stories_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_stories_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_stories", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_tasks": { + "name": "wanjiedaoyou_cultivator_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_stage": { + "name": "current_stage", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "objectives": { + "name": "objectives", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "cultivator_tasks_cultivator_status_updated_idx": { + "name": "cultivator_tasks_cultivator_status_updated_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivator_tasks_cultivator_definition_unique": { + "name": "cultivator_tasks_cultivator_definition_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_tasks_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_tasks_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_tasks", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivators": { + "name": "wanjiedaoyou_cultivators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "personality": { + "name": "personality", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "player_race": { + "name": "player_race", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "race_narrative": { + "name": "race_narrative", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "realm": { + "name": "realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "realm_stage": { + "name": "realm_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 18 + }, + "lifespan": { + "name": "lifespan", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "closed_door_years_total": { + "name": "closed_door_years_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "died_at": { + "name": "died_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "vitality": { + "name": "vitality", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "strength": { + "name": "strength", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "spirit": { + "name": "spirit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "endurance": { + "name": "endurance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "speed": { + "name": "speed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "willpower": { + "name": "willpower", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unallocated_attribute_points": { + "name": "unallocated_attribute_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spirit_stones": { + "name": "spirit_stones", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reputation": { + "name": "reputation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qi": { + "name": "qi", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "qi_last_refreshed_at": { + "name": "qi_last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_yield_at": { + "name": "last_yield_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "balance_notes": { + "name": "balance_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "game_settings": { + "name": "game_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cultivation_progress": { + "name": "cultivation_progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "cultivators_user_status_updated_idx": { + "name": "cultivators_user_status_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivators_status_created_idx": { + "name": "cultivators_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivators_name_idx": { + "name": "cultivators_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivators_status_spirit_stones_idx": { + "name": "cultivators_status_spirit_stones_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spirit_stones", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_daily_divinations": { + "name": "wanjiedaoyou_daily_divinations", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "draw_id": { + "name": "draw_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day_key": { + "name": "day_key", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "dice": { + "name": "dice", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "omen_id": { + "name": "omen_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interpretation": { + "name": "interpretation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fallback": { + "name": "fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reward_granted_at": { + "name": "reward_granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_dungeon_histories": { + "name": "wanjiedaoyou_dungeon_histories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "real_gains": { + "name": "real_gains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dungeon_histories_cultivator_created_idx": { + "name": "dungeon_histories_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_dungeon_histories_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_dungeon_histories_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_dungeon_histories", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_dungeon_runs": { + "name": "wanjiedaoyou_dungeon_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "map_node_id": { + "name": "map_node_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "default": "'EXPLORING'" + }, + "current_round": { + "name": "current_round", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "max_rounds": { + "name": "max_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "danger_score": { + "name": "danger_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "run_state": { + "name": "run_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cost_ledger": { + "name": "cost_ledger", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "gain_ledger": { + "name": "gain_ledger", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pending_action": { + "name": "pending_action", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "active_battle_id": { + "name": "active_battle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_payload": { + "name": "battle_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dungeon_runs_cultivator_status_updated_idx": { + "name": "dungeon_runs_cultivator_status_updated_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dungeon_runs_status_updated_idx": { + "name": "dungeon_runs_status_updated_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_dungeon_runs_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_dungeon_runs_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_dungeon_runs", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_feedbacks": { + "name": "wanjiedaoyou_feedbacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_user_created_at_idx": { + "name": "feedback_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_status_type_created_at_idx": { + "name": "feedback_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_feedbacks_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_feedbacks_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_feedbacks", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_inventory_items": { + "name": "wanjiedaoyou_inventory_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(160)", + "primaryKey": true, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "slot_index": { + "name": "slot_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "stack_key": { + "name": "stack_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instance_data": { + "name": "instance_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_owner_id_unique": { + "name": "inventory_owner_id_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_owner_location_idx": { + "name": "inventory_owner_location_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_stack_lookup_idx": { + "name": "inventory_stack_lookup_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stack_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_bag_slot_unique": { + "name": "inventory_bag_slot_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slot_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wanjiedaoyou_inventory_items\".\"location\" = 'bag'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_inventory_items_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_inventory_items_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_inventory_items", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inventory_quantity_positive": { + "name": "inventory_quantity_positive", + "value": "\"wanjiedaoyou_inventory_items\".\"quantity\" > 0 AND \"wanjiedaoyou_inventory_items\".\"revision\" >= 0" + }, + "inventory_slot_valid": { + "name": "inventory_slot_valid", + "value": "(\"wanjiedaoyou_inventory_items\".\"location\" = 'bag' AND \"wanjiedaoyou_inventory_items\".\"slot_index\" IS NOT NULL AND \"wanjiedaoyou_inventory_items\".\"slot_index\" >= 0) OR (\"wanjiedaoyou_inventory_items\".\"location\" = 'storage' AND \"wanjiedaoyou_inventory_items\".\"slot_index\" IS NULL) OR (\"wanjiedaoyou_inventory_items\".\"location\" = 'equipped' AND \"wanjiedaoyou_inventory_items\".\"slot_index\" IS NULL AND \"wanjiedaoyou_inventory_items\".\"definition_id\" = 'equipment.v6')" + } + }, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_item_library": { + "name": "wanjiedaoyou_item_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "sample_key": { + "name": "sample_key", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "editor_config": { + "name": "editor_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "item_library_item_id_unique": { + "name": "item_library_item_id_unique", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "item_library_status_type_idx": { + "name": "item_library_status_type_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "item_library_material_sample_idx": { + "name": "item_library_material_sample_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sample_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "item_library_name_idx": { + "name": "item_library_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_mails": { + "name": "wanjiedaoyou_mails", + "schema": "", + "columns": { + "system_mail_campaign_id": { + "name": "system_mail_campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mails_campaign_cultivator_unique": { + "name": "mails_campaign_cultivator_unique", + "columns": [ + { + "expression": "system_mail_campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mails_cultivator_created_idx": { + "name": "mails_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mails_cultivator_is_read_created_idx": { + "name": "mails_cultivator_is_read_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_mails_system_mail_campaign_id_wanjiedaoyou_system_mail_campaigns_id_fk": { + "name": "wanjiedaoyou_mails_system_mail_campaign_id_wanjiedaoyou_system_mail_campaigns_id_fk", + "tableFrom": "wanjiedaoyou_mails", + "tableTo": "wanjiedaoyou_system_mail_campaigns", + "columnsFrom": [ + "system_mail_campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "wanjiedaoyou_mails_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_mails_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_mails", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_materials": { + "name": "wanjiedaoyou_materials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "materials_cultivator_idx": { + "name": "materials_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "materials_cultivator_name_idx": { + "name": "materials_cultivator_name_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "materials_cultivator_name_rank_idx": { + "name": "materials_cultivator_name_rank_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_materials_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_materials_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_materials", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_message_consumptions": { + "name": "wanjiedaoyou_message_consumptions", + "schema": "", + "columns": { + "consumer_name": { + "name": "consumer_name", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_key": { + "name": "message_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "message_consumptions_processed_idx": { + "name": "message_consumptions_processed_idx", + "columns": [ + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "wanjiedaoyou_message_consumptions_consumer_name_message_id_pk": { + "name": "wanjiedaoyou_message_consumptions_consumer_name_message_id_pk", + "columns": [ + "consumer_name", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_player_journal": { + "name": "wanjiedaoyou_player_journal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation_key": { + "name": "operation_key", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "player_journal_operation_unique": { + "name": "player_journal_operation_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_journal_timeline_idx": { + "name": "player_journal_timeline_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_player_journal_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_player_journal_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_player_journal", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_player_mutation_requests": { + "name": "wanjiedaoyou_player_mutation_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "player_mutation_requests_scope_unique": { + "name": "player_mutation_requests_scope_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_mutation_requests_created_idx": { + "name": "player_mutation_requests_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_player_mutation_requests_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_player_mutation_requests_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_player_mutation_requests", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_pre_heaven_fates": { + "name": "wanjiedaoyou_pre_heaven_fates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quality": { + "name": "quality", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "pre_heaven_fates_cultivator_idx": { + "name": "pre_heaven_fates_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_pre_heaven_fates_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_pre_heaven_fates_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_pre_heaven_fates", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_qi_logs": { + "name": "wanjiedaoyou_qi_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "action_instance_id": { + "name": "action_instance_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "qi_cost": { + "name": "qi_cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qi_gain": { + "name": "qi_gain", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qi_before": { + "name": "qi_before", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qi_after": { + "name": "qi_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "qi_logs_action_instance_uidx": { + "name": "qi_logs_action_instance_uidx", + "columns": [ + { + "expression": "action_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "qi_logs_cultivator_created_idx": { + "name": "qi_logs_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "qi_logs_status_created_idx": { + "name": "qi_logs_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_qi_logs_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_qi_logs_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_qi_logs", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_redeem_code_claims": { + "name": "wanjiedaoyou_redeem_code_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "redeem_code_id": { + "name": "redeem_code_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mail_id": { + "name": "mail_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "redeem_code_claims_code_user_unique": { + "name": "redeem_code_claims_code_user_unique", + "columns": [ + { + "expression": "redeem_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_code_claims_user_idx": { + "name": "redeem_code_claims_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_code_claims_code_idx": { + "name": "redeem_code_claims_code_idx", + "columns": [ + { + "expression": "redeem_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_redeem_code_claims_redeem_code_id_wanjiedaoyou_redeem_codes_id_fk": { + "name": "wanjiedaoyou_redeem_code_claims_redeem_code_id_wanjiedaoyou_redeem_codes_id_fk", + "tableFrom": "wanjiedaoyou_redeem_code_claims", + "tableTo": "wanjiedaoyou_redeem_codes", + "columnsFrom": [ + "redeem_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_redeem_code_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_redeem_code_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_redeem_code_claims", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_redeem_code_claims_mail_id_wanjiedaoyou_mails_id_fk": { + "name": "wanjiedaoyou_redeem_code_claims_mail_id_wanjiedaoyou_mails_id_fk", + "tableFrom": "wanjiedaoyou_redeem_code_claims", + "tableTo": "wanjiedaoyou_mails", + "columnsFrom": [ + "mail_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_redeem_codes": { + "name": "wanjiedaoyou_redeem_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "reward_preset_id": { + "name": "reward_preset_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "reward_attachments": { + "name": "reward_attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mail_title": { + "name": "mail_title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "mail_content": { + "name": "mail_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "total_limit": { + "name": "total_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_count": { + "name": "claimed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "redeem_codes_code_unique": { + "name": "redeem_codes_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_codes_status_created_idx": { + "name": "redeem_codes_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_codes_created_idx": { + "name": "redeem_codes_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_reputation_shop_items": { + "name": "wanjiedaoyou_reputation_shop_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_library_item_id": { + "name": "item_library_item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "item_snapshot": { + "name": "item_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "per_user_limit": { + "name": "per_user_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reputation_shop_status_sort_idx": { + "name": "reputation_shop_status_sort_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_reputation_shop_items_item_library_item_id_wanjiedaoyou_item_library_item_id_fk": { + "name": "wanjiedaoyou_reputation_shop_items_item_library_item_id_wanjiedaoyou_item_library_item_id_fk", + "tableFrom": "wanjiedaoyou_reputation_shop_items", + "tableTo": "wanjiedaoyou_item_library", + "columnsFrom": [ + "item_library_item_id" + ], + "columnsTo": [ + "item_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_reputation_shop_purchases": { + "name": "wanjiedaoyou_reputation_shop_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shop_item_id": { + "name": "shop_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_library_item_id": { + "name": "item_library_item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reputation_cost": { + "name": "reputation_cost", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_week": { + "name": "purchase_week", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reputation_shop_purchases_cultivator_item_idx": { + "name": "reputation_shop_purchases_cultivator_item_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shop_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reputation_shop_purchases_week_idx": { + "name": "reputation_shop_purchases_week_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shop_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_week", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reputation_shop_purchases_created_idx": { + "name": "reputation_shop_purchases_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_reputation_shop_purchases_shop_item_id_wanjiedaoyou_reputation_shop_items_id_fk": { + "name": "wanjiedaoyou_reputation_shop_purchases_shop_item_id_wanjiedaoyou_reputation_shop_items_id_fk", + "tableFrom": "wanjiedaoyou_reputation_shop_purchases", + "tableTo": "wanjiedaoyou_reputation_shop_items", + "columnsFrom": [ + "shop_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_reputation_shop_purchases_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_reputation_shop_purchases_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_reputation_shop_purchases", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_resource_events": { + "name": "wanjiedaoyou_resource_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_version": { + "name": "scope_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resource_version": { + "name": "resource_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_cultivator_id": { + "name": "actor_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "mutation_ordinal": { + "name": "mutation_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_events_scope_version_ordinal_unique": { + "name": "resource_events_scope_version_ordinal_unique", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mutation_ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_events_scope_version_idx": { + "name": "resource_events_scope_version_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mutation_ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_events_replay_idx": { + "name": "resource_events_replay_idx", + "columns": [ + { + "expression": "actor_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mutation_ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_events_created_idx": { + "name": "resource_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_resource_events_scope_id_wanjiedaoyou_resource_scopes_id_fk": { + "name": "wanjiedaoyou_resource_events_scope_id_wanjiedaoyou_resource_scopes_id_fk", + "tableFrom": "wanjiedaoyou_resource_events", + "tableTo": "wanjiedaoyou_resource_scopes", + "columnsFrom": [ + "scope_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_resource_events_actor_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_resource_events_actor_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_resource_events", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "actor_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_resource_scopes": { + "name": "wanjiedaoyou_resource_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scope_kind": { + "name": "scope_kind", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "scope_version": { + "name": "scope_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_scopes_kind_key_unique": { + "name": "resource_scopes_kind_key_unique", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_resource_versions": { + "name": "wanjiedaoyou_resource_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_versions_scope_key_unique": { + "name": "resource_versions_scope_key_unique", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_versions_resource_idx": { + "name": "resource_versions_resource_idx", + "columns": [ + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_resource_versions_scope_id_wanjiedaoyou_resource_scopes_id_fk": { + "name": "wanjiedaoyou_resource_versions_scope_id_wanjiedaoyou_resource_scopes_id_fk", + "tableFrom": "wanjiedaoyou_resource_versions", + "tableTo": "wanjiedaoyou_resource_scopes", + "columnsFrom": [ + "scope_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_retreat_records": { + "name": "wanjiedaoyou_retreat_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "realm_stage": { + "name": "realm_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "years": { + "name": "years", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "chance": { + "name": "chance", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "roll": { + "name": "roll", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modifiers": { + "name": "modifiers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "retreat_records_cultivator_timestamp_idx": { + "name": "retreat_records_cultivator_timestamp_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_retreat_records_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_retreat_records_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_retreat_records", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_combat_states": { + "name": "wanjiedaoyou_sect_combat_states", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_path_id": { + "name": "active_path_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "meridian_depth": { + "name": "meridian_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_sect_combat_states_membership_id_wanjiedaoyou_sect_memberships_id_fk": { + "name": "wanjiedaoyou_sect_combat_states_membership_id_wanjiedaoyou_sect_memberships_id_fk", + "tableFrom": "wanjiedaoyou_sect_combat_states", + "tableTo": "wanjiedaoyou_sect_memberships", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sect_combat_progress_valid": { + "name": "sect_combat_progress_valid", + "value": "\"wanjiedaoyou_sect_combat_states\".\"revision\" >= 0 AND \"wanjiedaoyou_sect_combat_states\".\"meridian_depth\" BETWEEN 0 AND 7" + } + }, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_facilities": { + "name": "wanjiedaoyou_sect_facilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sect_id": { + "name": "sect_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "facility_key": { + "name": "facility_key", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sect_facilities_sect_key_unique": { + "name": "sect_facilities_sect_key_unique", + "columns": [ + { + "expression": "sect_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "facility_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_memberships": { + "name": "wanjiedaoyou_sect_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sect_id": { + "name": "sect_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'prospect'" + }, + "experienced_at": { + "name": "experienced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "contribution": { + "name": "contribution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_contribution": { + "name": "lifetime_contribution", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disciple_rank": { + "name": "disciple_rank", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'registered'" + }, + "office": { + "name": "office", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "promoted_at": { + "name": "promoted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sect_memberships_cultivator_sect_unique": { + "name": "sect_memberships_cultivator_sect_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sect_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_memberships_active_cultivator_unique": { + "name": "sect_memberships_active_cultivator_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wanjiedaoyou_sect_memberships\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_memberships_sect_status_idx": { + "name": "sect_memberships_sect_status_idx", + "columns": [ + { + "expression": "sect_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_memberships_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_sect_memberships_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_sect_memberships", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_meridian_loadouts": { + "name": "wanjiedaoyou_sect_meridian_loadouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path_id": { + "name": "path_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "sect_meridian_membership_path_unique": { + "name": "sect_meridian_membership_path_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_meridian_loadouts_membership_id_wanjiedaoyou_sect_memberships_id_fk": { + "name": "wanjiedaoyou_sect_meridian_loadouts_membership_id_wanjiedaoyou_sect_memberships_id_fk", + "tableFrom": "wanjiedaoyou_sect_meridian_loadouts", + "tableTo": "wanjiedaoyou_sect_memberships", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_meridian_nodes": { + "name": "wanjiedaoyou_sect_meridian_nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loadout_id": { + "name": "loadout_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "layer": { + "name": "layer", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sect_meridian_loadout_node_unique": { + "name": "sect_meridian_loadout_node_unique", + "columns": [ + { + "expression": "loadout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_meridian_loadout_layer_unique": { + "name": "sect_meridian_loadout_layer_unique", + "columns": [ + { + "expression": "loadout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_meridian_nodes_loadout_id_wanjiedaoyou_sect_meridian_loadouts_id_fk": { + "name": "wanjiedaoyou_sect_meridian_nodes_loadout_id_wanjiedaoyou_sect_meridian_loadouts_id_fk", + "tableFrom": "wanjiedaoyou_sect_meridian_nodes", + "tableTo": "wanjiedaoyou_sect_meridian_loadouts", + "columnsFrom": [ + "loadout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_method_progress": { + "name": "wanjiedaoyou_sect_method_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "method_id": { + "name": "method_id", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "sect_method_membership_method_unique": { + "name": "sect_method_membership_method_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_method_progress_membership_id_wanjiedaoyou_sect_memberships_id_fk": { + "name": "wanjiedaoyou_sect_method_progress_membership_id_wanjiedaoyou_sect_memberships_id_fk", + "tableFrom": "wanjiedaoyou_sect_method_progress", + "tableTo": "wanjiedaoyou_sect_memberships", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_shop_items": { + "name": "wanjiedaoyou_sect_shop_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_library_item_id": { + "name": "item_library_item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "item_snapshot": { + "name": "item_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "per_user_limit": { + "name": "per_user_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sect_shop_status_sort_idx": { + "name": "sect_shop_status_sort_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_shop_items_item_library_item_id_wanjiedaoyou_item_library_item_id_fk": { + "name": "wanjiedaoyou_sect_shop_items_item_library_item_id_wanjiedaoyou_item_library_item_id_fk", + "tableFrom": "wanjiedaoyou_sect_shop_items", + "tableTo": "wanjiedaoyou_item_library", + "columnsFrom": [ + "item_library_item_id" + ], + "columnsTo": [ + "item_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_shop_purchases": { + "name": "wanjiedaoyou_sect_shop_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shop_item_id": { + "name": "shop_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_library_item_id": { + "name": "item_library_item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_cost": { + "name": "contribution_cost", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_week": { + "name": "purchase_week", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sect_shop_purchases_cultivator_item_idx": { + "name": "sect_shop_purchases_cultivator_item_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shop_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_shop_purchases_week_idx": { + "name": "sect_shop_purchases_week_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shop_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_week", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_shop_purchases_created_idx": { + "name": "sect_shop_purchases_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_shop_purchases_shop_item_id_wanjiedaoyou_sect_shop_items_id_fk": { + "name": "wanjiedaoyou_sect_shop_purchases_shop_item_id_wanjiedaoyou_sect_shop_items_id_fk", + "tableFrom": "wanjiedaoyou_sect_shop_purchases", + "tableTo": "wanjiedaoyou_sect_shop_items", + "columnsFrom": [ + "shop_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_sect_shop_purchases_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_sect_shop_purchases_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_sect_shop_purchases", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_sect_shop_purchases_membership_id_wanjiedaoyou_sect_memberships_id_fk": { + "name": "wanjiedaoyou_sect_shop_purchases_membership_id_wanjiedaoyou_sect_memberships_id_fk", + "tableFrom": "wanjiedaoyou_sect_shop_purchases", + "tableTo": "wanjiedaoyou_sect_memberships", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_stipend_claims": { + "name": "wanjiedaoyou_sect_stipend_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "week_key": { + "name": "week_key", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "spirit_stones": { + "name": "spirit_stones", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sect_stipend_member_week_unique": { + "name": "sect_stipend_member_week_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_stipend_claimed_idx": { + "name": "sect_stipend_claimed_idx", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_stipend_claims_membership_id_wanjiedaoyou_sect_memberships_id_fk": { + "name": "wanjiedaoyou_sect_stipend_claims_membership_id_wanjiedaoyou_sect_memberships_id_fk", + "tableFrom": "wanjiedaoyou_sect_stipend_claims", + "tableTo": "wanjiedaoyou_sect_memberships", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sect_task_records": { + "name": "wanjiedaoyou_sect_task_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "membership_id": { + "name": "membership_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sect_task_membership_period_task_attempt_unique": { + "name": "sect_task_membership_period_task_attempt_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sect_task_membership_kind_period_idx": { + "name": "sect_task_membership_kind_period_idx", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sect_task_records_membership_id_wanjiedaoyou_sect_memberships_id_fk": { + "name": "wanjiedaoyou_sect_task_records_membership_id_wanjiedaoyou_sect_memberships_id_fk", + "tableFrom": "wanjiedaoyou_sect_task_records", + "tableTo": "wanjiedaoyou_sect_memberships", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_spirit_fields": { + "name": "wanjiedaoyou_spirit_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "self_harvest_count": { + "name": "self_harvest_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_care_count": { + "name": "total_care_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "starter_claimed": { + "name": "starter_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "plots": { + "name": "plots", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "spirit_fields_cultivator_uidx": { + "name": "spirit_fields_cultivator_uidx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spirit_fields_updated_idx": { + "name": "spirit_fields_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_spirit_fields_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_spirit_fields_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_spirit_fields", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_spiritual_roots": { + "name": "wanjiedaoyou_spiritual_roots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "strength": { + "name": "strength", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "marrow_wash_bonus": { + "name": "marrow_wash_bonus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "grade": { + "name": "grade", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "spiritual_roots_cultivator_idx": { + "name": "spiritual_roots_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_spiritual_roots_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_spiritual_roots_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_spiritual_roots", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_admin_actions": { + "name": "wanjiedaoyou_sponsorship_admin_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_admin_actions_admin_created_idx": { + "name": "sponsorship_admin_actions_admin_created_idx", + "columns": [ + { + "expression": "admin_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_admin_actions_order_created_idx": { + "name": "sponsorship_admin_actions_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_admin_actions_order_id_wanjiedaoyou_sponsorship_orders_id_fk": { + "name": "wanjiedaoyou_sponsorship_admin_actions_order_id_wanjiedaoyou_sponsorship_orders_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_admin_actions", + "tableTo": "wanjiedaoyou_sponsorship_orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_checkout_intents": { + "name": "wanjiedaoyou_sponsorship_checkout_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "expected_plan_id": { + "name": "expected_plan_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "public_listing": { + "name": "public_listing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider_order_id": { + "name": "provider_order_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "config_snapshot": { + "name": "config_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_checkout_provider_order_uidx": { + "name": "sponsorship_checkout_provider_order_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_checkout_user_created_idx": { + "name": "sponsorship_checkout_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_checkout_status_expires_idx": { + "name": "sponsorship_checkout_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_checkout_intents_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_sponsorship_checkout_intents_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_checkout_intents", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_claims": { + "name": "wanjiedaoyou_sponsorship_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_listing": { + "name": "public_listing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_status": { + "name": "message_status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message_attempts": { + "name": "message_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_message_error": { + "name": "last_message_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_claims_order_uidx": { + "name": "sponsorship_claims_order_uidx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_claims_code_hash_uidx": { + "name": "sponsorship_claims_code_hash_uidx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_claims_status_expires_idx": { + "name": "sponsorship_claims_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_claims_order_id_wanjiedaoyou_sponsorship_orders_id_fk": { + "name": "wanjiedaoyou_sponsorship_claims_order_id_wanjiedaoyou_sponsorship_orders_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_claims", + "tableTo": "wanjiedaoyou_sponsorship_orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_sponsorship_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_sponsorship_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_claims", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_merit_profiles": { + "name": "wanjiedaoyou_sponsorship_merit_profiles", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "highest_tier": { + "name": "highest_tier", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "merit_count": { + "name": "merit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_supported_at": { + "name": "first_supported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_supported_at": { + "name": "last_supported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_merit_public_tier_first_idx": { + "name": "sponsorship_merit_public_tier_first_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "highest_tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "first_supported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_merit_profiles_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_sponsorship_merit_profiles_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_merit_profiles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_merit_records": { + "name": "wanjiedaoyou_sponsorship_merit_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "supported_at": { + "name": "supported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "mail_id": { + "name": "mail_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_merit_order_uidx": { + "name": "sponsorship_merit_order_uidx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_merit_cultivator_supported_idx": { + "name": "sponsorship_merit_cultivator_supported_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "supported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_merit_records_order_id_wanjiedaoyou_sponsorship_orders_id_fk": { + "name": "wanjiedaoyou_sponsorship_merit_records_order_id_wanjiedaoyou_sponsorship_orders_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_merit_records", + "tableTo": "wanjiedaoyou_sponsorship_orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wanjiedaoyou_sponsorship_merit_records_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_sponsorship_merit_records_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_merit_records", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_sponsorship_merit_records_mail_id_wanjiedaoyou_mails_id_fk": { + "name": "wanjiedaoyou_sponsorship_merit_records_mail_id_wanjiedaoyou_mails_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_merit_records", + "tableTo": "wanjiedaoyou_mails", + "columnsFrom": [ + "mail_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_order_snapshots": { + "name": "wanjiedaoyou_sponsorship_order_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "purge_after": { + "name": "purge_after", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_snapshots_order_created_idx": { + "name": "sponsorship_snapshots_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_snapshots_purge_idx": { + "name": "sponsorship_snapshots_purge_idx", + "columns": [ + { + "expression": "purge_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_order_snapshots_order_id_wanjiedaoyou_sponsorship_orders_id_fk": { + "name": "wanjiedaoyou_sponsorship_order_snapshots_order_id_wanjiedaoyou_sponsorship_orders_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_order_snapshots", + "tableTo": "wanjiedaoyou_sponsorship_orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_sponsorship_orders": { + "name": "wanjiedaoyou_sponsorship_orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_order_id": { + "name": "provider_order_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "custom_order_id": { + "name": "custom_order_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "plan_id": { + "name": "plan_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "sku_id": { + "name": "sku_id", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_amount_fen": { + "name": "total_amount_fen", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "show_amount_fen": { + "name": "show_amount_fen", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_status": { + "name": "provider_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_status": { + "name": "verification_status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "fulfillment_status": { + "name": "fulfillment_status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "resolved_tier": { + "name": "resolved_tier", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "checkout_intent_id": { + "name": "checkout_intent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config_snapshot": { + "name": "config_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature_verified_at": { + "name": "signature_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sensitive_purged_at": { + "name": "sensitive_purged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sponsorship_orders_provider_order_uidx": { + "name": "sponsorship_orders_provider_order_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_orders_verification_created_idx": { + "name": "sponsorship_orders_verification_created_idx", + "columns": [ + { + "expression": "verification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_orders_fulfillment_created_idx": { + "name": "sponsorship_orders_fulfillment_created_idx", + "columns": [ + { + "expression": "fulfillment_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sponsorship_orders_provider_user_idx": { + "name": "sponsorship_orders_provider_user_idx", + "columns": [ + { + "expression": "provider_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_sponsorship_orders_checkout_intent_id_wanjiedaoyou_sponsorship_checkout_intents_id_fk": { + "name": "wanjiedaoyou_sponsorship_orders_checkout_intent_id_wanjiedaoyou_sponsorship_checkout_intents_id_fk", + "tableFrom": "wanjiedaoyou_sponsorship_orders", + "tableTo": "wanjiedaoyou_sponsorship_checkout_intents", + "columnsFrom": [ + "checkout_intent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_system_mail_campaigns": { + "name": "wanjiedaoyou_system_mail_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "creation_fingerprint": { + "name": "creation_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_selections": { + "name": "reward_selections", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "system_mail_campaigns_window_idx": { + "name": "system_mail_campaigns_window_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_tower_enemy_floors": { + "name": "wanjiedaoyou_tower_enemy_floors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_key": { + "name": "season_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enemy": { + "name": "enemy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tower_enemy_floors_season_realm_floor_uidx": { + "name": "tower_enemy_floors_season_realm_floor_uidx", + "columns": [ + { + "expression": "season_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "floor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tower_enemy_floors_realm_floor_generated_idx": { + "name": "tower_enemy_floors_realm_floor_generated_idx", + "columns": [ + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "floor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tower_enemy_floors_season_realm_status_idx": { + "name": "tower_enemy_floors_season_realm_status_idx", + "columns": [ + { + "expression": "season_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_tower_reward_states": { + "name": "wanjiedaoyou_tower_reward_states", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "season_key": { + "name": "season_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "claims": { + "name": "claims", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_tower_reward_states_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_tower_reward_states_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_tower_reward_states", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_tower_weeks": { + "name": "wanjiedaoyou_tower_weeks", + "schema": "", + "columns": { + "season_key": { + "name": "season_key", + "type": "varchar(40)", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "generator_version": { + "name": "generator_version", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_transactional_messages": { + "name": "wanjiedaoyou_transactional_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_key": { + "name": "message_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "publish_attempts": { + "name": "publish_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_publish_error": { + "name": "last_publish_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transactional_messages_dedupe_unique": { + "name": "transactional_messages_dedupe_unique", + "columns": [ + { + "expression": "message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wanjiedaoyou_transactional_messages\".\"deduplication_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_messages_pending_idx": { + "name": "transactional_messages_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"wanjiedaoyou_transactional_messages\".\"published_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_wild_searches": { + "name": "wanjiedaoyou_wild_searches", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "encounter": { + "name": "encounter", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "prepared_battle": { + "name": "prepared_battle", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_wild_searches_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_wild_searches_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_wild_searches", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b2b9ca09..35ff8fe9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1790578141727, "tag": "0059_cloudy_ink", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1790763373538, + "tag": "0060_player_journal", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/scripts/compact-pill-storage.ts b/scripts/compact-pill-storage.ts new file mode 100644 index 00000000..41524544 --- /dev/null +++ b/scripts/compact-pill-storage.ts @@ -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= 和绝对路径 --backup=', + ); + +const filter = and( + eq(inventoryItems.cultivatorId, owner), + eq(inventoryItems.location, 'storage'), + eq(inventoryItems.definitionId, 'consumable.v1'), + eq(sql`${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>) { + 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); diff --git a/src/react-app/components/feature/alchemy/useAlchemyCraftSessionState.ts b/src/react-app/components/feature/alchemy/useAlchemyCraftSessionState.ts index 1cd58b0d..eb7ab5cb 100644 --- a/src/react-app/components/feature/alchemy/useAlchemyCraftSessionState.ts +++ b/src/react-app/components/feature/alchemy/useAlchemyCraftSessionState.ts @@ -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( 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 diff --git a/src/react-app/components/feature/combat-v6/presentation.ts b/src/react-app/components/feature/combat-v6/presentation.ts index 8f294466..fe6740de 100644 --- a/src/react-app/components/feature/combat-v6/presentation.ts +++ b/src/react-app/components/feature/combat-v6/presentation.ts @@ -15,6 +15,7 @@ export type { export const combatV6HistorySources = { ranking: '天骄榜', + hunt: '结伴讨伐', 'arena-sparring': '擂台切磋', }; diff --git a/src/react-app/components/feature/consumables/pillDisplayModel.ts b/src/react-app/components/feature/consumables/pillDisplayModel.ts index edeb08c5..2e26db92 100644 --- a/src/react-app/components/feature/consumables/pillDisplayModel.ts +++ b/src/react-app/components/feature/consumables/pillDisplayModel.ts @@ -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, }; } diff --git a/src/react-app/components/feature/cultivator/YieldCard.tsx b/src/react-app/components/feature/cultivator/YieldCard.tsx index 00149a69..046a8c8e 100644 --- a/src/react-app/components/feature/cultivator/YieldCard.tsx +++ b/src/react-app/components/feature/cultivator/YieldCard.tsx @@ -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 = ( = 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 ? '历练中' : '领取'} ); const spiritStonesInfo = getGameConceptInfo('spirit_stones'); diff --git a/src/react-app/components/feature/hunts/HuntLobbyDrawer.tsx b/src/react-app/components/feature/hunts/HuntLobbyDrawer.tsx new file mode 100644 index 00000000..156e13ac --- /dev/null +++ b/src/react-app/components/feature/hunts/HuntLobbyDrawer.tsx @@ -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( + `/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('炼气'); + const [maxRealm, setMaxRealm] = useState('渡劫'); + 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(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 ( + + {error || actionError ? ( +

+ {actionError || error} + +

+ ) : null} + {!data ? ( +

正在探听讨伐消息……

+ ) : ( +
+
+ +
+

{data.event.locationName}

+

+ {data.open + ? `行踪将于 ${new Date(data.event.expiresAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} 隐去` + : '此处异动已平息'} +

+
+
+

{boss!.hint}

+ {data.claimed ? ( +

+ 你已领过此次讨伐的报酬,仍可助道友一战,助战不再获酬。 +

+ ) : null} + {team && team.event.id !== eventId ? ( +

+ 你已有一支讨伐队伍。 + + 查看我的队伍 + +

+ ) : team ? ( + <> +
+ + 我的队伍{' '} + {team.members.length}/4 + + + {team.minRealm}至{team.maxRealm} + +
+
    + {team.members.map((m) => ( +
  • + + + {m.name} + {m.cultivatorId === team.leaderId ? ' · 队长' : ''} + + {m.realm} + {m.assisting ? ' · 助战' : ''} + + + {m.ready ? '已准备' : '未准备'} +
  • + ))} +
+ {team.status === 'in_battle' && team.battleId ? ( + + navigate(`/game/combat-v6/hunt/${team.battleId}`) + } + > + 进入战斗 + + ) : ( +
+ command('ready')} + > + {self?.ready ? '取消准备' : '准备'} + + {team.leaderId === actorId ? ( + !m.ready) + } + onClick={() => command('start')} + > + {team.status === 'starting' ? '继续出战' : '开始讨伐'} + + ) : null} + command('leave')} + > + 离开队伍 + +
+ )} + {team.members.length < 2 ? ( +

+ 再邀一位道友,准备妥当便可出战。 +

+ ) : null} + + ) : data.open ? ( + <> + {creating ? ( +
{ + e.preventDefault(); + void act('/api/hunts/teams', { + eventId, + minRealm, + maxRealm, + }); + }} + className="space-y-3" + > +

招募何等境界的道友?

+
+ + +
+ + REALM_VALUES.indexOf(maxRealm) + } + > + 确认创建 + {' '} + setCreating(false)}>取消 +
+ ) : ( +
+ setCreating(true)} + > + 创建队伍 + + void act(`/api/hunts/${eventId}/join`, {})} + > + 快速匹配 + +
+ )} +
+ {data.teams.length ? ( + data.teams.map((t) => ( +
+ +
+

+ { + t.members.find((m) => m.cultivatorId === t.leaderId) + ?.name + } + 的队伍{' '} + + {t.members.length}/4 + +

+

+ {t.minRealm}至{t.maxRealm} ·{' '} + {t.members + .map( + (m) => + `${m.name}${m.assisting ? '(助战)' : ''}`, + ) + .join('、')} +

+
+ = 4 || + !identity || + !huntRealmAllowed(t, identity.realm) + } + onClick={() => + void act(`/api/hunts/${eventId}/join`, { + teamId: t.id, + }) + } + > + 加入 + +
+ )) + ) : ( +

+ 还没有队伍,先召集几位道友吧。 +

+ )} +
+ + ) : null} + +
+ 出战须知 +
+

+ 2~4 人结伴即可出战,不必等到满员。全员准备后,由队长发起讨伐。 +

+

+ 各队均可挑战,不必争抢。此次行踪消失前,每人只领一次报酬;落败可再战,领过报酬也能助战。 +

+

+ 入战时气血、法力充盈,战后不损原有气血、法力,也不折损灵兽寿元。 +

+

行踪隐去后便无法出战,已经交手的队伍仍可战至终局。

+
+
+
+ )} +
+ ); +} diff --git a/src/react-app/components/feature/hunts/HuntMapMarkers.tsx b/src/react-app/components/feature/hunts/HuntMapMarkers.tsx new file mode 100644 index 00000000..693242db --- /dev/null +++ b/src/react-app/components/feature/hunts/HuntMapMarkers.tsx @@ -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 ? ( +
+ + 异闻讨伐 · {events.length} + +
    + {events.map((event) => ( +
  • + +
  • + ))} +
+
+ ) : ( + points.map((point) => { + const event = events.find((e) => e.id === point.id); + return event ? ( + + ) : null; + }) + )} + + ); +} diff --git a/src/react-app/components/feature/hunts/HuntResult.tsx b/src/react-app/components/feature/hunts/HuntResult.tsx new file mode 100644 index 00000000..868e839a --- /dev/null +++ b/src/react-app/components/feature/hunts/HuntResult.tsx @@ -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( + `/api/hunts/battles/${battleId}/reward`, + ); + const [showRewards, setShowRewards] = useState(false); + return ( +
+

+ {error + ? '暂未查到此次所得,请稍后再看' + : !data || data.status === 'pending' + ? '正在清点此次所得……' + : data.status === 'assisting' + ? '助战得胜。你已领过此次报酬,不再另赠。' + : data.status === 'no-reward' + ? '此战未能得胜,行踪消失前还可再战。' + : '此次所得已收妥'} +

+ {data?.status === 'rewarded' && data.reward ? ( + setShowRewards(false)} + > +
+

+ + 修为{' '} + +{data.reward.experience} + + + 灵石{' '} + +{data.reward.spiritStones} + + + 感悟 +{data.reward.insight} + +

+ {data.reward.items.length ? ( + <> +
+ {data.reward.items.map((item, index) => ( + + ))} +
+

+ 寻得的物品已随信送来。 + + 查看邮件 + +

+ + ) : null} +
+
+ ) : null} +
+ {data?.status === 'rewarded' ? ( + + ) : null} + {error ? : null} + +
+
+ ); +} diff --git a/src/react-app/components/feature/hunts/HuntRewardPreview.tsx b/src/react-app/components/feature/hunts/HuntRewardPreview.tsx new file mode 100644 index 00000000..29fefa85 --- /dev/null +++ b/src/react-app/components/feature/hunts/HuntRewardPreview.tsx @@ -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 ( +
+

讨伐所得

+
+ {['cultivation_exp', 'spirit_stones', 'comprehension_insight'].map( + (key) => { + const resource = getGameConceptInfo(key); + return ( +
+ + } + /> +

必得

+
+ ); + }, + )} +
+ setShowMaterials((shown) => !shown)} + /> +

必得

+
+ {bookPools + .filter((p) => p.entries.length) + .map((p) => ( +
+ + } + /> +

可能获得

+
+ ))} + {dewEntries.map((entry) => ( +
+ +

可能获得

+
+ ))} +
+ {showMaterials ? ( +
+
+

可得灵材品质

+ +
+

+ {Object.entries(DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM[realm]) + .filter(([, chance]) => chance > 0) + .map(([quality]) => quality) + .join('、')} +

+
+ ) : null} +
+ ); +} diff --git a/src/react-app/components/feature/hunts/useHunts.ts b/src/react-app/components/feature/hunts/useHunts.ts new file mode 100644 index 00000000..3941e56d --- /dev/null +++ b/src/react-app/components/feature/hunts/useHunts.ts @@ -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( + url: string, + actorId: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + 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(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; + const run = async () => { + try { + const data = await huntRequest( + 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), + }; +} diff --git a/src/react-app/components/feature/items/InventoryFilters.tsx b/src/react-app/components/feature/items/InventoryFilters.tsx index c285d3d4..d1a77e7c 100644 --- a/src/react-app/components/feature/items/InventoryFilters.tsx +++ b/src/react-app/components/feature/items/InventoryFilters.tsx @@ -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(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({ { setDraft(value); + setDraftSort(sort); setOpen(true); }} > - 筛选{inventoryFilterActive(value) ? ' · 已启用' : ''} + 筛选{inventoryFilterActive(value) || sort ? ' · 已启用' : ''} + onClick={() => { setDraft( kindDisabled ? { kind: value.kind } : defaultInventoryFilter, - ) - } + ); + setDraftSort(undefined); + }} > 重置 @@ -98,6 +111,27 @@ export function InventoryFilters({ ))} + {onSortChange ? ( + + ) : null} {draft.kind === 'material' ? ( <>
@@ -141,6 +175,28 @@ export function InventoryFilters({ ))} + ) : null} diff --git a/src/react-app/components/feature/items/ItemSlot.tsx b/src/react-app/components/feature/items/ItemSlot.tsx index 5a803181..6bf454f3 100644 --- a/src/react-app/components/feature/items/ItemSlot.tsx +++ b/src/react-app/components/feature/items/ItemSlot.tsx @@ -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 ( + + item && ( + + ) + } + /> + ); +} + +/** 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(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 ? ( - - ) : ( - emptyIcon - )} + {item ? : emptyIcon} {slotName ? ( {slotName} ) : null} {item && item.quantity > 1 ? ( - + 9999 + ? { + // Leave room for the multiplier and 万 in narrow slots. + fontSize: `min(1rem, 20cqw, calc((100cqw - 0.5rem) / ${quantityText.length * 0.65 + 1}))`, + } + : undefined + } + > × - {item.quantity >= 10000 - ? `${Math.floor(item.quantity / 1000)}k` - : item.quantity} + {quantityText} ) : 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 ? ( - setOpen(false))} - /> - ) : null} + {open && item ? renderPreview(() => setOpen(false)) : null} ); diff --git a/src/react-app/components/feature/items/inventoryFilterModel.ts b/src/react-app/components/feature/items/inventoryFilterModel.ts index 98d2dabe..a3a3f4e6 100644 --- a/src/react-app/components/feature/items/inventoryFilterModel.ts +++ b/src/react-app/components/feature/items/inventoryFilterModel.ts @@ -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) ); } diff --git a/src/react-app/components/feature/items/presentation/consumable.ts b/src/react-app/components/feature/items/presentation/consumable.ts index a62b3f83..ac83d20f 100644 --- a/src/react-app/components/feature/items/presentation/consumable.ts +++ b/src/react-app/components/feature/items/presentation/consumable.ts @@ -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, }; }, }; diff --git a/src/react-app/components/feature/journal/JournalEventContent.tsx b/src/react-app/components/feature/journal/JournalEventContent.tsx new file mode 100644 index 00000000..620d4ce9 --- /dev/null +++ b/src/react-app/components/feature/journal/JournalEventContent.tsx @@ -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 ( + + {style.label} + + {value > 0 ? '+' : ''} + {(value === 0 ? 0 : value).toLocaleString('zh-CN')} + + + ); +} + +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) => ( + + {index > 0 ? ',' : null} + {change.kind === 'resource' ? ( + + ) : ( + + {change.name} + + {change.amount > 0 ? '+' : ''} + {change.amount.toLocaleString('zh-CN')} + + + )} + + ))} + 。 + + ); + } + if (event.type === 'retreat.completed') { + return ( + <> + 闭关{event.years}年 + {event.summary.epiphany_triggered ? '(顿悟)' : ''}: + , + + , + + {event.depleted ? ( + ,寿元已尽 + ) : null} + 。 + + ); + } + const summary = event.summary; + return ( + <> + 突破{summary.success ? '成功' : '失败'}({summary.fromRealm} + {summary.fromStage} + {summary.success ? `→${summary.toRealm}${summary.toStage}` : ''}): + , + , + + {summary.lifespanGained > 0 ? ( + + ,寿元上限+{summary.lifespanGained} + 年 + + ) : null} + {summary.naturalAttributeGrowth > 0 ? ( + + ,六维各 + +{summary.naturalAttributeGrowth} + + ) : null} + {summary.attributePointReward > 0 ? ( + + ,属性点 + +{summary.attributePointReward} + + ) : null} + {summary.inner_demon_triggered ? ( + ,心魔缠身 + ) : null} + 。 + + ); +} diff --git a/src/react-app/components/feature/retreat/useRetreatViewModel.tsx b/src/react-app/components/feature/retreat/useRetreatViewModel.tsx index 646a4b7b..c37b6d0e 100644 --- a/src/react-app/components/feature/retreat/useRetreatViewModel.tsx +++ b/src/react-app/components/feature/retreat/useRetreatViewModel.tsx @@ -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( null, ); @@ -262,6 +265,10 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn { action: RetreatAction; years?: number; }): Promise => { + 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 () => { diff --git a/src/react-app/components/feature/world-chat/WorldChatMessageItem.tsx b/src/react-app/components/feature/world-chat/WorldChatMessageItem.tsx index 9d21d8d0..4df4c40f 100644 --- a/src/react-app/components/feature/world-chat/WorldChatMessageItem.tsx +++ b/src/react-app/components/feature/world-chat/WorldChatMessageItem.tsx @@ -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({
- {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 ? ( + + {renderTextMessage(message)}{' '} + + 前往查看 + + + ) : 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]) ? ( 战斗回放 · {message.payload.sides[0].join('、')} 对阵 {message.payload.sides[1].join('、')} {message.payload.roundCount} 回合 · 点击查看 diff --git a/src/react-app/components/game-shell/gameNavigation.ts b/src/react-app/components/game-shell/gameNavigation.ts index a64e3104..f92bbbd3 100644 --- a/src/react-app/components/game-shell/gameNavigation.ts +++ b/src/react-app/components/game-shell/gameNavigation.ts @@ -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: '擂台切磋', diff --git a/src/react-app/components/mail/MailAttachmentSlot.tsx b/src/react-app/components/mail/MailAttachmentSlot.tsx new file mode 100644 index 00000000..aeddd6dc --- /dev/null +++ b/src/react-app/components/mail/MailAttachmentSlot.tsx @@ -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 ( + + ); + } + + if (attachment.type === 'beast_v1' && attachment.beastPreview) { + const beast = attachment.beastPreview; + const species = BEAST_SPECIES.find((entry) => entry.id === beast.speciesId); + return ( + } + /> + ); + } + + 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 ( + } + /> + ); +} diff --git a/src/react-app/components/mail/MailDetailModal.tsx b/src/react-app/components/mail/MailDetailModal.tsx index 0ac9c61c..35af537d 100644 --- a/src/react-app/components/mail/MailDetailModal.tsx +++ b/src/react-app/components/mail/MailDetailModal.tsx @@ -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({

🎁 附赠物品

-
- {mail.attachments?.map((item, idx) => - item.type === 'beast_v1' && item.beastPreview ? ( -
-
- -
-

{item.name} · 灵兽

-
- ) : item.type === 'inventory_v1' && item.inventory ? ( -
- -
- ) : ( -
-
- - {item.type === 'spirit_stones' && ( - {item.name} - )} - {item.type === 'reputation' && ( - {item.name} - )} - {item.type === 'cultivation_exp' && ( - {item.name} - )} - {item.type === 'comprehension_insight' && ( - {item.name} - )} - {item.type === 'material' && ( - - {item.name} - - )} - {item.type === 'consumable' && ( - - {item.name} - - )} - {item.type === 'artifact' && ( - - {item.name} - - )} -
- - ×{item.quantity} - {['material', 'consumable', 'artifact'].includes( - item.type, - ) ? ( - 洞府宝库 - ) : null} - -
- ), - )} +
+ {mail.attachments?.map((item, idx) => ( + + ))}
)} diff --git a/src/react-app/lib/pendingCommandRequest.ts b/src/react-app/lib/pendingCommandRequest.ts new file mode 100644 index 00000000..fd725e7c --- /dev/null +++ b/src/react-app/lib/pendingCommandRequest.ts @@ -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 + ); +} diff --git a/src/react-app/lib/resources/craftStorage.ts b/src/react-app/lib/resources/craftStorage.ts index 6a7895e0..1d124507 100644 --- a/src/react-app/lib/resources/craftStorage.ts +++ b/src/react-app/lib/resources/craftStorage.ts @@ -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(`/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, diff --git a/src/react-app/lib/router/gameShellRegistry.ts b/src/react-app/lib/router/gameShellRegistry.ts index 795fef04..7e5ab20b 100644 --- a/src/react-app/lib/router/gameShellRegistry.ts +++ b/src/react-app/lib/router/gameShellRegistry.ts @@ -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) || diff --git a/src/react-app/router.tsx b/src/react-app/router.tsx index c2b997fd..ef333238 100644 --- a/src/react-app/router.tsx +++ b/src/react-app/router.tsx @@ -916,6 +916,18 @@ export const router = createBrowserRouter( '【全部战绩】', )} /> + import('@app/routes/game/journal/route'))} + handle={scene( + { + id: 'journal', + presentation: 'archive', + summary: '翻阅道具、修为、感悟与各类货币的得失。', + }, + '修仙日志', + )} + /> import('@app/routes/game/rankings/route'))} @@ -1118,6 +1130,7 @@ export const router = createBrowserRouter( }> + import('@app/routes/game/combat-v6/arena/route'))} handle={scene({ id: 'hunt', chrome: 'immersive', dock: 'hidden' }, '结伴讨伐')} /> 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 ( {session ? ( <> void leave()} - back="/game/arena" - backLabel="返回擂台" + endAction={ + session.hunt && session.outcome ? ( + void leave()} /> + ) : undefined + } + back={back} + backLabel={backLabel} /> ) : null} diff --git a/src/react-app/routes/game/inventory/InventoryV6.tsx b/src/react-app/routes/game/inventory/InventoryV6.tsx index ce796c4f..bca723ae 100644 --- a/src/react-app/routes/game/inventory/InventoryV6.tsx +++ b/src/react-app/routes/game/inventory/InventoryV6.tsx @@ -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(defaultInventoryFilter); + const [sort, setSort] = useState(); const [selecting, setSelecting] = useState(false); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [storage, setData] = useState(); @@ -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(`${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() {
{ clearSelection(); setFilter(value); @@ -385,7 +398,7 @@ export default function InventoryV6() { {location === 'bag' ? ( void act({ action: 'sort', @@ -418,7 +431,7 @@ export default function InventoryV6() { ({ @@ -456,7 +469,7 @@ export default function InventoryV6() { )} {visibleData && visibleItems.length === 0 && - (location === 'storage' || filtered) ? ( + (location === 'storage' || filtered || !!sort) ? (

{filtered ? '暂无符合筛选条件的物品' : '暂无物品'}

diff --git a/src/react-app/routes/game/journal/route.tsx b/src/react-app/routes/game/journal/route.tsx new file mode 100644 index 00000000..7a40451b --- /dev/null +++ b/src/react-app/routes/game/journal/route.tsx @@ -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 ( + + setParams(value ? { type: value } : {})} + items={[ + { label: '全部', value: '' }, + { label: '闭关', value: 'retreat.completed' }, + { label: '突破', value: 'breakthrough.completed' }, + { label: '其他玩法', value: 'resources.settled' }, + ]} + /> + {cultivatorId ? ( + + ) : null} + + ); +} + +function JournalList({ + cultivatorId, + type, +}: { + cultivatorId: string; + type: string; +}) { + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [nextCursor, setNextCursor] = useState(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 ( + <> +
    + {items.map((item) => ( +
  1. + + +
  2. + ))} +
+ {loading ? ( + + ) : null} + {error ? ( +
+ {error} + { + setError(''); + setLoading(true); + setAttempt((value) => value + 1); + }} + > + 重试 + +
+ ) : null} + {!loading && !error && !items.length ? ( + + 尚无记录。道具、修为、感悟与各类货币的得失,会记在这里。 + + ) : null} + {!loading && !error && nextCursor ? ( + { + setLoading(true); + setCursor(nextCursor); + }} + > + 加载更多 + + ) : null} + + ); +} diff --git a/src/react-app/routes/game/mail/route.tsx b/src/react-app/routes/game/mail/route.tsx index 09198c5b..a7467cb9 100644 --- a/src/react-app/routes/game/mail/route.tsx +++ b/src/react-app/routes/game/mail/route.tsx @@ -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) => diff --git a/src/react-app/routes/game/map-v2/AtlasPhaserRuntime.ts b/src/react-app/routes/game/map-v2/AtlasPhaserRuntime.ts index 675d9a7d..8996f48a 100644 --- a/src/react-app/routes/game/map-v2/AtlasPhaserRuntime.ts +++ b/src/react-app/routes/game/map-v2/AtlasPhaserRuntime.ts @@ -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), diff --git a/src/react-app/routes/game/map-v2/route.tsx b/src/react-app/routes/game/map-v2/route.tsx index bb76bd59..c2ae993b 100644 --- a/src/react-app/routes/game/map-v2/route.tsx +++ b/src/react-app/routes/game/map-v2/route.tsx @@ -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(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([]); + 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() { >
+ {!teachingMap && !error && (!isAtlas || !loading) ? ( + { + setFocusId(event.nodeId); + setFocusRequest((n) => n + 1); + setSearchOpen(false); + setFilterOpen(false); + navigate(huntMapHref(event)); + }} + /> + ) : null} + {huntEventId ? ( + { + const next = new URLSearchParams(params); + next.delete('hunt'); + setParams(next, { replace: true }); + }} + /> + ) : null} + {huntFeed.error ? ( +

+ 讨伐消息暂不可用{' '} + +

+ ) : null} {isAtlas && loading && !error ? (
diff --git a/src/server/lib/drizzle/schema.ts b/src/server/lib/drizzle/schema.ts index 79d570f7..ebabe649 100644 --- a/src/server/lib/drizzle/schema.ts +++ b/src/server/lib/drizzle/schema.ts @@ -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(), + 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', { diff --git a/src/server/lib/mq/combatV6Messaging.ts b/src/server/lib/mq/combatV6Messaging.ts index 02b8e08e..6efd7b74 100644 --- a/src/server/lib/mq/combatV6Messaging.ts +++ b/src/server/lib/mq/combatV6Messaging.ts @@ -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 { export async function startCombatV6Messaging(): Promise { startArenaV6Coordinator(); + startHuntWorld(); if (consumerTask) return; stopping = false; const jetStream = await getJetStreamClient(); @@ -160,6 +162,7 @@ export async function startCombatV6Messaging(): Promise { } export async function stopCombatV6Messaging(): Promise { + await stopHuntWorld(); await stopArenaV6Coordinator(); stopping = true; if (timer) clearInterval(timer); diff --git a/src/server/lib/mq/domainEventRegistry.ts b/src/server/lib/mq/domainEventRegistry.ts index 9c5684e5..5e7f45d3 100644 --- a/src/server/lib/mq/domainEventRegistry.ts +++ b/src/server/lib/mq/domainEventRegistry.ts @@ -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 { 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 { acceptedTypes: [ 'cultivator.realm.changed', 'craft.item.created', + 'equipment.forged', 'market.material.revealed', 'ranking.position.changed', 'beast.exceptional.acquired', diff --git a/src/server/lib/repositories/messageConsumptionRepository.ts b/src/server/lib/repositories/messageConsumptionRepository.ts index 51bc6c5d..6e20a7f3 100644 --- a/src/server/lib/repositories/messageConsumptionRepository.ts +++ b/src/server/lib/repositories/messageConsumptionRepository.ts @@ -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 }); diff --git a/src/server/lib/repositories/playerJournalRepository.ts b/src/server/lib/repositories/playerJournalRepository.ts new file mode 100644 index 00000000..ec8d8d2f --- /dev/null +++ b/src/server/lib/repositories/playerJournalRepository.ts @@ -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, +): Promise { + const rows = await getExecutor() + .select({ + id: playerJournal.id, + event: sql`${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, + }; +} diff --git a/src/server/lib/repositories/playerStateRepository.ts b/src/server/lib/repositories/playerStateRepository.ts index eacc0aad..6022f31c 100644 --- a/src/server/lib/repositories/playerStateRepository.ts +++ b/src/server/lib/repositories/playerStateRepository.ts @@ -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( diff --git a/src/server/lib/services/BagConsumables.ts b/src/server/lib/services/BagConsumables.ts index 30ce3f82..6191a6d6 100644 --- a/src/server/lib/services/BagConsumables.ts +++ b/src/server/lib/services/BagConsumables.ts @@ -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, diff --git a/src/server/lib/services/CommandExecutors.ts b/src/server/lib/services/CommandExecutors.ts index e747f3d4..252d8076 100644 --- a/src/server/lib/services/CommandExecutors.ts +++ b/src/server/lib/services/CommandExecutors.ts @@ -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 = { result: TResult; resourceChanges: ResourceChangeDescriptor[]; + journalEvent?: PlayerJournalEvent; +}; + +type JournalExecution = { + operationKey: string; + fingerprint: string; + replay(event: PlayerJournalEvent): TResult; }; export type CommittedCommand = { @@ -82,6 +92,7 @@ export class PlayerCommandExecutor { source: string; requestId?: string | null; idempotency?: { key: string; fingerprint: string }; + journal?: JournalExecution; allowEmpty?: boolean; lock?: { context?: string; @@ -111,11 +122,15 @@ export class PlayerCommandExecutor { source: string; requestId?: string | null; idempotency?: { key: string; fingerprint: string }; + journal?: JournalExecution; allowEmpty?: boolean; command(tx: DbTransaction): Promise>; coordination: PlayerCommandCoordination; }, ): Promise> { + 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, diff --git a/src/server/lib/services/CraftApplicationService.ts b/src/server/lib/services/CraftApplicationService.ts index 9e943b23..101577fe 100644 --- a/src/server/lib/services/CraftApplicationService.ts +++ b/src/server/lib/services/CraftApplicationService.ts @@ -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; craftType: 'alchemy'; @@ -40,6 +41,14 @@ export async function executeCraftCommand(args: { input: CraftCommandInput; }): Promise> { 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) | 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, diff --git a/src/server/lib/services/EnlightenmentService.ts b/src/server/lib/services/EnlightenmentService.ts index 653a5cd7..c0bd5dbc 100644 --- a/src/server/lib/services/EnlightenmentService.ts +++ b/src/server/lib/services/EnlightenmentService.ts @@ -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, diff --git a/src/server/lib/services/ForgingService.ts b/src/server/lib/services/ForgingService.ts index 5b11d25f..6b6c260f 100644 --- a/src/server/lib/services/ForgingService.ts +++ b/src/server/lib/services/ForgingService.ts @@ -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 }; }, ); diff --git a/src/server/lib/services/InventoryService.ts b/src/server/lib/services/InventoryService.ts index d1737d12..c63dd5f4 100644 --- a/src/server/lib/services/InventoryService.ts +++ b/src/server/lib/services/InventoryService.ts @@ -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`${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, diff --git a/src/server/lib/services/JournalSettlement.ts b/src/server/lib/services/JournalSettlement.ts new file mode 100644 index 00000000..ee50ad86 --- /dev/null +++ b/src/server/lib/services/JournalSettlement.ts @@ -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 | 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( + tx: DbTransaction, + owner: string, + activity: JournalActivity, + run: () => Promise, +): Promise<{ + value: T; + event: Extract; +}> { + 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( + tx: DbTransaction, + owner: string, + activity: JournalActivity, + operationId: string, + run: () => Promise, +): Promise { + 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; +} diff --git a/src/server/lib/services/PlayerMailApplicationService.ts b/src/server/lib/services/PlayerMailApplicationService.ts index 0340d2b8..f670c133 100644 --- a/src/server/lib/services/PlayerMailApplicationService.ts +++ b/src/server/lib/services/PlayerMailApplicationService.ts @@ -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[]) || [], ); diff --git a/src/server/lib/services/RetreatApplicationService.ts b/src/server/lib/services/RetreatApplicationService.ts index e30c35ce..c1dae385 100644 --- a/src/server/lib/services/RetreatApplicationService.ts +++ b/src/server/lib/services/RetreatApplicationService.ts @@ -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; 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; 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, diff --git a/src/server/lib/services/WorldRumorDomainEventProjector.ts b/src/server/lib/services/WorldRumorDomainEventProjector.ts index e493a89c..c5871ce1 100644 --- a/src/server/lib/services/WorldRumorDomainEventProjector.ts +++ b/src/server/lib/services/WorldRumorDomainEventProjector.ts @@ -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 { diff --git a/src/server/lib/services/YieldApplicationService.ts b/src/server/lib/services/YieldApplicationService.ts index aaa3b306..9ff3efde 100644 --- a/src/server/lib/services/YieldApplicationService.ts +++ b/src/server/lib/services/YieldApplicationService.ts @@ -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({ + 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(); diff --git a/src/server/lib/services/combat-v6/CombatActivityNotice.ts b/src/server/lib/services/combat-v6/CombatActivityNotice.ts index 1d8af01b..d83075d8 100644 --- a/src/server/lib/services/combat-v6/CombatActivityNotice.ts +++ b/src/server/lib/services/combat-v6/CombatActivityNotice.ts @@ -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 { 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)}`, ); } diff --git a/src/server/lib/services/combat-v6/CombatV6ArenaService.ts b/src/server/lib/services/combat-v6/CombatV6ArenaService.ts index 412f50e0..0b2ff518 100644 --- a/src/server/lib/services/combat-v6/CombatV6ArenaService.ts +++ b/src/server/lib/services/combat-v6/CombatV6ArenaService.ts @@ -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( } } -export async function createArenaV6(room: ArenaRoomV1): Promise { +export async function createArenaV6( + room: ArenaRoomV1, + hunt?: Pick, +): Promise { 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 { const units: ArenaRuntime['units'] = []; const unitAppearances: Record = {}; const participants: ArenaRuntime['participants'] = []; + const huntRewards: NonNullable = {}; const autoStrategies: NonNullable = {}; for (const seat of seats) { if (await hasActiveCombat(seat.cultivatorId)) @@ -115,6 +139,12 @@ export async function createArenaV6(room: ArenaRoomV1): Promise { ), }); 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 { }); 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 { 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 { 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); +} diff --git a/src/server/lib/services/combat-v6/CombatV6ArenaStore.ts b/src/server/lib/services/combat-v6/CombatV6ArenaStore.ts index ae8cb76e..87d33842 100644 --- a/src/server/lib/services/combat-v6/CombatV6ArenaStore.ts +++ b/src/server/lib/services/combat-v6/CombatV6ArenaStore.ts @@ -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 diff --git a/src/server/lib/services/combat-v6/CombatV6ConditionProjector.ts b/src/server/lib/services/combat-v6/CombatV6ConditionProjector.ts index c7b4fcd8..93805d9b 100644 --- a/src/server/lib/services/combat-v6/CombatV6ConditionProjector.ts +++ b/src/server/lib/services/combat-v6/CombatV6ConditionProjector.ts @@ -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()); diff --git a/src/server/lib/services/combat-v6/CombatV6SectTaskService.ts b/src/server/lib/services/combat-v6/CombatV6SectTaskService.ts index 10be0e52..018cad31 100644 --- a/src/server/lib/services/combat-v6/CombatV6SectTaskService.ts +++ b/src/server/lib/services/combat-v6/CombatV6SectTaskService.ts @@ -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); } diff --git a/src/server/lib/services/hunts/HuntRewardProjector.ts b/src/server/lib/services/hunts/HuntRewardProjector.ts new file mode 100644 index 00000000..edc6cef0 --- /dev/null +++ b/src/server/lib/services/hunts/HuntRewardProjector.ts @@ -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 { + 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, + }; +} diff --git a/src/server/lib/services/hunts/HuntRewardService.ts b/src/server/lib/services/hunts/HuntRewardService.ts new file mode 100644 index 00000000..e43b1a95 --- /dev/null +++ b/src/server/lib/services/hunts/HuntRewardService.ts @@ -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 ?? '', + }), + })), + ], + }); +} diff --git a/src/server/lib/services/hunts/HuntTeamService.ts b/src/server/lib/services/hunts/HuntTeamService.ts new file mode 100644 index 00000000..e10210db --- /dev/null +++ b/src/server/lib/services/hunts/HuntTeamService.ts @@ -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 = (task: (lease: RedisLeaseContext) => Promise) => + withRedisLock( + { + key: 'lock:hunt:v1:teams', + context: 'hunt-team', + timeoutMs: 60000, + retries: 10, + }, + task, + ); + +async function readTeam(id: string): Promise { + 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 { + 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 { + 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); + } + }); +} diff --git a/src/server/lib/services/hunts/HuntWorldService.ts b/src/server/lib/services/hunts/HuntWorldService.ts new file mode 100644 index 00000000..d2423e0b --- /dev/null +++ b/src/server/lib/services/hunts/HuntWorldService.ts @@ -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 | undefined; +let task: Promise | 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; +} diff --git a/src/server/lib/services/resource/ResourceEngine.ts b/src/server/lib/services/resource/ResourceEngine.ts index 0d02533b..6c7b20fd 100644 --- a/src/server/lib/services/resource/ResourceEngine.ts +++ b/src/server/lib/services/resource/ResourceEngine.ts @@ -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', diff --git a/src/server/lib/services/spirit-field/SpiritFieldService.ts b/src/server/lib/services/spirit-field/SpiritFieldService.ts index 3a2fd201..34cd23ee 100644 --- a/src/server/lib/services/spirit-field/SpiritFieldService.ts +++ b/src/server/lib/services/spirit-field/SpiritFieldService.ts @@ -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); diff --git a/src/server/routes/api/craft.router.ts b/src/server/routes/api/craft.router.ts index e49d59e1..006015c8 100644 --- a/src/server/routes/api/craft.router.ts +++ b/src/server/routes/api/craft.router.ts @@ -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({ diff --git a/src/server/routes/api/cultivator/mail.router.ts b/src/server/routes/api/cultivator/mail.router.ts index d1fa7e45..2626381d 100644 --- a/src/server/routes/api/cultivator/mail.router.ts +++ b/src/server/routes/api/cultivator/mail.router.ts @@ -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, diff --git a/src/server/routes/api/cultivator/retreat.router.ts b/src/server/routes/api/cultivator/retreat.router.ts index f51911b8..91c45c45 100644 --- a/src/server/routes/api/cultivator/retreat.router.ts +++ b/src/server/routes/api/cultivator/retreat.router.ts @@ -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, 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(); +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 }, diff --git a/src/server/routes/api/cultivator/yield.router.ts b/src/server/routes/api/cultivator/yield.router.ts index 5194189b..3b82a3b7 100644 --- a/src/server/routes/api/cultivator/yield.router.ts +++ b/src/server/routes/api/cultivator/yield.router.ts @@ -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(); -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, diff --git a/src/server/routes/api/hunts.router.ts b/src/server/routes/api/hunts.router.ts new file mode 100644 index 00000000..6811f331 --- /dev/null +++ b/src/server/routes/api/hunts.router.ts @@ -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(); +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, + ), + }), +); +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; diff --git a/src/server/routes/api/index.ts b/src/server/routes/api/index.ts index 88c59cb9..1dcd44e7 100644 --- a/src/server/routes/api/index.ts +++ b/src/server/routes/api/index.ts @@ -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); diff --git a/src/server/routes/api/player-journal.router.ts b/src/server/routes/api/player-journal.router.ts new file mode 100644 index 00000000..49c38d2d --- /dev/null +++ b/src/server/routes/api/player-journal.router.ts @@ -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(); +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>(c); + c.header('Cache-Control', 'no-store'); + return c.json({ + success: true, + data: await listPlayerJournal(cultivatorId, query), + }); + }, +); +export default router; diff --git a/src/shared/combat-v6/arena.ts b/src/shared/combat-v6/arena.ts index fa98cd9d..393936bb 100644 --- a/src/shared/combat-v6/arena.ts +++ b/src/shared/combat-v6/arena.ts @@ -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(), diff --git a/src/shared/contracts/combatV6Arena.ts b/src/shared/contracts/combatV6Arena.ts index 20043a6f..b7e98bc0 100644 --- a/src/shared/contracts/combatV6Arena.ts +++ b/src/shared/contracts/combatV6Arena.ts @@ -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; 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; diff --git a/src/shared/contracts/combatV6Replay.ts b/src/shared/contracts/combatV6Replay.ts index 933cd959..92ba7b0c 100644 --- a/src/shared/contracts/combatV6Replay.ts +++ b/src/shared/contracts/combatV6Replay.ts @@ -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(), diff --git a/src/shared/contracts/combatV6Runtime.ts b/src/shared/contracts/combatV6Runtime.ts index 1324dc9a..c4b4d62a 100644 --- a/src/shared/contracts/combatV6Runtime.ts +++ b/src/shared/contracts/combatV6Runtime.ts @@ -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({ diff --git a/src/shared/contracts/domainEvents.ts b/src/shared/contracts/domainEvents.ts index e3dd7369..b4275a66 100644 --- a/src/shared/contracts/domainEvents.ts +++ b/src/shared/contracts/domainEvents.ts @@ -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`, diff --git a/src/shared/contracts/hunts.ts b/src/shared/contracts/hunts.ts new file mode 100644 index 00000000..44bb2d4d --- /dev/null +++ b/src/shared/contracts/hunts.ts @@ -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; +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; +}; diff --git a/src/shared/contracts/inventory.ts b/src/shared/contracts/inventory.ts index fb08b1db..1d1e1351 100644 --- a/src/shared/contracts/inventory.ts +++ b/src/shared/contracts/inventory.ts @@ -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; 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; diff --git a/src/shared/contracts/playerJournal.ts b/src/shared/contracts/playerJournal.ts new file mode 100644 index 00000000..523d39ff --- /dev/null +++ b/src/shared/contracts/playerJournal.ts @@ -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; + +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; + +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; + +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; + +// 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; + +/** 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; + +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(); diff --git a/src/shared/contracts/resources/bag.test.ts b/src/shared/contracts/resources/bag.test.ts index 90be8012..5e714098 100644 --- a/src/shared/contracts/resources/bag.test.ts +++ b/src/shared/contracts/resources/bag.test.ts @@ -19,6 +19,7 @@ describe('完整背包资源契约', () => { instanceData: null, stackKey: null, name: '长春功玉简', + updatedAt: '2026-09-30T00:00:00.000Z', equipped: false, }, ], diff --git a/src/shared/contracts/resources/bag.ts b/src/shared/contracts/resources/bag.ts index 72a146de..ee33e2ca 100644 --- a/src/shared/contracts/resources/bag.ts +++ b/src/shared/contracts/resources/bag.ts @@ -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(), diff --git a/src/shared/contracts/retreat.ts b/src/shared/contracts/retreat.ts index f0da3074..340dd2eb 100644 --- a/src/shared/contracts/retreat.ts +++ b/src/shared/contracts/retreat.ts @@ -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; + export type RetreatAction = 'cultivate' | 'breakthrough'; export type RetreatStoryType = 'breakthrough' | 'lifespan'; diff --git a/src/shared/engine/combat-v6/hunts/content.test.ts b/src/shared/engine/combat-v6/hunts/content.test.ts new file mode 100644 index 00000000..42b17b9c --- /dev/null +++ b/src/shared/engine/combat-v6/hunts/content.test.ts @@ -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, + playerCommands: Record = {}, +) { + 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 = { + 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!, + ); + } + } + }); +}); diff --git a/src/shared/engine/combat-v6/hunts/content.ts b/src/shared/engine/combat-v6/hunts/content.ts new file mode 100644 index 00000000..9ea1a710 --- /dev/null +++ b/src/shared/engine/combat-v6/hunts/content.ts @@ -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, + 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 }; +} diff --git a/src/shared/engine/cultivation/CultivationEngine.test.ts b/src/shared/engine/cultivation/CultivationEngine.test.ts index 6ccd2784..08ffbf8a 100644 --- a/src/shared/engine/cultivation/CultivationEngine.test.ts +++ b/src/shared/engine/cultivation/CultivationEngine.test.ts @@ -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', () => { diff --git a/src/shared/engine/cultivation/CultivationEngine.ts b/src/shared/engine/cultivation/CultivationEngine.ts index dfa1b1f2..947aecbf 100644 --- a/src/shared/engine/cultivation/CultivationEngine.ts +++ b/src/shared/engine/cultivation/CultivationEngine.ts @@ -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, }, diff --git a/src/shared/hunts/config.ts b/src/shared/hunts/config.ts new file mode 100644 index 00000000..2318d337 --- /dev/null +++ b/src/shared/hunts/config.ts @@ -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)}`; +} diff --git a/src/shared/hunts/hunts.test.ts b/src/shared/hunts/hunts.test.ts new file mode 100644 index 00000000..3e16dc10 --- /dev/null +++ b/src/shared/hunts/hunts.test.ts @@ -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()); + 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); + }); +}); diff --git a/src/shared/hunts/rules.ts b/src/shared/hunts/rules.ts new file mode 100644 index 00000000..9614f4cd --- /dev/null +++ b/src/shared/hunts/rules.ts @@ -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, + 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]; +} diff --git a/src/shared/inventory/alchemy.test.ts b/src/shared/inventory/alchemy.test.ts index 2f96d2cd..22b5f855 100644 --- a/src/shared/inventory/alchemy.test.ts +++ b/src/shared/inventory/alchemy.test.ts @@ -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, }), ); diff --git a/src/shared/inventory/index.ts b/src/shared/inventory/index.ts index 19ecfd71..7c0c4fe4 100644 --- a/src/shared/inventory/index.ts +++ b/src/shared/inventory/index.ts @@ -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[], diff --git a/src/shared/inventory/inventory.test.ts b/src/shared/inventory/inventory.test.ts index 5120fa9a..87c1ec0e 100644 --- a/src/shared/inventory/inventory.test.ts +++ b/src/shared/inventory/inventory.test.ts @@ -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', diff --git a/src/shared/inventory/sorting.test.ts b/src/shared/inventory/sorting.test.ts new file mode 100644 index 00000000..7046cf94 --- /dev/null +++ b/src/shared/inventory/sorting.test.ts @@ -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, + ); + }); +}); diff --git a/src/shared/inventory/sorting.ts b/src/shared/inventory/sorting.ts new file mode 100644 index 00000000..d0566bde --- /dev/null +++ b/src/shared/inventory/sorting.ts @@ -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( + 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) + ); + }); +} diff --git a/src/shared/inventory/stack-key.test.ts b/src/shared/inventory/stack-key.test.ts index 23ff9687..ac82f07e 100644 --- a/src/shared/inventory/stack-key.test.ts +++ b/src/shared/inventory/stack-key.test.ts @@ -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); +}); diff --git a/src/shared/inventory/stack-key.ts b/src/shared/inventory/stack-key.ts index c438fd27..06611499 100644 --- a/src/shared/inventory/stack-key.ts +++ b/src/shared/inventory/stack-key.ts @@ -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); diff --git a/src/shared/items/alchemyShowcase.test.ts b/src/shared/items/alchemyShowcase.test.ts new file mode 100644 index 00000000..574345b9 --- /dev/null +++ b/src/shared/items/alchemyShowcase.test.ts @@ -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(); + }); +}); diff --git a/src/shared/items/alchemyShowcase.ts b/src/shared/items/alchemyShowcase.ts new file mode 100644 index 00000000..89b4c62a --- /dev/null +++ b/src/shared/items/alchemyShowcase.ts @@ -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 | 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, + }; +} diff --git a/src/shared/items/forgingShowcase.test.ts b/src/shared/items/forgingShowcase.test.ts new file mode 100644 index 00000000..f19a4aa4 --- /dev/null +++ b/src/shared/items/forgingShowcase.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { DomainEventDataSchemas } from '../contracts/domainEvents'; +import { generateForgedEquipment } from '../engine/combat-v6/equipment/forging'; +import { forgingShowcaseSnapshot } from './forgingShowcase'; +import { isInventoryShowcase } from './showcase'; + +function equipment() { + const result = generateForgedEquipment({ + id: 'forge-showcase', + createdAt: '2026-09-30T00:00:00Z', + seed: 1, + templateId: 'dao_equipment.standard.weapon.v1', + equipmentLevel: 10, + weaponType: 'sword', + boosts: { ore: 0, essence: 0, attributes: 0 }, + }); + if (!result.ok) throw new Error('equipment generation failed'); + return { + ...result.instance, + crafterName: '铸器道友', + essenceIds: [] as string[], + artId: undefined as string | undefined, + }; +} + +describe('forging showcases', () => { + it('does not showcase ordinary equipment', () => { + expect(forgingShowcaseSnapshot(equipment())).toBeUndefined(); + }); + + it.each([ + { artId: 'dao_equipment.art.huiyuan', essenceIds: [] }, + { artId: undefined, essenceIds: ['dao_equipment.essence.cangfeng'] }, + { + artId: 'dao_equipment.art.huiyuan', + essenceIds: ['dao_equipment.essence.cangfeng'], + }, + ])( + 'showcases equipment with either or both special properties: %j', + (special) => { + const item = { ...equipment(), ...special }; + const data = DomainEventDataSchemas['equipment.forged'].parse({ + userId: '11111111-1111-4111-8111-111111111111', + cultivatorId: '22222222-2222-4222-8222-222222222222', + cultivatorName: item.crafterName, + equipment: item, + }); + const snapshot = forgingShowcaseSnapshot(data.equipment); + expect(snapshot).toEqual({ + definitionId: 'equipment.v6', + name: item.name, + quantity: 1, + instanceData: item, + }); + expect(isInventoryShowcase({ version: 1, snapshot })).toBe(true); + item.essenceIds.push('later-change'); + item.name = 'later-name'; + expect(snapshot?.name).not.toBe(item.name); + expect(snapshot?.instanceData).not.toEqual(item); + }, + ); + + it('rejects incomplete equipment snapshots at the event boundary', () => { + expect( + DomainEventDataSchemas['equipment.forged'].safeParse({ + userId: '11111111-1111-4111-8111-111111111111', + cultivatorId: '22222222-2222-4222-8222-222222222222', + cultivatorName: '铸器道友', + equipment: { artId: 'art' }, + }).success, + ).toBe(false); + }); +}); diff --git a/src/shared/items/forgingShowcase.ts b/src/shared/items/forgingShowcase.ts new file mode 100644 index 00000000..11dac67c --- /dev/null +++ b/src/shared/items/forgingShowcase.ts @@ -0,0 +1,16 @@ +import type { z } from 'zod'; +import type { InventoryEquipmentSchema } from '../inventory/equipment'; +import { EQUIPMENT_ITEM } from './definitions/equipment'; +import type { InventoryShowcaseSnapshot } from './showcase'; + +export function forgingShowcaseSnapshot( + equipment: z.infer, +): InventoryShowcaseSnapshot | undefined { + if (!equipment.artId && equipment.essenceIds.length === 0) return undefined; + return { + definitionId: EQUIPMENT_ITEM.id, + name: equipment.name, + quantity: 1, + instanceData: structuredClone(equipment), + }; +} diff --git a/src/shared/lib/consumables.ts b/src/shared/lib/consumables.ts index e487736c..1e060703 100644 --- a/src/shared/lib/consumables.ts +++ b/src/shared/lib/consumables.ts @@ -118,7 +118,7 @@ function sortJsonValue(value: unknown): unknown { return value; } -export function stableSerializeConsumableSpec(spec: ConsumableSpec): string { +export function stableSerializeConsumableSpec(spec: T): string { return JSON.stringify(sortJsonValue(spec)); } diff --git a/src/shared/playerJournal.test.ts b/src/shared/playerJournal.test.ts new file mode 100644 index 00000000..66d63aab --- /dev/null +++ b/src/shared/playerJournal.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + PlayerJournalEventSchema, + StoredJournalEventSchema, + retreatResultFromJournal, +} from './contracts/playerJournal'; +import type { InventoryItem } from './inventory'; +import { + compactJournalChanges, + inventoryJournalChanges, +} from './playerJournal'; + +const item: InventoryItem = { + id: 'ore', + definitionId: 'material.v1', + quantity: 8, + location: 'bag', + slotIndex: 0, + revision: 0, + stackKey: null, + instanceData: { name: '玄铁', type: 'ore', rank: '凡品' }, +}; + +describe('journal settlement changes', () => { + it('records the increment of an existing stack, not the final stack quantity', () => { + expect( + inventoryJournalChanges([item], [{ ...item, quantity: 11 }]), + ).toEqual([{ kind: 'item', id: 'ore', name: '玄铁', amount: 3 }]); + }); + it('records consumed and new items together, including fully removed stacks', () => { + expect( + inventoryJournalChanges([item], [{ ...item, id: 'new', quantity: 2 }]), + ).toEqual([ + { kind: 'item', id: 'ore', name: '玄铁', amount: -8 }, + { kind: 'item', id: 'new', name: '玄铁', amount: 2 }, + ]); + }); + it('ignores location/revision changes and does not mutate the snapshots', () => { + const before = structuredClone(item); + expect( + inventoryJournalChanges( + [item], + [{ ...item, revision: 1, location: 'storage', slotIndex: null }], + ), + ).toEqual([]); + expect(item).toEqual(before); + }); + it('keeps costs and gains distinct while combining repeated same-direction writes', () => { + const changes = compactJournalChanges([ + { kind: 'item', id: 'ore', name: '玄铁', amount: -3 }, + { kind: 'item', id: 'ore', name: '玄铁', amount: -2 }, + { kind: 'item', id: 'ore', name: '玄铁', amount: 5 }, + { kind: 'resource', resource: 'exp', amount: 0 }, + ]); + expect(changes.map((change) => change.amount)).toEqual([-5, 5]); + }); + it('stores the replay result but excludes it from the public event contract', () => { + const event = { + type: 'resources.settled', + activity: 'mail_claim', + changes: [], + result: { internal: 'private' }, + }; + expect(StoredJournalEventSchema.parse(event).result).toEqual({ + internal: 'private', + }); + expect(PlayerJournalEventSchema.parse(event)).not.toHaveProperty('result'); + }); + it('does not interpret new events as breakthrough receipts', () => { + const event = PlayerJournalEventSchema.parse({ + type: 'resources.settled', + activity: 'yield_claim', + changes: [], + }); + expect(() => retreatResultFromJournal(event)).toThrow('非闭关突破执行记录'); + }); + it.each(['reputation', 'contribution'] as const)( + 'accepts standalone %s gains and costs without other resources', + (resource) => { + for (const amount of [10, -10]) { + const event = { + type: 'resources.settled', + activity: 'sect_task_action', + changes: [{ kind: 'resource', resource, amount }], + }; + expect(PlayerJournalEventSchema.parse(event)).toEqual(event); + } + expect( + PlayerJournalEventSchema.safeParse({ + type: 'resources.settled', + activity: 'sect_task_action', + changes: [{ kind: 'resource', resource, amount: 0 }], + }).success, + ).toBe(false); + }, + ); +}); diff --git a/src/shared/playerJournal.ts b/src/shared/playerJournal.ts new file mode 100644 index 00000000..b6779dd2 --- /dev/null +++ b/src/shared/playerJournal.ts @@ -0,0 +1,52 @@ +import type { JournalChange } from './contracts/playerJournal'; +import type { InventoryItem } from './inventory'; +import { findItemDefinition } from './items/registry'; + +/** Quantity changes only: moving/equipping an existing item is not a gain. */ +export function inventoryJournalChanges( + before: readonly InventoryItem[], + after: readonly InventoryItem[], +): JournalChange[] { + const previous = new Map(before.map((item) => [item.id, item])); + const next = new Map(after.map((item) => [item.id, item])); + const changes: JournalChange[] = []; + for (const id of new Set([...previous.keys(), ...next.keys()])) { + const old = previous.get(id); + const current = next.get(id); + const amount = (current?.quantity ?? 0) - (old?.quantity ?? 0); + if (!amount) continue; + const item = current ?? old!; + const facts = item.instanceData as { name?: string } | null; + changes.push({ + kind: 'item', + id, + amount, + name: + facts?.name ?? + findItemDefinition(item.definitionId)?.name ?? + item.definitionId, + }); + } + return changes; +} + +/** Keep costs and rewards separate, even when they concern the same item. */ +export function compactJournalChanges( + changes: readonly JournalChange[], +): JournalChange[] { + const grouped = new Map(); + for (const change of changes) { + if (!change.amount) continue; + const key = JSON.stringify([ + change.kind, + change.kind === 'item' ? change.id : change.resource, + Math.sign(change.amount), + ]); + const previous = grouped.get(key); + grouped.set(key, { + ...change, + amount: (previous?.amount ?? 0) + change.amount, + }); + } + return [...grouped.values()]; +} diff --git a/src/shared/rewards/data/README.md b/src/shared/rewards/data/README.md index c25f50dc..18b2650c 100644 --- a/src/shared/rewards/data/README.md +++ b/src/shared/rewards/data/README.md @@ -51,3 +51,13 @@ 2026-09-16 本地验收:本地道友1在页面领取满24小时收益,邮件为5件材料、1枚魂生灵印、2张炼气图纸,共8件;正常领取成功,数据库只读核对快照与邮件一致,法冠由1增至2、腰封和魂生各新增1。重试去重和旧事件兼容经过代码检查,未进行消息故障注入或旧事件重放。 2026-09-16 副本材料库接入验收:本地道友1在青溪灵草坡探索第2轮获得灵品「雷痕灰」1件,途中记录及退出结算显示真实名称、品质、雷元素和描述;退出后只读核对统一库存为 `material.v1` 且实例事实完整,已返回洞府结束本次活动。相关12个测试文件82项通过,lint、前后端构建通过。图纸境界映射和奖励去重由共享测试覆盖;本轮未实测战斗胜利/完整通关、高境界副本、移动端或消息故障重放,未运行全量测试或部署。 + +## 结伴讨伐 + +`hunt.json` 使用通用掉落池,业务校验补充物品引用与必掉材料约束。 + +- 基础资源独立于物品池,复用挂机历练计算器,输入 BOSS 境界、中期、6 小时;不更新角色的 `last_yield_at`。 +- `materials` 必掉2件材料,品质使用副本分布,发布材料库提供完整的 `material.v1` 事实。 +- `book` 组20%概率,显式配置当前开放的67种灵印(普通34种、上品33种),每种普通权重4、上品权重1;命中灵印后,上品占33/169,约19.53%,即每次有奖励资格的胜利约3.91%掉落上品灵印。新注册灵印不会自动加入。`dew` 组20%概率,普通/上品灵露权重3:1。两个组独立,可同时命中。 +- 出现 ID、角色 ID、池版本和组名组成稳定的随机身份,开战前冻结完整奖励。调整概率或条目时递增 `version`,已开始的战斗沿用快照。 +- 首次胜利在同一事务保存领奖凭证、实际资源增量和道具邮件;重复助战不发任何资源或道具。历史灵石单项奖励保持原样,不追补新奖励。 diff --git a/src/shared/rewards/data/hunt.json b/src/shared/rewards/data/hunt.json new file mode 100644 index 00000000..6dd34145 --- /dev/null +++ b/src/shared/rewards/data/hunt.json @@ -0,0 +1,374 @@ +{ + "id": "hunt.v1.items", + "version": 2, + "groups": [ + { + "id": "materials", + "chance": 1, + "entries": [ + { + "rewardId": "hunt.material", + "weight": 1, + "quantity": { "min": 2, "max": 2 } + } + ] + }, + { + "id": "book", + "chance": 0.2, + "entries": [ + { + "rewardId": "book.beast.combo", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.counter", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.critical", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spell-critical", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-combo", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-counter", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spirit-flame", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-critical", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-spell-critical", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.regeneration", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-regeneration", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.meditation", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-meditation", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.agility", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-agility", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spell-mastery", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-spell-mastery", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.sluggish", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.thunder", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.falling-rock", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.water-attack", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.lifesteal", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-lifesteal", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.reflection", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-reflection", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.divine-revival", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-divine-revival", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spell-reflection", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-spell-reflection", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.wisdom", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-wisdom", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.sneak-attack", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-sneak-attack", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spell-resistance", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-spell-resistance", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.parry", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-parry", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.defense", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-defense", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.strength", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-strength", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.thunderstorm", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.mountain-crush", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.flood", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.wildfire", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spell-combo", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-spell-combo", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.spell-fluctuation", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-spell-fluctuation", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.stealth", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-stealth", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.perception", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-perception", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.poison", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-poison", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.miracle", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-miracle", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.concentration", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-concentration", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.eternity", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-eternity", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.ghost", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-ghost", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.exorcism", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-exorcism", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.denial", + "weight": 4, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "book.beast.advanced-denial", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + } + ] + }, + { + "id": "dew", + "chance": 0.2, + "entries": [ + { + "rewardId": "beast.refinement.origin-dew", + "weight": 3, + "quantity": { "min": 1, "max": 1 } + }, + { + "rewardId": "beast.refinement.superior-origin-dew", + "weight": 1, + "quantity": { "min": 1, "max": 1 } + } + ] + } + ] +} diff --git a/src/shared/rewards/hunt.test.ts b/src/shared/rewards/hunt.test.ts new file mode 100644 index 00000000..203b89cd --- /dev/null +++ b/src/shared/rewards/hunt.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { YieldCalculator } from '../engine/yield/YieldCalculator'; +import { HUNT_REALMS, huntEventsAt } from '../hunts/config'; +import { DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM } from './dungeon-material-quality'; +import { HUNT_DROP_POOL, HuntDropPoolSchema, planHuntReward } from './hunt'; + +describe('讨伐奖励', () => { + it.each(HUNT_REALMS)('%s 的三项基础资源与六小时挂机算法一致', (realm) => { + for (const roll of [0, 0.49, 0.999999]) { + const actual = planHuntReward({ realm }, () => () => roll); + const expected = YieldCalculator.calculateCultivatorYield( + { realm, realmStage: '中期', hoursElapsed: 6 }, + () => roll, + ); + expect(actual.experience).toBe( + expected.find((r) => r.type === 'cultivation_exp')?.value ?? 0, + ); + expect(actual.spiritStones).toBe( + expected.find((r) => r.type === 'spirit_stones')?.value ?? 0, + ); + expect(actual.insight).toBe( + expected.find((r) => r.type === 'comprehension_insight')?.value ?? 0, + ); + expect(actual.materialCount).toBe(2); + } + }); + it('概率道具为空时仍有保底;灵印、灵露可同时命中', () => { + const event = huntEventsAt(100000)[0]; + const empty = planHuntReward(event, () => () => 0.99); + expect(empty.items).toEqual([]); + expect(empty.materialCount).toBe(2); + const both = planHuntReward(event, () => () => 0); + expect(both.items).toEqual([ + { definitionId: 'book.beast.combo', quantity: 1 }, + { definitionId: 'beast.refinement.origin-dew', quantity: 1 }, + ]); + const dewOnly = planHuntReward( + event, + (stream) => () => (stream.endsWith(':book') ? 0.99 : 0), + ); + expect(dewOnly.items).toEqual([ + { definitionId: 'beast.refinement.origin-dew', quantity: 1 }, + ]); + expect(dewOnly.experience).toBe(both.experience); + expect(dewOnly.materialCount).toBe(both.materialCount); + }); + it('配置拒绝未知物品或取消保底材料', () => { + const badItem = structuredClone(HUNT_DROP_POOL); + badItem.groups[1].entries[0].rewardId = 'missing.item'; + expect(HuntDropPoolSchema.safeParse(badItem).success).toBe(false); + const badGuarantee = structuredClone(HUNT_DROP_POOL); + badGuarantee.groups[0].chance = 0.9; + expect(HuntDropPoolSchema.safeParse(badGuarantee).success).toBe(false); + }); + it('副本材料范围覆盖全部讨伐境界且概率归一化', () => { + for (const realm of HUNT_REALMS) { + expect( + Object.values(DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM[realm]).reduce( + (a, b) => a + b, + 0, + ), + ).toBeCloseTo(1); + } + expect(DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM['渡劫'].凡品).toBe(0); + expect(DUNGEON_MATERIAL_QUALITY_CHANCE_BY_REALM['金丹'].神品).toBe(0); + }); +}); diff --git a/src/shared/rewards/hunt.ts b/src/shared/rewards/hunt.ts new file mode 100644 index 00000000..09457218 --- /dev/null +++ b/src/shared/rewards/hunt.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; +import { DropPoolSchema, rollDrops } from '../drops'; +import { YieldCalculator } from '../engine/yield/YieldCalculator'; +import type { HuntEvent } from '../hunts/config'; +import { ItemGrantSchema } from '../inventory'; +import { findItemDefinition } from '../items/registry'; +import raw from './data/hunt.json'; + +export const HUNT_REWARD_HOURS = 6; +export const HuntDropPoolSchema = DropPoolSchema.superRefine((pool, ctx) => { + for (const [index, group] of pool.groups.entries()) { + for (const entry of group.entries) { + if ( + entry.rewardId !== 'hunt.material' && + !findItemDefinition(entry.rewardId) + ) + ctx.addIssue({ + code: 'custom', + path: ['groups', index], + message: '未知讨伐道具', + }); + } + } + if ( + !pool.groups.some( + (group) => + group.chance === 1 && + group.entries.every((e) => e.rewardId === 'hunt.material'), + ) + ) + ctx.addIssue({ code: 'custom', message: '讨伐必须保底掉落材料' }); +}); +export const HUNT_DROP_POOL = HuntDropPoolSchema.parse(raw); +const amount = z.number().int().nonnegative().max(2147483647); +export const HuntRewardSnapshotSchema = z + .object({ + poolId: z.string(), + poolVersion: z.number().int().positive(), + experience: amount, + spiritStones: amount, + insight: amount, + items: z.array(ItemGrantSchema), + }) + .strict(); +export type HuntRewardSnapshot = z.infer; + +/** Same random ranges and rounding as six hours of offline cultivation. */ +export function planHuntReward( + event: Pick, + random: (stream: string) => () => number, + pool = HUNT_DROP_POOL, +) { + const resources = YieldCalculator.calculateCultivatorYield( + { realm: event.realm, realmStage: '中期', hoursElapsed: HUNT_REWARD_HOURS }, + random('hunt.resources:1'), + ); + const drops = rollDrops(pool, (group) => + random(`${pool.id}:${pool.version}:${group}`), + ); + return { + poolId: drops.poolId, + poolVersion: drops.version, + experience: resources.find((r) => r.type === 'cultivation_exp')?.value ?? 0, + spiritStones: resources.find((r) => r.type === 'spirit_stones')?.value ?? 0, + insight: + resources.find((r) => r.type === 'comprehension_insight')?.value ?? 0, + materialCount: drops.rewards + .filter((r) => r.rewardId === 'hunt.material') + .reduce((n, r) => n + r.quantity, 0), + items: drops.rewards + .filter((r) => r.rewardId !== 'hunt.material') + .map((r) => ({ definitionId: r.rewardId, quantity: r.quantity })), + }; +} diff --git a/src/shared/types/world-chat.ts b/src/shared/types/world-chat.ts index 215ed1cc..5279f05c 100644 --- a/src/shared/types/world-chat.ts +++ b/src/shared/types/world-chat.ts @@ -7,7 +7,7 @@ export type WorldChatMessageChannel = 'system' | 'world' | 'sect'; export type WorldChatChannel = WorldChatMessageChannel; export type WorldChatMessageType = - 'text' | 'item_showcase' | 'beast_showcase' | 'battle_showcase' | 'combat_v6_replay'; + 'hunt_rumor' | 'text' | 'item_showcase' | 'beast_showcase' | 'battle_showcase' | 'combat_v6_replay'; export interface WorldChatCombatV6ReplayPayload { version: 1; @@ -43,6 +43,7 @@ export interface WorldChatBattleShowcasePayload { } export interface WorldChatPayloadMap { + hunt_rumor: { text: string; eventId: string; nodeId: string }; text: WorldChatTextPayload; item_showcase: WorldChatItemShowcasePayload; beast_showcase: WorldChatBeastShowcasePayload;