战斗数据的领域归属重构

This commit is contained in:
桃树夭 committed 2026-09-11 09:49:52 +08:00
1 parent 550ff7b935
commit 884936357d
98 files changed
+18322 -1756

No files matched your search

+13
View File
@@ -30,6 +30,19 @@ description: Daoyou PostgreSQL、Drizzle schema/migrations、repositories、tran
## Durable Model Boundaries
### Character assets and sect combat progression
- Current domain cutover is documented in `docs/combat-domain-ownership.md`; runtime acceptance is tracked separately in `docs/combat-domain-cutover-acceptance.md`.
- Personal manuals, equipment slots and beasts belong directly to `cultivators.id`; use `cultivator_manual_states`, `cultivator_manual_slots`, `cultivator_equipment_slots`, `cultivator_beasts`, and `cultivator_beast_lineups`.
- Sect combat state, methods and meridian loadouts belong to `sect_memberships.id`; nodes belong to a loadout. There is no build profile parent or old sect progression hydration.
- Read independent personal assets through `characterLoadoutRepository`; read sect combat progression through `sectCombatRepository`. Assemble combat projections at runtime, with sect data optional for personal display.
- Equipment slots reference inventory owner and item ID together. Transfer a sect's progression only; do not copy or reparent personal assets.
- Beast relational columns are authoritative for ID and owner. Use `beastIndividualData` / `beastFromRow` for writes / reads; do not duplicate identity inside JSON. Starter claim time is independent of the beast's lifetime.
- V6 history uses `combat_replay_archives` / `combat_replay_participants`; historical participants deliberately do not cascade from character deletion.
- Do not restore unpublished V6 profile tables, schemaVersion compatibility, old progression APIs, or dual read/write paths. The older product paths described below are separate systems, not fallback sources for the new domain tables.
### Other existing persistent models
- `cultivators.condition` is the current persistent state field. Do not restore old `persistent_state` or `persistent_statuses`.
- `consumables.spec` is the current consumable authority. Do not restore old `effects`, `use_spec`, or `details` columns.
- `wanjiedaoyou_creation_products` is the v2 creation product path for `skill | artifact | gongfa`.
@@ -17,6 +17,10 @@ description: Daoyou shared game engine domain guide for battle-v5, creation-v2,
## Attribute Boundary
- Current V6 personal display projects `CharacterCombatInput`: character attributes, equipment and personal manuals work with optional sect progress. Do not require a sect profile to apply personal effects.
- Sect progress contains shared meridian depth, per-path node selections and method levels. Personal manuals and equipment are separate character-owned inputs; see `docs/combat-domain-ownership.md`.
- `buildRevision` and stored general build readiness were removed. Keep domain revisions separate and derive sect readiness from membership / selected path.
- `Cultivator.attributes` stores only six base attributes: vitality, strength, spirit, endurance, speed, willpower.
- Derived attributes, hp/mp max, and combat display stats are computed through battle-v5 `AttributeSet` and adapters.
- `src/shared/engine/cultivator/index.ts` is a migration placeholder. Do not rebuild old cultivator attribute logic there.
+181
View File
@@ -0,0 +1,181 @@
# 战斗领域归属硬切换与验收
日期:2026-09-10 至 2026-09-11。状态:硬切换与本地领域验收完成。下文按时间保留过程记录,早期“待验/待恢复”以顶部矩阵和末尾收尾记录为准。
## 授权与范围
V6 尚未正式发布。本次一次性替换领域结构,不保留 profile、旧宗门进度、双写或旧格式迁移逻辑。仅使用 env/local.env 对应本地环境(PostgreSQL 127.0.0.1:15432/daoyou_local,API 3001,前端 5174)。保留已有 eslint.config.js 用户改动。
## 最终归属
- 角色:cultivator_manual_states、cultivator_manual_slots、cultivator_equipment_slots、cultivator_beasts、cultivator_beast_lineups。个人资源不依赖宗门状态。
- 宗门成员:sect_combat_states、sect_method_progress、sect_meridian_loadouts;节点属于经脉方案。
- 历史战斗:combat_replay_archives、combat_replay_participants。参与者不以角色级联删除。
- 背包物品保留 inventory_items;穿戴通过角色和物品组合外键约束归属。
- 删除旧 build_profiles、旧宗门流派/技能槽和旧进度接口。完整战斗构筑只在读取时组装。
- 宗门流派选择接口改为 /api/combat-v6/sect/path,资源读取改为 player.sect-combat;个人变更只失效角色投影。
## 迁移
0044_domain_ownership_cutover.sql:创建新领域表;按子表到父表顺序删除替换表;清空重用表中的旧宗门进度;删除重复 active_path_id;保留共享角色、物品、认证及非本次范围的历史表。没有兼容回填。
生成命令:bun --env-file=env/local.env node_modules/drizzle-kit/bin.cjs generate --name=domain_ownership_cutover。
当前 bun x 子进程未收到显式 env,直接运行 CLI 入口解决;不修改环境文件。
## 静态与领域检查
- 首轮前后端 TypeScript 检查通过。
- 首轮相关领域测试:29 文件、160 测试通过,包含未入宗装备与功法属性投影。
- 首轮 lint 通过。
- 全量测试通过:114 文件、759 测试;客户端与服务端 build 通过。日志位于 `/tmp/daoyou-domain-{all-tests,build,lint,types}.log`。
- SQL 已应用到本地;数据库确认新领域表存在,旧 combat_v6 表已移除。停 API 后只清理了 8 个 `combat:v6:*` Redis 键,再以无 watch 模式启动本地 API。
- 最终生成器检查显示 `No schema changes`,日志 `/tmp/daoyou-domain-schema-final.log`;0044和0045已在本地应用。
## 真实功能验收矩阵
每项必须记录实际账号、步骤、结果;准备接口不能作为玩法成功证据。
| 范围 | 必验行为 | 状态 |
| --- | --- | --- |
| 新角色/无宗门 | 生成、预览、保存、首次入宗;个人投影无宗门依赖 | 新建入宗通过;未入宗按原设计进入引导,个人投影由纯领域测试覆盖 |
| 功法 | 玉简学习、自动激活、切换、修炼升级、瓶颈突破、资源扣除、刷新持久化 | 通过,见下方证据 |
| 装备 | 穿脱、替换、属性变化、已穿物品转移限制、锻造与库存交互 | 穿脱/替换/存取/真实锻造通过;已穿装备禁止寄售,回收铺按原规则不收道装 |
| 宗门 | 加入、选择流派、心法修炼、经脉解锁/保存/切换/重置 | 全部通过,见下方证据 |
| 转宗 | 保留个人功法等级/槽位、装备、灵兽;按原规则折算宗门进度;返回旧宗不复活旧进度 | 往返转宗通过;转出旧成员进度清除、返回节点为空 |
| 灵兽 | 初始领取、携带/首发、技能与养成、战斗出场和结算 | 领取/编组/首发/技能/加点/经验升级/死亡/休养/放生及刷新通过 |
| 角色面板 | 本人/他人查看、未选流派、装备和功法增减后的气血法力上限 | 本人/榜单查探/未选流派/功法与炼体上限刷新/恢复后投影一致通过 |
| 演武 | 开战、操作、结束 | 人物与灵兽操作/主动放弃/刷新无活动通过;重复操作保护见并发项 |
| 野外 | 遭遇、战斗、奖励、角色及灵兽持久结算 | 胜利/经验/归档/次数/捕捉通过;灵兽死亡在长老试炼通过 |
| 竞技场 | 两账号匹配、指令、终局、战绩权限 | 双账号房间/指令/终局/参与者回放通过;未初始化错误已修复 |
| 宗门任务 | 任务领取/提交;宗门战斗任务、奖励和晋升资格 | 巡矿/悬赏/试炼/真传晋升及刷新通过 |
| 秘境 | 进入、触发战斗、返回探索、结算与历史 | 战斗/继续探索/提前退出/五轮完整通关/历史页通过;推演失败重试无重复扣费 |
| 蜃楼 | 挑战、战斗、进度、奖励 | 胜负/祝福/榜单/退出/第5层物资奖励与刷新无重复发奖通过 |
| 突破 | 试炼开战、真实战果、任务推进、突破结算 | 真实试炼、任务推进及48%判定成功突破通过 |
| 回放/排行 | 战绩列表、参与者回放权限、排名读取与挑战 | 野外/竞技场/排名时间线通过,非参与者404;真实排名挑战3回合落败、次数与名次持久化 |
| 资源联动 | 客栈恢复、闭关/炼体及物品使用受新面板影响 | 客栈/炼体丹拒绝与服用/同页上限更新/闭关通过 |
| 并发与生命周期 | 过期修订拒绝、重复操作不重复扣除、宗门变更不删除个人记录 | 灵兽过期修订409、秘境推演重试无重复扣费、蜃楼刷新无重复发奖、往返转宗资产保留通过 |
| 收尾 | 结束创建的活动;恢复准备属性;记录测试物资和清理范围 | 三账号无活动战斗;无未结束秘境;两角色准备恢复及剩余物资清理完成 |
## 验收边界
本次覆盖表结构切换影响的个人资产、宗门成长、战斗消费者、资源投影与历史权限,不新增玩法。未入宗主流程仍按原设计强制引导;回收铺不收道装。当前V6回放接口按参与者授权,没有在本次范围新增公开聊天分享;未发送手动聊天或邮件。未将这几项原设计限制记作迁移失败,也未伪称进行了未授权的消息发送验收。
## 本轮运行证据
本地账号 local2@daoyou.local,角色 `5e49eec4-b983-4c25-b904-522ec72e15bd`。以下均为真实页面操作;本地 dev 接口仅用于准备资源。
- 尚未选择宗门流派时,穿戴青石刀成功,装备槽直接属于角色;功法学习与面板可用。此时仍有幽都成员关系,不算真正无宗门验收。
- 悟道室学习长春功并自动激活;上限 1550→1570、当前气血保持 1550。刷新后保留。资源不足时修炼禁用;准备资源后真实修炼 1→2→3,消耗另一枚玉简解锁上限 6;学习轻风诀后主动切换槽位。数据库:manual revision=6,长春功 level=3/unlockedLevel=6,轻风诀 level=1/unlockedLevel=3,slot1=轻风诀。
- 演武选择勾魂阎罗并成功开战,提交攻击后进入第 2 回合。放弃后 Redis active 清除,新标签页回到开战界面;原标签页控制超时,结束界面仍需复验。
- 三魂阁修炼幽都轮回总典 1→2,实际扣除 60 修为、200 灵石,生命加成 +2→+4。
- 七魄台在人物 15 级时禁止解锁第 1 层;准备金丹初期、资源后成功解锁,扣 5000 修为、25000 灵石、100 感悟。勾魂阎罗保存“敕威”,六道魍魉保存“断狱”,两流派共享深度 1,各自保存节点。启用六道魍魉成功。
- 背包使用 1 枚验收洗脉符成功消耗,清空两流派节点。随后转宗预览仍为深度 1、主心法 2 级。
- 灵兽袋首次领取疾风狼成功:id=`7659061d-2b22-47f3-ab81-ed60980936f0`,10 级,寿命 1000,自动携带与首发。移出编组后数量变 0/6,再设首发恢复 1/6;lineup revision=3,starter_claimed_at 已持久化。
- 使用 2 枚验收欺天符完成幽都→红尘剑宗→幽都。转入红尘后完整走完入门演出。数据库确认每次旧成员战斗状态清除,返回幽都 activePath=six_paths、depth=1、revision=9、nodes=0。个人功法 revision、learned、slot、青石刀装备关系、灵兽完整个体与编组均未改变。
准备/证据文件:`/tmp/daoyou-domain-local2-baseline.json`、`/tmp/daoyou-domain-local2-grants.json`、`/tmp/daoyou-domain-local2-preparation.json`、`/tmp/daoyou-domain-local2-meridian-prep.json`、`/tmp/daoyou-domain-local2-talisman-grants.json`、`/tmp/daoyou-domain-local2-transfer-proof.json`。角色仍为验收用金丹初期与准备资源,尚未恢复;两个转宗符与一个洗脉符已真实消费。
## 后续修正与复验
- 灵兽关系主键/归属从 JSON 中移除,所有写入经过 `beastIndividualData`、读取经过 `beastFromRow`。只清理本轮生成的疾风狼 JSON 重复字段,没有引入兼容读写。
- API 已再次重启以加载改动。真实页面读取灵兽、取消及重新设置首发成功;发放并真实消费 1 本连击兽诀,数据库确认 skills=`["beast.combo"]`、revision=1,JSON 不含 `id`/`ownerCultivatorId`。发放记录:`/tmp/daoyou-domain-local2-beast-book-grants.json`。
- 再次开单体木桩演武,角色与灵兽均提交攻击,进入第 2 回合,木桩 HP=29998。点击放弃后正常回到开战页面,前次标签页超时未复现。本轮没有遗留演武活动。
- 角色构筑展示改为同时监听个人资料与宗门状态,避免仅订阅宗门资源而漏刷新个人资产。此改动的同页刷新行为仍待专项浏览器核对。
- 修正演武初始化文案,移除“不可切换、升级或重置”的过期说明及内部流派 ID 展示。
- 删除失去调用方的旧宗门路径存档类型、技能槽规范化函数及其单元测试,更新领域文档和相关技能。
- 再次 Drizzle generate 显示 `No schema changes`。删除旧技能槽测试后的全量测试为 113 文件、758 测试通过;最新 lint/build 结果以日志为准。
## 野外、蜃楼与双人战验收
- 野外青溪灵草坡:真实攻击获胜,战斗 `5d536b49-21df-417a-99ec-0dcced1893a1`。页面显示资源已结算;灵兽 exp 0→80、revision 1→2;新归档表 source=wild-encounter、1 回合。结束后剩余次数 20→19。回放真实播放 0/2→2/2 至胜利,战绩列表可见。
- 蜃楼第一场真实落败:`c97cb47a-ac74-4acb-a744-71cc9134f193`,9 回合,结束后回到本次通过 0 层,无活动遗留。随后用本地 dev 接口将六维临时设为 1000、补满资源,以验证胜利分支,准备记录 `/tmp/daoyou-domain-local2-battle-prep.json`;基础恢复目标仍为最初基准的六维 50。
- 蜃楼第二场真实胜利:`f638b133-f618-4d18-99c8-70962e349a5e`,9 回合。层间保留气血 23356/23404、法力 14040/14200,选取体魄澎湃祝福,榜单出现本地道友2通过 1 层。正式结束挑战后显示通过 1 层、祝福×1。第一层不是里程碑,物资奖励尚待验收。
- 第二账号 local1@daoyou.local 使用 Chrome,local2 使用内置浏览器,界面确认两者分别为本地道友1/2。Chrome 密码扩展两次阻挡点击,用户协助完成本地 local1 登录;之后浏览器操作恢复。
- 房间 706829(`arena-daaf881a-5e02-4732-b30f-84eddee7400c`)双方准备。初次开战因 local1 未选流派,领域错误被房间接口误转为 500。`arena.router.ts` 补充 `CombatV6BuildError` 映射,提示改为“请先前往宗门或练功房选择修行流派”。重启 API 后同房间复验提示正确且可重试;local1 真实选择斩尘证道后成功开战。
- 双端真实下令:local1 防御,local2 人物与灵兽攻击。战斗 `e10da04f-3e2f-4566-b42b-3c9e2b46c816`,双方显示胜/负一致。数据库归档 source=arena-sparring、outcome=side-1、1 回合、2 名参与者。结束后两端擂台均无候场房间。
- local1 可正常读取本次败局回放;访问未参与的上述野外回放返回 404,页面显示“战斗回放不存在”,没有显示他人战斗数据。共享链接权限与排名挑战另行核对。
- local1 的宗门流派选择为真实玩法修改,不做准备数据回滚;本轮没有调整 local1 的属性或物资。local2 仍带有准备境界/资源/六维,待完成全部验收后恢复。
## 秘境结算与灵兽修订保护
- 太岳山·无名古修士洞府 run=`debb5da5-0abf-4019-abdc-6f7c3f75cb4f`:从地图选择秘境、确认消耗 50 天地灵气后开始真实探索。选择强攻消耗 250 灵石并触发残魂守卫战斗 `e367012a-ee8d-4a11-a80b-3d7f741afd57`,人物与灵兽均正常出场,1 回合获胜并返回战后选择。
- 点击继续深入成功推进第 2 轮,再选择无代价分支进入第 3 轮,收获中出现青溪磨石。正式确认提前离开,页面显示 125 灵石、275 修为、青溪磨石×1。
- 数据库确认 run=FINISHED、active_battle_id=null、ended_at 有值;灵石 475000-250+125=474875,修为 95000+275=95275。秘境战斗积累的 250 灵兽经验在结束时发放,疾风狼由 level10/exp80 升到 level11/exp30、待分配5、revision3。JSON 仍无重复身份字段。记录 `/tmp/daoyou-domain-local2-dungeon-proof.json`。
- 刷新结束页回到秘境入场页,灵兽等级和剩余经验保持相同,未重复发放。
- 灵兽真实加点:主页面分配 1 点力量,确认后已分配力量 50→51、待分配5→4、revision3→4。预先打开的另一页面持有旧修订,提交同样加点得到 HTTP409;数据库确认仍为力量51、待分配4、revision4。已关闭过期页面,主页面刷新可读取持久结果。
- 当前本轮野外、蜃楼、竞技场、秘境活动均已结束。剩余物资、境界、六维及资源准备恢复仍留待全部验收收尾;Chrome local1 登录会话供后续权限检查继续使用。
## 回放协议硬切与捕捉
- 删除未发布旧回放格式、静态回放和缺失时间线补建分支。新协议强制 timeline/display;0045 删除 playable 列,已应用本地。
- 全量共享测试 113 文件、758 测试通过。后续 lint/build 均通过;Drizzle generate 再次显示 No schema changes,git diff --check 通过。
- 野外捕捉战斗 `724fff98-6d44-4b25-8acc-1c7e36d31be0`:人物捕捉、首发灵兽防御,1 回合成功收服疾风狼,法力 14200→14185;新归档含 timeline/display,真实播放 0/2→2/2,敌人消失且显示捕捉成功。
- 收服个体 `26ff4c22-2dd7-4d44-ada6-47982bdaa75e` 直接属于 local2 角色;12 级、双技能,JSON 不重复身份。通过页面确认放生本次捕捉个体并刷新;原 11 级首发灵兽保留。当前野外活动已正式结束。
## 道装入库、铸造与替换
- 青石刀已穿戴时“存入储藏室”禁用;正式卸下后入库成功,储藏室显示 1 件,再取回背包并重新装备成功。装备槽在卸下时为空,穿戴后恢复。
- 宗门镇铁炉按弟子等级拦截(须内门);公共炼器室正常可用。选择已有 10 级法兵图纸和青溪铁砂×1,确认开炉,消费 100 灵石、7 天地灵气,生成青石刀 `811282cf-4bfa-45a7-b578-9d1ba59504a2`(命中+6、器蕴定魂)。当前灵石 474775,天地灵气 143。
- 背包选择新成品并正式“替换当前道装”;数据库角色 weapon 槽仅一行,直接指向新成品,原验收武器仍在背包。新成品为真实玩法产物,清理时区分原 dev 发放武器。
## 宗门任务
- 真实领取矿场巡视,经守脉执事“开始巡逻”进入战斗 `5846f050-6ed7-498b-86e6-7821754424be`;人物与灵兽正常出场,1 回合击败岩牙矿兽。结束返回任务地点,再到值日执事提交,获得贡献5、修为256、灵石3500。
- 刷新后矿场巡视只提供功簿查询,无再次提交入口。数据库贡献/累计贡献=5/5、修为95531、灵石478275。
- 曾临时准备 inner/3000/3000 检查长老试炼,但 local1/local2 都存在 2026-09-08 已完成试炼,不能作为本轮验收;未改动旧试炼记录。local2 已恢复 registered/5/5。准备和恢复记录为 `/tmp/daoyou-domain-local2-sect-promotion-{baseline,prep,restore}.json`。
## 蜃楼里程碑
- 从第1层真实推进至第5层,前四层普通攻击一回合胜利,第5层两个敌人两回合胜利;人物与灵兽正常出场,力贯千钧叠加3层、体魄澎湃1层。体魄祝福使幻境内气血上限23404→25004。
- 第5层战斗 `0c7c9247-17b0-498c-8cf6-7f76b9822824`,奖励225灵石、5声望、青溪铁砂×1。数据库灵石478275→478500,声望0→5。
- 刷新层间页面后仍只有一份本周第5层奖励;数据库金额和声望不变。正式结束挑战后显示本次通过5层,活动已结束。没有用准备接口制造楼层通过或奖励。
## 客栈、炼体与消耗品
- 本地准备HP23000/MP14000后,正式引泉疗伤,支付1500灵石、扣955修为,恢复HP23404/MP14200,回满后按钮禁用;灵石477000、修为94576。
- 初次凡品气血丹在金丹境界被明确拒绝,HTTP400且物品保留;此样本待清理。换用本地灵品气血丹后正式服用成功,物品消耗、气血炼体0→1、HP上限23404→23521且当前HP仍23404,背包页头即时更新。记录 `/tmp/daoyou-domain-local2-body-pill-{grant,spirit-grant}.json`。
- 突破验收准备前已保存 `/tmp/daoyou-domain-local2-breakthrough-baseline.json`。随后通过dev准备元婴圆满、修为100000、感悟90、清心/护脉和clear-archive前置目标;前置目标是准备数据,不作为真实秘境通关证据。此准备尚待后续恢复。
## 破境试炼与正式突破
- 任务 `802b8b14-fdb7-41da-8609-d926728d04e8` 正式开战,人物及灵兽出场。第一回合人物防御、灵兽攻击,第二回合人物击败天劫投影,战斗 `18d4e36a-7da1-474e-9bb1-1e86da34cdc7` 胜利。人物气血23521→23520;页面显示结算,任务中心变为“前置已成”,不直接提高境界。
- 静室正式冲关,消耗20天地灵气,48%实际判定成功:元婴圆满→化神初期,六维各+4、可分配属性点+20、寿元+2000年。数据库修为6000、感悟100,清心/护脉已消耗,上限HP23613/MP14256。系统自动产生晋升公告,本轮没有手动发布聊天消息。
- 正式闭关1年,消耗4天地灵气和1年寿元,获得43修为;当前修为6043、天地灵气119、剩余寿元2081。突破与闭关活动均已结算。
- 收尾需区分突破真实收益与准备属性:当前六维1004(原准备1000+真实4),不能直接将本轮真实+4增长当作准备清除。其他准备资源及境界尚待恢复。
## 新角色预览回归
- 本地 `auth-cleanup-0906@daoyou.local` 已验证且原本没有角色,使用测试规范密码正常登录。角色生成HTTP200后预览曾因空字符串ID报“角色id不能为空”;这是未保存角色展示路径的实际故障。
- `projectCharacterDisplay` 的预览标识条件由 `??` 改为 `||`,覆盖生成候选的空字符串ID;不放宽战斗投影的身份校验。新增纯领域回归测试,先失败后通过(8项)。API已重启,页面重新生成复验进行中。
- 主流程 `sectOnboardingGuard` 在未入宗时强制转入宗门引导;这次不额外引入散修游戏模式。无宗门装备/功法效果已有纯领域测试,真实新建/入宗链路另行验收。
## 全新角色入宗与灵兽完整生命周期
- 新角色林归尘 `c3fa6091-164c-4081-a872-4748f1623b7a` 从真实生成、命格选择、保存到首次入宗通过。保存后、入宗前数据库成员记录为空,预览HP630/MP340;访问储物袋按原设计返回入宗引导。完成红尘剑宗五幕演出后拜入,成员 `737f4b93-981f-4756-9b5d-69358147e1c0` 的战斗状态 revision0/depth0/activePath=null。
- 修复 DevCultivatorService 的残留耦合:资源准备原来调用必须已选流派的战斗组装器,导致未选流派时500;改为同事务读取角色资源权威,已在流派为空时复验成功。随后真实选择斩尘证道。最新lint/build通过,全量113文件759测试通过。
- 新角色验收准备:元婴初期;体魄/力道/灵力/身法1000,根骨/神识10;灵石10000;内门、贡献/累计3000。记录 `/tmp/daoyou-domain-new-character-{baseline,trial-prep}.json`。原始六维10、炼气初期、灵石0,准备尚待恢复。
- 初次领取岩甲猪 `1934c498-7adf-424b-a2ac-860877aff704`。炼气初期人物等级不足时首发禁用;准备等级后正常设置首发。
- 长老试炼 `328513aa-705f-4aab-a3c6-d9f95149ab1f`:人物防御、灵兽保护,灵兽两次受击456/498后死亡,第三回合人物击败化身。死亡结算寿命1000→950、revision0→1。正式交回长老试炼,晋升资格入卷。
- 正式休养消耗5灵石,恢复50寿命至1000,按钮禁用;取消首发后确认放生。刷新兽栏为0且不再显示初始领取入口,数据库兽数0、编组空、revision4,starter_claimed_at仍为2026-09-10T15:55:53.908Z。
## 材料悬赏与正式晋升
- 林归尘通过正式周常提交本地准备的岩兽灵核,物品消耗,奖励27贡献、2016修为、16200灵石;灵石9995→26195、贡献及累计3000→3027。准备物资记录 `/tmp/daoyou-domain-new-character-bounty-grant.json`。
- 长老确认已完成真实试炼与悬赏条件,正式点击晋升真传成功;刷新后显示“你已列真传”,不再出现晋升按钮。数据库成员身份=true、贡献3027/累计3027。身份/贡献前置由dev准备,试炼、悬赏和最终晋升均由真实页面完成。
## 完整秘境与排行榜
- 林归尘完成太岳山洞府五轮探索,run=`cd014872-2fe4-4a22-bc71-3eb2ef2d6f58`,结局A/功成身退,奖励250灵石、500修为、青溪灵玉。数据库FINISHED、5/5、active_battle_id=null;灵石26195→26445、修为2016→2516。历史页仅1次探险,能打开五轮详情。
- 第二轮LLM输出不符合schema,页面明确进入中断状态。正式点击重新推演恢复至第三轮;cost_ledger中第二轮仅扣86法力一次,第三轮172,未重复支付。该问题是一次生成失败,未绕过校验或伪造结果。
- 榜单查探本地道友1,角色六维50、HP1552/MP900、红尘斩尘证道、青石袍及五个技能正常显示。
- 林归尘恢复准备属性后,正式挑战炼气榜首本地道友1,战斗 `5597fb72-dfe2-4e0b-90d5-a1341474a8fb`,3回合落败,完整时间线8/8;归档source=ranking。回榜后为第3名,次数10→9;角色持久HP仍632/632,切磋没有消耗实际生命。
- 新角色准备恢复已完成:炼气初期、六维10、记名、贡献27/累计27;扣回10000准备灵石后保留16445(含真实奖励及5灵石休养成本)、2516真实修为、试炼记录与功法流派。记录 `/tmp/daoyou-domain-new-character-restore.json`。真传身份依赖临时前置,按测试规范恢复原身份,真实资格记录保留。
## 最终恢复与检查
- local2拍卖物品选择中,已穿青石刀标记“已装备”且无法选入,未穿青石刀可选;取消寄售,没有生成货单。回收铺对道装明确提示“这里只收购材料与丹药”。未使用的凡品测试丹药正式回收,物品消失,产生62灵石,恢复资源时一并扣回该准备物资收益。
- local2恢复炼气后期,移除1000准备六维,保留真实突破+4,最终六维54;保留20自由点、气血炼体1级、真实寿元增长、声望5、功法/经脉成长、疾风狼11级与技能加点、正式锻造青石刀。
- 多次绝对值资源准备已撤回:灵石按原13184基准加最后准备后实际净变动2000恢复15184;经脉解锁等准备阶段消耗由准备资源承担。修为恢复原235加真实闭关43为278,移除破境准备经验余额;感悟保留实际突破增加的10。临时境界恢复,真实试炼与突破历史保留。记录 `/tmp/daoyou-domain-local2-final-restore.json`。
- 只删除本轮发放且未装备的准备武器 `ea16d97a-b832-4d82-a561-74de509b14fd` 与剩余玉简 `f243c3f3-8045-4432-b2c6-ec7e09076194`,事务限定角色和精确ID且校验两条。快照 `/tmp/daoyou-domain-prep-item-cleanup.json`。原有物资及正式玩法生成物保留。页面刷新后背包8格,正式锻造武器仍穿戴,HP1654/MP956与角色页一致。
- 三个验收角色的Redis `combat:v6:active`均为空;数据库没有未结束秘境,任务JSON中无`profileId`残留。没有清空其他角色数据、历史战绩或整个Redis。
- 最终 `git diff --check` 通过;Drizzle generate显示无差异。代码修改后的最新 `bun run lint`、`bun run build`、`bun run test` 均通过(113文件、759测试);构建仅有chunk体积提示。此后只有验收文档和本地数据操作,未重复运行未改变代码的全套测试。没有新增服务端/前端单元测试、E2E脚本或兼容层,没有部署到预发布或生产。
+73
View File
@@ -0,0 +1,73 @@
# 战斗数据的领域归属与硬切换方案
2026-09-10。结构已实施,功能验收进展见 [验收记录](combat-domain-cutover-acceptance.md)。
## 判断
装备、个人功法、灵兽属于角色,应直接关联 `cultivator_id`。宗门心法与经脉属于角色在某个宗门的修行关系,应关联 `membership_id`。完整战斗构筑是这两类数据加角色基础属性的读取结果,不需要再建一张持有所有数据的 build/profile 根表。
旧 `combat_v6_build_profiles` 的实际职责混合了两件事:它以 `membership_id` 唯一绑定宗门成员,保存当前流派、经脉深度与修订号;同时它又成为装备和个人功法的父记录。因此个人资产被间接绑定到宗门生命周期,转宗必须处理本来不该移动的数据,个人修改也容易让整个宗门构筑失效。
把 profile 的外键改成角色只能消除部分问题,仍会混合个人资产和宗门成长的状态、修订与服务边界。本次直接移除该中间层。
## 表结构
下表省略共同前缀 `wanjiedaoyou_`。
| 旧表 | 当前表 | 归属与用途 |
| --- | --- | --- |
| `combat_v6_build_profiles` | `sect_combat_states` | `membership_id` 主键;只存当前宗门流派、共用经脉深度、宗门修订号 |
| `combat_v6_method_progress` + 原 `sect_method_progress` | `sect_method_progress` | 成员 + 心法唯一;保存宗门心法等级,合并重复权威 |
| `combat_v6_meridian_loadouts` + 原 `sect_meridian_loadouts` | `sect_meridian_loadouts` | 成员 + 流派唯一;保存各流派方案修订号 |
| `combat_v6_meridian_nodes` | `sect_meridian_nodes` | 关联方案;每层最多一个节点,同一方案节点不重复 |
| `combat_v6_manual_states` | `cultivator_manual_states` | `cultivator_id` 主键;保存已学个人功法与个人功法修订号 |
| `combat_v6_manual_slots` | `cultivator_manual_slots` | 角色 + 槽位主键;保存激活功法,角色内同一功法不重复占槽 |
| `combat_v6_equipment_loadouts` | `cultivator_equipment_slots` | 角色 + 装备槽主键;指向背包中真实装备实例 |
| `combat_v6_beasts` | `cultivator_beasts` | 灵兽 ID 主键、角色外键;JSON 存个体成长数据 |
| `combat_v6_beast_lineups` | `cultivator_beast_lineups` | `cultivator_id` 主键;编组、首发与首次领取时间 |
| 旧 V6 回放归档及参与者表 | `combat_replay_archives`、`combat_replay_participants` | 历史快照与访问者索引;角色删除不级联删除历史参与记录 |
| 旧 `sect_path_progress`、`sect_ability_loadouts` | 删除 | 经脉深度改为成员共用,神通由当前心法和节点推导,无需另一套持久化进度或技能槽 |
`sect_memberships` 继续负责组织身份、贡献、成员状态;`inventory_items` 继续负责物品实例、归属、背包位置和数量。它们不复制宗门战斗进度或装备槽信息。
## 个人功法如何存
旧 `combat_v6_manual_states` 并非功法定义库,也不是战斗中临时状态。每个 profile 一行,`learned` 是 JSONB 数组,保存已学功法的 ID、当前等级及已解锁上限;激活哪些功法另存 slots 表。新表保留这一数据形状,只把权威归属改为角色:
```json
{
"cultivator_id": "角色 UUID",
"revision": 6,
"learned": [
{ "manualId": "character_manual.changchun", "level": 3, "unlockedLevel": 6 },
{ "manualId": "character_manual.qingfeng", "level": 1, "unlockedLevel": 3 }
]
}
```
功法名称、效果、费用等静态定义继续来自共享内容注册表;不把它们复制到角色存档。`level` 表示已修炼等级,`unlockedLevel` 表示玉简已解锁的等级上限,二者不能合并。
现有玩法按角色整体读取、在角色锁和事务内修改,数组规模有限,因此暂时保留 JSONB 能保持代码简单。如果未来需要跨角色查询“修炼某功法的人”、批量调整或数据库外键约束激活槽必须指向已学功法,再将 learned 拆为 `(cultivator_id, manual_id)` 行表。当前槽位合法性、功法已学及等级规则由领域校验保证;数据库本身不验证 JSON 内的功法引用。
## 一致性与生命周期
- 个人装备、功法和灵兽不要求先选宗门流派。人物投影接受可空的宗门修行数据。
- 装备槽用 `(cultivator_id, equipment_instance_id)` 组合外键引用背包的 `(cultivator_id, id)`,防止穿戴其他角色的装备;装备实例在槽表全局唯一。已穿戴物品的转移仍须经过背包服务的卸装规则。
- 经脉深度是同一成员的共用成长;每条流派只保存自己的节点方案。切换流派不复制个人资产,也不复制解锁深度。
- 转宗只折算宗门心法与共用深度、清空节点并处理成员身份。旧成员的战斗进度清除;返回旧宗不会读回陈旧进度。个人资产没有转移代码。
- 灵兽 JSON 不再重复 `id` 和 `ownerCultivatorId`;读取时从关系列组装。首次领取用 `starter_claimed_at`,不会因初始灵兽放生而重新开放领取。
- 修订号按各自修改单元管理:个人功法、宗门状态、流派方案、灵兽与编组,不保留一个容易误用的全局 buildRevision。
- 构筑就绪状态由成员和流派选择推导,不保存可相互矛盾的 profile status/schemaVersion。
- 回放保存开战时的历史事实,不能重读当前装备或宗门来“还原”过去的战斗。
## 代码边界与发布方式
角色资产读取位于 `characterLoadoutRepository.ts`,宗门战斗进度位于 `sectCombatRepository.ts`。最终战斗投影按需组装,两类数据各自变更与失效;角色构筑展示同时订阅个人资料和宗门状态。
宗门流派接口为 `/api/combat-v6/sect/path`,宗门状态接口为 `/api/combat-v6/sect/state`,资源主题为 `player.sect-combat`。删除旧 build 初始化接口和旧宗门 progression 接口,不保留别名兼容。
迁移 `0044_domain_ownership_cutover.sql` 一次性创建新表、删除旧 V6 表并清空重用表的旧进度。不迁移旧 V6 数据、不双写、不运行时兼容。保留共享角色、库存、认证和非此次范围的历史数据。表名按领域命名,战斗引擎版本不再决定持久化表的名称;回放协议自身仍可保留必要的版本标识。
回放只接受 `combat_v6_replay_v2`,必须含时间线与展示快照;删除旧协议静态展示和竞技场缺失时间线恢复分支。`0045_replay_protocol_cutover.sql` 删除冗余 `playable` 列,所有新归档都使用可播放协议,不保留历史格式兼容。
这次没有扩展为多套个人配装预设。若将来确实需要多套方案,角色资产仍属于角色,预设只能保存对资产的选择,不能重新成为资产父级。
+123
View File
@@ -0,0 +1,123 @@
CREATE TABLE "wanjiedaoyou_combat_replay_archives" (
"battle_id" uuid PRIMARY KEY NOT NULL,
"metadata_version" integer NOT NULL,
"source_type" varchar(64) NOT NULL,
"battle_type" varchar(64) NOT NULL,
"idempotency_key" uuid NOT NULL,
"engine_version" varchar(40) NOT NULL,
"ruleset_version" varchar(60) NOT NULL,
"started_at" timestamp NOT NULL,
"finished_at" timestamp NOT NULL,
"outcome" varchar(24) NOT NULL,
"replay" jsonb NOT NULL,
"round_count" integer DEFAULT 0 NOT NULL,
"sides" jsonb DEFAULT '[[],[]]'::jsonb NOT NULL,
"playable" boolean DEFAULT false NOT NULL,
"archived_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_combat_replay_participants" (
"battle_id" uuid NOT NULL,
"cultivator_id" uuid NOT NULL,
"side" integer DEFAULT 0 NOT NULL,
CONSTRAINT "wanjiedaoyou_combat_replay_participants_battle_id_cultivator_id_pk" PRIMARY KEY("battle_id","cultivator_id")
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_cultivator_beast_lineups" (
"cultivator_id" uuid PRIMARY KEY NOT NULL,
"lineup" jsonb NOT NULL,
"starter_claimed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_cultivator_beasts" (
"id" uuid PRIMARY KEY NOT NULL,
"cultivator_id" uuid NOT NULL,
"individual" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_cultivator_equipment_slots" (
"cultivator_id" uuid NOT NULL,
"slot" varchar(32) NOT NULL,
"equipment_instance_id" varchar(160) NOT NULL,
CONSTRAINT "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_slot_pk" PRIMARY KEY("cultivator_id","slot")
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_cultivator_manual_slots" (
"cultivator_id" uuid NOT NULL,
"slot" integer NOT NULL,
"manual_id" varchar(160) NOT NULL,
CONSTRAINT "wanjiedaoyou_cultivator_manual_slots_cultivator_id_slot_pk" PRIMARY KEY("cultivator_id","slot"),
CONSTRAINT "cultivator_manual_slot_valid" CHECK ("wanjiedaoyou_cultivator_manual_slots"."slot" BETWEEN 1 AND 4)
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_cultivator_manual_states" (
"cultivator_id" uuid PRIMARY KEY NOT NULL,
"revision" integer DEFAULT 0 NOT NULL,
"learned" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "cultivator_manual_revision_valid" CHECK ("wanjiedaoyou_cultivator_manual_states"."revision" >= 0)
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_combat_states" (
"membership_id" uuid PRIMARY KEY NOT NULL,
"revision" integer DEFAULT 0 NOT NULL,
"active_path_id" varchar(160),
"meridian_depth" integer DEFAULT 0 NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "sect_combat_progress_valid" CHECK ("wanjiedaoyou_sect_combat_states"."revision" >= 0 AND "wanjiedaoyou_sect_combat_states"."meridian_depth" BETWEEN 0 AND 7)
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sect_meridian_nodes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"loadout_id" uuid NOT NULL,
"node_id" varchar(160) NOT NULL,
"layer" integer NOT NULL
);
--> statement-breakpoint
-- Unreleased V6 hard cutover: discard replaced state, without migrating old payloads.
DROP TABLE "wanjiedaoyou_combat_v6_manual_slots";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_manual_states";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_equipment_loadouts";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_meridian_nodes";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_meridian_loadouts";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_method_progress";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_build_profiles";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_beast_lineups";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_beasts";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_replay_participants";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_combat_v6_replay_archives";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_sect_ability_loadouts";--> statement-breakpoint
DROP TABLE "wanjiedaoyou_sect_path_progress";--> statement-breakpoint
DELETE FROM "wanjiedaoyou_sect_meridian_loadouts";--> statement-breakpoint
DELETE FROM "wanjiedaoyou_sect_method_progress";--> statement-breakpoint
DROP INDEX "sect_meridian_membership_path_slot_unique";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" ALTER COLUMN "path_id" SET DATA TYPE varchar(160);--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" ALTER COLUMN "path_id" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_method_progress" ALTER COLUMN "method_id" SET DATA TYPE varchar(160);--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" ADD COLUMN "revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_combat_replay_participants" ADD CONSTRAINT "wanjiedaoyou_combat_replay_participants_battle_id_wanjiedaoyou_combat_replay_archives_battle_id_fk" FOREIGN KEY ("battle_id") REFERENCES "public"."wanjiedaoyou_combat_replay_archives"("battle_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_beast_lineups" ADD CONSTRAINT "wanjiedaoyou_cultivator_beast_lineups_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_beasts" ADD CONSTRAINT "wanjiedaoyou_cultivator_beasts_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 "inventory_owner_id_unique" ON "wanjiedaoyou_inventory_items" USING btree ("cultivator_id","id");--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_equipment_slots" ADD CONSTRAINT "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_equipment_slots" ADD CONSTRAINT "wanjiedaoyou_cultivator_equipment_slots_cultivator_id_equipment_instance_id_wanjiedaoyou_inventory_items_cultivator_id_id_fk" FOREIGN KEY ("cultivator_id","equipment_instance_id") REFERENCES "public"."wanjiedaoyou_inventory_items"("cultivator_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_manual_slots" ADD CONSTRAINT "wanjiedaoyou_cultivator_manual_slots_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_cultivator_manual_states" ADD CONSTRAINT "wanjiedaoyou_cultivator_manual_states_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_combat_states" ADD CONSTRAINT "wanjiedaoyou_sect_combat_states_membership_id_wanjiedaoyou_sect_memberships_id_fk" FOREIGN KEY ("membership_id") REFERENCES "public"."wanjiedaoyou_sect_memberships"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_nodes" ADD CONSTRAINT "wanjiedaoyou_sect_meridian_nodes_loadout_id_wanjiedaoyou_sect_meridian_loadouts_id_fk" FOREIGN KEY ("loadout_id") REFERENCES "public"."wanjiedaoyou_sect_meridian_loadouts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "combat_replay_source_idempotency_uidx" ON "wanjiedaoyou_combat_replay_archives" USING btree ("source_type","idempotency_key");--> statement-breakpoint
CREATE INDEX "combat_replay_finished_idx" ON "wanjiedaoyou_combat_replay_archives" USING btree ("finished_at");--> statement-breakpoint
CREATE INDEX "combat_replay_participant_cultivator_idx" ON "wanjiedaoyou_combat_replay_participants" USING btree ("cultivator_id","battle_id");--> statement-breakpoint
CREATE INDEX "cultivator_beasts_owner_idx" ON "wanjiedaoyou_cultivator_beasts" USING btree ("cultivator_id");--> statement-breakpoint
CREATE UNIQUE INDEX "cultivator_equipment_instance_unique" ON "wanjiedaoyou_cultivator_equipment_slots" USING btree ("equipment_instance_id");--> statement-breakpoint
CREATE UNIQUE INDEX "cultivator_manual_unique" ON "wanjiedaoyou_cultivator_manual_slots" USING btree ("cultivator_id","manual_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sect_meridian_loadout_node_unique" ON "wanjiedaoyou_sect_meridian_nodes" USING btree ("loadout_id","node_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sect_meridian_loadout_layer_unique" ON "wanjiedaoyou_sect_meridian_nodes" USING btree ("loadout_id","layer");--> statement-breakpoint
CREATE UNIQUE INDEX "sect_meridian_membership_path_unique" ON "wanjiedaoyou_sect_meridian_loadouts" USING btree ("membership_id","path_id");--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_memberships" DROP COLUMN "active_path_id";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" DROP COLUMN "slot";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" DROP COLUMN "node_ids";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" DROP COLUMN "version";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_meridian_loadouts" DROP COLUMN "updated_at";--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sect_method_progress" DROP COLUMN "updated_at";
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "wanjiedaoyou_combat_replay_archives" DROP COLUMN "playable";
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -309,6 +309,20 @@
"when": 1789048437114,
"tag": "0043_manual-level-progression",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1789050374352,
"tag": "0044_domain_ownership_cutover",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1789053555643,
"tag": "0045_replay_protocol_cutover",
"breakpoints": true
}
]
}
@@ -51,7 +51,7 @@ export function CombatV6HistoryList({
</time>
<span>
{record.roundCount} 回合 ·{' '}
{record.playable ? '查看回放 →' : '查看战报 →'}
查看回放 →
</span>
</div>
</Link>
@@ -36,22 +36,12 @@ export function CombatV6ReplayPlayer({
endContent?: ReactNode;
}) {
const timeline = record.timeline;
const [seek] = useState(() =>
replaySeeker(
timeline ?? {
format: 'delta-v1',
initialUnits: record.units,
initialRound: record.round,
fromEventSeq: record.events[record.events.length - 1]?.seq ?? -1,
frames: [],
},
),
);
const [seek] = useState(() => replaySeeker(timeline));
const [position, setPosition] = useState(() => seek(0));
const [running, setRunning] = useState(autoPlay);
const [speed, setSpeed] = useState(1);
const [inspected, setInspected] = useState<string>();
const count = timeline?.frames.length ?? 0;
const count = timeline.frames.length;
const playing = running && position.index < count;
const ended = position.index === count;
const move = useCallback(
@@ -72,8 +62,8 @@ export function CombatV6ReplayPlayer({
const labels = useMemo(() => unitLabels(position.units), [position.units]);
const log = useMemo(() => {
const units = new Map(record.units.map((u) => [u.id, u]));
for (const u of record.timeline?.initialUnits ?? []) units.set(u.id, u);
for (const frame of record.timeline?.frames ?? [])
for (const u of record.timeline.initialUnits) units.set(u.id, u);
for (const frame of record.timeline.frames)
for (const u of frame.added ?? []) units.set(u.id, u);
return appendBattleEntries(
{ entries: [], round: 0, open: false, seq: -1 },
@@ -82,14 +72,12 @@ export function CombatV6ReplayPlayer({
);
}, [record]);
const rounds = useMemo(() => {
const result = new Map<number, number>([
[timeline?.initialRound ?? record.round, 0],
]);
timeline?.frames.forEach((frame, index) => {
const result = new Map<number, number>([[timeline.initialRound, 0]]);
timeline.frames.forEach((frame, index) => {
if (!result.has(frame.round)) result.set(frame.round, index + 1);
});
return [...result].map(([round, index]) => ({ round, index }));
}, [timeline, record.round]);
}, [timeline]);
const unit = position.units.find((u) => u.id === inspected);
const close = useCallback(() => setInspected(undefined), []);
return (
@@ -113,78 +101,71 @@ export function CombatV6ReplayPlayer({
<CombatV6Log entries={log.entries} visibleSeq={position.visibleSeq} />
</div>
{ended ? endContent : null}
{timeline ? (
<footer className="cv6-replay-controls" aria-label="回放控制">
<div className="cv6-replay-actions">
<button
onClick={() => move(0)}
disabled={position.index === 0 && !playing}
<footer className="cv6-replay-controls" aria-label="回放控制">
<div className="cv6-replay-actions">
<button
onClick={() => move(0)}
disabled={position.index === 0 && !playing}
>
重播
</button>
<button
onClick={() => move(position.index - 1)}
disabled={!position.index}
>
上一行动
</button>
<button
onClick={() => {
if (ended) setPosition(seek(0));
setRunning(!playing);
}}
disabled={!count}
>
{playing ? '暂停' : '播放'}
</button>
<button onClick={() => move(position.index + 1)} disabled={ended}>
下一行动
</button>
</div>
<div className="cv6-replay-settings">
<label>
回合{' '}
<select
aria-label="跳转回合"
value={position.round}
onChange={(e) =>
move(
rounds.find((r) => r.round === Number(e.target.value))!.index,
)
}
>
重播
</button>
<button
onClick={() => move(position.index - 1)}
disabled={!position.index}
{rounds.map((r) => (
<option key={r.round} value={r.round}>
{r.round}
</option>
))}
</select>
</label>
<label>
速度{' '}
<select
aria-label="播放速度"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
>
上一行动
</button>
<button
onClick={() => {
if (ended) setPosition(seek(0));
setRunning(!playing);
}}
disabled={!count}
>
{playing ? '暂停' : '播放'}
</button>
<button onClick={() => move(position.index + 1)} disabled={ended}>
下一行动
</button>
</div>
<div className="cv6-replay-settings">
<label>
回合{' '}
<select
aria-label="跳转回合"
value={position.round}
onChange={(e) =>
move(
rounds.find((r) => r.round === Number(e.target.value))!
.index,
)
}
>
{rounds.map((r) => (
<option key={r.round} value={r.round}>
{r.round}
</option>
))}
</select>
</label>
<label>
速度{' '}
<select
aria-label="播放速度"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
>
{[0.5, 1, 2, 4].map((s) => (
<option key={s} value={s}>
{s}×
</option>
))}
</select>
</label>
<span>
{position.index} / {count}
</span>
</div>
</footer>
) : (
<footer className="cv6-replay-controls cv6-muted">
此记录仅保留战报与最终状态,暂无逐行动回放数据。
</footer>
)}
{[0.5, 1, 2, 4].map((s) => (
<option key={s} value={s}>
{s}×
</option>
))}
</select>
</label>
<span>
{position.index} / {count}
</span>
</div>
</footer>
{unit ? (
<CombatV6Details
detailUnit={unit}
@@ -1,20 +1,24 @@
import { InkNotice } from '@app/components/ui';
import { useCombatV6Build } from '@app/lib/resources/player';
import {
useCultivatorIdentity,
useSectCombatState,
} from '@app/lib/resources/player';
import type { CultivatorInspectionData } from '@shared/contracts/player';
import { useEffect, useState } from 'react';
import { CultivatorLoadoutSections } from './CultivatorLoadoutSections';
/** Refresh the public view when the existing build resource changes. */
/** Refresh for independent character assets and sect progression changes. */
export function CurrentCultivatorBuild({
cultivatorId,
}: {
cultivatorId: string;
}) {
const source = useCombatV6Build();
const source = useSectCombatState();
const profile = useCultivatorIdentity();
const [data, setData] = useState<CultivatorInspectionData>();
const [error, setError] = useState('');
useEffect(() => {
if (!source.data) return;
if (!profile.data) return;
const controller = new AbortController();
void (async () => {
try {
@@ -34,9 +38,9 @@ export function CurrentCultivatorBuild({
}
})();
return () => controller.abort();
}, [cultivatorId, source.data]);
if (error || source.error)
return <InkNotice>{error || source.error}</InkNotice>;
}, [cultivatorId, source.data, profile.data]);
if (error || source.error || profile.error)
return <InkNotice>{error || source.error || profile.error}</InkNotice>;
if (!data) return <InkNotice>正在读取当前构筑…</InkNotice>;
return <CultivatorLoadoutSections build={data.build} />;
}
@@ -1,7 +1,6 @@
import {
buildSectProgressionState,
membershipState,
useActiveSectContextQuery,
useSectProgressionQuery,
} from '@app/components/feature/sect/sectResources';
import {
useCultivatorCondition,
@@ -59,19 +58,15 @@ export function useCultivatorDisplayProjection(enabled = true) {
const profile = useCultivatorIdentity(enabled);
const condition = useCultivatorCondition(enabled);
const sectContext = useActiveSectContextQuery(enabled);
const sectProgression = useSectProgressionQuery(
enabled && sectContext.hasSect,
);
const identity = profile.data?.cultivator;
const sect = useMemo(
() =>
sectContext.hasSect && sectContext.data && sectProgression.data
? buildSectProgressionState(sectContext.data, sectProgression.data)
sectContext.hasSect && sectContext.data
? membershipState(sectContext.data)
: undefined,
[
sectContext.data,
sectContext.hasSect,
sectProgression.data,
],
);
const sectReady =
@@ -183,14 +178,13 @@ export function useCultivatorDisplayProjection(enabled = true) {
condition.loading ||
sectContext.sessionLoading ||
(sectContext.hasSect &&
(sectContext.loading || sectProgression.loading)));
sectContext.loading));
const error =
profile.error ??
condition.error ??
(condition.data && !condition.data.combatV6 ? '角色战斗属性尚未加载' : undefined) ??
sectContext.sessionError ??
sectContext.error ??
(sectContext.hasSect ? sectProgression.error : undefined);
sectContext.error;
return {
data,
@@ -105,7 +105,7 @@ export function SectWorkspace({
pending={pending}
disabled={view.blockedReason !== '请先选择流派,启用宗门传承'}
onClick={() =>
void request('/api/combat-v6/build/initialize', {
void request('/api/combat-v6/sect/path', {
activePathId: path.id,
expectedRevision: view.build.revision,
})
@@ -4,7 +4,6 @@ import {
sectContextResource,
sectInfrastructureResource,
sectMembersResource,
sectProgressionResource,
sectShopResource,
sectTasksResource,
type SectMembersParams,
@@ -21,7 +20,6 @@ import {
SectStipendDataSchema,
} from '@shared/contracts/sect';
import {
createAbilitySlots,
resolveSectBenefitSnapshot,
resolveSectPresentation,
type CultivatorSectState,
@@ -64,10 +62,6 @@ export function useSectInfrastructureQuery() {
return useSingletonResource(sectInfrastructureResource);
}
export function useSectProgressionQuery(enabled = true) {
return useSingletonResource(sectProgressionResource, enabled);
}
export function useSectTasksQuery() {
return useSingletonResource(sectTasksResource);
}
@@ -159,19 +153,6 @@ export function resolveSectBenefits(
);
}
export function buildSectProgressionState(
context: NonNullable<ReturnType<typeof useSectContextQuery>['data']>,
progression: NonNullable<ReturnType<typeof useSectProgressionQuery>['data']>,
): CultivatorSectState {
return {
...membershipState(context),
activePathId: progression.activePathId,
methods: progression.methods,
paths: progression.paths,
abilityLoadout: progression.abilityLoadout,
};
}
export function getSectPresentationForContext(
context: { sectId: string } | undefined,
): ResolvedSectPresentation {
@@ -180,7 +161,7 @@ export function getSectPresentationForContext(
: resolveSectPresentation('standard');
}
function membershipState(
export function membershipState(
context: NonNullable<ReturnType<typeof useSectContextQuery>['data']>,
): CultivatorSectState {
return {
@@ -194,9 +175,6 @@ function membershipState(
office: context.office,
promotedAt: context.promotedAt,
configVersion: context.configVersion,
methods: {},
paths: [],
abilityLoadout: createAbilitySlots([]),
};
}
+3 -6
View File
@@ -6,7 +6,7 @@ export {
type InventoryPageParams,
} from './inventoryDefinitions';
export {
playerCombatV6BuildResource,
playerSectCombatResource,
playerConditionResource,
playerCurrencyResource,
playerMailSummaryResource,
@@ -23,7 +23,6 @@ export {
sectContextResource,
sectInfrastructureResource,
sectMembersResource,
sectProgressionResource,
sectShopResource,
sectTasksResource,
type SectMembersParams,
@@ -35,7 +34,7 @@ import {
inventoryMaterialsResource,
} from './inventoryDefinitions';
import {
playerCombatV6BuildResource,
playerSectCombatResource,
playerConditionResource,
playerCurrencyResource,
playerMailSummaryResource,
@@ -51,7 +50,6 @@ import {
sectContextResource,
sectInfrastructureResource,
sectMembersResource,
sectProgressionResource,
sectShopResource,
sectTasksResource,
} from './sectDefinitions';
@@ -63,14 +61,13 @@ export const resourceRegistry = {
playerCondition: playerConditionResource,
playerProgress: playerProgressResource,
playerCurrency: playerCurrencyResource,
playerCombatV6Build: playerCombatV6BuildResource,
playerCombatV6Build: playerSectCombatResource,
playerMailSummary: playerMailSummaryResource,
playerTaskSummary: playerTaskSummaryResource,
playerTasks: playerTasksResource,
sectContext: sectContextResource,
sectMembers: sectMembersResource,
sectInfrastructure: sectInfrastructureResource,
sectProgression: sectProgressionResource,
sectTasks: sectTasksResource,
sectShop: sectShopResource,
sectConstructionMember: sectConstructionMemberResource,
+3 -8
View File
@@ -1,14 +1,13 @@
import {
playerCombatV6BuildResource,
playerConditionResource,
playerCurrencyResource,
playerMailSummaryResource,
playerProfileResource,
playerProgressResource,
playerSectCombatResource,
playerSessionResource,
playerTaskSummaryResource,
sectContextResource,
sectProgressionResource,
} from '@app/lib/resources/definitions';
import { useSingletonResource } from '@app/lib/resources/hooks';
@@ -32,8 +31,8 @@ export function useCultivatorCurrency(enabled = true) {
return useSingletonResource(playerCurrencyResource, enabled);
}
export function useCombatV6Build(enabled = true) {
return useSingletonResource(playerCombatV6BuildResource, enabled);
export function useSectCombatState(enabled = true) {
return useSingletonResource(playerSectCombatResource, enabled);
}
export function useUnreadMailCount() {
@@ -52,7 +51,3 @@ export function useTaskSummary() {
export function useSectMembership() {
return useSingletonResource(sectContextResource);
}
export function useSectProgression() {
return useSingletonResource(sectProgressionResource);
}
@@ -186,7 +186,7 @@ export const playerCurrencyResource: ResourceDefinition<
return defaultResourceReducer(current, change);
},
};
export const playerCombatV6BuildResource = playerDefinition('combat-v6-build');
export const playerSectCombatResource = playerDefinition('sect-combat');
export const playerMailSummaryResource = playerDefinition('mail-summary');
export const playerTaskSummaryResource = playerDefinition('task-summary');
@@ -28,10 +28,6 @@ export const sectInfrastructureResource = endpointResource(
'sect.infrastructure',
'/api/sects/current/infrastructure',
);
export const sectProgressionResource = endpointResource(
'sect.progression',
'/api/sects/current/progression',
);
export const sectTasksResource = endpointResource(
'sect.tasks',
'/api/sects/current/tasks',
@@ -6,10 +6,10 @@ import { InkButton } from '@app/components/ui/InkButton';
import { InkCard } from '@app/components/ui/InkCard';
import { inkFieldVariants } from '@app/components/ui/inkFieldStyles';
import { consumeResourceMutation } from '@app/lib/resources/mutations';
import { useCombatV6Build } from '@app/lib/resources/player';
import { useSectCombatState } from '@app/lib/resources/player';
import type {
CombatV6BuildViewV1,
CombatV6TrainingSessionViewV1,
SectCombatView,
} from '@shared/contracts/combatV6';
import { useEffect, useState } from 'react';
@@ -23,7 +23,7 @@ function BuildInitialization({
pending,
onInitialize,
}: {
build: CombatV6BuildViewV1;
build: SectCombatView;
pending: boolean;
onInitialize: (pathId: string) => void;
}) {
@@ -31,9 +31,10 @@ function BuildInitialization({
return (
<div className="space-y-4">
<InkCard variant="highlighted" padding="lg">
<h2 className="font-heading text-xl">立定 v6 修行流派</h2>
<h2 className="font-heading text-xl">选择宗门修行流派</h2>
<p className="text-ink-secondary mt-2 text-sm leading-7">
当前宗门:{build.sectName}。此阶段完成选择后不可切换、升级或重置。
当前宗门:{build.sectName}
。选择后可前往宗门修炼心法、参悟经脉或切换流派。
</p>
</InkCard>
<div className="grid gap-3 md:grid-cols-2">
@@ -45,7 +46,6 @@ function BuildInitialization({
className={`border p-4 text-left ${pathId === path.id ? 'border-crimson bg-crimson/5' : 'border-ink/15'}`}
>
<strong>{path.name}</strong>
<p className="text-ink-secondary mt-1 text-xs">{path.id}</p>
</button>
))}
</div>
@@ -145,7 +145,7 @@ function EncounterSelection({
}
export default function TrainingRoomPage() {
const buildQuery = useCombatV6Build();
const buildQuery = useSectCombatState();
const build = buildQuery.data;
const [content, setContent] = useState<ContentView>();
const combat = useCombatV6Session<CombatV6TrainingSessionViewV1>(
@@ -211,7 +211,7 @@ export default function TrainingRoomPage() {
pending={pending}
onInitialize={(activePathId) =>
void run(async () => {
const response = await fetch('/api/combat-v6/build/initialize', {
const response = await fetch('/api/combat-v6/sect/path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -220,7 +220,7 @@ export default function TrainingRoomPage() {
}),
});
const next =
await consumeResourceMutation<CombatV6BuildViewV1>(response);
await consumeResourceMutation<SectCombatView>(response);
buildQuery.setData(next);
})
}
+2 -2
View File
@@ -7,8 +7,8 @@ import {
import { useCombatV6Session } from '@app/components/feature/combat-v6/useCombatV6Session';
import { InkButton } from '@app/components/ui/InkButton';
import {
useCombatV6Build,
useCultivatorCondition,
useSectCombatState,
} from '@app/lib/resources/player';
import type { WildSessionView } from '@shared/contracts/combatV6Wild';
import { itemDefinition } from '@shared/inventory';
@@ -50,7 +50,7 @@ export default function WildPage() {
return <WildRegion key={nodeId} nodeId={nodeId} />;
}
function WildRegion({ nodeId }: { nodeId: string }) {
const build = useCombatV6Build();
const build = useSectCombatState();
const { reload: reloadCondition } = useCultivatorCondition();
const combat = useCombatV6Session<WildSessionView>('/api/combat-v6/wild');
const {
+109 -205
View File
@@ -30,6 +30,7 @@ import {
boolean,
check,
doublePrecision,
foreignKey,
index,
integer,
jsonb,
@@ -260,7 +261,6 @@ export const sectMemberships = pgTable(
status: varchar('status', { length: 16 }).notNull().default('prospect'),
experiencedAt: timestamp('experienced_at'),
joinedAt: timestamp('joined_at'),
activePathId: varchar('active_path_id', { length: 64 }),
contribution: integer('contribution').notNull().default(0),
lifetimeContribution: integer('lifetime_contribution').notNull().default(0),
discipleRank: varchar('disciple_rank', { length: 16 })
@@ -403,6 +403,57 @@ export const sectStipendClaims = pgTable(
],
);
// ===== 角色资产与宗门战斗进度 =====
export const cultivatorBeasts = pgTable(
'wanjiedaoyou_cultivator_beasts',
{
id: uuid('id').primaryKey(),
cultivatorId: uuid('cultivator_id')
.notNull()
.references(() => cultivators.id, { onDelete: 'cascade' }),
individual: jsonb('individual')
.$type<Omit<SummonedBeast, 'id' | 'ownerCultivatorId'>>()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [index('cultivator_beasts_owner_idx').on(table.cultivatorId)],
);
export const cultivatorBeastLineups = pgTable(
'wanjiedaoyou_cultivator_beast_lineups',
{
cultivatorId: uuid('cultivator_id')
.primaryKey()
.references(() => cultivators.id, { onDelete: 'cascade' }),
lineup: jsonb('lineup').$type<BeastLineup>().notNull(),
starterClaimedAt: timestamp('starter_claimed_at', { withTimezone: true }),
},
);
export const sectCombatStates = pgTable(
'wanjiedaoyou_sect_combat_states',
{
membershipId: uuid('membership_id')
.primaryKey()
.references(() => sectMemberships.id, { onDelete: 'cascade' }),
revision: integer('revision').notNull().default(0),
activePathId: varchar('active_path_id', { length: 160 }),
meridianDepth: integer('meridian_depth').notNull().default(0),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
check(
'sect_combat_progress_valid',
sql`${table.revision} >= 0 AND ${table.meridianDepth} BETWEEN 0 AND 7`,
),
],
);
export const sectMethodProgress = pgTable(
'wanjiedaoyou_sect_method_progress',
{
@@ -410,12 +461,8 @@ export const sectMethodProgress = pgTable(
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
methodId: varchar('method_id', { length: 64 }).notNull(),
methodId: varchar('method_id', { length: 160 }).notNull(),
level: integer('level').notNull().default(0),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_method_membership_method_unique').on(
@@ -425,33 +472,6 @@ export const sectMethodProgress = pgTable(
],
);
export const sectPathProgress = pgTable(
'wanjiedaoyou_sect_path_progress',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
pathId: varchar('path_id', { length: 64 }).notNull(),
unlockedLayerIds: jsonb('unlocked_layer_ids')
.$type<string[]>()
.notNull()
.default([]),
tacticId: varchar('tactic_id', { length: 32 }).notNull(),
activeMeridianSlot: integer('active_meridian_slot').notNull().default(1),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_path_membership_path_unique').on(
table.membershipId,
table.pathId,
),
],
);
export const sectMeridianLoadouts = pgTable(
'wanjiedaoyou_sect_meridian_loadouts',
{
@@ -459,193 +479,78 @@ export const sectMeridianLoadouts = pgTable(
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
pathId: varchar('path_id', { length: 64 }).notNull().default(''),
slot: integer('slot').notNull(),
nodeIds: jsonb('node_ids').$type<string[]>().notNull().default([]),
version: integer('version').notNull().default(1),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_meridian_membership_path_slot_unique').on(
table.membershipId,
table.pathId,
table.slot,
),
],
);
export const sectAbilityLoadouts = pgTable(
'wanjiedaoyou_sect_ability_loadouts',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
slot: integer('slot').notNull(),
abilityId: varchar('ability_id', { length: 64 }).notNull(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('sect_ability_membership_slot_unique').on(
table.membershipId,
table.slot,
),
uniqueIndex('sect_ability_membership_ability_unique').on(
table.membershipId,
table.abilityId,
),
],
);
// ===== combat-v6 独立人物构筑 =====
export const combatV6Beasts = pgTable(
'wanjiedaoyou_combat_v6_beasts',
{
id: uuid('id').primaryKey(),
cultivatorId: uuid('cultivator_id')
.notNull()
.references(() => cultivators.id, { onDelete: 'cascade' }),
individual: jsonb('individual').$type<SummonedBeast>().notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [index('combat_v6_beasts_owner_idx').on(table.cultivatorId)],
);
export const combatV6BeastLineups = pgTable(
'wanjiedaoyou_combat_v6_beast_lineups',
{
cultivatorId: uuid('cultivator_id')
.primaryKey()
.references(() => cultivators.id, { onDelete: 'cascade' }),
lineup: jsonb('lineup').$type<BeastLineup>().notNull(),
starterBeastId: uuid('starter_beast_id'),
},
);
export const combatV6BuildProfiles = pgTable(
'wanjiedaoyou_combat_v6_build_profiles',
{
id: uuid('id').primaryKey().defaultRandom(),
membershipId: uuid('membership_id')
.references(() => sectMemberships.id, { onDelete: 'cascade' })
.notNull(),
schemaVersion: integer('schema_version').notNull().default(1),
revision: integer('revision').notNull().default(0),
status: varchar('status', { length: 16 }).notNull().default('pending'),
activePathId: varchar('active_path_id', { length: 160 }),
meridianDepth: integer('meridian_depth').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('combat_v6_build_membership_unique').on(table.membershipId),
],
);
export const combatV6MethodProgress = pgTable(
'wanjiedaoyou_combat_v6_method_progress',
{
id: uuid('id').primaryKey().defaultRandom(),
profileId: uuid('profile_id')
.references(() => combatV6BuildProfiles.id, { onDelete: 'cascade' })
.notNull(),
methodId: varchar('method_id', { length: 160 }).notNull(),
level: integer('level').notNull().default(0),
},
(table) => [
uniqueIndex('combat_v6_method_profile_method_unique').on(
table.profileId,
table.methodId,
),
],
);
export const combatV6MeridianLoadouts = pgTable(
'wanjiedaoyou_combat_v6_meridian_loadouts',
{
id: uuid('id').primaryKey().defaultRandom(),
profileId: uuid('profile_id')
.references(() => combatV6BuildProfiles.id, { onDelete: 'cascade' })
.notNull(),
pathId: varchar('path_id', { length: 160 }).notNull(),
revision: integer('revision').notNull().default(0),
},
(table) => [
uniqueIndex('combat_v6_meridian_profile_path_unique').on(
table.profileId,
uniqueIndex('sect_meridian_membership_path_unique').on(
table.membershipId,
table.pathId,
),
],
);
export const combatV6MeridianNodes = pgTable(
'wanjiedaoyou_combat_v6_meridian_nodes',
export const sectMeridianNodes = pgTable(
'wanjiedaoyou_sect_meridian_nodes',
{
id: uuid('id').primaryKey().defaultRandom(),
loadoutId: uuid('loadout_id')
.references(() => combatV6MeridianLoadouts.id, { onDelete: 'cascade' })
.references(() => sectMeridianLoadouts.id, { onDelete: 'cascade' })
.notNull(),
nodeId: varchar('node_id', { length: 160 }).notNull(),
layer: integer('layer').notNull(),
},
(table) => [
uniqueIndex('combat_v6_meridian_loadout_node_unique').on(
uniqueIndex('sect_meridian_loadout_node_unique').on(
table.loadoutId,
table.nodeId,
),
uniqueIndex('combat_v6_meridian_loadout_layer_unique').on(
uniqueIndex('sect_meridian_loadout_layer_unique').on(
table.loadoutId,
table.layer,
),
],
);
export const combatV6ManualStates = pgTable(
'wanjiedaoyou_combat_v6_manual_states',
export const cultivatorManualStates = pgTable(
'wanjiedaoyou_cultivator_manual_states',
{
id: uuid('id').primaryKey().defaultRandom(),
profileId: uuid('profile_id')
.references(() => combatV6BuildProfiles.id, { onDelete: 'cascade' })
.notNull(),
schemaVersion: integer('schema_version').notNull().default(1),
cultivatorId: uuid('cultivator_id')
.primaryKey()
.references(() => cultivators.id, { onDelete: 'cascade' }),
revision: integer('revision').notNull().default(0),
learned: jsonb('learned').$type<Array<{ manualId: string; level: number; unlockedLevel: number }>>().notNull().default([]),
learned: jsonb('learned')
.$type<
Array<{ manualId: string; level: number; unlockedLevel: number }>
>()
.notNull()
.default([]),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
uniqueIndex('combat_v6_manual_profile_unique').on(table.profileId),
check('cultivator_manual_revision_valid', sql`${table.revision} >= 0`),
],
);
export const combatV6ManualSlots = pgTable(
'wanjiedaoyou_combat_v6_manual_slots',
export const cultivatorManualSlots = pgTable(
'wanjiedaoyou_cultivator_manual_slots',
{
id: uuid('id').primaryKey().defaultRandom(),
stateId: uuid('state_id')
.references(() => combatV6ManualStates.id, { onDelete: 'cascade' })
.notNull(),
cultivatorId: uuid('cultivator_id')
.notNull()
.references(() => cultivators.id, { onDelete: 'cascade' }),
slot: integer('slot').notNull(),
manualId: varchar('manual_id', { length: 160 }).notNull(),
},
(table) => [
uniqueIndex('combat_v6_manual_state_slot_unique').on(
table.stateId,
table.slot,
),
uniqueIndex('combat_v6_manual_state_manual_unique').on(
table.stateId,
primaryKey({ columns: [table.cultivatorId, table.slot] }),
uniqueIndex('cultivator_manual_unique').on(
table.cultivatorId,
table.manualId,
),
check('cultivator_manual_slot_valid', sql`${table.slot} BETWEEN 1 AND 4`),
],
);
@@ -669,6 +574,7 @@ export const inventoryItems = pgTable(
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('inventory_owner_id_unique').on(table.cultivatorId, table.id),
index('inventory_owner_location_idx').on(
table.cultivatorId,
table.location,
@@ -693,27 +599,26 @@ export const inventoryItems = pgTable(
],
);
export const combatV6EquipmentLoadouts = pgTable(
'wanjiedaoyou_combat_v6_equipment_loadouts',
export const cultivatorEquipmentSlots = pgTable(
'wanjiedaoyou_cultivator_equipment_slots',
{
id: uuid('id').primaryKey().defaultRandom(),
profileId: uuid('profile_id')
.references(() => combatV6BuildProfiles.id, { onDelete: 'cascade' })
.notNull(),
cultivatorId: uuid('cultivator_id')
.notNull()
.references(() => cultivators.id, { onDelete: 'cascade' }),
slot: varchar('slot', { length: 32 }).notNull(),
equipmentInstanceId: varchar('equipment_instance_id', { length: 160 })
.references(() => inventoryItems.id, { onDelete: 'restrict' })
.notNull(),
equipmentInstanceId: varchar('equipment_instance_id', {
length: 160,
}).notNull(),
},
(table) => [
uniqueIndex('combat_v6_equipment_profile_slot_unique').on(
table.profileId,
table.slot,
),
uniqueIndex('combat_v6_equipment_profile_instance_unique').on(
table.profileId,
primaryKey({ columns: [table.cultivatorId, table.slot] }),
uniqueIndex('cultivator_equipment_instance_unique').on(
table.equipmentInstanceId,
),
foreignKey({
columns: [table.cultivatorId, table.equipmentInstanceId],
foreignColumns: [inventoryItems.cultivatorId, inventoryItems.id],
}).onDelete('no action'),
],
);
@@ -1013,7 +918,7 @@ export const cultivatorTasks = pgTable(
/**
* @deprecated V5 历史战绩。本次退役版本上线后的下一版本删除表及 schema。
* 10J 已解除战报分享、旧仓储和 retention 清理引用,仅保留历史 schema。
* 删除时先解除 betBattles 的外键依赖。当前 V6 战绩使用 combatV6ReplayArchives。
* 删除时先解除 betBattles 的外键依赖。当前 V6 战绩使用 combatReplayArchives。
* 计划见 docs/combat-v6-legacy-table-retirement.md;本版不得生成 DROP。
*/
export const battleRecordsV3 = pgTable(
@@ -1084,8 +989,8 @@ export const battleReplayArchives = pgTable(
);
// combat-v6活动战局、指令与RNG仅存在Redis。
export const combatV6ReplayArchives = pgTable(
'wanjiedaoyou_combat_v6_replay_archives',
export const combatReplayArchives = pgTable(
'wanjiedaoyou_combat_replay_archives',
{
battleId: uuid('battle_id').primaryKey(),
metadataVersion: integer('metadata_version').notNull(),
@@ -1103,25 +1008,24 @@ export const combatV6ReplayArchives = pgTable(
.$type<[string[], string[]]>()
.notNull()
.default([[], []]),
playable: boolean('playable').notNull().default(false),
archivedAt: timestamp('archived_at').defaultNow().notNull(),
},
(table) => [
uniqueIndex('combat_v6_replay_source_idempotency_uidx').on(
uniqueIndex('combat_replay_source_idempotency_uidx').on(
table.sourceType,
table.idempotencyKey,
),
index('combat_v6_replay_finished_idx').on(table.finishedAt),
index('combat_replay_finished_idx').on(table.finishedAt),
],
);
// 不关联角色生命周期:删除角色不应连带删除历史战斗及参与记录。
export const combatV6ReplayParticipants = pgTable(
'wanjiedaoyou_combat_v6_replay_participants',
export const combatReplayParticipants = pgTable(
'wanjiedaoyou_combat_replay_participants',
{
battleId: uuid('battle_id')
.notNull()
.references(() => combatV6ReplayArchives.battleId, {
.references(() => combatReplayArchives.battleId, {
onDelete: 'cascade',
}),
cultivatorId: uuid('cultivator_id').notNull(),
@@ -1129,7 +1033,7 @@ export const combatV6ReplayParticipants = pgTable(
},
(table) => [
primaryKey({ columns: [table.battleId, table.cultivatorId] }),
index('combat_v6_replay_participant_cultivator_idx').on(
index('combat_replay_participant_cultivator_idx').on(
table.cultivatorId,
table.battleId,
),
+14 -12
View File
@@ -1,11 +1,15 @@
import { db, type DbExecutor } from '@server/lib/drizzle/db';
import {
combatV6Beasts,
cultivatorBeasts,
cultivators,
dungeonRuns,
} from '@server/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import { settleBeastDeaths } from '@server/lib/repositories/combatV6BeastRepository';
import {
beastFromRow,
beastIndividualData,
settleBeastDeaths,
} from '@server/lib/repositories/combatV6BeastRepository';
import { archiveCombatV6Replay } from '@server/lib/repositories/combatV6ReplayRepository';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import { assembleCombatV6TrainingPlayer } from '@server/lib/services/combat-v6/CombatV6BuildService';
@@ -24,7 +28,7 @@ import { createCombatV6Replay } from '@shared/combat-v6/replay';
import { liveReplayDelta } from '@shared/combat-v6/replay-timeline';
import type { CombatV6CommandGroup } from '@shared/contracts/combatV6';
import type { DungeonSessionView } from '@shared/contracts/combatV6Dungeon';
import { beastDeathIds, BeastSchema } from '@shared/engine/combat-v6/beasts';
import { beastDeathIds } from '@shared/engine/combat-v6/beasts';
import {
beastVictoryExperience,
gainBeastExp,
@@ -369,23 +373,21 @@ export async function grantDungeonBeastExperience(
const { beastId, amount } = reward.beastExperience;
const [row] = await tx
.select()
.from(combatV6Beasts)
.from(cultivatorBeasts)
.where(
and(
eq(combatV6Beasts.id, beastId),
eq(combatV6Beasts.cultivatorId, state.cultivatorId),
eq(cultivatorBeasts.id, beastId),
eq(cultivatorBeasts.cultivatorId, state.cultivatorId),
),
);
if (!row) throw new Error('经验接收灵兽不存在');
await tx
.update(combatV6Beasts)
.update(cultivatorBeasts)
.set({
individual: gainBeastExp(
BeastSchema.parse(row.individual),
amount,
level,
individual: beastIndividualData(
gainBeastExp(beastFromRow(row), amount, level),
),
})
.where(eq(combatV6Beasts.id, beastId));
.where(eq(cultivatorBeasts.id, beastId));
}
}
@@ -0,0 +1,84 @@
import type { DbExecutor } from '@server/lib/drizzle/db';
import {
cultivatorEquipmentSlots,
cultivatorManualSlots,
cultivatorManualStates,
inventoryItems,
} from '@server/lib/drizzle/schema';
import type { DaoEquipmentLoadoutV1 } from '@shared/engine/combat-v6/equipment';
import type { CultivatorManualStateV1 } from '@shared/engine/combat-v6/manuals';
import { eq } from 'drizzle-orm';
import { readActiveSectCombatProgress } from './sectCombatRepository';
export async function readCharacterManuals(
cultivatorId: string,
q: DbExecutor,
): Promise<CultivatorManualStateV1> {
const [manualState] = await q
.select()
.from(cultivatorManualStates)
.where(eq(cultivatorManualStates.cultivatorId, cultivatorId))
.limit(1);
const slots = await q
.select({
slot: cultivatorManualSlots.slot,
manualId: cultivatorManualSlots.manualId,
})
.from(cultivatorManualSlots)
.where(eq(cultivatorManualSlots.cultivatorId, cultivatorId));
return {
version: 1,
revision: manualState?.revision ?? 0,
learned: manualState?.learned ?? [],
build: {
slots: slots.map((slot) => ({
slot: slot.slot as 1 | 2 | 3 | 4,
manualId: slot.manualId,
})),
},
};
}
export async function readCharacterEquipment(
cultivatorId: string,
q: DbExecutor,
): Promise<DaoEquipmentLoadoutV1> {
const equipmentRows = await q
.select({
slot: cultivatorEquipmentSlots.slot,
instance: inventoryItems.instanceData,
ownerId: inventoryItems.cultivatorId,
location: inventoryItems.location,
definitionId: inventoryItems.definitionId,
})
.from(cultivatorEquipmentSlots)
.innerJoin(
inventoryItems,
eq(cultivatorEquipmentSlots.equipmentInstanceId, inventoryItems.id),
)
.where(eq(cultivatorEquipmentSlots.cultivatorId, cultivatorId));
if (
equipmentRows.some(
(row) =>
row.ownerId !== cultivatorId ||
row.location !== 'bag' ||
row.definitionId !== 'equipment.v6',
)
)
throw new Error('装备穿戴数据不合法');
return Object.fromEntries(
equipmentRows.map((row) => [row.slot, structuredClone(row.instance)]),
) as DaoEquipmentLoadoutV1;
}
export async function readCharacterCombatBuild(
cultivatorId: string,
q: DbExecutor,
) {
const sect = await readActiveSectCombatProgress(cultivatorId, q);
return {
sect: sect?.sect,
manuals: await readCharacterManuals(cultivatorId, q),
equipment: await readCharacterEquipment(cultivatorId, q),
};
}
@@ -1,7 +1,7 @@
import type { DbExecutor } from '@server/lib/drizzle/db';
import {
combatV6BeastLineups,
combatV6Beasts,
cultivatorBeastLineups,
cultivatorBeasts,
cultivators,
} from '@server/lib/drizzle/schema';
import type { WildSettlement } from '@shared/contracts/combatV6Wild';
@@ -9,6 +9,7 @@ import {
BeastLineupSchema,
type BeastRoster,
BeastSchema,
type SummonedBeast,
loseBeastLifespan,
} from '@shared/engine/combat-v6/beasts';
import {
@@ -19,6 +20,22 @@ import { combatCharacterLevel } from '@shared/engine/combat-v6/projection/charac
import type { RealmStage, RealmType } from '@shared/types/constants';
import { and, eq, inArray } from 'drizzle-orm';
/** Identity and ownership have a single authority in relational columns. */
export function beastIndividualData(beast: SummonedBeast) {
const { id: _id, ownerCultivatorId: _owner, ...individual } = beast;
void _id;
void _owner;
return individual;
}
export function beastFromRow(row: typeof cultivatorBeasts.$inferSelect) {
return BeastSchema.parse({
...row.individual,
id: row.id,
ownerCultivatorId: row.cultivatorId,
});
}
export async function readBeastOwner(cultivatorId: string, tx: DbExecutor) {
const [row] = await tx
.select({
@@ -51,23 +68,23 @@ export async function readBeastRoster(
}
> {
const rows = await tx
.select({ individual: combatV6Beasts.individual })
.from(combatV6Beasts)
.where(eq(combatV6Beasts.cultivatorId, cultivatorId))
.orderBy(combatV6Beasts.createdAt, combatV6Beasts.id);
.select()
.from(cultivatorBeasts)
.where(eq(cultivatorBeasts.cultivatorId, cultivatorId))
.orderBy(cultivatorBeasts.createdAt, cultivatorBeasts.id);
const [state] = await tx
.select()
.from(combatV6BeastLineups)
.where(eq(combatV6BeastLineups.cultivatorId, cultivatorId));
.from(cultivatorBeastLineups)
.where(eq(cultivatorBeastLineups.cultivatorId, cultivatorId));
const { ownerLevel, spiritStones } = await readBeastOwner(cultivatorId, tx);
return {
ownerLevel,
spiritStones,
beasts: rows.map((row) => BeastSchema.parse(row.individual)),
beasts: rows.map(beastFromRow),
lineup: BeastLineupSchema.parse(
state?.lineup ?? { carriedBeastIds: [], revision: 0 },
),
starterClaimed: !!state?.starterBeastId,
starterClaimed: !!state?.starterClaimedAt,
};
}
@@ -84,10 +101,10 @@ export async function settleBeastProgress(
const beast = BeastSchema.parse(individual);
if (beast.ownerCultivatorId !== summary.cultivatorId)
throw new Error('捕获灵兽归属不符');
await tx.insert(combatV6Beasts).values({
await tx.insert(cultivatorBeasts).values({
id: beast.id,
cultivatorId: summary.cultivatorId,
individual: beast,
individual: beastIndividualData(beast),
});
}
if (summary.beastExperience) {
@@ -97,9 +114,9 @@ export async function settleBeastProgress(
const next = gainBeastExp(beast, reward.amount, roster.ownerLevel);
if (next !== beast)
await tx
.update(combatV6Beasts)
.set({ individual: next })
.where(eq(combatV6Beasts.id, beast.id));
.update(cultivatorBeasts)
.set({ individual: beastIndividualData(next) })
.where(eq(cultivatorBeasts.id, beast.id));
}
}
@@ -112,16 +129,18 @@ export async function settleBeastDeaths(
if (!ids.length) return;
const rows = await tx
.select()
.from(combatV6Beasts)
.from(cultivatorBeasts)
.where(
and(
eq(combatV6Beasts.cultivatorId, cultivatorId),
inArray(combatV6Beasts.id, [...new Set(ids)]),
eq(cultivatorBeasts.cultivatorId, cultivatorId),
inArray(cultivatorBeasts.id, [...new Set(ids)]),
),
);
for (const row of rows)
await tx
.update(combatV6Beasts)
.set({ individual: loseBeastLifespan(BeastSchema.parse(row.individual)) })
.where(eq(combatV6Beasts.id, row.id));
.update(cultivatorBeasts)
.set({
individual: beastIndividualData(loseBeastLifespan(beastFromRow(row))),
})
.where(eq(cultivatorBeasts.id, row.id));
}
@@ -1,224 +0,0 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6EquipmentLoadouts,
combatV6ManualSlots,
combatV6ManualStates,
combatV6MeridianLoadouts,
combatV6MeridianNodes,
combatV6MethodProgress,
cultivators,
inventoryItems,
sectMemberships,
} from '@server/lib/drizzle/schema';
import type { CombatV6PersistedBuildV1 } from '@shared/contracts/combatV6';
import {
COMBAT_V6_SECT_DEFINITIONS_V4,
type CombatV6SectId,
type SectCombatProgressV6,
} from '@shared/engine/combat-v6/content';
import type { DaoEquipmentLoadoutV1 } from '@shared/engine/combat-v6/equipment';
import type { CultivatorManualStateV1 } from '@shared/engine/combat-v6/manuals';
import { and, eq } from 'drizzle-orm';
export type ActiveCombatV6Membership = {
membershipId: string;
cultivatorId: string;
sectId: string;
};
export async function findActiveCombatV6Membership(
cultivatorId: string,
q: DbExecutor,
): Promise<ActiveCombatV6Membership | null> {
const [row] = await q
.select({
membershipId: sectMemberships.id,
cultivatorId: sectMemberships.cultivatorId,
sectId: sectMemberships.sectId,
})
.from(sectMemberships)
.where(
and(
eq(sectMemberships.cultivatorId, cultivatorId),
eq(sectMemberships.status, 'active'),
),
)
.limit(1);
return row ?? null;
}
export async function findCombatV6Profile(membershipId: string, q: DbExecutor) {
const [row] = await q
.select()
.from(combatV6BuildProfiles)
.where(eq(combatV6BuildProfiles.membershipId, membershipId))
.limit(1);
return row ?? null;
}
export async function listCombatV6MethodLevels(
profileId: string,
q: DbExecutor,
): Promise<Record<string, number>> {
const rows = await q
.select({
methodId: combatV6MethodProgress.methodId,
level: combatV6MethodProgress.level,
})
.from(combatV6MethodProgress)
.where(eq(combatV6MethodProgress.profileId, profileId));
return Object.fromEntries(rows.map((row) => [row.methodId, row.level]));
}
export async function loadActiveCombatV6Build(
cultivatorId: string,
q: DbExecutor,
): Promise<CombatV6PersistedBuildV1 | null> {
const membership = await findActiveCombatV6Membership(cultivatorId, q);
if (!membership || !(membership.sectId in COMBAT_V6_SECT_DEFINITIONS_V4))
return null;
const profile = await findCombatV6Profile(membership.membershipId, q);
if (!profile || profile.status !== 'active' || !profile.activePathId)
return null;
const sectId = membership.sectId as CombatV6SectId;
const methods = await listCombatV6MethodLevels(profile.id, q);
const loadouts = await q
.select({
id: combatV6MeridianLoadouts.id,
pathId: combatV6MeridianLoadouts.pathId,
revision: combatV6MeridianLoadouts.revision,
})
.from(combatV6MeridianLoadouts)
.where(eq(combatV6MeridianLoadouts.profileId, profile.id));
const meridianLoadouts =
[] as SectCombatProgressV6['meridianLoadouts'][number][];
for (const loadout of loadouts) {
const nodes = await q
.select({
nodeId: combatV6MeridianNodes.nodeId,
layer: combatV6MeridianNodes.layer,
})
.from(combatV6MeridianNodes)
.where(eq(combatV6MeridianNodes.loadoutId, loadout.id));
meridianLoadouts.push({
pathId: loadout.pathId,
revision: loadout.revision,
nodeIds: nodes
.sort(
(left, right) =>
left.layer - right.layer || left.nodeId.localeCompare(right.nodeId),
)
.map((node) => node.nodeId),
});
}
const [manualState] = await q
.select()
.from(combatV6ManualStates)
.where(eq(combatV6ManualStates.profileId, profile.id))
.limit(1);
if (!manualState) return null;
const slots = await q
.select({
slot: combatV6ManualSlots.slot,
manualId: combatV6ManualSlots.manualId,
})
.from(combatV6ManualSlots)
.where(eq(combatV6ManualSlots.stateId, manualState.id));
const manuals: CultivatorManualStateV1 = {
version: 1,
revision: manualState.revision,
learned: manualState.learned,
build: {
slots: slots.map((slot) => ({
slot: slot.slot as 1 | 2 | 3 | 4,
manualId: slot.manualId,
})),
},
};
const equipmentRows = await q
.select({
slot: combatV6EquipmentLoadouts.slot,
instance: inventoryItems.instanceData,
ownerId: inventoryItems.cultivatorId,
location: inventoryItems.location,
definitionId: inventoryItems.definitionId,
})
.from(combatV6EquipmentLoadouts)
.innerJoin(
inventoryItems,
eq(combatV6EquipmentLoadouts.equipmentInstanceId, inventoryItems.id),
)
.where(eq(combatV6EquipmentLoadouts.profileId, profile.id));
if (
equipmentRows.some(
(row) =>
row.ownerId !== cultivatorId ||
row.location !== 'bag' ||
row.definitionId !== 'equipment.v6',
)
)
return null;
const equipment = Object.fromEntries(
equipmentRows.map((row) => [row.slot, structuredClone(row.instance)]),
) as DaoEquipmentLoadoutV1;
return {
schemaVersion: 1,
profileId: profile.id,
membershipId: membership.membershipId,
cultivatorId,
status: 'active',
revision: profile.revision,
sect: {
version: 1,
sectId,
methods,
meridianDepth:
profile.meridianDepth as SectCombatProgressV6['meridianDepth'],
activePathId: profile.activePathId,
meridianLoadouts:
meridianLoadouts as SectCombatProgressV6['meridianLoadouts'],
},
manuals,
equipment,
};
}
export async function lockActiveMembership(
cultivatorId: string,
tx: DbTransaction,
) {
const [row] = await tx
.select({
membershipId: sectMemberships.id,
cultivatorId: sectMemberships.cultivatorId,
sectId: sectMemberships.sectId,
})
.from(sectMemberships)
.where(
and(
eq(sectMemberships.cultivatorId, cultivatorId),
eq(sectMemberships.status, 'active'),
),
)
.for('update')
.limit(1);
return row ?? null;
}
export async function characterIdentityRow(
cultivatorId: string,
q: DbExecutor,
) {
const [row] = await q
.select()
.from(cultivators)
.where(
and(eq(cultivators.id, cultivatorId), eq(cultivators.status, 'active')),
)
.limit(1);
return row ?? null;
}
@@ -1,7 +1,7 @@
import { db, type DbExecutor } from '@server/lib/drizzle/db';
import {
combatV6ReplayArchives,
combatV6ReplayParticipants,
combatReplayArchives,
combatReplayParticipants,
} from '@server/lib/drizzle/schema';
import type {
CombatV6HistoryPage,
@@ -11,20 +11,20 @@ import type { CombatV6ReplayV1 } from '@shared/contracts/combatV6Runtime';
import { and, desc, eq } from 'drizzle-orm';
async function archive(
values: typeof combatV6ReplayArchives.$inferInsert,
values: typeof combatReplayArchives.$inferInsert,
participants: CombatV6ReplayV1['participants'],
executor: DbExecutor,
) {
await executor.transaction(async (tx) => {
await tx
.insert(combatV6ReplayArchives)
.insert(combatReplayArchives)
.values(values)
.onConflictDoNothing();
const existing = await tx.query.combatV6ReplayArchives.findFirst({
const existing = await tx.query.combatReplayArchives.findFirst({
columns: { battleId: true },
where: and(
eq(combatV6ReplayArchives.sourceType, values.sourceType),
eq(combatV6ReplayArchives.idempotencyKey, values.idempotencyKey),
eq(combatReplayArchives.sourceType, values.sourceType),
eq(combatReplayArchives.idempotencyKey, values.idempotencyKey),
),
});
if (existing?.battleId !== values.battleId)
@@ -33,7 +33,7 @@ async function archive(
values.idempotencyKey,
);
await tx
.insert(combatV6ReplayParticipants)
.insert(combatReplayParticipants)
.values(
participants.map(({ cultivatorId, side }) => ({
battleId: values.battleId,
@@ -65,7 +65,6 @@ export async function archiveCombatV6Replay(
sides: [0, 1].map((side) =>
replay.initialUnits.filter((u) => u.side === side).map((u) => u.name),
) as [string[], string[]],
playable: !!replay.timeline,
replay,
},
replay.participants,
@@ -91,9 +90,9 @@ export async function combatV6ReplayExists(
executor: DbExecutor = db,
) {
const [row] = await executor
.select({ battleId: combatV6ReplayArchives.battleId })
.from(combatV6ReplayArchives)
.where(eq(combatV6ReplayArchives.battleId, battleId))
.select({ battleId: combatReplayArchives.battleId })
.from(combatReplayArchives)
.where(eq(combatReplayArchives.battleId, battleId))
.limit(1);
return !!row;
}
@@ -104,16 +103,16 @@ export async function findOwnedCombatV6Replay(
executor: DbExecutor = db,
) {
const [row] = await executor
.select({ archive: combatV6ReplayArchives })
.from(combatV6ReplayArchives)
.select({ archive: combatReplayArchives })
.from(combatReplayArchives)
.innerJoin(
combatV6ReplayParticipants,
eq(combatV6ReplayParticipants.battleId, combatV6ReplayArchives.battleId),
combatReplayParticipants,
eq(combatReplayParticipants.battleId, combatReplayArchives.battleId),
)
.where(
and(
eq(combatV6ReplayArchives.battleId, battleId),
eq(combatV6ReplayParticipants.cultivatorId, cultivatorId),
eq(combatReplayArchives.battleId, battleId),
eq(combatReplayParticipants.cultivatorId, cultivatorId),
),
)
.limit(1);
@@ -126,8 +125,8 @@ export async function listOwnedCombatV6Replays(
query: CombatV6HistoryQuery,
executor: DbExecutor = db,
): Promise<CombatV6HistoryPage> {
const a = combatV6ReplayArchives;
const p = combatV6ReplayParticipants;
const a = combatReplayArchives;
const p = combatReplayParticipants;
const pageSize = 10;
const rows = await executor
.select({
@@ -137,7 +136,6 @@ export async function listOwnedCombatV6Replays(
roundCount: a.roundCount,
sides: a.sides,
outcome: a.outcome,
playable: a.playable,
side: p.side,
})
.from(p)
@@ -0,0 +1,175 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import {
cultivators,
sectCombatStates,
sectMemberships,
sectMeridianLoadouts,
sectMeridianNodes,
sectMethodProgress,
} from '@server/lib/drizzle/schema';
import {
COMBAT_V6_SECT_DEFINITIONS_V4,
type CombatV6SectId,
type SectCombatProgressV6,
} from '@shared/engine/combat-v6/content';
import { and, eq } from 'drizzle-orm';
export type ActiveSectMembership = {
membershipId: string;
cultivatorId: string;
sectId: string;
};
export async function findActiveSectMembership(
cultivatorId: string,
q: DbExecutor,
): Promise<ActiveSectMembership | null> {
const [row] = await q
.select({
membershipId: sectMemberships.id,
cultivatorId: sectMemberships.cultivatorId,
sectId: sectMemberships.sectId,
})
.from(sectMemberships)
.where(
and(
eq(sectMemberships.cultivatorId, cultivatorId),
eq(sectMemberships.status, 'active'),
),
)
.limit(1);
return row ?? null;
}
export async function findSectCombatState(membershipId: string, q: DbExecutor) {
const [row] = await q
.select()
.from(sectCombatStates)
.where(eq(sectCombatStates.membershipId, membershipId));
return row ?? null;
}
export async function readSectMethodLevels(
membershipId: string,
q: DbExecutor,
): Promise<Record<string, number>> {
const rows = await q
.select({
methodId: sectMethodProgress.methodId,
level: sectMethodProgress.level,
})
.from(sectMethodProgress)
.where(eq(sectMethodProgress.membershipId, membershipId));
return Object.fromEntries(rows.map((row) => [row.methodId, row.level]));
}
export async function readSectCombatProgress(
membershipId: string,
q: DbExecutor,
): Promise<{
membershipId: string;
revision: number;
sect: SectCombatProgressV6;
} | null> {
const [membership] = await q
.select({
membershipId: sectMemberships.id,
sectId: sectMemberships.sectId,
})
.from(sectMemberships)
.where(eq(sectMemberships.id, membershipId));
if (!membership || !(membership.sectId in COMBAT_V6_SECT_DEFINITIONS_V4))
return null;
const state = await findSectCombatState(membership.membershipId, q);
if (!state?.activePathId) return null;
const sectId = membership.sectId as CombatV6SectId;
const methods = await readSectMethodLevels(membership.membershipId, q);
const loadouts = await q
.select({
id: sectMeridianLoadouts.id,
pathId: sectMeridianLoadouts.pathId,
revision: sectMeridianLoadouts.revision,
})
.from(sectMeridianLoadouts)
.where(eq(sectMeridianLoadouts.membershipId, membership.membershipId));
const meridianLoadouts =
[] as SectCombatProgressV6['meridianLoadouts'][number][];
for (const loadout of loadouts) {
const nodes = await q
.select({
nodeId: sectMeridianNodes.nodeId,
layer: sectMeridianNodes.layer,
})
.from(sectMeridianNodes)
.where(eq(sectMeridianNodes.loadoutId, loadout.id));
meridianLoadouts.push({
pathId: loadout.pathId,
revision: loadout.revision,
nodeIds: nodes
.sort(
(left, right) =>
left.layer - right.layer || left.nodeId.localeCompare(right.nodeId),
)
.map((node) => node.nodeId),
});
}
return {
membershipId: membership.membershipId,
revision: state.revision,
sect: {
version: 1,
sectId,
methods,
meridianDepth:
state.meridianDepth as SectCombatProgressV6['meridianDepth'],
activePathId: state.activePathId,
meridianLoadouts:
meridianLoadouts as SectCombatProgressV6['meridianLoadouts'],
},
};
}
export async function lockActiveMembership(
cultivatorId: string,
tx: DbTransaction,
) {
const [row] = await tx
.select({
membershipId: sectMemberships.id,
cultivatorId: sectMemberships.cultivatorId,
sectId: sectMemberships.sectId,
})
.from(sectMemberships)
.where(
and(
eq(sectMemberships.cultivatorId, cultivatorId),
eq(sectMemberships.status, 'active'),
),
)
.for('update')
.limit(1);
return row ?? null;
}
export async function characterIdentityRow(
cultivatorId: string,
q: DbExecutor,
) {
const [row] = await q
.select()
.from(cultivators)
.where(
and(eq(cultivators.id, cultivatorId), eq(cultivators.status, 'active')),
)
.limit(1);
return row ?? null;
}
export async function readActiveSectCombatProgress(
cultivatorId: string,
q: DbExecutor,
) {
const membership = await findActiveSectMembership(cultivatorId, q);
return membership ? readSectCombatProgress(membership.membershipId, q) : null;
}
+10 -131
View File
@@ -1,25 +1,19 @@
import { createFreshCombatV6MethodLevels } from '@shared/engine/combat-v6/build-state';
import { COMBAT_V6_SECT_DEFINITIONS_V4, type CombatV6SectId } from '@shared/engine/combat-v6/content';
import {
runDbTasks,
type DbExecutor,
type DbTransaction,
} from '@server/lib/drizzle/db';
import {
creationProducts,
cultivators,
sectAbilityLoadouts,
sectMemberships,
sectMeridianLoadouts,
sectCombatStates,
sectMethodProgress,
sectPathProgress,
} from '@server/lib/drizzle/schema';
import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils';
import type { ResourceDataMap } from '@shared/contracts/resources';
import type { SectProgressionData } from '@shared/contracts/sect';
import {
StandardSectRules,
createAbilitySlots,
type CultivatorSectState,
type SectAbilitySlots,
type SectDefinition,
type SectRuntime,
type SectTrainingCost,
@@ -107,17 +101,6 @@ export async function spendTrainingResources(
return rows.length === 1;
}
export function hydrateSectAbilitySlots(
rows: Array<{ slot: number; abilityId: string }>,
): SectAbilitySlots {
const slots: SectAbilitySlots = createAbilitySlots([]);
for (const row of rows)
if (row.slot >= 1 && row.slot <= StandardSectRules.activeAbilitySlotCount) {
slots[row.slot - 1] = row.abilityId;
}
return slots;
}
export async function findMembership(
cultivatorId: string,
q: DbExecutor | DbTransaction,
@@ -168,34 +151,11 @@ async function hydrateMembership(
q: DbExecutor | DbTransaction,
runtime: SectRuntime,
): Promise<CultivatorSectState> {
const [methods, pathRows, meridians, abilities] = await runDbTasks(q, [
() =>
q
.select()
.from(sectMethodProgress)
.where(eq(sectMethodProgress.membershipId, membership.id)),
() =>
q
.select()
.from(sectPathProgress)
.where(eq(sectPathProgress.membershipId, membership.id)),
() =>
q
.select()
.from(sectMeridianLoadouts)
.where(eq(sectMeridianLoadouts.membershipId, membership.id)),
() =>
q
.select()
.from(sectAbilityLoadouts)
.where(eq(sectAbilityLoadouts.membershipId, membership.id)),
]);
const state: CultivatorSectState = {
membershipId: membership.id,
sectId: membership.sectId,
status: membership.status as CultivatorSectState['status'],
joinedAt: membership.joinedAt?.toISOString(),
activePathId: membership.activePathId ?? undefined,
contribution: membership.contribution,
lifetimeContribution: membership.lifetimeContribution,
discipleRank:
@@ -203,24 +163,6 @@ async function hydrateMembership(
office: membership.office as CultivatorSectState['office'],
promotedAt: membership.promotedAt?.toISOString(),
configVersion: membership.configVersion,
methods: Object.fromEntries(
methods.map((row) => [row.methodId, row.level]),
),
paths: pathRows.map((path) => ({
pathId: path.pathId,
unlockedLayerIds: path.unlockedLayerIds,
tacticId: path.tacticId,
activeMeridianSlot: path.activeMeridianSlot as 1 | 2 | 3,
meridianLoadouts: meridians
.filter((loadout) => loadout.pathId === path.pathId)
.map((loadout) => ({
slot: loadout.slot as 1 | 2 | 3,
nodeIds: loadout.nodeIds,
version: loadout.version,
}))
.sort((a, b) => a.slot - b.slot),
})),
abilityLoadout: hydrateSectAbilitySlots(abilities),
};
try {
runtime.validateState(state);
@@ -234,42 +176,6 @@ async function hydrateMembership(
return state;
}
export async function loadCultivatorSectProgression(
cultivatorId: string,
q: DbExecutor | DbTransaction,
): Promise<Pick<
SectProgressionData,
'activePathId' | 'methods' | 'paths' | 'abilityLoadout'
> | null> {
const membership = await findMembership(cultivatorId, q);
if (!membership) return null;
const state = await hydrateMembership(membership, q, productionSectRuntime);
return {
activePathId: state.activePathId,
methods: state.methods,
paths: state.paths,
abilityLoadout: state.abilityLoadout,
};
}
export async function loadSectProgressionForMembership(
membership: SectMembershipRow,
q: DbExecutor | DbTransaction,
): Promise<
Pick<
SectProgressionData,
'activePathId' | 'methods' | 'paths' | 'abilityLoadout'
>
> {
const state = await hydrateMembership(membership, q, productionSectRuntime);
return {
activePathId: state.activePathId,
methods: state.methods,
paths: state.paths,
abilityLoadout: state.abilityLoadout,
};
}
export async function loadCultivatorSectState(
cultivatorId: string,
q: DbExecutor | DbTransaction,
@@ -324,7 +230,6 @@ export async function activateMembership(
contribution: definition.onboarding.initialContribution,
// Rejoining a sect resets spendable balance, but never erases earned history.
lifetimeContribution: sql`GREATEST(${sectMemberships.lifetimeContribution}, ${definition.onboarding.initialContribution})`,
activePathId: null,
configVersion: definition.configVersion,
})
.where(
@@ -333,39 +238,13 @@ export async function activateMembership(
eq(sectMemberships.status, 'prospect'),
),
);
const initialMethods = Object.entries(
definition.onboarding.initialMethods,
).map(([methodId, level]) => ({ membershipId, methodId, level }));
if (initialMethods.length)
await tx
.insert(sectMethodProgress)
.values(initialMethods)
.onConflictDoNothing();
const initialAbilities = definition.onboarding.initialAbilityLoadout.flatMap(
(abilityId, index) =>
abilityId ? [{ membershipId, slot: index + 1, abilityId }] : [],
);
if (initialAbilities.length)
await tx
.insert(sectAbilityLoadouts)
.values(initialAbilities)
.onConflictDoNothing();
const [membership] = await tx
.select({ cultivatorId: sectMemberships.cultivatorId })
.from(sectMemberships)
.where(eq(sectMemberships.id, membershipId))
.limit(1);
if (membership) {
await tx
.update(creationProducts)
.set({ isEquipped: false })
.where(
and(
eq(creationProducts.cultivatorId, membership.cultivatorId),
eq(creationProducts.productType, 'skill'),
),
);
}
const sectId = definition.id;
if (!(sectId in COMBAT_V6_SECT_DEFINITIONS_V4)) throw new Error('宗门战斗定义不存在');
await tx.insert(sectCombatStates).values({membershipId}).onConflictDoNothing();
await tx.insert(sectMethodProgress).values(
Object.entries(createFreshCombatV6MethodLevels(sectId as CombatV6SectId)).map(([methodId, level]) => ({membershipId, methodId, level})),
).onConflictDoNothing();
}
export async function spendContribution(
+2 -2
View File
@@ -153,8 +153,8 @@ export async function listItem(
throw new InventoryError('物品已变化或数量不足,请重新选择');
const equipped = await tx
.select()
.from(schema.combatV6EquipmentLoadouts)
.where(eq(schema.combatV6EquipmentLoadouts.equipmentInstanceId, item.id))
.from(schema.cultivatorEquipmentSlots)
.where(eq(schema.cultivatorEquipmentSlots.equipmentInstanceId, item.id))
.limit(1);
const reason = auctionBlockReason({
...item,
@@ -98,7 +98,7 @@ export function conditionChangesAfterConsumable(args: {
isSectMeridianResetTalismanScenario(args.consumable.spec.scenario)
) {
changes.push({
resourceTopic: 'player.combat-v6-build',
resourceTopic: 'player.sect-combat',
eventType: 'combat_v6.meridian_nodes.reset',
operation: 'invalidate',
});
@@ -4,7 +4,6 @@ import {
} from '@server/utils/cultivationUtils';
import { allowsLocalDevTools } from '@shared/config/deployment';
import type { DevCultivatorPatch } from '@shared/contracts/devTools';
import { projectCultivatorMultiSectV5ToCombatV6 } from '@shared/engine/combat-v6/projection';
import type { CultivatorCondition } from '@shared/types/condition';
import type { RealmStage, RealmType } from '@shared/types/constants';
import type { CultivationProgress } from '@shared/types/cultivator';
@@ -21,7 +20,7 @@ import { ConditionService } from './ConditionService';
import { assertInventoryIdle, InventoryError } from './InventoryService';
import { ResourceEventCommitter } from './ResourceEventCommitter';
import { TaskService } from './TaskService';
import { assembleCombatV6TrainingPlayer } from './combat-v6/CombatV6BuildService';
import { readCombatV6ConditionAuthority } from './combat-v6/CombatV6ConditionAuthority';
import { getBreakthroughTaskDefinition } from './taskDefinitions';
export async function patchDevCultivator(
@@ -115,21 +114,14 @@ export async function patchDevCultivator(
})
.where(eq(cultivators.id, owner));
if (input.resources) {
const { player } = await assembleCombatV6TrainingPlayer(owner, tx);
const projected = projectCultivatorMultiSectV5ToCombatV6({
...player,
side: 0,
slot: 0,
resourcePolicy: 'full',
});
if (!projected.ok) throw new InventoryError('角色 v6 构筑不可用');
const { maxHp, maxMp } = await readCombatV6ConditionAuthority(owner, tx);
const condition = ConditionService.applyCombatV6Resources(
before.condition as CultivatorCondition,
{
hp: Math.min(input.resources.hp, projected.unit.attrs!.maxHp!),
mp: Math.min(input.resources.mp, projected.unit.attrs!.maxMp!),
maxHp: projected.unit.attrs!.maxHp!,
maxMp: projected.unit.attrs!.maxMp!,
hp: Math.min(input.resources.hp, maxHp),
mp: Math.min(input.resources.mp, maxMp),
maxHp,
maxMp,
},
);
await tx
@@ -238,7 +230,7 @@ export async function patchDevCultivator(
'player.currency',
'player.progress',
'player.condition',
'player.combat-v6-build',
'player.sect-combat',
'player.tasks',
'player.task-summary',
] as const
+14 -7
View File
@@ -1,4 +1,3 @@
import { parseMailAttachments } from '@shared/lib/itemLibrary';
import type {
DevGrantSchema,
ForgeRequest,
@@ -27,19 +26,23 @@ import {
} from '@shared/items/definitions/materials';
import { seedFactsOf } from '@shared/items/definitions/seeds';
import { materialFactsOf } from '@shared/items/material';
import { parseMailAttachments } from '@shared/lib/itemLibrary';
import { and, asc, count, eq, gte, ilike, inArray, sql } from 'drizzle-orm';
import { randomInt, randomUUID } from 'node:crypto';
import type { z } from 'zod';
import { db, type DbTransaction } from '../drizzle/db';
import {
combatV6Beasts,
consumables,
cultivatorBeasts,
cultivators,
inventoryItems,
materials,
} from '../drizzle/schema';
import { redisLockKeys, withRedisLock } from '../redis/lock';
import { readBeastOwner } from '../repositories/combatV6BeastRepository';
import {
beastIndividualData,
readBeastOwner,
} from '../repositories/combatV6BeastRepository';
import { lockCultivatorForStateMutation } from '../repositories/playerStateRepository';
import { mapConsumableRow } from './consumablePersistence';
import { addConsumableToInventoryInTransaction } from './cultivator/CultivatorInventoryRepository';
@@ -374,8 +377,8 @@ export async function grantDevResources(input: z.infer<typeof DevGrantSchema>) {
throw new InventoryError('灵兽物种无效');
const [held] = await tx
.select({ total: count() })
.from(combatV6Beasts)
.where(eq(combatV6Beasts.cultivatorId, input.cultivatorId));
.from(cultivatorBeasts)
.where(eq(cultivatorBeasts.cultivatorId, input.cultivatorId));
if (held.total >= BEAST_CAPACITY)
throw new InventoryError('灵兽持有数量已达上限');
const id = randomUUID();
@@ -386,8 +389,12 @@ export async function grantDevResources(input: z.infer<typeof DevGrantSchema>) {
randomInt(0x100000000),
);
await tx
.insert(combatV6Beasts)
.values({ id, cultivatorId: input.cultivatorId, individual });
.insert(cultivatorBeasts)
.values({
id,
cultivatorId: input.cultivatorId,
individual: beastIndividualData(individual),
});
ids.push(id);
} else if (grant.type === 'vault-seed') {
const [row] = await tx
+26 -41
View File
@@ -1,4 +1,5 @@
import { hasActiveRanking } from '@server/lib/redis/rankingChallenge';
import { readCharacterEquipment } from '@server/lib/repositories/characterLoadoutRepository';
import { hasActiveTower } from '@server/lib/tower/occupancy';
import type {
InventoryAction,
@@ -30,23 +31,18 @@ import { randomInt, randomUUID } from 'node:crypto';
import type { z } from 'zod';
import { db, type DbTransaction } from '../drizzle/db';
import {
combatV6Beasts,
combatV6BuildProfiles,
combatV6EquipmentLoadouts,
cultivatorBeasts,
cultivatorEquipmentSlots,
inventoryItems,
} from '../drizzle/schema';
import { hasActiveDungeon } from '../dungeon/occupancy';
import { redis } from '../redis';
import { redisLockKeys, withRedisLock } from '../redis/lock';
import {
beastIndividualData,
readBeastOwner,
readBeastRoster,
} from '../repositories/combatV6BeastRepository';
import {
findActiveCombatV6Membership,
findCombatV6Profile,
loadActiveCombatV6Build,
} from '../repositories/combatV6BuildRepository';
import { lockCultivatorForStateMutation } from '../repositories/playerStateRepository';
import { arenaOccupancyKey } from './combat-v6/CombatV6ArenaStore';
import { hasActiveBreakthroughBattle } from './combat-v6/CombatV6BreakthroughOccupancy';
@@ -150,11 +146,11 @@ export async function readInventory(
.offset(page * 40);
const equipped = rows.length
? await db
.select({ id: combatV6EquipmentLoadouts.equipmentInstanceId })
.from(combatV6EquipmentLoadouts)
.select({ id: cultivatorEquipmentSlots.equipmentInstanceId })
.from(cultivatorEquipmentSlots)
.where(
inArray(
combatV6EquipmentLoadouts.equipmentInstanceId,
cultivatorEquipmentSlots.equipmentInstanceId,
rows.map((r) => r.id),
),
)
@@ -365,8 +361,8 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
} else if (item) {
const equipped = await tx
.select()
.from(combatV6EquipmentLoadouts)
.where(eq(combatV6EquipmentLoadouts.equipmentInstanceId, item.id));
.from(cultivatorEquipmentSlots)
.where(eq(cultivatorEquipmentSlots.equipmentInstanceId, item.id));
if (input.action === 'transfer') {
if (equipped.length) throw new InventoryError('请先卸下装备');
if (item.location === input.location)
@@ -484,12 +480,12 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
slot,
);
await tx
.update(combatV6Beasts)
.set({ individual: learned })
.update(cultivatorBeasts)
.set({ individual: beastIndividualData(learned) })
.where(
and(
eq(combatV6Beasts.id, beast.id),
eq(combatV6Beasts.cultivatorId, owner),
eq(cultivatorBeasts.id, beast.id),
eq(cultivatorBeasts.cultivatorId, owner),
),
);
result = {
@@ -502,20 +498,14 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
} else if (input.action === 'equip') {
if (item.location !== 'bag' || item.definitionId !== 'equipment.v6')
throw new InventoryError('请先将道装取入背包');
const membership = await findActiveCombatV6Membership(owner, tx);
const profile = membership
? await findCombatV6Profile(membership.membershipId, tx)
: null;
if (!profile || profile.status !== 'active')
throw new InventoryError('请先完成战斗构筑');
const equipment = item.instanceData as DaoEquipmentInstanceV1;
const [previous] = await tx
.select()
.from(combatV6EquipmentLoadouts)
.from(cultivatorEquipmentSlots)
.where(
and(
eq(combatV6EquipmentLoadouts.profileId, profile.id),
eq(combatV6EquipmentLoadouts.slot, equipment.slot),
eq(cultivatorEquipmentSlots.cultivatorId, owner),
eq(cultivatorEquipmentSlots.slot, equipment.slot),
),
);
if (input.equipped && previous?.equipmentInstanceId === item.id)
@@ -530,34 +520,33 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
if (input.equipped && replaced) replaced.revision++;
if (input.equipped) {
await tx
.insert(combatV6EquipmentLoadouts)
.insert(cultivatorEquipmentSlots)
.values({
profileId: profile.id,
cultivatorId: owner,
slot: equipment.slot,
equipmentInstanceId: item.id,
})
.onConflictDoUpdate({
target: [
combatV6EquipmentLoadouts.profileId,
combatV6EquipmentLoadouts.slot,
cultivatorEquipmentSlots.cultivatorId,
cultivatorEquipmentSlots.slot,
],
set: { equipmentInstanceId: item.id },
});
} else
await tx
.delete(combatV6EquipmentLoadouts)
.delete(cultivatorEquipmentSlots)
.where(
and(
eq(combatV6EquipmentLoadouts.profileId, profile.id),
eq(combatV6EquipmentLoadouts.equipmentInstanceId, item.id),
eq(cultivatorEquipmentSlots.cultivatorId, owner),
eq(cultivatorEquipmentSlots.equipmentInstanceId, item.id),
),
);
item.revision++;
const build = await loadActiveCombatV6Build(owner, tx);
const loadout = await readCharacterEquipment(owner, tx);
const character = await readBeastOwner(owner, tx);
if (!build) throw new InventoryError('战斗构筑不可用');
const compiled = compileDaoEquipmentSpecialLoadoutV1(
build.equipment,
loadout,
character.ownerLevel,
);
if (!compiled.ok)
@@ -565,17 +554,13 @@ export async function mutateInventory(owner: string, input: InventoryAction) {
compiled.diagnostics.find((d) => d.severity === 'error')
?.message ?? '装配无效',
);
await tx
.update(combatV6BuildProfiles)
.set({ revision: profile.revision + 1 })
.where(eq(combatV6BuildProfiles.id, profile.id));
await new ResourceEventCommitter().commit(tx, {
actor: { userId: character.userId, cultivatorId: owner },
source: 'inventory-equipment',
scopeDefaults: { cultivatorId: owner },
changes: [
{
resourceTopic: 'player.combat-v6-build',
resourceTopic: 'player.profile',
operation: 'invalidate',
eventType: 'combat_v6.equipment.changed',
},
+2 -2
View File
@@ -86,8 +86,8 @@ async function detachAttachment(
throw new InventoryError('附件已变化或数量不足,请重新选择');
const equipped = await tx
.select()
.from(schema.combatV6EquipmentLoadouts)
.where(eq(schema.combatV6EquipmentLoadouts.equipmentInstanceId, item.id))
.from(schema.cultivatorEquipmentSlots)
.where(eq(schema.cultivatorEquipmentSlots.equipmentInstanceId, item.id))
.limit(1);
const reason = mailGiftBlockReason({
...item,
@@ -17,7 +17,7 @@ import {
} from '@server/lib/repositories/playerStateRepository';
import { getPlayerIdentityCultivatorById } from '@server/lib/services/cultivator/CultivatorProfileRepository';
import { QiService } from '@server/lib/services/QiService';
import { getCombatV6BuildView } from '@server/lib/services/combat-v6/CombatV6BuildService';
import { getSectCombatView } from '@server/lib/services/combat-v6/CombatV6BuildService';
import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils';
import {
PLAYER_RESOURCE_KEYS,
@@ -154,8 +154,8 @@ const readers: {
qiLastRefreshedAt: qiState.qiLastRefreshedAt?.toISOString() ?? null,
};
},
'combat-v6-build': ({ cultivatorId, q }) =>
getCombatV6BuildView(cultivatorId, q),
'sect-combat': ({ cultivatorId, q }) =>
getSectCombatView(cultivatorId, q),
'mail-summary': ({ cultivatorId, q }) =>
readPlayerMailSummary(cultivatorId, q),
'task-summary': ({ cultivatorId, q }) =>
@@ -51,7 +51,7 @@ export class ResourceEventCommitter {
);
const changedCharacters = new Set(originalChanges.filter(change =>
change.scope.kind === 'cultivator' &&
['player.profile', 'player.combat-v6-build'].includes(change.resourceTopic),
['player.profile', 'player.sect-combat'].includes(change.resourceTopic),
).map(change => change.scope.id));
for (const id of changedCharacters) await refreshCombatV6CharacterResources(id, tx);
const scopedChanges = withCharacterPanelInvalidations(originalChanges);
@@ -1,10 +1,10 @@
import type { DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6MeridianLoadouts,
combatV6MeridianNodes,
sectCombatStates,
sectMeridianLoadouts,
sectMeridianNodes,
} from '@server/lib/drizzle/schema';
import { loadActiveCombatV6Build } from '@server/lib/repositories/combatV6BuildRepository';
import { readActiveSectCombatProgress } from '@server/lib/repositories/sectCombatRepository';
import { eq, inArray, sql } from 'drizzle-orm';
import { assertInventoryIdle } from './InventoryService';
@@ -22,7 +22,7 @@ export const SectMeridianResetService = {
tx: DbTransaction;
}): Promise<{ resetLoadoutCount: number }> {
await assertInventoryIdle(args.cultivatorId);
const build = await loadActiveCombatV6Build(args.cultivatorId, args.tx);
const build = await readActiveSectCombatProgress(args.cultivatorId, args.tx);
if (!build) throw new SectMeridianResetServiceError('请先启用新版宗门传承');
const paths = build.sect.meridianLoadouts
.filter((loadout) => loadout.nodeIds.length > 0)
@@ -30,21 +30,21 @@ export const SectMeridianResetService = {
if (!paths.length)
throw new SectMeridianResetServiceError('当前宗门流派没有已选择的节点');
const rows = await args.tx
.select({ id: combatV6MeridianLoadouts.id })
.from(combatV6MeridianLoadouts)
.where(eq(combatV6MeridianLoadouts.profileId, build.profileId));
.select({ id: sectMeridianLoadouts.id })
.from(sectMeridianLoadouts)
.where(eq(sectMeridianLoadouts.membershipId, build.membershipId));
const ids = rows.map((row) => row.id);
await args.tx
.delete(combatV6MeridianNodes)
.where(inArray(combatV6MeridianNodes.loadoutId, ids));
.delete(sectMeridianNodes)
.where(inArray(sectMeridianNodes.loadoutId, ids));
await args.tx
.update(combatV6MeridianLoadouts)
.set({ revision: sql`${combatV6MeridianLoadouts.revision} + 1` })
.where(inArray(combatV6MeridianLoadouts.id, ids));
.update(sectMeridianLoadouts)
.set({ revision: sql`${sectMeridianLoadouts.revision} + 1` })
.where(inArray(sectMeridianLoadouts.id, ids));
await args.tx
.update(combatV6BuildProfiles)
.update(sectCombatStates)
.set({ revision: build.revision + 1 })
.where(eq(combatV6BuildProfiles.id, build.profileId));
.where(eq(sectCombatStates.membershipId, build.membershipId));
return { resetLoadoutCount: paths.length };
},
};
@@ -1,14 +1,14 @@
import { hasActiveSectTaskBattle } from './CombatV6SectTaskOccupancy';
import { db, type DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6BeastLineups,
combatV6Beasts,
cultivatorBeastLineups,
cultivatorBeasts,
} from '@server/lib/drizzle/schema';
import { hasActiveDungeon } from '@server/lib/dungeon/occupancy';
import { redis } from '@server/lib/redis';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import { hasActiveRanking } from '@server/lib/redis/rankingChallenge';
import {
beastIndividualData,
readBeastOwner,
readBeastRoster,
} from '@server/lib/repositories/combatV6BeastRepository';
@@ -33,7 +33,9 @@ import type { z } from 'zod';
import { updateSpiritStones } from '../cultivator/CultivatorStateRepository';
import { ResourceEventCommitter } from '../ResourceEventCommitter';
import { arenaOccupancyKey } from './CombatV6ArenaStore';
import { hasActiveBreakthroughBattle } from './CombatV6BreakthroughOccupancy';
import { CombatV6RuntimeStore } from './CombatV6RuntimeStore';
import { hasActiveSectTaskBattle } from './CombatV6SectTaskOccupancy';
import { CombatV6WildStore } from './CombatV6WildStore';
export class BeastError extends Error {
@@ -58,7 +60,8 @@ async function mutate<T>(
(await hasActiveTower(cultivatorId)) ||
(await hasActiveRanking(cultivatorId)) ||
(await hasActiveDungeon(cultivatorId)) ||
((await hasActiveSectTaskBattle(cultivatorId)) || (await hasActiveBreakthroughBattle(cultivatorId))) ||
(await hasActiveSectTaskBattle(cultivatorId)) ||
(await hasActiveBreakthroughBattle(cultivatorId)) ||
(await new CombatV6WildStore().lock(cultivatorId)) ||
(await redis.get(arenaOccupancyKey(cultivatorId))) ||
(await new CombatV6RuntimeStore().currentId(cultivatorId))
@@ -89,8 +92,12 @@ export async function claimStarterBeast(
randomInt(0, 0x7fffffff),
);
await tx
.insert(combatV6Beasts)
.values({ id: beast.id, cultivatorId, individual: beast });
.insert(cultivatorBeasts)
.values({
id: beast.id,
cultivatorId,
individual: beastIndividualData(beast),
});
const canCarry = roster.lineup.carriedBeastIds.length < 6;
const lineup = {
carriedBeastIds: canCarry
@@ -104,11 +111,11 @@ export async function claimStarterBeast(
revision: roster.lineup.revision + 1,
};
await tx
.insert(combatV6BeastLineups)
.values({ cultivatorId, lineup, starterBeastId: beast.id })
.insert(cultivatorBeastLineups)
.values({ cultivatorId, lineup, starterClaimedAt: new Date() })
.onConflictDoUpdate({
target: combatV6BeastLineups.cultivatorId,
set: { lineup, starterBeastId: beast.id },
target: cultivatorBeastLineups.cultivatorId,
set: { lineup, starterClaimedAt: new Date() },
});
return readBeastRoster(cultivatorId, tx);
});
@@ -139,10 +146,10 @@ export async function updateBeastLineup(
throw new BeastError('等级或寿命不满足出战条件,不能设为首发');
const next = { ...lineup, revision: lineup.revision + 1 };
await tx
.insert(combatV6BeastLineups)
.insert(cultivatorBeastLineups)
.values({ cultivatorId, lineup: next })
.onConflictDoUpdate({
target: combatV6BeastLineups.cultivatorId,
target: cultivatorBeastLineups.cultivatorId,
set: { lineup: next },
});
return readBeastRoster(cultivatorId, tx);
@@ -171,15 +178,15 @@ export async function restBeast(
tx,
);
await tx
.update(combatV6Beasts)
.update(cultivatorBeasts)
.set({
individual: {
individual: beastIndividualData({
...beast,
currentLifespan: beast.maxLifespan,
revision: beast.revision + 1,
},
}),
})
.where(eq(combatV6Beasts.id, id));
.where(eq(cultivatorBeasts.id, id));
await new ResourceEventCommitter().commit(tx, {
actor: { userId: owner.userId, cultivatorId },
source: 'combat-v6-beast-rest',
@@ -216,9 +223,9 @@ export async function allocateBeastPoints(
throw new BeastError(e instanceof Error ? e.message : '加点无效');
}
await tx
.update(combatV6Beasts)
.set({ individual: next })
.where(eq(combatV6Beasts.id, id));
.update(cultivatorBeasts)
.set({ individual: beastIndividualData(next) })
.where(eq(cultivatorBeasts.id, id));
return readBeastRoster(cultivatorId, tx);
});
}
@@ -235,7 +242,7 @@ export async function releaseBeast(
throw new BeastError('灵兽状态已变化,请刷新后重试');
if (roster.lineup.leadBeastId === id) throw new BeastError('请先取消首发');
await tx
.update(combatV6BeastLineups)
.update(cultivatorBeastLineups)
.set({
lineup: {
...roster.lineup,
@@ -245,9 +252,8 @@ export async function releaseBeast(
revision: roster.lineup.revision + 1,
},
})
.where(eq(combatV6BeastLineups.cultivatorId, cultivatorId));
await tx.delete(combatV6Beasts).where(eq(combatV6Beasts.id, id));
.where(eq(cultivatorBeastLineups.cultivatorId, cultivatorId));
await tx.delete(cultivatorBeasts).where(eq(cultivatorBeasts.id, id));
return readBeastRoster(cultivatorId, tx);
});
}
import { hasActiveBreakthroughBattle } from './CombatV6BreakthroughOccupancy';
@@ -1,28 +1,28 @@
import { readCharacterEquipment, readCharacterManuals } from '@server/lib/repositories/characterLoadoutRepository';
import { db, runDbTasks, type DbExecutor } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6ManualStates,
combatV6MeridianLoadouts,
combatV6MethodProgress,
sectCombatStates,
sectMeridianLoadouts,
sectMethodProgress,
} from '@server/lib/drizzle/schema';
import { readBeastRoster } from '@server/lib/repositories/combatV6BeastRepository';
import {
characterIdentityRow,
findActiveCombatV6Membership,
findCombatV6Profile,
listCombatV6MethodLevels,
loadActiveCombatV6Build,
findActiveSectMembership,
findSectCombatState,
readSectMethodLevels,
readActiveSectCombatProgress,
lockActiveMembership,
} from '@server/lib/repositories/combatV6BuildRepository';
} from '@server/lib/repositories/sectCombatRepository';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import { ResourceEventCommitter } from '@server/lib/services/ResourceEventCommitter';
import type {
CombatV6BuildInitializeRequest,
CombatV6BuildViewV1,
SectPathSelectionRequest,
SectCombatView,
} from '@shared/contracts/combatV6';
import { COMBAT_V6_BUILD_ERROR_CODE } from '@shared/contracts/combatV6';
import {
createCombatV6BuildView,
createSectCombatView,
createFreshCombatV6MethodLevels,
} from '@shared/engine/combat-v6/build-state';
import {
@@ -33,7 +33,7 @@ import type { CombatV6TrainingPlayerInput } from '@shared/engine/combat-v6/encou
import { projectCultivatorMultiSectV5ToCombatV6 } from '@shared/engine/combat-v6/projection';
import type { CultivatorCondition } from '@shared/types/condition';
import type { RealmStage, RealmType } from '@shared/types/constants';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { assertCombatV6MutationAllowed } from './CombatV6MutationGuard';
export class CombatV6BuildError extends Error {
@@ -47,39 +47,39 @@ export class CombatV6BuildError extends Error {
}
}
export async function getCombatV6BuildView(
export async function getSectCombatView(
cultivatorId: string,
q: DbExecutor,
): Promise<CombatV6BuildViewV1> {
const membership = await findActiveCombatV6Membership(cultivatorId, q);
if (!membership) return createCombatV6BuildView({ status: 'uninitialized' });
): Promise<SectCombatView> {
const membership = await findActiveSectMembership(cultivatorId, q);
if (!membership) return createSectCombatView({ status: 'uninitialized' });
if (!(membership.sectId in COMBAT_V6_SECT_DEFINITIONS_V4)) {
return createCombatV6BuildView({
return createSectCombatView({
status: 'uninitialized',
membershipId: membership.membershipId,
});
}
const sectId = membership.sectId as CombatV6SectId;
const profile = await findCombatV6Profile(membership.membershipId, q);
const methodLevels = profile
? await listCombatV6MethodLevels(profile.id, q)
const sectState = await findSectCombatState(membership.membershipId, q);
const methodLevels = sectState
? await readSectMethodLevels(membership.membershipId, q)
: createFreshCombatV6MethodLevels(sectId);
const base = createCombatV6BuildView({
const base = createSectCombatView({
status:
profile?.status === 'active'
sectState?.activePathId
? 'active'
: profile
: sectState
? 'pending'
: 'uninitialized',
revision: profile?.revision ?? 0,
revision: sectState?.revision ?? 0,
membershipId: membership.membershipId,
sectId,
activePathId: profile?.activePathId ?? undefined,
activePathId: sectState?.activePathId ?? undefined,
methodLevels,
});
if (profile?.status !== 'active')
return { ...base, meridianDepth: profile?.meridianDepth ?? 0 };
const build = await loadActiveCombatV6Build(cultivatorId, q);
if (!sectState?.activePathId)
return { ...base, meridianDepth: sectState?.meridianDepth ?? 0 };
const build = await readActiveSectCombatProgress(cultivatorId, q);
if (!build)
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.Invalid,
@@ -89,14 +89,12 @@ export async function getCombatV6BuildView(
return {
...base,
meridianDepth: build.sect.meridianDepth,
manuals: build.manuals,
equipment: build.equipment,
};
}
export async function initializeCombatV6Build(
export async function selectInitialSectPath(
actor: { userId: string; cultivatorId: string },
input: CombatV6BuildInitializeRequest,
input: SectPathSelectionRequest,
) {
return db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, actor.cultivatorId);
@@ -127,58 +125,57 @@ export async function initializeCombatV6Build(
400,
);
}
let profile = await findCombatV6Profile(membership.membershipId, tx);
let sectState = await findSectCombatState(membership.membershipId, tx);
if (
profile?.status === 'active' ||
(profile && profile.revision !== input.expectedRevision)
sectState?.activePathId ||
(sectState && sectState.revision !== input.expectedRevision)
) {
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.RevisionConflict,
'构筑状态已经变化',
);
}
if (!profile) {
[profile] = await tx
.insert(combatV6BuildProfiles)
.values({ membershipId: membership.membershipId, status: 'pending' })
if (!sectState) {
[sectState] = await tx
.insert(sectCombatStates)
.values({ membershipId: membership.membershipId })
.returning();
if (!profile)
if (!sectState)
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.Invalid,
'无法创建combat-v6构筑',
422,
);
const fresh = createFreshCombatV6MethodLevels(sectId);
await tx.insert(combatV6MethodProgress).values(
await tx.insert(sectMethodProgress).values(
Object.entries(fresh).map(([methodId, level]) => ({
profileId: profile!.id,
membershipId: membership.membershipId,
methodId,
level,
})),
);
}
const levels = await listCombatV6MethodLevels(profile.id, tx);
const levels = await readSectMethodLevels(membership.membershipId, tx);
if (
definition.methods.some((method) => !Number.isInteger(levels[method.id]))
) {
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.Invalid,
'心法迁移数据不完整',
'宗门心法数据不完整',
422,
);
}
const [activated] = await tx
.update(combatV6BuildProfiles)
.update(sectCombatStates)
.set({
status: 'active',
activePathId: input.activePathId,
revision: sql`${combatV6BuildProfiles.revision} + 1`,
revision: sql`${sectCombatStates.revision} + 1`,
})
.where(
and(
eq(combatV6BuildProfiles.id, profile.id),
eq(combatV6BuildProfiles.status, 'pending'),
eq(combatV6BuildProfiles.revision, input.expectedRevision),
eq(sectCombatStates.membershipId, membership.membershipId),
isNull(sectCombatStates.activePathId),
eq(sectCombatStates.revision, input.expectedRevision),
),
)
.returning();
@@ -187,31 +184,27 @@ export async function initializeCombatV6Build(
COMBAT_V6_BUILD_ERROR_CODE.RevisionConflict,
'构筑状态已经变化',
);
await tx.insert(combatV6MeridianLoadouts).values(
await tx.insert(sectMeridianLoadouts).values(
definition.paths.map((path) => ({
profileId: profile!.id,
membershipId: membership.membershipId,
pathId: path.id,
revision: 0,
})),
);
await tx
.insert(combatV6ManualStates)
.values({ profileId: profile.id, schemaVersion: 1, revision: 0 });
const state = await new ResourceEventCommitter().commit(tx, {
actor,
source: 'combat-v6-build',
source: 'sect-combat',
scopeDefaults: { cultivatorId: actor.cultivatorId },
changes: [
{
resourceTopic: 'player.combat-v6-build',
resourceTopic: 'player.sect-combat',
operation: 'invalidate',
eventType: 'combat_v6.build.initialized',
},
],
});
return {
result: await getCombatV6BuildView(actor.cultivatorId, tx),
result: await getSectCombatView(actor.cultivatorId, tx),
state,
};
});
@@ -223,9 +216,8 @@ export async function assembleCombatV6TrainingPlayer(
): Promise<{
player: CombatV6TrainingPlayerInput;
membershipId: string;
buildRevision: number;
}> {
const membership = await findActiveCombatV6Membership(cultivatorId, q);
const membership = await findActiveSectMembership(cultivatorId, q);
if (!membership) {
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.MembershipRequired,
@@ -240,22 +232,22 @@ export async function assembleCombatV6TrainingPlayer(
422,
);
}
const profile = await findCombatV6Profile(membership.membershipId, q);
if (!profile) {
const sectState = await findSectCombatState(membership.membershipId, q);
if (!sectState) {
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.NotInitialized,
'请先初始化combat-v6构筑',
'请先前往宗门或练功房选择修行流派',
);
}
if (profile.status === 'pending') {
if (!sectState.activePathId) {
throw new CombatV6BuildError(
COMBAT_V6_BUILD_ERROR_CODE.Pending,
'请先确认combat-v6流派',
'请先前往宗门或练功房选择修行流派',
);
}
const [cultivator, build] = await runDbTasks(q, [
() => characterIdentityRow(cultivatorId, q),
() => loadActiveCombatV6Build(cultivatorId, q),
() => readActiveSectCombatProgress(cultivatorId, q),
]);
if (!cultivator)
throw new CombatV6BuildError(
@@ -287,8 +279,8 @@ export async function assembleCombatV6TrainingPlayer(
(cultivator.condition as CultivatorCondition | null) ?? undefined,
},
sect: build.sect,
equipment: build.equipment,
manuals: build.manuals,
equipment: await readCharacterEquipment(cultivatorId, q),
manuals: await readCharacterManuals(cultivatorId, q),
beasts: await readBeastRoster(cultivatorId, q),
};
const projected = projectCultivatorMultiSectV5ToCombatV6({
@@ -309,6 +301,5 @@ export async function assembleCombatV6TrainingPlayer(
return {
player: structuredClone(player),
membershipId: build.membershipId,
buildRevision: build.revision,
};
}
@@ -1,6 +1,5 @@
import type { DbTransaction } from '@server/lib/drizzle/db';
import { cultivators } from '@server/lib/drizzle/schema';
import { findActiveCombatV6Membership, findCombatV6Profile } from '@server/lib/repositories/combatV6BuildRepository';
import { rebaseCharacterResources } from '@shared/lib/cultivatorDisplay';
import { evaluateFateContext } from '@shared/lib/fates';
import type { CultivatorCondition } from '@shared/types/condition';
@@ -13,10 +12,6 @@ export async function refreshCombatV6CharacterResources(id: string, tx: DbTransa
const [row] = await tx.select({ condition: cultivators.condition })
.from(cultivators).where(eq(cultivators.id, id)).for('update');
if (!row?.condition) return;
const membership = await findActiveCombatV6Membership(id, tx);
// Joining/transferring a sect must be able to enter its existing build-initialization flow.
// The read model still reports "not ready" until initialization supplies an active build.
if (membership && (await findCombatV6Profile(membership.membershipId, tx))?.status !== 'active') return;
const condition = row.condition as CultivatorCondition;
const authority = await readCombatV6ConditionAuthority(id, tx);
if (condition.resources.hp.max === authority.maxHp && condition.resources.mp.max === authority.maxMp) return;
@@ -1,11 +1,10 @@
import { readCharacterCombatBuild } from '@server/lib/repositories/characterLoadoutRepository';
import type { DbExecutor } from '@server/lib/drizzle/db';
import { hasActiveDungeon } from '@server/lib/dungeon/occupancy';
import {
findActiveCombatV6Membership,
characterIdentityRow,
loadActiveCombatV6Build,
} from '@server/lib/repositories/combatV6BuildRepository';
import { projectCharacterDisplay, type CharacterDisplayBuild } from '@shared/lib/cultivatorDisplay';
} from '@server/lib/repositories/sectCombatRepository';
import { projectCharacterDisplay } from '@shared/lib/cultivatorDisplay';
import type { CultivatorCondition } from '@shared/types/condition';
import type { RealmStage, RealmType } from '@shared/types/constants';
import { CombatV6WildStore } from './CombatV6WildStore';
@@ -19,9 +18,7 @@ export async function readCombatV6ConditionAuthority(
id: string,
q: DbExecutor,
) {
const membership = await findActiveCombatV6Membership(id, q);
const build: CharacterDisplayBuild | null = membership ? await loadActiveCombatV6Build(id, q) : null;
if (membership && !build) throw new Error('V6 构筑尚未就绪,请完成宗门构筑初始化');
const build = await readCharacterCombatBuild(id, q);
const row = await characterIdentityRow(id, q);
if (!row) throw new Error('角色不存在');
const attrs = projectCharacterDisplay({
@@ -1,16 +1,15 @@
import { readCharacterManuals } from '@server/lib/repositories/characterLoadoutRepository';
import { db, type DbExecutor } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6ManualSlots,
combatV6ManualStates,
cultivatorManualSlots,
cultivatorManualStates,
cultivators,
inventoryItems,
} from '@server/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import {
characterIdentityRow,
loadActiveCombatV6Build,
} from '@server/lib/repositories/combatV6BuildRepository';
} from '@server/lib/repositories/sectCombatRepository';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import {
getOrInitCultivationProgress,
@@ -25,7 +24,7 @@ import { findItemDefinition } from '@shared/items/registry';
import { previewManualAction } from '@shared/manuals/action';
import type { RealmStage, RealmType } from '@shared/types/constants';
import type { CultivationProgress } from '@shared/types/cultivator';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import {
assertInventoryIdle,
InventoryError,
@@ -37,7 +36,7 @@ import { ResourceEventCommitter } from '../ResourceEventCommitter';
async function readManualFacts(owner: string, q: DbExecutor) {
const character = await characterIdentityRow(owner, q);
if (!character) throw new InventoryError('角色不可用');
const build = await loadActiveCombatV6Build(owner, q);
const manuals = await readCharacterManuals(owner, q);
const [row] = await q
.select({
progress: cultivators.cultivation_progress,
@@ -50,13 +49,13 @@ async function readManualFacts(owner: string, q: DbExecutor) {
character.realm as RealmType,
row.stage as RealmStage,
);
return { character, build, progress };
return { character, manuals, progress };
}
export async function readManuals(owner: string): Promise<ManualView> {
return db.transaction(
async (tx) => {
const { character, build, progress } = await readManualFacts(owner, tx);
const { character, manuals, progress } = await readManualFacts(owner, tx);
const rows = await tx
.select()
.from(inventoryItems)
@@ -66,10 +65,8 @@ export async function readManuals(owner: string): Promise<ManualView> {
eq(inventoryItems.location, 'bag'),
),
);
let blockedReason: string | null = build
? null
: '请先在宗门完成战斗构筑';
if (build) {
let blockedReason: string | null = null;
{
try {
await assertInventoryIdle(owner);
} catch (error) {
@@ -84,7 +81,7 @@ export async function readManuals(owner: string): Promise<ManualView> {
insight: progress.comprehension_insight,
experienceCap: progress.exp_cap,
},
state: build?.manuals ?? null,
state: manuals,
items: rows
.filter(
(row) =>
@@ -110,13 +107,9 @@ export async function mutateManuals(owner: string, action: ManualAction) {
db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, owner);
await assertInventoryIdle(owner);
const { character, build, progress } = await readManualFacts(owner, tx);
if (!build) throw new InventoryError('请先在宗门完成战斗构筑');
const [manualState] = await tx
.select()
.from(combatV6ManualStates)
.where(eq(combatV6ManualStates.profileId, build.profileId));
if (!manualState || manualState.revision !== action.expectedRevision)
const { character, manuals, progress } = await readManualFacts(owner, tx);
await tx.insert(cultivatorManualStates).values({cultivatorId: owner}).onConflictDoNothing();
if (manuals.revision !== action.expectedRevision)
throw new InventoryError('功法已变化,请刷新后重试');
const rows =
'item' in action
@@ -133,7 +126,7 @@ export async function mutateManuals(owner: string, action: ManualAction) {
const before = rows.map(inventoryItemOf);
const item = before[0];
const result = previewManualAction(
build.manuals,
manuals,
character.realm as RealmType,
action,
{
@@ -148,26 +141,26 @@ export async function mutateManuals(owner: string, action: ManualAction) {
);
const updated = await tx
.update(combatV6ManualStates)
.update(cultivatorManualStates)
.set({
revision: result.state.revision,
learned: result.state.learned,
})
.where(
and(
eq(combatV6ManualStates.id, manualState.id),
eq(combatV6ManualStates.revision, action.expectedRevision),
eq(cultivatorManualStates.cultivatorId, owner),
eq(cultivatorManualStates.revision, action.expectedRevision),
),
)
.returning({ id: combatV6ManualStates.id });
.returning({ id: cultivatorManualStates.cultivatorId });
if (!updated.length)
throw new InventoryError('功法已变化,请刷新后重试');
await tx
.delete(combatV6ManualSlots)
.delete(cultivatorManualSlots)
.where(
and(
eq(combatV6ManualSlots.stateId, manualState.id),
eq(combatV6ManualSlots.slot, action.slot),
eq(cultivatorManualSlots.cultivatorId, owner),
eq(cultivatorManualSlots.slot, action.slot),
),
);
const nextSlot = result.state.build.slots.find(
@@ -175,8 +168,8 @@ export async function mutateManuals(owner: string, action: ManualAction) {
);
if (nextSlot)
await tx
.insert(combatV6ManualSlots)
.values({ stateId: manualState.id, ...nextSlot });
.insert(cultivatorManualSlots)
.values({ cultivatorId: owner, ...nextSlot });
if ('item' in action) {
await saveInventoryPlan(
owner,
@@ -205,10 +198,6 @@ export async function mutateManuals(owner: string, action: ManualAction) {
})
.where(eq(cultivators.id, owner));
}
await tx
.update(combatV6BuildProfiles)
.set({ revision: sql`${combatV6BuildProfiles.revision} + 1` })
.where(eq(combatV6BuildProfiles.id, build.profileId));
const state = await new ResourceEventCommitter().commit(tx, {
actor: { userId: character.userId, cultivatorId: owner },
source: 'combat-v6-manuals',
@@ -224,11 +213,6 @@ export async function mutateManuals(owner: string, action: ManualAction) {
operation: 'invalidate',
eventType: 'combat_v6.manuals.changed',
},
{
resourceTopic: 'player.combat-v6-build',
operation: 'invalidate',
eventType: 'combat_v6.manuals.changed',
},
],
});
lease.assertHeld();
@@ -1,15 +1,15 @@
import { db } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6MeridianLoadouts,
combatV6MeridianNodes,
combatV6MethodProgress,
sectCombatStates,
sectMeridianLoadouts,
sectMeridianNodes,
sectMethodProgress,
} from '@server/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import {
characterIdentityRow,
loadActiveCombatV6Build,
} from '@server/lib/repositories/combatV6BuildRepository';
readActiveSectCombatProgress,
} from '@server/lib/repositories/sectCombatRepository';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import {
loadSectCultivatorProgress,
@@ -23,17 +23,17 @@ import type { RealmStage, RealmType } from '@shared/types/constants';
import { and, eq } from 'drizzle-orm';
import { assertInventoryIdle, InventoryError } from '../InventoryService';
import { ResourceEventCommitter } from '../ResourceEventCommitter';
import { getCombatV6BuildView } from './CombatV6BuildService';
import { getSectCombatView } from './CombatV6BuildService';
export async function readSectV6(owner: string): Promise<SectV6View> {
return db.transaction(
async (tx) => {
const character = await loadSectCultivatorProgress(owner, tx);
if (!character) throw new InventoryError('角色不存在');
const build = await getCombatV6BuildView(owner, tx);
const build = await getSectCombatView(owner, tx);
const active =
build.status === 'active'
? await loadActiveCombatV6Build(owner, tx)
? await readActiveSectCombatProgress(owner, tx)
: null;
let blockedReason: string | null =
build.status === 'active' ? null : '请先选择流派,启用宗门传承';
@@ -71,7 +71,7 @@ export async function mutateSectV6(owner: string, action: SectV6Action) {
db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, owner);
await assertInventoryIdle(owner);
const build = await loadActiveCombatV6Build(owner, tx);
const build = await readActiveSectCombatProgress(owner, tx);
if (
!build ||
build.membershipId !== action.membershipId ||
@@ -94,7 +94,7 @@ export async function mutateSectV6(owner: string, action: SectV6Action) {
)
throw new InventoryError('修为、灵石或感悟不足');
const updated = await tx
.update(combatV6BuildProfiles)
.update(sectCombatStates)
.set({
revision: build.revision + 1,
meridianDepth: change.progress.meridianDepth,
@@ -102,26 +102,26 @@ export async function mutateSectV6(owner: string, action: SectV6Action) {
})
.where(
and(
eq(combatV6BuildProfiles.id, build.profileId),
eq(combatV6BuildProfiles.revision, action.expectedRevision),
eq(sectCombatStates.membershipId, build.membershipId),
eq(sectCombatStates.revision, action.expectedRevision),
),
)
.returning({ id: combatV6BuildProfiles.id });
.returning({ id: sectCombatStates.membershipId });
if (!updated.length)
throw new InventoryError('宗门构筑已变化,请刷新后重试');
if (action.action === 'train')
await tx
.update(combatV6MethodProgress)
.update(sectMethodProgress)
.set({ level: change.progress.methods[action.methodId] })
.where(
and(
eq(combatV6MethodProgress.profileId, build.profileId),
eq(combatV6MethodProgress.methodId, action.methodId),
eq(sectMethodProgress.membershipId, build.membershipId),
eq(sectMethodProgress.methodId, action.methodId),
),
);
if (action.action === 'save') {
const [loadout] = await tx
.update(combatV6MeridianLoadouts)
.update(sectMeridianLoadouts)
.set({
revision: change.progress.meridianLoadouts.find(
(l) => l.pathId === action.pathId,
@@ -129,20 +129,20 @@ export async function mutateSectV6(owner: string, action: SectV6Action) {
})
.where(
and(
eq(combatV6MeridianLoadouts.profileId, build.profileId),
eq(combatV6MeridianLoadouts.pathId, action.pathId),
eq(sectMeridianLoadouts.membershipId, build.membershipId),
eq(sectMeridianLoadouts.pathId, action.pathId),
),
)
.returning();
if (!loadout) throw new InventoryError('经脉方案缺失');
await tx
.delete(combatV6MeridianNodes)
.where(eq(combatV6MeridianNodes.loadoutId, loadout.id));
.delete(sectMeridianNodes)
.where(eq(sectMeridianNodes.loadoutId, loadout.id));
const path = COMBAT_V6_SECT_DEFINITIONS_V4[
build.sect.sectId
].paths.find((p) => p.id === action.pathId)!;
if (action.nodeIds.length)
await tx.insert(combatV6MeridianNodes).values(
await tx.insert(sectMeridianNodes).values(
action.nodeIds.map((nodeId) => ({
loadoutId: loadout.id,
nodeId,
@@ -156,7 +156,7 @@ export async function mutateSectV6(owner: string, action: SectV6Action) {
scopeDefaults: { cultivatorId: owner },
changes: [
{
resourceTopic: 'player.combat-v6-build',
resourceTopic: 'player.sect-combat',
operation: 'invalidate',
eventType: 'combat_v6.sect.changed',
},
@@ -1,7 +1,8 @@
import { isNotNull } from 'drizzle-orm';
import { db, type DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6ReplayArchives,
sectCombatStates,
combatReplayArchives,
cultivators,
sectMemberships,
sectTaskRecords,
@@ -117,14 +118,14 @@ export async function freezeSectTaskTarget(
.from(sectMemberships)
.innerJoin(cultivators, eq(cultivators.id, sectMemberships.cultivatorId))
.innerJoin(
combatV6BuildProfiles,
eq(combatV6BuildProfiles.membershipId, sectMemberships.id),
sectCombatStates,
eq(sectCombatStates.membershipId, sectMemberships.id),
)
.where(
and(
eq(sectMemberships.status, 'active'),
eq(cultivators.status, 'active'),
eq(combatV6BuildProfiles.status, 'active'),
isNotNull(sectCombatStates.activePathId),
ne(cultivators.id, context.cultivatorId),
inArray(
cultivators.realm,
@@ -361,9 +362,9 @@ export async function changeSectTaskBattle(
await db.transaction(async (tx) => {
await lockCultivatorForStateMutation(tx, actor.cultivatorId);
const [receipt] = await tx
.select({ id: combatV6ReplayArchives.battleId })
.from(combatV6ReplayArchives)
.where(eq(combatV6ReplayArchives.battleId, id));
.select({ id: combatReplayArchives.battleId })
.from(combatReplayArchives)
.where(eq(combatReplayArchives.battleId, id));
if (receipt) return;
const [row] = await tx
.select()
@@ -1,14 +1,14 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6BuildProfiles,
combatV6MeridianLoadouts,
combatV6MethodProgress,
sectCombatStates,
sectMeridianLoadouts,
sectMethodProgress,
} from '@server/lib/drizzle/schema';
import {
findCombatV6Profile,
listCombatV6MethodLevels,
loadActiveCombatV6Build,
} from '@server/lib/repositories/combatV6BuildRepository';
findSectCombatState,
readSectCombatProgress,
readSectMethodLevels,
} from '@server/lib/repositories/sectCombatRepository';
import {
createEmptySectCombatProgressV6,
createFreshCombatV6MethodLevels,
@@ -22,7 +22,6 @@ import { eq } from 'drizzle-orm';
import { InventoryError } from '../InventoryService';
export async function planV6SectTransfer(
owner: string,
membershipId: string,
sourceId: string,
targetId: string,
@@ -36,26 +35,25 @@ export async function planV6SectTransfer(
throw new InventoryError('目标宗门尚未接入新版传承');
const source = COMBAT_V6_SECT_DEFINITIONS_V4[sourceId as CombatV6SectId];
const target = COMBAT_V6_SECT_DEFINITIONS_V4[targetId as CombatV6SectId];
const profile = await findCombatV6Profile(membershipId, q);
const active =
profile?.status === 'active'
? await loadActiveCombatV6Build(owner, q)
: null;
if (profile?.status === 'active' && !active)
const sectState = await findSectCombatState(membershipId, q);
const active = sectState?.activePathId
? await readSectCombatProgress(membershipId, q)
: null;
if (sectState?.activePathId && !active)
throw new InventoryError('当前构筑数据不完整');
const progress = active?.sect ?? {
...createEmptySectCombatProgressV6(
source.id,
source.paths[0].id,
profile
? await listCombatV6MethodLevels(profile.id, q)
sectState
? await readSectMethodLevels(membershipId, q)
: createFreshCombatV6MethodLevels(source.id),
),
meridianDepth: (profile?.meridianDepth ?? 0) as
meridianDepth: (sectState?.meridianDepth ?? 0) as
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7,
};
return {
profile,
sectState,
source,
target,
progress,
@@ -68,51 +66,63 @@ export async function carryV6SectBuild(
targetMembershipId: string,
tx: DbTransaction,
) {
const previous = await findCombatV6Profile(targetMembershipId, tx);
// The departing build is authoritative; a historical target profile must not resurrect old loadouts.
if (previous)
await tx
.delete(combatV6BuildProfiles)
.where(eq(combatV6BuildProfiles.id, previous.id));
const [profile] = plan.profile
? await tx
.update(combatV6BuildProfiles)
.set({
membershipId: targetMembershipId,
activePathId:
plan.profile.status === 'active' ? plan.next.activePathId : null,
revision: plan.profile.revision + 1,
})
.where(eq(combatV6BuildProfiles.id, plan.profile.id))
.returning()
: await tx
.insert(combatV6BuildProfiles)
.values({
membershipId: targetMembershipId,
status: 'pending',
meridianDepth: plan.next.meridianDepth,
})
.returning();
// Only sect progress moves. Character manuals and equipment are independent.
await tx
.delete(combatV6MethodProgress)
.where(eq(combatV6MethodProgress.profileId, profile.id));
await tx.insert(combatV6MethodProgress).values(
Object.entries(plan.next.methods).map(([methodId, level]) => ({
profileId: profile.id,
methodId,
level,
})),
);
.delete(sectMeridianLoadouts)
.where(eq(sectMeridianLoadouts.membershipId, targetMembershipId));
await tx
.delete(combatV6MeridianLoadouts)
.where(eq(combatV6MeridianLoadouts.profileId, profile.id));
if (profile.status === 'active')
await tx.insert(combatV6MeridianLoadouts).values(
plan.next.meridianLoadouts.map((loadout) => ({
profileId: profile.id,
pathId: loadout.pathId,
revision: 0,
.delete(sectMethodProgress)
.where(eq(sectMethodProgress.membershipId, targetMembershipId));
await tx
.insert(sectCombatStates)
.values({
membershipId: targetMembershipId,
activePathId: plan.sectState?.activePathId
? plan.next.activePathId
: null,
meridianDepth: plan.next.meridianDepth,
revision: (plan.sectState?.revision ?? 0) + 1,
})
.onConflictDoUpdate({
target: sectCombatStates.membershipId,
set: {
activePathId: plan.sectState?.activePathId
? plan.next.activePathId
: null,
meridianDepth: plan.next.meridianDepth,
revision: (plan.sectState?.revision ?? 0) + 1,
},
});
await tx
.insert(sectMethodProgress)
.values(
Object.entries(plan.next.methods).map(([methodId, level]) => ({
membershipId: targetMembershipId,
methodId,
level,
})),
);
// Manual states and equipment loadouts stay attached to this same profile ID.
if (plan.sectState?.activePathId)
await tx
.insert(sectMeridianLoadouts)
.values(
plan.next.meridianLoadouts.map((loadout) => ({
membershipId: targetMembershipId,
pathId: loadout.pathId,
revision: 0,
})),
);
if (plan.sectState && plan.sectState.membershipId !== targetMembershipId) {
await tx
.delete(sectMeridianLoadouts)
.where(
eq(sectMeridianLoadouts.membershipId, plan.sectState.membershipId),
);
await tx
.delete(sectMethodProgress)
.where(eq(sectMethodProgress.membershipId, plan.sectState.membershipId));
await tx
.delete(sectCombatStates)
.where(eq(sectCombatStates.membershipId, plan.sectState.membershipId));
}
}
@@ -4,7 +4,7 @@ import { hasActiveDungeon } from '@server/lib/dungeon/occupancy';
import { redis } from '@server/lib/redis';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import { hasActiveRanking } from '@server/lib/redis/rankingChallenge';
import { findActiveCombatV6Membership } from '@server/lib/repositories/combatV6BuildRepository';
import { findActiveSectMembership } from '@server/lib/repositories/sectCombatRepository';
import { hasActiveTower } from '@server/lib/tower/occupancy';
import { automaticCommands } from '@shared/combat-v6/auto';
import {
@@ -148,7 +148,6 @@ export class CombatV6TrainingSessionService {
userId: actor.userId,
cultivatorId: actor.cultivatorId,
membershipId: assembled.membershipId,
buildRevision: assembled.buildRevision,
metadata: {
schemaVersion: 1,
sourceType: 'training-room',
@@ -328,7 +327,7 @@ export class CombatV6TrainingSessionService {
);
throw this.error('CommandNotAllowed', '训练运行态与当前版本不兼容', 422);
}
const membership = await findActiveCombatV6Membership(
const membership = await findActiveSectMembership(
runtime.cultivatorId,
db,
);
@@ -5,7 +5,7 @@ import { hasActiveDungeon } from '@server/lib/dungeon/occupancy';
import { redis } from '@server/lib/redis';
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
import { hasActiveRanking } from '@server/lib/redis/rankingChallenge';
import { findActiveCombatV6Membership } from '@server/lib/repositories/combatV6BuildRepository';
import { findActiveSectMembership } from '@server/lib/repositories/sectCombatRepository';
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
import { hasActiveTower } from '@server/lib/tower/occupancy';
import { automaticCommands } from '@shared/combat-v6/auto';
@@ -291,7 +291,6 @@ export class CombatV6WildSessionService {
battleId: randomUUID(),
...actor,
membershipId: assembled.membershipId,
buildRevision: assembled.buildRevision,
metadata: {
schemaVersion: 1,
sourceType: 'wild-encounter',
@@ -401,7 +400,7 @@ export class CombatV6WildSessionService {
if (s) await store.finish(s, wildTerminal(s, 'expired'));
throw new WildError('WILD_SESSION_NOT_FOUND', '战斗已过期', 404);
}
const membership = await findActiveCombatV6Membership(
const membership = await findActiveSectMembership(
actor.cultivatorId,
db,
);
@@ -1,3 +1,4 @@
import { readCharacterCombatBuild } from '@server/lib/repositories/characterLoadoutRepository';
import {
db,
type DbExecutor,
@@ -5,8 +6,7 @@ import {
} from '@server/lib/drizzle/db';
import {
characterIdentityRow,
loadActiveCombatV6Build,
} from '@server/lib/repositories/combatV6BuildRepository';
} from '@server/lib/repositories/sectCombatRepository';
import { findCultivatorOwnerStatusById } from '@server/lib/repositories/cultivatorRepository';
import { publicCombatV6Build } from '@shared/combat-v6/public-build';
import type { CultivatorInspectionData } from '@shared/contracts/player';
@@ -33,7 +33,7 @@ export async function loadCultivatorInspectionData(
const row = await characterIdentityRow(cultivatorId, q);
if (!identity || !row) return null;
const condition = (row.condition as CultivatorCondition | null) ?? undefined;
const build = await loadActiveCombatV6Build(cultivatorId, q);
const build = await readCharacterCombatBuild(cultivatorId, q);
const publicBuild = build
? publicCombatV6Build({ ...identity, condition }, build)
: { combatPanel: null, build: null };
@@ -1,6 +1,6 @@
import type { DbExecutor, DbTransaction } from '@server/lib/drizzle/db';
import {
combatV6EquipmentLoadouts,
cultivatorEquipmentSlots,
inventoryItems,
} from '@server/lib/drizzle/schema';
import { createPostgresDomainEventWriter } from '@server/lib/mq/domainEventWriter';
@@ -264,10 +264,10 @@ function submissionInventoryAdapter(q: DbExecutor | DbTransaction) {
const loadouts = rows.length
? await q
.select()
.from(combatV6EquipmentLoadouts)
.from(cultivatorEquipmentSlots)
.where(
inArray(
combatV6EquipmentLoadouts.equipmentInstanceId,
cultivatorEquipmentSlots.equipmentInstanceId,
rows.map((row) => row.id),
),
)
@@ -88,7 +88,7 @@ export class SectAdmissionApplicationService {
result: { sect },
resourceChanges: [
{
resourceTopic: 'player.combat-v6-build',
resourceTopic: 'player.sect-combat',
eventType: 'sect.joined',
operation: 'invalidate',
},
@@ -144,7 +144,6 @@ export async function previewSectTransfer(args: {
const { source, sourceModule, targetModule } =
await requireTransferPlan(args);
const v6 = await planV6SectTransfer(
args.cultivatorId,
source.membershipId,
source.sectId,
args.targetSectId,
@@ -220,7 +219,6 @@ export async function executeSectTransfer(args: {
});
await assertInventoryIdle(args.cultivatorId);
const v6 = await planV6SectTransfer(
args.cultivatorId,
source.membershipId,
source.sectId,
args.targetSectId,
@@ -18,7 +18,7 @@ export async function executeSectTransferCommand(
result: { membership: result.membership },
resourceChanges: [
{
resourceTopic: 'player.combat-v6-build',
resourceTopic: 'player.sect-combat',
operation: 'invalidate',
eventType: 'combat_v6.sect.transferred',
},
+4
View File
@@ -9,6 +9,7 @@ import type { AppEnv } from '@server/lib/hono/types';
import { ArenaBattleStartOrchestrator } from '@server/lib/services/ArenaBattleStartOrchestrator';
import { publishArenaRoomChanges } from '@server/lib/services/arenaRoomBroadcaster';
import { ArenaRoomService } from '@server/lib/services/ArenaRoomService';
import { CombatV6BuildError } from '@server/lib/services/combat-v6/CombatV6BuildService';
import {
ArenaCreateRoomSchema,
ArenaJoinRoomSchema,
@@ -282,6 +283,9 @@ async function requireArenaMember(c: Context<AppEnv>, roomId: string) {
}
function arenaError(c: Context<AppEnv>, error: unknown) {
if (error instanceof CombatV6BuildError) {
return c.json({ error: error.message, code: error.code }, error.status);
}
const message = error instanceof Error ? error.message : '擂台房间操作失败';
if (/不存在|过期|邀请码无效/.test(message)) {
return c.json({ error: message }, 404);
+9 -9
View File
@@ -28,8 +28,8 @@ import {
} from '@server/lib/services/combat-v6/CombatV6BeastService';
import {
CombatV6BuildError,
getCombatV6BuildView,
initializeCombatV6Build,
getSectCombatView,
selectInitialSectPath,
} from '@server/lib/services/combat-v6/CombatV6BuildService';
import { CombatV6RuntimeStore } from '@server/lib/services/combat-v6/CombatV6RuntimeStore';
import {
@@ -45,7 +45,7 @@ import { CombatAutoRequestSchema } from '@shared/combat-v6/auto';
import { combatV6ReplayView } from '@shared/combat-v6/replay';
import {
COMBAT_V6_REPLAY_ERROR_CODE,
CombatV6BuildInitializeRequestSchema,
SectPathSelectionRequestSchema,
CombatV6ReplayParamsSchema,
CombatV6TrainingCommandParamsSchema,
CombatV6TrainingCommandRequestSchema,
@@ -288,14 +288,14 @@ router.post('/beasts/release', async (c) => {
}
});
router.get('/build', async (c) => {
router.get('/sect/state', async (c) => {
try {
const current = actor(c);
return c.json(
await readResourceWithMeta(
{ kind: 'cultivator', id: current.cultivatorId },
'player.combat-v6-build',
(tx) => getCombatV6BuildView(current.cultivatorId, tx),
'player.sect-combat',
(tx) => getSectCombatView(current.cultivatorId, tx),
),
);
} catch (error) {
@@ -303,14 +303,14 @@ router.get('/build', async (c) => {
}
});
router.post('/build/initialize', async (c) => {
router.post('/sect/path', async (c) => {
try {
const input = CombatV6BuildInitializeRequestSchema.parse(
const input = SectPathSelectionRequestSchema.parse(
await c.req.json(),
);
return c.json(
toPlayerStateMutationResponse(
await initializeCombatV6Build(actor(c), input),
await selectInitialSectPath(actor(c), input),
),
);
} catch (error) {
-27
View File
@@ -11,7 +11,6 @@ import {
import type { AppEnv } from '@server/lib/hono/types';
import {
findMembership,
loadSectProgressionForMembership,
} from '@server/lib/repositories/sectRepository';
import {
PlayerCommandIdempotencyError,
@@ -212,32 +211,6 @@ export function createSectsRouter(
},
);
router.get(
'/current/progression',
requireActiveCultivatorRef(),
async (c) => {
const ref = c.get('activeCultivatorRef');
if (!ref)
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
try {
return c.json(
await readResourceWithMeta(
{ kind: 'cultivator', id: ref.cultivatorId },
'sect.progression',
async (q) => {
const membership = await findMembership(ref.cultivatorId, q);
if (!membership)
throw new SectError('SECT_MEMBERSHIP_REQUIRED', '尚未拜入宗门');
return loadSectProgressionForMembership(membership, q);
},
),
);
} catch (error) {
return failure(c, error);
}
},
);
router.get('/current/stipend', requireActiveCultivatorRef(), async (c) => {
const ref = c.get('activeCultivatorRef');
if (!ref) return c.json({ success: false, error: '当前没有活跃角色' }, 404);
+2
View File
@@ -23,6 +23,7 @@ import {
import { automaticCommands } from './auto';
import { applyUnitDelta, contiguousEvents } from './playback';
import { combatV6ReplayView, createCombatV6Replay } from './replay';
import { startReplayTimeline } from './replay-timeline';
function fixture(count = 8): ArenaRuntime {
const units = Array.from({ length: count }, (_, i) => ({
@@ -52,6 +53,7 @@ function fixture(count = 8): ArenaRuntime {
const battle = arenaBattle(input);
return {
...input,
timeline: startReplayTimeline(battle.snapshot(), [], battle.log().length - 1),
protocol: ARENA_V6_PROTOCOL,
battleId: 'arena',
roomId: 'room',
+1 -7
View File
@@ -25,7 +25,7 @@ import {
combatV6Units,
visibleUnitNames,
} from './presentation';
import { replayRound, startReplayTimeline } from './replay-timeline';
import { replayRound } from './replay-timeline';
export function arenaBattle(
runtime: Pick<ArenaRuntime, 'seed' | 'units' | 'skills' | 'statusDefs'> &
@@ -278,12 +278,6 @@ export function arenaView(
export function resolveArena(runtime: ArenaRuntime, now: number): ArenaRuntime {
const next = structuredClone(runtime);
if (!next.timeline && next.rounds.length === 0)
next.timeline = startReplayTimeline(
next.state,
next.statusDefs,
next.events.length - 1,
);
const recording = replayRound(
next.timeline,
next.state,
+3 -4
View File
@@ -24,7 +24,7 @@ export function publicCombatV6Build(
});
if (!projection.ok)
throw new Error(projection.diagnostics.map((d) => d.message).join(';'));
const definition = COMBAT_V6_SECT_DEFINITIONS_V4[build.sect.sectId];
const definition = build.sect ? COMBAT_V6_SECT_DEFINITIONS_V4[build.sect.sectId] : undefined;
const skills = projection.skills.map(
(skill) =>
projection.unit.skillOverrides?.find(
@@ -35,9 +35,8 @@ export function publicCombatV6Build(
return {
combatPanel: projectCharacterDisplay(character, build),
build: {
sectName: definition.name,
pathName: definition.paths.find((p) => p.id === build.sect.activePathId)!
.name,
sectName: definition?.name ?? null,
pathName: definition?.paths.find((p) => p.id === build.sect?.activePathId)?.name ?? null,
equipment: structuredClone(build.equipment),
manuals: build.manuals.build.slots.map(entry => ({ ...entry, level: build.manuals.learned.find(m => m.manualId === entry.manualId)!.level })),
skills: (projection.unit.skills ?? []).map((id) => {
+4 -4
View File
@@ -5,10 +5,10 @@ import { combatV6Playback, combatV6Units } from './presentation';
/** Incremental HTTP playback after server-driven rounds; recovery still loads the full baseline. */
export function liveReplayDelta(
timeline: CombatV6ReplayTimeline | undefined,
timeline: CombatV6ReplayTimeline,
after: number,
) {
if (!timeline || after < timeline.fromEventSeq) return undefined;
if (after < timeline.fromEventSeq) return undefined;
const frames = timeline.frames.filter((frame) => frame.afterEventSeq > after);
return frames.length
? { format: 'delta-v1' as const, fromEventSeq: after, frames }
@@ -31,7 +31,7 @@ export function startReplayTimeline(
/** Collect only the current round, then append it to the durable timeline in the same CAS. */
export function replayRound(
timeline: CombatV6ReplayTimeline | undefined,
timeline: CombatV6ReplayTimeline,
state: BattleState,
statuses: StatusDef[],
seq: number,
@@ -41,7 +41,7 @@ export function replayRound(
capture: collector.capture,
finish(final: BattleState, finalSeq: number) {
collector.capture(final, finalSeq);
if (timeline) timeline.frames.push(...collector.playback.frames);
timeline.frames.push(...collector.playback.frames);
},
};
}
+7 -6
View File
@@ -144,14 +144,15 @@ describe('historical presentation replay', () => {
expect(after.timeline).toEqual(before.timeline);
expect(after.display).toEqual(before.display);
});
it('keeps legacy records static, rejects incomplete or unknown formats and corrupt deltas', () => {
it('rejects legacy, incomplete or unknown formats and corrupt deltas', () => {
const { replay } = record('1v3');
const { timeline, display, ...legacy } = replay;
const old = parseCombatV6Replay({
...legacy,
replayVersion: 'combat_v6_replay_v1',
});
expect(combatV6ReplayView(old, id, 'user').timeline).toBeUndefined();
expect(() =>
parseCombatV6Replay({
...legacy,
replayVersion: 'combat_v6_replay_v1',
}),
).toThrow();
expect(() => parseCombatV6Replay(legacy)).toThrow();
expect(() =>
parseCombatV6Replay({ ...replay, replayVersion: 'future' }),
+16 -31
View File
@@ -4,7 +4,7 @@ import {
type CombatV6ReplayV1,
} from '@shared/contracts/combatV6Runtime';
import type { CombatV6ReplayTimeline } from '../contracts/combatV6Replay';
import { arenaEvents, arenaUnits, projectReplayUnits } from './arena';
import { arenaEvents, projectReplayUnits } from './arena';
import { applyUnitDelta, diffUnits } from './playback';
import {
combatV6Display,
@@ -28,7 +28,7 @@ export function createCombatV6Replay(
'seed' | 'initialUnits' | 'skills' | 'statusDefs' | 'rounds' | 'events'
> & {
finalState?: CombatV6ReplayV1['finalState'];
timeline?: CombatV6ReplayTimeline;
timeline: CombatV6ReplayTimeline;
};
},
): CombatV6ReplayV1 {
@@ -37,18 +37,12 @@ export function createCombatV6Replay(
const winner = trace.finalState.result?.winner;
return parseCombatV6Replay({
...metadata,
replayVersion: trace.timeline
? COMBAT_V6_REPLAY_VERSION
: 'combat_v6_replay_v1',
...(trace.timeline
? {
timeline: {
...trace.timeline,
finalUnits: combatV6Units(trace.finalState, trace.statusDefs),
},
display: combatV6Display(trace.skills, trace.statusDefs),
}
: {}),
replayVersion: COMBAT_V6_REPLAY_VERSION,
timeline: {
...trace.timeline,
finalUnits: combatV6Units(trace.finalState, trace.statusDefs),
},
display: combatV6Display(trace.skills, trace.statusDefs),
seed: trace.seed,
combatVersions: trace.finalState.versions,
initialUnits: trace.initialUnits,
@@ -83,12 +77,7 @@ export function combatV6ReplayView(
.filter((u) => u.id === viewer.unitId || u.ownerId === viewer.unitId)
.flatMap((u) => u.skills),
);
const frozenDisplay =
replay.display ??
combatV6Display(
replay.skills.filter((s) => ownSkills.has(s.id)),
replay.statusDefs,
);
const frozenDisplay = replay.display;
const display = {
...frozenDisplay,
unitNames: visibleUnitNames(
@@ -109,9 +98,7 @@ export function combatV6ReplayView(
return {
battleId: replay.battleId,
controlledUnitId: viewer.unitId,
timeline: replay.timeline
? projectTimeline(replay, viewer.unitId, viewer.side)
: undefined,
timeline: projectTimeline(replay, viewer.unitId, viewer.side),
combatVersions: replay.combatVersions,
startedAt: replay.startedAt,
finishedAt: replay.finishedAt,
@@ -123,13 +110,11 @@ export function combatV6ReplayView(
: replay.outcome === `side-${viewer.side}`
? 'victory'
: 'defeat',
units: replay.timeline?.finalUnits
? projectReplayUnits(
replay.timeline.finalUnits,
viewer.unitId,
viewer.side,
)
: arenaUnits(replay.finalState, replay, viewer.unitId),
units: projectReplayUnits(
replay.timeline.finalUnits!,
viewer.unitId,
viewer.side,
),
events,
display: {
...display,
@@ -148,7 +133,7 @@ function projectTimeline(
viewerId: string,
side: 0 | 1,
): CombatV6ReplayTimeline {
const tape = replay.timeline!;
const tape = replay.timeline;
const cursors: number[] = [];
let cursor = -1;
for (const event of replay.events) {
+4 -4
View File
@@ -1,5 +1,5 @@
import {
CombatV6BuildInitializeRequestSchema,
SectPathSelectionRequestSchema,
CombatV6TrainingCommandParamsSchema,
CombatV6TrainingCommandRequestSchema,
CombatV6TrainingCreateRequestSchema,
@@ -16,19 +16,19 @@ import {
describe('combat-v6 Phase 7B contracts', () => {
test('accepts only supported training tiers and strict build initialization', () => {
expect(
CombatV6BuildInitializeRequestSchema.parse({
SectPathSelectionRequestSchema.parse({
activePathId: 'lingxiao.path.zhanchen',
expectedRevision: 0,
}),
).toBeTruthy();
expect(
CombatV6BuildInitializeRequestSchema.safeParse({
SectPathSelectionRequestSchema.safeParse({
activePathId: 'x',
expectedRevision: -1,
}).success,
).toBe(false);
expect(
CombatV6BuildInitializeRequestSchema.safeParse({
SectPathSelectionRequestSchema.safeParse({
activePathId: 'lingxiao.path.zhanchen',
expectedRevision: 3,
}).success,
+12 -29
View File
@@ -1,6 +1,5 @@
import type {
CombatV6SectId,
SectCombatProgressV6,
} from '@shared/engine/combat-v6/content';
import type {
BattleEvent,
@@ -12,23 +11,21 @@ import type {
CombatV6TrainingTierV1,
TrainingEncounterOutcome,
} from '@shared/engine/combat-v6/encounter';
import type { DaoEquipmentLoadoutV1 } from '@shared/engine/combat-v6/equipment';
import type { CultivatorManualStateV1 } from '@shared/engine/combat-v6/manuals';
import { z } from 'zod';
export const COMBAT_V6_BUILD_SCHEMA_VERSION = 1 as const;
export const COMBAT_V6_TRAINING_API_VERSION = 1 as const;
export const CombatV6BuildInitializationStatusSchema = z.enum([
export const SectCombatReadinessSchema = z.enum([
'uninitialized',
'pending',
'active',
]);
export type CombatV6BuildInitializationStatus = z.infer<
typeof CombatV6BuildInitializationStatusSchema
export type SectCombatReadiness = z.infer<
typeof SectCombatReadinessSchema
>;
export interface CombatV6BuildMethodViewV1 {
export interface SectCombatMethodView {
id: string;
name: string;
slot: 1 | 2 | 3 | 4 | 5 | 6;
@@ -36,46 +33,32 @@ export interface CombatV6BuildMethodViewV1 {
isPrimary: boolean;
}
export interface CombatV6BuildPathViewV1 {
export interface SectCombatPathView {
id: string;
name: string;
}
export interface CombatV6BuildViewV1 {
export interface SectCombatView {
schemaVersion: typeof COMBAT_V6_BUILD_SCHEMA_VERSION;
status: CombatV6BuildInitializationStatus;
status: SectCombatReadiness;
revision: number;
membershipId?: string;
sectId?: CombatV6SectId;
sectName?: string;
activePathId?: string;
meridianDepth: number;
methods: CombatV6BuildMethodViewV1[];
paths: CombatV6BuildPathViewV1[];
manuals?: CultivatorManualStateV1;
equipment?: DaoEquipmentLoadoutV1;
methods: SectCombatMethodView[];
paths: SectCombatPathView[];
}
export interface CombatV6PersistedBuildV1 {
schemaVersion: typeof COMBAT_V6_BUILD_SCHEMA_VERSION;
profileId: string;
membershipId: string;
cultivatorId: string;
status: 'active';
revision: number;
sect: SectCombatProgressV6;
manuals: CultivatorManualStateV1;
equipment: DaoEquipmentLoadoutV1;
}
export const CombatV6BuildInitializeRequestSchema = z
export const SectPathSelectionRequestSchema = z
.object({
activePathId: z.string().min(1).max(160),
expectedRevision: z.number().int().nonnegative(),
})
.strict();
export type CombatV6BuildInitializeRequest = z.infer<
typeof CombatV6BuildInitializeRequestSchema
export type SectPathSelectionRequest = z.infer<
typeof SectPathSelectionRequestSchema
>;
export const CombatV6TrainingTierSchema = z.union([
+1 -1
View File
@@ -29,7 +29,7 @@ export type ArenaParticipant = {
slot: number;
};
export type ArenaRuntime = {
timeline?: CombatV6ReplayTimeline;
timeline: CombatV6ReplayTimeline;
protocol: typeof ARENA_V6_PROTOCOL;
battleId: string;
roomId: string;
-1
View File
@@ -121,7 +121,6 @@ export type CombatV6HistoryItem = {
roundCount: number;
sides: [string[], string[]];
outcome: 'victory' | 'defeat' | 'draw' | 'aborted';
playable: boolean;
};
export type CombatV6HistoryPage = {
items: CombatV6HistoryItem[];
+34 -32
View File
@@ -39,20 +39,28 @@ export const CombatV6BattleMetadataV1Schema = z.discriminatedUnion(
'sourceType',
[
CombatV6TrainingBattleMetadataV1Schema,
z.object({
schemaVersion: z.literal(1),
sourceType: z.literal('breakthrough'),
battleType: z.literal('pve'),
idempotencyKey: z.uuid(),
payload: z.object({ taskId: z.uuid(), challengeId: z.string().min(1) }).strict(),
}).strict(),
z.object({
schemaVersion: z.literal(1),
sourceType: z.literal('sect-task'),
battleType: z.literal('pve'),
idempotencyKey: z.uuid(),
payload: z.object({ recordId: z.uuid(), taskId: z.string().min(1) }).strict(),
}).strict(),
z
.object({
schemaVersion: z.literal(1),
sourceType: z.literal('breakthrough'),
battleType: z.literal('pve'),
idempotencyKey: z.uuid(),
payload: z
.object({ taskId: z.uuid(), challengeId: z.string().min(1) })
.strict(),
})
.strict(),
z
.object({
schemaVersion: z.literal(1),
sourceType: z.literal('sect-task'),
battleType: z.literal('pve'),
idempotencyKey: z.uuid(),
payload: z
.object({ recordId: z.uuid(), taskId: z.string().min(1) })
.strict(),
})
.strict(),
z
.object({
schemaVersion: z.literal(1),
@@ -188,7 +196,6 @@ export interface CombatV6RedisRuntimeV1 {
userId: string;
cultivatorId: string;
membershipId: string;
buildRevision: number;
metadata: z.infer<typeof CombatV6TrainingBattleMetadataV1Schema>;
revision: number;
createdAt: string;
@@ -204,7 +211,6 @@ export const CombatV6RedisRuntimeV1Schema = z
userId: z.uuid(),
cultivatorId: z.uuid(),
membershipId: z.uuid(),
buildRevision: z.number().int().nonnegative(),
metadata: CombatV6BattleMetadataV1Schema,
revision: z.number().int().nonnegative(),
createdAt: z.string().datetime(),
@@ -230,7 +236,7 @@ export const CombatV6RedisRuntimeV1Schema = z
.passthrough(),
rounds: z.array(z.unknown()),
events: z.array(z.unknown()),
timeline: CombatV6ReplayTimelineSchema.optional(),
timeline: CombatV6ReplayTimelineSchema,
})
.strict(),
})
@@ -260,8 +266,8 @@ export const CombatV6ReplayParticipantSchema = z
.strict();
export interface CombatV6ReplayV1 {
timeline?: CombatV6ReplayTimeline;
display?: CombatV6ReplayDisplay;
timeline: CombatV6ReplayTimeline;
display: CombatV6ReplayDisplay;
seed: number;
combatVersions: CombatV6VersionStamp;
initialUnits: LineupUnit[];
@@ -272,7 +278,7 @@ export interface CombatV6ReplayV1 {
commands: Array<{ unitId: string; command: Command }>;
}>;
events: BattleEvent[];
replayVersion: 'combat_v6_replay_v1' | typeof COMBAT_V6_REPLAY_VERSION;
replayVersion: typeof COMBAT_V6_REPLAY_VERSION;
battleId: string;
participants: z.infer<typeof CombatV6ReplayParticipantSchema>[];
metadata: z.infer<typeof CombatV6ReplayMetadataSchema>;
@@ -285,15 +291,13 @@ export interface CombatV6ReplayV1 {
export const CombatV6ReplayV1Schema = z
.object({
replayVersion: z.enum(['combat_v6_replay_v1', COMBAT_V6_REPLAY_VERSION]),
timeline: CombatV6ReplayTimelineSchema.optional(),
display: z
.object({
skills: z.record(z.string(), z.string()),
statuses: z.record(z.string(), z.string()),
skillDetails: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
replayVersion: z.literal(COMBAT_V6_REPLAY_VERSION),
timeline: CombatV6ReplayTimelineSchema,
display: z.object({
skills: z.record(z.string(), z.string()),
statuses: z.record(z.string(), z.string()),
skillDetails: z.record(z.string(), z.unknown()).optional(),
}),
battleId: z.uuid(),
participants: z.array(CombatV6ReplayParticipantSchema).min(1).max(8),
metadata: CombatV6ReplayMetadataSchema,
@@ -312,9 +316,7 @@ export const CombatV6ReplayV1Schema = z
})
.strict()
.refine(
(value) =>
value.replayVersion !== COMBAT_V6_REPLAY_VERSION ||
(!!value.timeline?.finalUnits && !!value.display),
(value) => !!value.timeline.finalUnits,
'Playable replay requires frozen presentation',
)
.refine(
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import type { SectCombatProgressV6 } from '../engine/combat-v6/content/types';
import type { CombatV6BuildViewV1 } from './combatV6';
import type { SectCombatView } from './combatV6';
const reference = {
membershipId: z.uuid(),
@@ -38,7 +38,7 @@ export interface SectV6Cost {
comprehensionInsight: number;
}
export interface SectV6View {
build: CombatV6BuildViewV1;
build: SectCombatView;
progress: SectCombatProgressV6 | null;
characterLevel: number;
resources: SectV6Cost;
+1 -2
View File
@@ -37,7 +37,6 @@ export const WildRuntimeSchema = z
userId: z.uuid(),
cultivatorId: z.uuid(),
membershipId: z.uuid(),
buildRevision: z.number().int().nonnegative(),
metadata: CombatV6BattleMetadataV1Schema,
revision: z.number().int().nonnegative(),
createdAt: z.iso.datetime(),
@@ -101,7 +100,7 @@ export const WildRuntimeSchema = z
.passthrough(),
rounds: z.array(z.unknown()),
events: z.array(z.unknown()),
timeline: CombatV6ReplayTimelineSchema.optional(),
timeline: CombatV6ReplayTimelineSchema,
})
.strict(),
})
+3 -3
View File
@@ -1,7 +1,7 @@
import type { ApiSuccess } from '@shared/contracts/http';
import type { CultivationProgress, Cultivator } from '@shared/types/cultivator';
import type { QiProjectionBaseline } from '@shared/types/qi';
import type { CombatV6BuildViewV1 } from './combatV6';
import type { SectCombatView } from './combatV6';
import type {
ResourceChange,
ResourceReadMeta,
@@ -51,7 +51,7 @@ export const PLAYER_RESOURCE_KEYS = [
'condition',
'progress',
'currency',
'combat-v6-build',
'sect-combat',
'mail-summary',
'task-summary',
] as const;
@@ -78,7 +78,7 @@ export interface PlayerResourceMap {
spiritStones: number;
reputation: number;
};
'combat-v6-build': CombatV6BuildViewV1;
'sect-combat': SectCombatView;
'mail-summary': {
unreadCount: number;
};
+5 -89
View File
@@ -9,7 +9,7 @@ import {
} from '@shared/types/constants';
import type { TaskInstance } from '@shared/types/task';
import { z } from 'zod';
import { CombatV6BuildInitializationStatusSchema } from '../combatV6';
import { SectCombatReadinessSchema } from '../combatV6';
import { BreakthroughBattlePointerSchema } from '../combatV6Breakthrough';
import type { PlayerResourceMap } from '../player';
import type { ResourceChange } from './registry';
@@ -23,71 +23,13 @@ const combatV6SlotSchema = z.union([
z.literal(6),
]);
const combatV6PanelAttrSchema = z.enum([
'physicalAtk',
'physicalDef',
'magicAtk',
'magicDef',
'maxHp',
'maxMp',
'healPower',
'speed',
'hit',
'dodge',
'critRate',
'spellCritRate',
'physicalFuryRate',
'sealHit',
'sealResist',
]);
const combatV6AttributeSchema = z.enum([
'vitality',
'strength',
'spirit',
'endurance',
'speed',
'willpower',
]);
const combatV6EquipmentInstanceSchema = z
.object({
schemaVersion: z.literal(1),
id: z.string(),
templateId: z.string(),
name: z.string(),
slot: z.enum(['weapon', 'head', 'armor', 'necklace', 'belt', 'footwear']),
equipmentLevel: z.number().int(),
requiredLevel: z.number().int(),
baseStats: z.array(
z.object({ attr: combatV6PanelAttrSchema, value: z.number() }).strict(),
),
attributeBonuses: z.array(
z.object({ attr: combatV6AttributeSchema, value: z.number() }).strict(),
),
essenceIds: z.array(z.string()),
artId: z.string().optional(),
formationInscription: z
.object({ patternId: z.string(), level: z.number().int() })
.strict()
.optional(),
appraisalState: z.literal('appraised'),
generatorVersion: z.enum([
'dao_equipment_generator_v1',
'dao_equipment_generator_v2',
'dao_equipment_generator_v3',
]),
createdAt: z.string(),
})
.strict();
export const PLAYER_RESOURCE_TOPICS = [
'player.session',
'player.profile',
'player.condition',
'player.progress',
'player.currency',
'player.combat-v6-build',
'player.sect-combat',
'player.mail-summary',
'player.task-summary',
'player.tasks',
@@ -101,7 +43,7 @@ export interface PlayerResourceDataMap {
'player.condition': PlayerResourceMap['condition'];
'player.progress': PlayerResourceMap['progress'];
'player.currency': PlayerResourceMap['currency'];
'player.combat-v6-build': PlayerResourceMap['combat-v6-build'];
'player.sect-combat': PlayerResourceMap['sect-combat'];
'player.mail-summary': PlayerResourceMap['mail-summary'];
'player.task-summary': PlayerResourceMap['task-summary'];
'player.tasks': TaskInstance[];
@@ -547,10 +489,10 @@ export const PLAYER_RESOURCE_DATA_SCHEMAS = {
qiLastRefreshedAt: z.string().nullable(),
})
.strict(),
'player.combat-v6-build': z
'player.sect-combat': z
.object({
schemaVersion: z.literal(1),
status: CombatV6BuildInitializationStatusSchema,
status: SectCombatReadinessSchema,
revision: z.number().int().nonnegative(),
membershipId: z.string().uuid().optional(),
sectId: z
@@ -569,32 +511,6 @@ export const PLAYER_RESOURCE_DATA_SCHEMAS = {
}),
),
paths: z.array(z.object({ id: z.string(), name: z.string() })),
manuals: z
.object({
version: z.literal(1),
revision: z.number().int().nonnegative(),
learned: z.array(z.object({ manualId: z.string(), level: z.number().int().positive(), unlockedLevel: z.number().int().positive() })),
build: z.object({
slots: z.array(
z.object({
slot: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
manualId: z.string(),
}),
),
}),
})
.optional(),
equipment: z
.object({
weapon: combatV6EquipmentInstanceSchema.optional(),
head: combatV6EquipmentInstanceSchema.optional(),
armor: combatV6EquipmentInstanceSchema.optional(),
necklace: combatV6EquipmentInstanceSchema.optional(),
belt: combatV6EquipmentInstanceSchema.optional(),
footwear: combatV6EquipmentInstanceSchema.optional(),
})
.strict()
.optional(),
})
.strict(),
'player.mail-summary': z
+1 -5
View File
@@ -52,7 +52,7 @@ export const RESOURCE_TOPIC_SCOPE_KIND = {
'player.condition': 'cultivator',
'player.progress': 'cultivator',
'player.currency': 'cultivator',
'player.combat-v6-build': 'cultivator',
'player.sect-combat': 'cultivator',
'player.mail-summary': 'cultivator',
'player.task-summary': 'cultivator',
'player.tasks': 'cultivator',
@@ -60,7 +60,6 @@ export const RESOURCE_TOPIC_SCOPE_KIND = {
'sect.members': 'sect',
'sect.contribution-ranking': 'sect',
'sect.infrastructure': 'sect',
'sect.progression': 'cultivator',
'sect.tasks': 'cultivator',
'sect.shop': 'cultivator',
'sect.construction-member': 'cultivator',
@@ -92,7 +91,6 @@ function getResourceMergeSchema(topic: ResourceTopic): z.ZodTypeAny {
'player.mail-summary',
'player.task-summary',
'sect.membership',
'sect.progression',
'sect.construction-member',
]);
if (!mergeableTopics.has(topic)) return z.never();
@@ -121,7 +119,6 @@ type MergeableResourceTopic =
| 'player.mail-summary'
| 'player.task-summary'
| 'sect.membership'
| 'sect.progression'
| 'sect.construction-member';
type ItemResourceTopic =
@@ -217,7 +214,6 @@ const MERGEABLE_RESOURCE_TOPICS = new Set<ResourceTopic>([
'player.mail-summary',
'player.task-summary',
'sect.membership',
'sect.progression',
'sect.construction-member',
]);
-33
View File
@@ -12,7 +12,6 @@ import type {
SectContextData,
SectInfrastructureData,
SectMembersData,
SectProgressionData,
SectShopData,
SectTasksData,
} from '../sect';
@@ -22,7 +21,6 @@ export const SECT_RESOURCE_TOPICS = [
'sect.members',
'sect.contribution-ranking',
'sect.infrastructure',
'sect.progression',
'sect.tasks',
'sect.shop',
'sect.construction-member',
@@ -35,29 +33,11 @@ export interface SectResourceDataMap {
'sect.members': SectMembersData;
'sect.contribution-ranking': SectContributionRankingData;
'sect.infrastructure': SectInfrastructureData;
'sect.progression': SectProgressionData;
'sect.tasks': SectTasksData;
'sect.shop': SectShopData;
'sect.construction-member': SectConstructionMemberData;
}
const sectPathStateSchema = z
.object({
pathId: z.string(),
unlockedLayerIds: z.array(z.string()),
tacticId: z.string(),
activeMeridianSlot: z.union([z.literal(1), z.literal(2), z.literal(3)]),
meridianLoadouts: z.array(
z
.object({
slot: z.union([z.literal(1), z.literal(2), z.literal(3)]),
nodeIds: z.array(z.string()),
version: z.number(),
})
.strict(),
),
})
.strict();
const sectTaskDialoguePresentationSchema = z
.object({
offeredReply: z.string(),
@@ -242,19 +222,6 @@ export const SECT_RESOURCE_DATA_SCHEMAS = {
),
})
.strict(),
'sect.progression': z
.object({
activePathId: z.string().optional(),
methods: z.record(z.string(), z.number()),
paths: z.array(sectPathStateSchema),
abilityLoadout: z.tuple([
z.string().nullable(),
z.string().nullable(),
z.string().nullable(),
z.string().nullable(),
]),
})
.strict(),
'sect.tasks': z
.object({
dateKey: z.string(),
-7
View File
@@ -281,13 +281,6 @@ export interface SectContextData {
configVersion: number;
}
export interface SectProgressionData {
activePathId?: CultivatorSectState['activePathId'];
methods: CultivatorSectState['methods'];
paths: CultivatorSectState['paths'];
abilityLoadout: CultivatorSectState['abilityLoadout'];
}
export interface SectStipendData {
weekKey: string;
claimed: boolean;
@@ -1,33 +1,17 @@
import { COMBAT_V6_SECT_DEFINITIONS_V4, type CombatV6SectId } from '../content';
import {
COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1,
createCombatV6BuildView,
createSectCombatView,
createEmptySectCombatProgressV6,
createFreshCombatV6MethodLevels,
normalizeCombatV6MigratedMethodLevels,
} from './index';
describe('combat-v6 Phase 7B build state', () => {
test('defines six unique slot mappings for every sect', () => {
for (const sectId of Object.keys(COMBAT_V6_SECT_DEFINITIONS_V4) as CombatV6SectId[]) {
const legacyIds = COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1[sectId];
expect(legacyIds).toHaveLength(6);
expect(new Set(legacyIds).size).toBe(6);
expect(COMBAT_V6_SECT_DEFINITIONS_V4[sectId].methods.map((method) => method.slot).sort()).toEqual([1, 2, 3, 4, 5, 6]);
}
});
test('clamps migrated levels to character and primary method caps', () => {
const ids = COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1.lingxiao;
const levels = normalizeCombatV6MigratedMethodLevels(
'lingxiao',
{ [ids[0]]: 30, [ids[1]]: 50, [ids[2]]: -1, [ids[3]]: 200 },
25,
);
const methods = COMBAT_V6_SECT_DEFINITIONS_V4.lingxiao.methods.slice().sort((a, b) => a.slot - b.slot);
expect(methods.map((method) => levels[method.id])).toEqual([30, 30, 0, 30, 0, 0]);
});
test('creates a fresh level-one, empty two-path build without mutating levels', () => {
const levels = createFreshCombatV6MethodLevels('youdu');
const before = structuredClone(levels);
@@ -41,13 +25,13 @@ describe('combat-v6 Phase 7B build state', () => {
});
test('build views are immutable copies for all initialization states', () => {
expect(createCombatV6BuildView({ status: 'uninitialized' })).toMatchObject({
expect(createSectCombatView({ status: 'uninitialized' })).toMatchObject({
status: 'uninitialized',
methods: [],
paths: [],
});
const levels = createFreshCombatV6MethodLevels('wuxiang');
const view = createCombatV6BuildView({ status: 'pending', sectId: 'wuxiang', methodLevels: levels });
const view = createSectCombatView({ status: 'pending', sectId: 'wuxiang', methodLevels: levels });
levels[Object.keys(levels)[0]] = 99;
expect(view.methods.every((method) => method.level === 1)).toBe(true);
});
@@ -1,42 +1,10 @@
import type { CombatV6BuildViewV1 } from '@shared/contracts/combatV6';
import type { SectCombatView } from '@shared/contracts/combatV6';
import {
COMBAT_V6_SECT_DEFINITIONS_V4,
type CombatV6SectId,
type SectCombatProgressV6,
} from '../content/index.ts';
export const COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1 = Object.freeze({
lingxiao: ['lingxiao-canon', 'edge-cleansing', 'sword-guidance', 'void-step', 'origin-returning', 'sword-nurturing'],
youdu: ['youdu-canon', 'three-souls-separation', 'forgetful-river-record', 'seven-souls-seizure', 'soul-pinning-ironbook', 'dead-heart-living-spirit'],
wuxiang: ['wuxiang-canon', 'blood-lotus', 'white-bone', 'wrathful-ming', 'six-senses', 'reed-crossing-method'],
tianyan: ['tianyan-canon', 'wood-vitality', 'fire-illumination', 'earth-bearing', 'metal-severing', 'water-flowing'],
jiujie: ['jiujie-canon', 'calamity-eye', 'heavenly-record', 'thunder-prison', 'cause-judgment', 'crossing-calamity'],
} satisfies Record<CombatV6SectId, readonly string[]>);
export type CombatV6MethodLevelsBySlot = readonly [number, number, number, number, number, number];
export function normalizeCombatV6MigratedMethodLevels(
sectId: CombatV6SectId,
legacyLevelsById: Readonly<Record<string, number | undefined>>,
characterLevel: number,
): Record<string, number> {
const definition = COMBAT_V6_SECT_DEFINITIONS_V4[sectId];
const legacyIds = COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1[sectId];
const cap = Math.min(180, Math.max(0, Math.floor(characterLevel)) + 10);
const primary = clampLevel(legacyLevelsById[legacyIds[0]], cap);
return Object.fromEntries(
definition.methods
.slice()
.sort((left, right) => left.slot - right.slot)
.map((method, index) => [
method.id,
index === 0
? primary
: Math.min(primary, clampLevel(legacyLevelsById[legacyIds[index]], cap)),
]),
);
}
export function createFreshCombatV6MethodLevels(
sectId: CombatV6SectId,
): Record<string, number> {
@@ -71,14 +39,14 @@ export function createEmptySectCombatProgressV6(
};
}
export function createCombatV6BuildView(input: {
status: CombatV6BuildViewV1['status'];
export function createSectCombatView(input: {
status: SectCombatView['status'];
revision?: number;
membershipId?: string;
sectId?: CombatV6SectId;
activePathId?: string;
methodLevels?: Readonly<Record<string, number>>;
}): CombatV6BuildViewV1 {
}): SectCombatView {
const definition = input.sectId
? COMBAT_V6_SECT_DEFINITIONS_V4[input.sectId]
: undefined;
@@ -108,8 +76,3 @@ export function createCombatV6BuildView(input: {
meridianDepth: 0,
});
}
function clampLevel(value: number | undefined, cap: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(cap, Math.max(0, Math.floor(value!)));
}
@@ -104,7 +104,7 @@ export type PveRestoredState = Pick<
/** Shared command orchestration. Source-specific compilation and serialization stay outside. */
export class CombatV6PveHostSession {
protected readonly timeline?: CombatV6ReplayTimeline;
protected readonly timeline: CombatV6ReplayTimeline;
readonly playerId: string;
protected readonly battle: BattleSession;
protected readonly initialUnits: CompiledCombatV6TrainingEncounterV1['battleInput']['units'];
@@ -111,7 +111,7 @@ export type TrainingEncounterOutcome =
'victory' | 'defeat' | 'draw' | 'aborted';
export interface CombatV6EncounterTraceV1 {
timeline?: CombatV6ReplayTimeline;
timeline: CombatV6ReplayTimeline;
schemaVersion: 1;
hostVersion: 'combat_v6_encounter_host_v1';
encounterId: string;
@@ -133,7 +133,7 @@ export interface CombatV6EncounterTraceV1 {
/** Redis运行时快照;仅保证当前combat-v6版本内恢复,不是长期录像协议。 */
export interface CombatV6TrainingRuntimeSnapshotV1 {
timeline?: CombatV6ReplayTimeline;
timeline: CombatV6ReplayTimeline;
schemaVersion: 1;
hostVersion: 'combat_v6_training_runtime_v1';
input: CompileCombatV6TrainingEncounterV1Input;
@@ -17,6 +17,7 @@ export { projectCultivatorMultiSectV3ToCombatV6 } from "./project-cultivator-mul
export { projectCultivatorMultiSectV4ToCombatV6 } from "./project-cultivator-multi-sect-v4.ts"
export { projectCultivatorMultiSectV5ToCombatV6 } from "./project-cultivator-multi-sect-v5.ts"
export type {
CharacterCombatInput,
CombatV6PanelContribution,
CombatV6ProjectionDiagnostic,
CombatV6ProjectionDiagnosticCode,
@@ -1,7 +1,7 @@
import { withManualAttributes, CHARACTER_MANUALS_V1, compileCharacterManualsV1, resolveCombatCapabilitiesV1 } from "../manuals/index.ts"
import { COMBAT_V6_PHASE_6D_VERSIONS } from "../version.ts"
import { projectCultivatorWithEquipmentSpecialInternal } from "./project-cultivator-with-equipment-special.ts"
import type { CombatV6ProjectionDiagnostic, CombatV6ProjectionResult, ProjectCultivatorMultiSectToCombatV6Input } from "./types.ts"
import type { CombatV6ProjectionDiagnostic, CombatV6ProjectionResult, CharacterCombatInput } from "./types.ts"
function manualContentConflicts(existingSkills: string[], existingStatuses: string[]): CombatV6ProjectionDiagnostic[] {
const existing = new Set([...existingSkills, ...existingStatuses])
@@ -10,7 +10,7 @@ function manualContentConflicts(existingSkills: string[], existingStatuses: stri
.map((id) => ({ severity: "error" as const, code: "CONTENT_ID_CONFLICT" as const, message: `功法战斗内容 ID 冲突:${id}` }))
}
export function projectCultivatorMultiSectV5ToCombatV6(input: ProjectCultivatorMultiSectToCombatV6Input): CombatV6ProjectionResult {
export function projectCultivatorMultiSectV5ToCombatV6(input: CharacterCombatInput): CombatV6ProjectionResult {
const versions = { ...COMBAT_V6_PHASE_6D_VERSIONS }
const manuals = compileCharacterManualsV1({ state: input.manuals, realm: input.cultivator.realm })
if (!manuals.ok) return { ok: false, diagnostics: manuals.diagnostics, versions }
@@ -17,7 +17,7 @@ import type {
CombatV6ProjectionResult,
CompareDaoEquipmentSpecialLoadoutsV1Input,
CompareDaoEquipmentSpecialLoadoutsV1Result,
ProjectCultivatorWithEquipmentSpecialInput,
CharacterCombatInput,
} from "./types.ts"
const ATTRIBUTE_KEYS = [
@@ -69,7 +69,7 @@ function panelAttrs(attributes: Attributes): LineupUnit["attrs"] {
}
function applyResources(
input: ProjectCultivatorWithEquipmentSpecialInput,
input: Omit<CharacterCombatInput, "manuals">,
attrs: LineupUnit["attrs"],
diagnostics: CombatV6ProjectionDiagnostic[],
): void {
@@ -121,17 +121,17 @@ function contentConflicts(
}
export function projectCultivatorWithEquipmentSpecialInternal(
input: ProjectCultivatorWithEquipmentSpecialInput,
input: Omit<CharacterCombatInput, "manuals">,
versions: CombatV6VersionStamp,
allowMultiSect: boolean,
allowWuxiang = false,
allowTianyan = false,
allowJiujie = false,
): CombatV6ProjectionResult {
if (input.sect.sectId === "jiujie" && !allowJiujie) return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: `${versions.projectionVersion} 不接受九劫天宫`, path: "sect.sectId" }], versions }
if (input.sect.sectId === "tianyan" && !allowTianyan) return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: `${versions.projectionVersion} 不接受天衍圣地`, path: "sect.sectId" }], versions }
if (input.sect.sectId === "wuxiang" && !allowWuxiang) return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: `${versions.projectionVersion} 不接受无相禅宗`, path: "sect.sectId" }], versions }
if (!allowMultiSect && input.sect.sectId !== "lingxiao") return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: "character_equipment_special_v1 只接受红尘剑宗", path: "sect.sectId" }], versions }
if (input.sect?.sectId === "jiujie" && !allowJiujie) return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: `${versions.projectionVersion} 不接受九劫天宫`, path: "sect.sectId" }], versions }
if (input.sect?.sectId === "tianyan" && !allowTianyan) return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: `${versions.projectionVersion} 不接受天衍圣地`, path: "sect.sectId" }], versions }
if (input.sect?.sectId === "wuxiang" && !allowWuxiang) return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: `${versions.projectionVersion} 不接受无相禅宗`, path: "sect.sectId" }], versions }
if (!allowMultiSect && input.sect?.sectId !== "lingxiao") return { ok: false, diagnostics: [{ severity: "error", code: "INVALID_SECT_ID", message: "character_equipment_special_v1 只接受红尘剑宗", path: "sect.sectId" }], versions }
const base = projectCultivatorBaseToCombatV6({ ...input, resourcePolicy: "full" })
if (!base.ok) return { ok: false, diagnostics: base.diagnostics, versions }
const equipment = compileDaoEquipmentSpecialLoadoutV1(input.equipment, base.unit.level ?? 0)
@@ -141,7 +141,7 @@ export function projectCultivatorWithEquipmentSpecialInternal(
for (const key of ATTRIBUTE_KEYS) effectiveAttributes[key] += equipment.projection.attributeBonuses[key]
const characterPanel = compileCharacterPanelV1(effectiveAttributes)
const training = compileBodyCultivationV6(input.cultivator.condition?.tracks.bodyCultivation, characterPanel)
const sect = allowJiujie
const sect = !input.sect ? undefined : allowJiujie
? compileSectCombatV6V4({ progress: input.sect, characterLevel: base.unit.level ?? 0 })
: allowTianyan
? compileSectCombatV6V3({ progress: input.sect, characterLevel: base.unit.level ?? 0 })
@@ -152,10 +152,10 @@ export function projectCultivatorWithEquipmentSpecialInternal(
...base.diagnostics,
...equipment.projection.diagnostics,
...training.diagnostics,
...(sect.ok ? sect.projection.diagnostics : sect.diagnostics),
...(sect ? (sect.ok ? sect.projection.diagnostics : sect.diagnostics) : []),
]
if (!sect.ok) return { ok: false, diagnostics, versions }
diagnostics.push(...contentConflicts(sect.projection, equipment.projection))
if (sect && !sect.ok) return { ok: false, diagnostics, versions }
if (sect) diagnostics.push(...contentConflicts(sect.projection, equipment.projection))
if (hasErrors(diagnostics)) return { ok: false, diagnostics, versions }
const attrs = panelAttrs(effectiveAttributes)
@@ -166,7 +166,7 @@ export function projectCultivatorWithEquipmentSpecialInternal(
attrs.spellCultivate = training.spellCultivate
attrs.resistSpellCultivate = training.resistSpellCultivate
for (const roll of equipment.projection.panel) applyContribution(attrs, { attr: roll.attr, mode: "add", value: roll.value })
for (const contribution of sect.projection.panel) applyContribution(attrs, contribution)
for (const contribution of sect?.projection.panel ?? []) applyContribution(attrs, contribution)
applyResources(input, attrs, diagnostics)
if (hasErrors(diagnostics)) return { ok: false, diagnostics, versions }
@@ -184,25 +184,25 @@ export function projectCultivatorWithEquipmentSpecialInternal(
unit: {
...base.unit,
attrs,
skills: [...sect.projection.activeSkillIds, ...artSkillIds],
passives: [...sect.projection.passiveSkillIds, ...equipment.projection.passiveSkillIds],
skillLevels: { ...sect.projection.skillLevels, ...artSkillLevels },
skillOverrides: [...sect.projection.skillOverrides, ...equipment.projection.skillOverrides, ...phase6Overrides],
skills: [...(sect?.projection.activeSkillIds ?? []), ...artSkillIds],
passives: [...(sect?.projection.passiveSkillIds ?? []), ...equipment.projection.passiveSkillIds],
skillLevels: { ...sect?.projection.skillLevels, ...artSkillLevels },
skillOverrides: [...(sect?.projection.skillOverrides ?? []), ...equipment.projection.skillOverrides, ...phase6Overrides],
resources: [
...sect.projection.resources,
...(sect?.projection.resources ?? []),
{ id: DAO_RAGE_RESOURCE_ID, name: "战意", current: 0, max: 150 },
],
tags: [...(base.unit.tags ?? []), ...sect.projection.unitTags],
tags: [...(base.unit.tags ?? []), ...(sect?.projection.unitTags ?? [])],
},
skills: [...sect.projection.skills, ...equipment.projection.skills],
statusDefs: [...sect.projection.statusDefs, ...equipment.projection.statusDefs],
skills: [...(sect?.projection.skills ?? []), ...equipment.projection.skills],
statusDefs: [...(sect?.projection.statusDefs ?? []), ...equipment.projection.statusDefs],
diagnostics,
versions,
}
}
export function projectCultivatorWithEquipmentSpecialToCombatV6(
input: ProjectCultivatorWithEquipmentSpecialInput,
input: Omit<CharacterCombatInput, "manuals">,
): CombatV6ProjectionResult {
return projectCultivatorWithEquipmentSpecialInternal(input, { ...COMBAT_V6_PHASE_4B_VERSIONS }, false)
}
@@ -233,3 +233,6 @@ export type CombatV6ProjectionResult =
| (ProjectionCommon & {
ok: false
})
/** Personal equipment and manuals exist independently of sect membership. */
export type CharacterCombatInput = Omit<ProjectCultivatorMultiSectToCombatV6Input, 'sect'> & { sect?: SectCombatProgressV6 };
+10 -11
View File
@@ -1,24 +1,23 @@
# 宗门组织与历史进度目录
# 宗门组织目录
Phase 10K 起,此模块只负责宗门组织、准入、任务、经营、展示主题及历史进度水合。宗门战斗内容和养成以 `combat-v6/content`、`combat-v6/sect-progression` 为准,不再提供 V5 编译器或选招策略。
此模块负责宗门组织、准入、任务、经营与展示主题。宗门战斗内容和养成以 `combat-v6/content`、`combat-v6/sect-progression` 为准,不再提供历史进度水合、V5 编译器或选招策略。
## 目录职责
- `core/domain`:宗门身份、组织与历史玉牒状态类型。
- `core/domain`:宗门身份与组织状态类型。
- `core/plugin`:`SectModule`、标准组织组合与准入策略;注册宗门不需要战斗实现。
- `core/runtime`:宗门注册表与历史状态校验入口。
- `core/validation`:静态 ID、槽位、节点归属及历史状态结构校验。
- `core/progression`:仅保留历史神通栏水合所需的解锁校验,不提供养成写入。
- `core/runtime`:宗门注册表与组织状态校验入口。
- `core/validation`:静态定义及组织状态结构校验。
- `core/organization`:任务目录、组织策略、奖励、晋升和交付物规则。
- `core/presentation`:宗门场景主题与历史槽位规范化;没有旧战斗详情生成器。
- `core/presentation`:宗门场景主题与设施对话;没有旧战斗详情生成器或技能槽规范化。
- `content/productionRuntime.ts`:五宗组织模块与展示主题的生产组合根。
- `content/*/definition.ts`:组织身份与历史心法、流派、节点、战术 ID 等静态数据,用于旧玉牒水合与迁移核对。不能当成 V6 内容生成器。
- `content/*/definition.ts`:组织身份与静态定义。不能当成 V6 战斗内容生成器。
## V6 与历史进度边界
## 战斗进度边界
V6 六心法、双流派和七层经脉由现有 V6 定义提供。入宗介绍展示 V6 双流派名称,正式心法/经脉页面继续走 V6 接口。旧养成接口只返回 410,专属命令、服务和旧写入方法已删除。
六心法、双流派和七层经脉由 V6 定义提供。正式心法/经脉页面走 V6 接口,旧 progression 读取及其水合逻辑已删除。
历史定义保留原 ID、槽位、层顺序和节点归属,避免已有玉牒无法读取。历史数据不自动重写;现有 V6 迁移表与心法槽位的对应关系有共享测试校验。转宗沿用 `CombatV6SectTransfer` 与现有事务,不再保留旧转宗进度计算器。
战斗成长以 membership 为归属,个人功法、装备和灵兽以 cultivator 为归属。转宗由 `CombatV6SectTransfer` 在现有事务内折算宗门进度,不移动个人资产,也不恢复旧宗进度。领域表映射见 `docs/combat-domain-ownership.md`。
旧的 `core/authoring`、`core/compilation`、流派插件和 V5 对手生成器已删除。各宗目录下的旧 `DESIGN.md` 仅是历史设计说明,不能作为当前实现依据。
+1 -34
View File
@@ -1,42 +1,12 @@
import type {
SectAbilityId,
SectId,
SectMethodId,
SectNodeId,
SectPathId,
SectPathLayerId,
SectTacticId,
} from './definitions';
import type { SectId } from './definitions';
import type { SectDiscipleRank, SectOffice } from './organization';
export type SectMembershipStatus = 'prospect' | 'active' | 'transferred';
export type SectAbilitySlots = [
SectAbilityId | null,
SectAbilityId | null,
SectAbilityId | null,
SectAbilityId | null,
];
export interface SectMeridianLoadoutState {
slot: 1 | 2 | 3;
nodeIds: SectNodeId[];
version: number;
}
export interface CultivatorSectPathState {
pathId: SectPathId;
unlockedLayerIds: SectPathLayerId[];
tacticId: SectTacticId;
activeMeridianSlot: 1 | 2 | 3;
meridianLoadouts: SectMeridianLoadoutState[];
}
export interface CultivatorSectState {
membershipId: string;
sectId: SectId;
status: SectMembershipStatus;
joinedAt?: string;
activePathId?: SectPathId;
contribution: number;
/** Total contribution earned; unlike contribution, this is not reduced by spending. */
lifetimeContribution?: number;
@@ -44,7 +14,4 @@ export interface CultivatorSectState {
office?: SectOffice;
promotedAt?: string;
configVersion: number;
methods: Partial<Record<SectMethodId, number>>;
paths: CultivatorSectPathState[];
abilityLoadout: SectAbilitySlots;
}
-1
View File
@@ -3,7 +3,6 @@ export * from './mining';
export * from './organization';
export * from './plugin';
export * from './presentation';
export * from './progression';
export * from './runtime';
export * from './sweep';
export * from './validation';
@@ -1,14 +0,0 @@
import {
StandardSectRules,
type SectAbilityId,
type SectAbilitySlots,
} from '../domain';
export function createAbilitySlots(
loadout: readonly (SectAbilityId | null)[],
): SectAbilitySlots {
return Array.from(
{ length: StandardSectRules.activeAbilitySlotCount },
(_, index) => loadout[index] ?? null,
) as SectAbilitySlots;
}
@@ -1,3 +1,2 @@
export * from './abilityLoadout';
export * from './facilityDialogue';
export * from './sectPresentation';
@@ -1 +0,0 @@
export * from './progression';
@@ -1,25 +0,0 @@
import type {
CultivatorSectState,
SectAbilityId,
SectDefinition,
} from '../domain';
/** 仅用于旧持久化神通栏水合校验,不参与 V6 解锁。 */
export function isAbilityUnlocked(
definition: SectDefinition,
abilityId: SectAbilityId,
sect: CultivatorSectState,
): boolean {
const ability = definition.abilities.find((entry) => entry.id === abilityId);
if (!ability || sect.status !== 'active') return false;
switch (ability.unlock.type) {
case 'always':
return true;
case 'active_path':
return sect.activePathId === ability.unlock.pathId;
case 'method':
return (
(sect.methods[ability.unlock.methodId] ?? 0) >= ability.unlock.level
);
}
}
@@ -1,10 +0,0 @@
import { describe, expect, it } from 'vitest';
import { createAbilitySlots } from '..';
describe('宗门神通装配', () => {
it('固定保留四个稀疏槽位', () => {
expect(createAbilitySlots(['guiding-sword', null, 'turning-body'])).toEqual(
['guiding-sword', null, 'turning-body', null],
);
});
});
@@ -1,7 +1,5 @@
import { COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1 } from '@shared/engine/combat-v6/build-state';
import { describe, expect, it } from 'vitest';
import { SectRegistry, type SectModule } from '..';
import { PRODUCTION_SECTS } from '../../content';
import { FIXTURE_SECT_MODULE } from '../../testing/fixtures/FixtureSectModule';
function withDefinition(
@@ -15,19 +13,8 @@ function withDefinition(
checkAdmission: (context) => FIXTURE_SECT_MODULE.checkAdmission(context),
};
}
describe('历史宗门目录校验', () => {
it('保留与既有 V6 迁移表一致的五宗六心法槽位', () => {
for (const { module } of PRODUCTION_SECTS) {
const id = module.definition
.id as keyof typeof COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1;
expect(
[...module.definition.methods]
.sort((a, b) => a.slot - b.slot)
.map((method) => method.id),
).toEqual(COMBAT_V6_LEGACY_METHOD_IDS_BY_SLOT_V1[id]);
}
});
it('拒绝重复心法槽位,防止迁移映射歧义', () => {
describe('宗门目录校验', () => {
it('拒绝重复心法槽位', () => {
expect(
() =>
new SectRegistry([
@@ -47,7 +34,7 @@ describe('历史宗门目录校验', () => {
]),
).toThrow('跨流派重复节点');
});
it('拒绝入宗未知心法,防止生成无法迁移的玉牒', () => {
it('拒绝入宗未知心法', () => {
expect(
() =>
new SectRegistry([
@@ -1,6 +1,5 @@
import { StandardSectRules, type CultivatorSectState } from '../domain';
import { type CultivatorSectState } from '../domain';
import type { SectModule } from '../plugin';
import { isAbilityUnlocked } from '../progression';
/** 持久化水合与运行时入口共用的结构校验器。 */
export class SectStateValidator {
@@ -19,123 +18,6 @@ export class SectStateValidator {
`宗门 ${state.sectId} 配置版本不兼容: ${state.configVersion}`,
);
}
const methodIds = new Set(
module.definition.methods.map((method) => method.id),
);
const abilityIds = new Set(
module.definition.abilities.map((ability) => ability.id),
);
if (!state.methods || typeof state.methods !== 'object')
throw new Error('宗门心法进度结构无效');
for (const [id, level] of Object.entries(state.methods)) {
if (!methodIds.has(id)) throw new Error(`未知心法进度: ${id}`);
if (typeof level !== 'number' || !Number.isInteger(level) || level < 0)
throw new Error(`心法等级无效: ${id}`);
}
if (
!Array.isArray(state.abilityLoadout) ||
state.abilityLoadout.length !== StandardSectRules.activeAbilitySlotCount
)
throw new Error(
`宗门神通栏必须包含${StandardSectRules.activeAbilitySlotCount}个固定槽位`,
);
const loadoutIds = state.abilityLoadout.filter(
(id): id is string => id !== null,
);
if (new Set(loadoutIds).size !== loadoutIds.length)
throw new Error('宗门神通栏不可重复装配');
for (const id of loadoutIds) {
if (!abilityIds.has(id)) throw new Error(`未知宗门法术: ${id}`);
const ability = module.definition.abilities.find(
(entry) => entry.id === id,
);
if (ability?.kind !== 'active')
throw new Error(`非主动法术不可装配: ${id}`);
if (!isAbilityUnlocked(module.definition, id, state))
throw new Error(`未解锁宗门法术不可装配: ${id}`);
}
if (!Array.isArray(state.paths)) throw new Error('宗门流派进度结构无效');
if (
new Set(state.paths.map((path) => path.pathId)).size !==
state.paths.length
) {
throw new Error('流派进度不可重复');
}
for (const pathState of state.paths) {
const path = module.definition.paths.find(
(candidate) => candidate.id === pathState.pathId,
);
if (!path) throw new Error(`未知流派进度: ${pathState.pathId}`);
if (!Array.isArray(pathState.unlockedLayerIds))
throw new Error(`流派 ${pathState.pathId} 解锁层级结构无效`);
if (
new Set(pathState.unlockedLayerIds).size !==
pathState.unlockedLayerIds.length
)
throw new Error(`流派 ${pathState.pathId} 解锁层级不可重复`);
const orderedLayerIds = [...path.layers]
.sort((left, right) => left.order - right.order)
.map((layer) => layer.id);
const expectedPrefix = orderedLayerIds.slice(
0,
pathState.unlockedLayerIds.length,
);
if (expectedPrefix.join(',') !== pathState.unlockedLayerIds.join(','))
throw new Error(`流派 ${pathState.pathId} 层级必须按顺序解锁`);
if (
!StandardSectRules.meridianLoadoutSlots.includes(
pathState.activeMeridianSlot,
)
)
throw new Error(`流派 ${pathState.pathId} 当前参悟方案槽无效`);
const nodeIds = new Set(path.nodes.map((node) => node.id));
const unlockedLayerIds = new Set(pathState.unlockedLayerIds);
const tacticIds = new Set(path.tactics.map((tactic) => tactic.id));
if (!tacticIds.has(pathState.tacticId))
throw new Error(`未知流派战术: ${pathState.tacticId}`);
const slots = pathState.meridianLoadouts
.map((loadout) => loadout.slot)
.sort();
if (
slots.length !== StandardSectRules.meridianLoadoutSlots.length ||
slots.join(',') !== StandardSectRules.meridianLoadoutSlots.join(',')
) {
throw new Error(
`流派 ${pathState.pathId} 必须保存${StandardSectRules.meridianLoadoutSlots.length}套唯一参悟方案`,
);
}
if (!slots.includes(pathState.activeMeridianSlot)) {
throw new Error(`流派 ${pathState.pathId} 当前参悟方案不存在`);
}
for (const loadout of pathState.meridianLoadouts) {
if (!Number.isInteger(loadout.version) || loadout.version < 1)
throw new Error('参悟方案版本必须为正整数');
if (new Set(loadout.nodeIds).size !== loadout.nodeIds.length)
throw new Error('参悟节点不可重复');
const occupiedLayers = new Set<string>();
for (const nodeId of loadout.nodeIds) {
if (!nodeIds.has(nodeId)) throw new Error(`未知参悟节点: ${nodeId}`);
const node = path.nodes.find((entry) => entry.id === nodeId)!;
const layer = node.layerId;
if (occupiedLayers.has(layer))
throw new Error(`参悟第${layer}层只能选择一个节点`);
occupiedLayers.add(layer);
if (!unlockedLayerIds.has(node.layerId))
throw new Error(`${node.name}所属层级尚未解锁`);
for (const [methodId, level] of Object.entries(
node.requiredMethods ?? {},
)) {
if ((state.methods[methodId] ?? 0) < level)
throw new Error(`${node.name}尚未达到心法要求`);
}
}
}
}
if (
state.activePathId &&
!state.paths.some((path) => path.pathId === state.activePathId)
) {
throw new Error(`激活流派尚未习得: ${state.activePathId}`);
}
if (state.sectId !== module.definition.id) throw new Error('宗门归属不匹配');
}
}
@@ -6,7 +6,7 @@ import {
fixtureSectState,
} from './fixtures/FixtureSectModule';
describe('宗门组织扩展与历史状态', () => {
describe('宗门组织扩展与成员状态', () => {
it('组织目录注册不需要战斗编译器或选招策略', () => {
const runtime = createSectRuntime([FIXTURE_SECT_MODULE]);
expect(runtime.registry.require('fixture-sect')).toBe(FIXTURE_SECT_MODULE);
@@ -33,18 +33,9 @@ describe('宗门组织扩展与历史状态', () => {
0,
);
});
it('历史水合仍拒绝重复装配和未知心法', () => {
it('成员校验拒绝负贡献与错误归属', () => {
const runtime = createSectRuntime([FIXTURE_SECT_MODULE]);
const duplicate = fixtureSectState();
duplicate.abilityLoadout = [
'fixture-ability-2',
'fixture-ability-2',
null,
null,
];
expect(() => runtime.validateState(duplicate)).toThrow('不可重复');
const unknown = fixtureSectState();
unknown.methods.missing = 1;
expect(() => runtime.validateState(unknown)).toThrow('未知心法');
expect(() => runtime.validateState({ ...fixtureSectState(), contribution: -1 })).toThrow('非负整数');
expect(() => runtime.validateState({ ...fixtureSectState(), status: 'invalid' as never })).toThrow('状态无效');
});
});
@@ -140,8 +140,5 @@ export function fixtureSectState(): CultivatorSectState {
status: 'active',
contribution: 30,
configVersion: 1,
methods: { 'fixture-method-1': 1, 'fixture-method-2': 1 },
paths: [],
abilityLoadout: ['fixture-ability-2', null, null, null],
};
}
@@ -7,7 +7,7 @@ const change = (resourceTopic: ResourceChangeDescriptor['resourceTopic'], id = s
describe('character panel resource invalidations', () => {
it('refreshes the panel once per character after profile and build changes', () => {
const result = withCharacterPanelInvalidations([change('player.profile'), change('player.combat-v6-build'), change('player.profile', 'player-2')]);
const result = withCharacterPanelInvalidations([change('player.profile'), change('player.sect-combat'), change('player.profile', 'player-2')]);
expect(result.filter(c => c.resourceTopic === 'player.condition').map(c => c.scope.id)).toEqual(['player-1', 'player-2']);
});
it('replaces persisted condition updates with a read-model invalidation', () => {
+1 -1
View File
@@ -8,7 +8,7 @@ export function withCharacterPanelInvalidations(changes: readonly ScopedChange[]
const invalidated = new Set<string>();
for (const change of changes) {
if (change.resourceTopic !== 'player.condition') result.push(change);
if (!['player.profile', 'player.combat-v6-build', 'player.condition'].includes(change.resourceTopic)) continue;
if (!['player.profile', 'player.sect-combat', 'player.condition'].includes(change.resourceTopic)) continue;
const key = `${change.scope.kind}:${change.scope.id}`;
if (invalidated.has(key)) continue;
invalidated.add(key);
+31 -2
View File
@@ -1,3 +1,5 @@
import { CHARACTER_MANUALS_V1, manualRule } from '@shared/engine/combat-v6/manuals/content';
import { DAO_EQUIPMENT_GENERATOR_VERSION, DAO_EQUIPMENT_TEMPLATE_ID, generateDaoEquipmentV1 } from '@shared/engine/combat-v6/equipment';
import { describe, expect, it } from 'vitest';
import { COMBAT_V6_SECT_DEFINITIONS_V4 } from '@shared/engine/combat-v6/content';
import { projectCultivatorMultiSectV5ToCombatV6 } from '@shared/engine/combat-v6/projection';
@@ -13,12 +15,17 @@ const build: CharacterDisplayBuild = {
sect: {
version: 1, sectId: 'youdu', methods: Object.fromEntries(definition.methods.map(m => [m.id, 1])),
activePathId: definition.paths[0].id, meridianDepth: 0,
meridianLoadouts: definition.paths.map(p => ({ pathId: p.id, nodeIds: [], revision: 0 })) as CharacterDisplayBuild['sect']['meridianLoadouts'],
meridianLoadouts: definition.paths.map(p => ({ pathId: p.id, nodeIds: [], revision: 0 })) as NonNullable<CharacterDisplayBuild['sect']>['meridianLoadouts'],
},
equipment: {}, manuals: { version: 1, revision: 0, learned: [], build: { slots: [] } },
};
describe('V6 character panel authority', () => {
it('projects an unsaved character whose generated ID is empty', () => {
expect(projectCharacterDisplay({ ...player, id: '' }, null)).toEqual(
projectCharacterDisplay(player, null),
);
});
it('provides the unaffiliated V6 base panel', () => {
expect(projectCharacterDisplay(player, null)).toEqual({
physicalAtk: 75, magicAtk: 75, physicalDef: 27, magicDef: 27,
@@ -36,7 +43,7 @@ describe('V6 character panel authority', () => {
expect(() => projectCharacterDisplay({ ...player, attributes: { ...player.attributes, vitality: NaN } }, build)).toThrow();
});
it('rejects an invalid sect build rather than returning the base panel', () => {
expect(() => projectCharacterDisplay(player, { ...build, sect: { ...build.sect, activePathId: 'missing-path' } })).toThrow();
expect(() => projectCharacterDisplay(player, { ...build, sect: { ...build.sect!, activePathId: 'missing-path' } })).toThrow();
});
it('recomputes changed permanent attributes but preserves occupied resource maxima', () => {
const attrs = projectCharacterDisplay(player, build);
@@ -51,3 +58,25 @@ describe('V6 character panel authority', () => {
expect(normalizeCharacterResource(900, 630)).toEqual({ current: 630, max: 630 });
});
});
describe('角色个人构筑独立于宗门', () => {
it('未加入宗门也计入功法和装备;移除后属性回到基准', () => {
const manual = CHARACTER_MANUALS_V1.find(m => m.realm === '炼气' && m.effects.some(effect => effect.attribute === 'vitality'))!;
const generated = generateDaoEquipmentV1({id: 'independent-weapon', createdAt: '2026-09-10T00:00:00Z', seed: 17, templateId: DAO_EQUIPMENT_TEMPLATE_ID.Weapon, equipmentLevel: 10, generatorVersion: DAO_EQUIPMENT_GENERATOR_VERSION});
if (!generated.ok) throw new Error('装备生成失败');
const personal: CharacterDisplayBuild = {
equipment: {weapon: generated.instance},
manuals: {version: 1, revision: 1, learned: [{manualId: manual.id, level: 1, unlockedLevel: manualRule(manual).bottlenecks[0]}], build: {slots: [{slot: 1, manualId: manual.id}]}},
};
const baseline = projectCharacterDisplay(player, null);
const panel = projectCharacterDisplay(player, personal);
expect(panel.maxHp).toBeGreaterThan(baseline.maxHp);
expect(panel.physicalAtk).toBeGreaterThan(baseline.physicalAtk);
const battle = projectCultivatorMultiSectV5ToCombatV6({...personal, cultivator: {...player, id: player.id!}, side: 0, slot: 0, resourcePolicy: 'full'});
expect(battle.ok).toBe(true);
if (!battle.ok) throw new Error('投影失败');
expect(battle.unit.attrs).toMatchObject(panel);
expect(projectCharacterDisplay(player, {equipment: {}, manuals: {version: 1, revision: 0, learned: [], build: {slots: []}}})).toEqual(baseline);
});
});
+3 -3
View File
@@ -5,7 +5,7 @@ import {
projectCultivatorMultiSectV5ToCombatV6,
projectCultivatorWithTrainingToCombatV6,
type CharacterPanelV1,
type ProjectCultivatorMultiSectToCombatV6Input,
type CharacterCombatInput,
} from '@shared/engine/combat-v6/projection';
export type CultivatorDisplayInput = Pick<Cultivator,
@@ -14,7 +14,7 @@ export type CultivatorDisplayInput = Pick<Cultivator,
combatV6ResourceAuthority?: CombatV6ResourceAuthority & { build: CharacterDisplayBuild | null };
};
export type CharacterDisplayBuild = Pick<ProjectCultivatorMultiSectToCombatV6Input,
export type CharacterDisplayBuild = Pick<CharacterCombatInput,
'sect' | 'equipment' | 'manuals'>;
export interface CombatV6ResourceAuthority {
@@ -60,7 +60,7 @@ export function projectCharacterDisplay(
build: CharacterDisplayBuild | null,
): CharacterPanelV1 {
const input = {
cultivator: { ...cultivator, id: cultivator.id ?? 'character-preview' },
cultivator: { ...cultivator, id: cultivator.id || 'character-preview' },
side: 0 as const, slot: 0, resourcePolicy: 'full' as const,
};
const result = build