战斗数据的领域归属重构
This commit is contained in:
1 parent
550ff7b935
commit
884936357d
98 files changed
+18322
-1756
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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脚本或兼容层,没有部署到预发布或生产。
|
||||
@@ -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` 列,所有新归档都使用可播放协议,不保留历史格式兼容。
|
||||
|
||||
这次没有扩展为多套个人配装预设。若将来确实需要多套方案,角色资产仍属于角色,预设只能保存对资产的选择,不能重新成为资产父级。
|
||||
@@ -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";
|
||||
@@ -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
@@ -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([]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -121,7 +121,6 @@ export type CombatV6HistoryItem = {
|
||||
roundCount: number;
|
||||
sides: [string[], string[]];
|
||||
outcome: 'victory' | 'defeat' | 'draw' | 'aborted';
|
||||
playable: boolean;
|
||||
};
|
||||
export type CombatV6HistoryPage = {
|
||||
items: CombatV6HistoryItem[];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+21
-21
@@ -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 };
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user