# Design: Port ROK Hero & Bag System ## Context 工程 `Survivors` 基于 OctoberStudio Unity 模板(单机肉鸽),现在要引入 ROK 那种重养成 SLG 玩法(英雄+背包+装备)。已有的技术栈: - **客户端**:ET 框架(Entity/Component/System)+ YIUI UI 框架 + HybridCLR 热更 + YooAsset 资源 + ExcelExporter 配置 - **服务端**:ET Server(dotnet)+ 已有 Login Demo 全链路 + MongoDB Driver(未启用业务) - **协议**:protobuf3 + MemoryPack 序列化 - **已就绪**:cn.etetet.login 登录链路、cn.etetet.proto 代码生成、cn.etetet.yiuiloopscrollrectasync 循环列表 **约束**: 1. ROK 源码用了 PureMVC + sproto + ILRuntime/IFix,**完全不能直接复制** 2. 协议层必须用 ET protobuf 重新定义 3. UI 必须用 YIUI 重写 4. 服务端必须用 ET Entity/Component/System 重写 5. ROK Server 是 C++/Lua(9 个微服务),与 ET 协议不兼容,**放弃对接** 6. OctoberStudio 现有 `SaveManager` 本地存档系统与 ET 服务端并存 **驱动者**:单机肉鸽 + SLG 卡牌养成的混合体验需要"局外永久成长"沉淀玩家,背包/英雄是核心载体。 ## Goals / Non-Goals **Goals:** 1. 借鉴 ROK 业务设计(数据模型/Config 字段/UI 流程/服务端 RPC 列表),用 ET + YIUI 重新实现一套完整的英雄+背包+装备系统 2. 客户端-服务端真权威架构(防作弊),所有养成操作服务端校验,DB 持久化 3. 与现有 OctoberStudio 流程无缝衔接(启动顺序、场景切换、UI 风格) 4. 分阶段可交付(先英雄基础养成 → 装备 → 天赋),每个阶段独立可用 5. 协议、配置、UI 三套自动化工具链完整跑通 **Non-Goals:** 1. **不**对接 ROK Server(C++/Lua),ET Server 是新写的真权威 2. **不**搬运 ROK 的美术资源(图集/Spine/Sound),只搬数据结构和流程 3. **不**做 ROK 的"雕像兑换"/"天赋页改名"/"建造锻造"等次要功能(一期不做) 4. **不**修改 OctoberStudio 局内战斗代码(先把养成搭好,后续接入"出战英雄"逻辑) 5. **不**实现服务端集群(玩家数量小时单进程足够,未来再分布式) ## Decisions ### D1: 服务端 vs 客户端权威架构 **选择**:**服务端真权威 + 客户端缓存 + 推送同步** **为什么**: - 防作弊是 SLG 卡牌养成的基本盘 - 客户端只展示+操作,所有数值/规则在服务端校验 - ROK 设计就是这套,移植成本低 **备选**: - 纯客户端单机(无服务器)→ 后续做联网/跨设备同步会非常痛苦 - 纯无状态服务器(每请求都查 DB)→ DB 压力大、响应慢 **取舍**:客户端有少量延迟(操作 → 服务端 → 推送回执 → UI 刷新),但接受。 ### D2: 业务包组织方式 **选择**:**新增 3 个 cn.etetet.* 业务包**: - `cn.etetet.hero` - 英雄系统(含装备穿戴关联) - `cn.etetet.bag` - 背包+装备(含锻造合成) - `cn.etetet.gacha` - 抽卡系统(依赖 hero + bag) 每个包内部按 ET 标准目录结构: ``` cn.etetet.hero/ ├── package.json ├── Scripts/ │ ├── Model/Share/ # HeroInfo / SkillInfo 等 DTO │ ├── Model/Client/ # 客户端缓存 Component │ ├── Model/Server/ # 服务端权威 Component │ ├── Hotfix/Client/ # 客户端 Handler + System │ └── Hotfix/Server/ # 服务端 Handler + System ├── Proto/HeroOuter_C_3000.proto └── ModelView/Client/YIUIComponent + YIUIGen # YIUI UI 代码(由工具生成) ``` **为什么不放在 Assets/**: - ET 包结构方便复用/导出/版本控制 - YooAsset 收集规则已默认 `Packages/cn.etetet.*` 路径 - Asmdef 隔离编译速度更快 **备选**: - 全部塞进现有 `cn.etetet.statesync` 包 → 包变巨大,不清晰 - 放在 `Assets/Scripts/` → 编译慢、与 ET 框架代码分离 ### D3: Hero 实体在 ET 中的建模 **选择**:每个英雄一个 `Hero` Entity,挂在 `Player.GetComponent()` 下作为子 Entity。 ``` Player (Entity, _id = playerId) ├── HeroComponent │ ├── Hero (Entity, _id = heroEntityId, ChildOf = HeroComponent) │ │ ├── HeroSkillComponent # 技能等级数组 │ │ ├── HeroTalentComponent # 3 套天赋页 │ │ └── HeroEquipComponent # 8 个装备槽 │ └── Hero (Entity, _id = ...) └── BagComponent └── ItemUnit (Entity, _id = itemIndex) ``` **为什么用 Entity 而不是 POCO 类**: - 自动支持 BSON 序列化(写 DB 容易) - 子 Entity 自动有 EntityId(用作 itemIndex) - ET System 自动绑定(Awake/Destroy/Update 等生命周期) - 跟 ET 框架风格统一 **备选**: - POCO `Dictionary` → 简单,但失去 ET ECS 优势、序列化麻烦 - 一个 Hero 拆多个 Entity(按子系统) → 过度设计,性能没必要 ### D4: 协议 ID 段与 proto 文件划分 **选择**:占用 3000-3999 段,分三个文件: - `HeroOuter_C_3000.proto` - 英雄业务(含装备穿脱)+ DTO - `BagOuter_C_3100.proto` - 背包业务(含锻造合成) - `GachaOuter_C_3200.proto` - 抽卡业务 | ID 范围 | 用途 | |---|---| | 3000-3099 | Hero RPC + 推送 + DTO | | 3100-3199 | Bag/Equipment RPC + 推送 + DTO | | 3200-3299 | Gacha RPC + 推送 + DTO | **为什么不放一起**: - 文件太大不好维护 - 业务模块独立,便于将来拆解 **ET 框架要求**:每个 proto 文件起始 ID 用 `OpcodeRangeStart` 控制,工具自动按顺序分配。 ### D4.1: 通用 Item 设计原则 **选择**:**所有游戏内可获得的资源统一为 Item**——金币、钻石、英雄碎片、装备、材料、经验书、升星石、觉醒符文、礼包、邮件附件,全部用同一个 `ItemConfig` + 一个 `itemId` 管理。 **核心约定**: ```protobuf // 全游戏通用,凡是"奖励"列表都用它 message RewardEntry { int32 itemId = 1; int64 count = 2; } // 与 RewardEntry 结构相同,只是语义不同(消耗) message CostEntry { int32 itemId = 1; int64 count = 2; } ``` ```csharp // 所有发奖/扣资源 MUST 走这两个统一接口 public static class BagComponentSystem { public static void AddItems(this BagComponent self, List rewards); public static bool RemoveItems(this BagComponent self, List costs); // 不足则全失败 public static long GetItemCount(this BagComponent self, int itemId); } ``` **为什么**: - **数据建模简化**:玩家身上不存"金币字段、钻石字段、英雄碎片字段……",只存一个 `BagComponent`,金币也是 BagComponent 里的一条 ItemInfo(itemId=1) - **业务接口收敛**:抽卡发奖、升级扣钱、商店买卖、邮件附件、关卡奖励——全部用 `AddItems`/`RemoveItems`,业务代码大量减少 - **新增资源零成本**:新增"幸运碎片"只需要在 ItemConfig.xlsx 加一行,**不需要写任何代码** - **UI 复用最大化**:`BagItemUICommon` 通过 `(itemId, count)` 渲染任何道具,所有面板都用它 **itemId 段位规划**: | itemId 段 | 用途 | |---|---| | 1-99 | 货币(1=金币、2=钻石、3=PvP 币、…) | | 100-999 | 通用道具(经验书、升星石、礼包) | | 1000-9999 | 英雄碎片(itemId = heroBaseId + 1000) | | 10000-19999 | 装备物品(itemId = equipBaseId) | | 20000-29999 | 装备材料/图纸 | | 30000+ | 时装、皮肤、其他 | **反例(要避免)**: - ❌ Player Entity 上加 `public long Gold; public long Diamond;` —— 违反通用原则 - ❌ 抽卡系统自己直接操作 BagComponent 的内部字典 —— 应该调 AddItems - ❌ 不同业务自己实现"扣钱"逻辑 —— 必须走 RemoveItems **取舍**:金币/钻石这种高频访问的字段查询会比独立字段稍慢(多一次字典查找),但可接受。如真有性能问题,可在 BagComponent 上加 `Dictionary _quickCache` 缓存常用 itemId 的数量。 ### D5: 配置表方案 - Luban(替代 ExcelExporter) **选择**:**引入 Luban**(新业务表全部用 Luban;OctoberStudio 老配置保持不变) **为什么改变方向**: - 本次新增的 14+ 张表大量使用 `List`、`Map>`、bean 继承、外键引用,**ExcelExporter 表达力不足** - 抽卡权重表、技能效果表、天赋树前置依赖在 Luban 里是一等公民,在 ExcelExporter 里要拆多列/塞 JSON 字符串 - Luban 编译期校验外键,**消灭运行时 NullReference** - 后续商店/邮件/活动/多语言配置膨胀时,Luban 优势越来越大 **不写自定义模板,用原生 API + 一层 Component 包装**: ```csharp // Luban 原生生成(零自定义模板) public partial class Tables { public TbHero TbHero { get; } public TbItem TbItem { get; } public TbGachaPool TbGachaPool { get; } public TbGachaWeight TbGachaWeight { get; } // ... 自动生成 } // ET 集成(挂在 Scene 上) [ComponentOf(typeof(Scene))] public class ConfigComponent : Entity, IAwake { public cfg.Tables Tables; } public static class ConfigComponentSystem { [EntitySystem] private static void Awake(this ConfigComponent self) { self.Tables = new cfg.Tables(name => JsonConfigLoader.Load($"Config/{name}.json")); } } // 业务访问 public static class SceneExtensions { public static cfg.Tables Cfg(this Scene self) => self.GetComponent().Tables; } // 使用 var hero = scene.Cfg().TbHero.Get(10001); var pool = scene.Cfg().TbGachaPool.Get(poolId); ``` **为什么不写自定义模板**: - 模板维护成本高(升级 Luban 要同步改) - 原生 API 已足够好用,多一个 `.Cfg().` 调用可接受 - 符合 ET ECS 哲学(配置作为 Scene 资源 Component) **Luban 集成方式**: - 用 dotnet luban 作为命令行工具,通过 `tools/luban/gen.ps1` 一键导出 - 输入:`Config/Datas/*.xlsx` + `Config/Defines/*.xml` - 输出:客户端 `Bundles/Config/*.json` + `cn.etetet.config/Scripts/Model/Share/cfg/*.cs`,服务端共享同一份代码 - 客户端运行时通过 YooAsset 加载 json,服务端从 `Bin/Config/` 读取 **老配置不动**:OctoberStudio 现有零散表 + ET ExcelExporter 现有几张测试表保留,不强制迁移。**新业务(Hero/Bag/Equipment/Gacha)全部走 Luban**。 **ROK 配置数据迁移(不需要手输)**: - **数据源真相**:ROK 工程**没有 Excel 源文件**。配置只有两份: - 服务端:`E:/Game/gmd/ROK/Server/common/config/gen/Configs.data`(45 万行 Lua 表,`tabtoy` 生成) - 客户端:`E:/Game/gmd/ROK/Client/Assets/Resources/.../*.db`(SqlCipher4Unity3D 加密 SQLite)+ 205 个 `*Define.cs` DTO - **现成工具**:姐妹工程 `E:/Game/gmd/Tools/export_hero_json_from_configs_data.py` 已能解析 `Configs.data` 输出 JSON(已验证 Hero/HeroLevel/HeroStar 等表) - **迁移路径**: ``` Configs.data (Lua) ROK Client *Define.cs │ │ │ 扩展 export_*.py │ 扫描字段名 + 类型 │ ┌─────────┐ │ ┌─────────┐ ▼ │ Python │ ▼ │ Roslyn │ raw_*.json ◄────────────────────── *.bean.xml (Luban Schema) │ │ Python (raw_*.json → Luban *.xlsx 数据填充) ▼ Config/Datas/*.xlsx + Config/Defines/*.xml │ dotnet luban ▼ Bundles/Config/*.json + cfg/*.cs ``` - **优势**:**配置内容不丢失** + **Schema 派生自 ROK Client DTO** + **零手工录入** **风险与缓解**: - [Luban 学习曲线] → P0 阶段做 1 张示例表(ItemConfig)跑通完整链路,文档化 - [Luban 与 HybridCLR 兼容] → Luban 生成的代码都是 partial class + 基础类型,无 AOT 元数据问题 - [json 文件 YooAsset 收集] → 在 `AssetBundleCollectorSetting` 加 `Config/` 路径规则 **备选**: - 继续用 ExcelExporter → 复杂表难写,长期成本高 - 代码硬编码 → 数值改一次重编一次,不可接受 ### D6: 服务端持久化策略 - **基于现成 MongoHelper 的业务封装** **前置事实**: - `cn.etetet.core/Scripts/Core/Share/Serialize/MongoHelper.cs` 已提供 BSON 序列化(ToJson/FromJson/Clone/CloneBytes/Register) - `com.etetet.init/Plugins/MongoDB/*` 已自带 MongoDB.Driver 原生 DLL - **但是没有**:`IDBComponent.Save/Query/Delete` 业务封装、连接池、慢查询、重试 **结论**:**新建业务包 `cn.etetet.db` 包装这层**,不重写 BSON 也不引入新 ORM。 **选择**:**MongoDB + 脏标记 + 30 秒定时刷盘 + 离线立即刷盘** **为什么 MongoDB**: - ET 框架原生集成(已有 MongoDB.Driver DLL + MongoHelper) - 文档型契合 Entity 结构(Hero、Bag 嵌套天然存为子文档) - 索引、查询、复制集都有现成方案 **为什么脏标记 + 定时**: - 每操作都直写 DB → 性能差、网络抖动会阻塞业务 - 全在内存 → 服务器崩了数据全丢 - 折中:操作改内存 + 标记 + 定时批量写 **风险与缓解**: - [服务器突然崩溃 → 30 秒未刷盘数据丢失] → 关键操作(充值、付费)走"实时刷盘"通道 - [DB 写入失败 → 标记保留待重试] → 监控告警 + 自动重试 3 次 - [玩家强退 → 离线刷盘耗时] → 异步等待,最大 5 秒超时 **开发期支持**:内存模式 `MemoryDBComponent`,StartConfig.DBConnection = "memory",无需起 mongod ### D7: UI 架构 - **深度复用 YIUI 全套子包**(UI 几乎零造轮) **前置事实**:本工程已集成 9 个 YIUI 子包(framework/yiui/yiuiinvoke/yiuitips/yiuireddot/yiuiloopscrollrectasync/yiuiyooassets/yiuieffect/yiui3ddisplay),覆盖面板管理 / 数据绑定 / 事件绑定 / 弹窗 / 红点 / 长列表 / 资源加载 / UI 特效 / 3D 模型展示。详见 `Doc/ET-Packages-Audit.md` "YIUI 框架" 一节。 **选择**:使用 YIUI 框架原生 MVC 模式 + 全套子包能力,**禁止自建 PanelHelper / 长列表 / Tooltip 容器 / 红点树 / 3D 展示等已有能力** ``` HeroListPanel (Panel) ├── HeroListPanelComponent.cs # ET Entity,状态层(YIUI 工具生成) ├── HeroListPanelComponentSystem.cs # ET System,业务层 └── HeroListPanel.prefab # YIUI UIBindCDETable 配置 ├── CommonHeader (已有 UICommon,复用) ├── YIUILoopScrollVertical (yiuiloopscrollrectasync) │ └── HeroListItem (UICommon,u_Data* + UIDataBind*) └── BottomBar ``` **面板生命周期统一入口**(**不要自建 PanelHelper**): ```csharp // 打开 await scene.YIUIRoot().OpenPanelAsync(); // 带参打开(最多 5 个参数) await scene.YIUIRoot().OpenPanelParamAsync(heroId); // 模态等待结果 var result = await scene.YIUIRoot().OpenPanelWaitAsync("确认抽卡?"); ``` **YIUI 子包对业务的具体映射**: | ROK 业务点 | YIUI 子包/API | 备注 | |---|---|---| | 长列表(背包 1000+ / 英雄列表) | `cn.etetet.yiuiloopscrollrectasync` → `YIUILoopScrollChild.SetDataRefresh` | 数据变长度不变用 `ReRenderer()` 增量刷新 | | 道具图标异步加载 | `cn.etetet.yiuiyooassets` → `UIDataBindImage` + `u_DataIcon.SetValue("ItemIcon_1001")` | YooAsset 自动加载,**不写 LoadHelper** | | 抽卡按钮灰显 / 不可用 | `cn.etetet.yiuieffect` → `UIDataBindGray` + `u_DataCanDraw.SetValue(false)` | 不写 Material 置灰 | | 抽卡结果闪光/粒子 | `cn.etetet.yiuieffect` → `UIEffect` + `UIParticle` | 不引第三方 VFX | | 英雄详情 3D 模型 | `cn.etetet.yiui3ddisplay` → `YIUI3DDisplayChild.ShowAsync("HeroModel_10001")` | 框架管 RT/Camera/Pool | | 抽卡 CD / 活动倒计时 | `cn.etetet.yiuiframework` → `CountDownMgr` | 不自建 Timer 管理器 | | 抽卡 RPC 等待挡点击 | `cn.etetet.yiuiframework` → `scene.YIUIMgr().BanLayerOptionForever()` | 异步期间防双击 | | 确认/取消弹窗 | `cn.etetet.yiuitips` → `TipsHelper.OpenWait(scene, msg)` | 自带 `HashWait` 模态返回 | | 操作成功飘字 | `cn.etetet.yiuitips` → `TipsHelper.OpenSync(scene, "招募成功")` | 自带淡入淡出动画 | | 客户端错误提示(错误码) | `cn.etetet.yiuitips` + `ErrorMessageHelper.GetMessage(code)` | 走 `TipsTextView`,不写 Toast 系统 | | 红点(英雄/背包/抽卡入口) | `cn.etetet.yiuireddot` → `RedDotMgr.Inst.SetCount(key, count)` + Prefab `RedDotBind` | **只写叶子节点**,父节点自动汇总 | | 顶栏 + 关闭 | `CommonHeader` + `YIUICloseCommon`(已有) | 直接拖入新 Panel | **UI 与服务端业务的解耦**:用 `cn.etetet.yiuiinvoke` 的 `[YIUIInvokeSystem("Key")]` 机制 + invoke 契约包(D2 已定义 `cn.etetet.heroinvoke` 等),让 GM 或其他业务不直接 reference 业务包。 ### D8: 客户端缓存与事件机制 - **UIData 局部驱动 + ET EventSystem 跨面板广播** **两层职责拆分**(明确边界,避免误用): | 层 | 用什么 | 时机 | |---|---|---| | **Panel/View 内部数据 → 自身 UI 刷新** | YIUI `UIDataBind*` + `u_DataXxx.SetValue(...)` | Panel 内 List Item 的图标、名字、数量等局部字段,**不广播** | | **跨面板的业务变化广播** | ET `EventSystem.Instance.Publish(new XxxEvent)` | 英雄列表 + 英雄详情同时打开时,一处变多处响应 | **示例**: ```csharp // 收到服务端推送 public class Hero_HeroInfoHandler : MessageHandler { protected override async ETTask Run(Session session, Hero_HeroInfo msg) { var heroComp = session.GetComponent().Player.GetComponent(); heroComp.UpdateHero(msg.Hero); // 跨面板广播(多个 Panel 订阅) EventSystem.Instance.Publish(new HeroUpdatedEvent { HeroId = msg.Hero.HeroId }); } } // Panel 监听 [Event] public class HeroUpdatedEvent_RefreshList : AEvent { protected override async ETTask Run(Scene scene, HeroUpdatedEvent args) { var panel = scene.YIUIMgr().GetPanel(); if (panel == null) return; // Panel 内部通过 u_Data* 局部刷新,不再二次广播 await panel.RefreshHero(args.HeroId); } } // Panel 内部刷新(YIUI 数据驱动,不发事件) public static async ETTask RefreshHero(this HeroListPanelComponent self, long heroId) { var hero = self.ClientScene().GetHero(heroId); var item = self.GetItemByHeroId(heroId); item.u_DataLevel.SetValue(hero.Level); // 局部刷新,YIUI 自动绑定到 TMP_Text item.u_DataPower.SetValue(hero.Power); item.u_DataIcon.SetValue($"HeroIcon_{hero.HeroId}"); // 自动走 YooAsset 加载 } ``` **为什么不全用 EventSystem**: - Panel 内部 List Item 数百个字段,全发事件 → 事件爆炸 + 性能问题 - YIUI `UIDataBind` 是为局部数据刷新设计,零事件开销 **为什么不全用 UIDataBind**: - 跨面板(列表 + 详情)数据同步需要广播机制 - UIData 只在单个 Panel/Component 内有效 ### D9: 与 OctoberStudio 集成 **选择**:**Init.unity 作为主入口**,登录后切到 OctoberStudio Main Menu ``` 启动流程: 1. Init.unity (ET 启动) → Realm → Gate 登录 2. 登录成功,拉取 HeroInfoList + ItemInfoList 3. 切换到 OctoberStudio Main Menu 场景(保留原玩法入口) 4. 在 Main Menu 添加"英雄"/"背包"按钮 → 打开 YIUI Panel ``` **为什么不替换 Main Menu**: - OctoberStudio 已有完整局内战斗、宝箱、设置等流程 - 强行替换风险大 - "ET 是基础设施 + OctoberStudio 是单机玩法"双层架构 **OctoberStudio 存档**: - `CurrencySave`、`CharactersSave` 保留为局内/设置用途 - 英雄、装备、背包用 ET 服务端存档(DB) - 二者通过适配层访问(如金币:先查 ET,再降级到本地) ### D9.1: 抽卡系统设计 **选择**:抽卡作为独立业务包 `cn.etetet.gacha`,但**强依赖** `bag-system`(消耗+奖励)+ `hero-system`(抽到英雄/碎片)。**抽卡是端到端验证最佳工具**,进入 P1 阶段第一个落地。 **核心算法(服务端权威)**: ```csharp public static class GachaComponentSystem { public static List Draw(this GachaComponent self, int poolId, GachaDrawType type) { var poolCfg = GachaPoolConfigCategory.Instance.Get(poolId); var weights = GachaWeightConfigCategory.Instance.GetByPool(poolId); var times = type == GachaDrawType.Single ? 1 : 10; // 1. 校验消耗 var costItemId = type == GachaDrawType.Single ? poolCfg.SingleCostItem : poolCfg.MultiCostItem; var costCount = type == GachaDrawType.Single ? poolCfg.SingleCostCount : poolCfg.MultiCostCount; if (!bag.HasItem(costItemId, costCount)) return null; // 由调用方判错返回 // 2. 扣消耗 bag.RemoveItem(costItemId, costCount); // 3. 随机抽取 var rewards = new List(); var totalWeight = weights.Sum(w => w.Weight); for (int i = 0; i < times; i++) { int r = RandomGenerator.RandomNumber(0, totalWeight); var pick = SelectByWeight(weights, r); rewards.Add(new RewardEntry { ItemId = pick.ItemId, Count = pick.Count }); // 4. 更新保底 self.UpdateGuarantee(poolId, pick.Rare); } // 5. 十连保底兜底(必出指定品质) if (type == GachaDrawType.Multi10 && !rewards.Any(r => GetItemRare(r.ItemId) >= poolCfg.MultiGuaranteeRare)) ReplaceLowestWithGuaranteed(rewards, weights, poolCfg.MultiGuaranteeRare); // 6. 累计保底兜底(90 抽必出) if (self.GetGuaranteeRemain(poolId) == 0) ReplaceWithGuaranteed(rewards, weights, poolCfg.GuaranteeRare); // 7. 发放奖励 bag.AddItems(rewards); // 8. 日志 GachaAuditLog.Log(self.Player.Id, poolId, rewards, self.GetPoolStats(poolId)); return rewards; } } ``` **保底设计**: - **每卡池独立保底**:玩家在不同卡池的保底计数互不影响 - **双重保底**:十连必出(如必有 1 个 4 星)+ 累计必出(90 抽必有 5 星) - **重置规则**:自然出 ≥ 保底品质 → 计数清零;保底强制出 → 也清零 **防作弊**: - 客户端**完全不参与**随机数生成 - 客户端 payload 中的任何"种子/运气/概率加成"字段都被忽略 - 服务端日志记录每次抽卡的完整信息,便于事后审计/玩家投诉处理 **为什么把抽卡放 P1 第一个**: - 它能**端到端验证**:消耗道具 → 服务端随机 → 发奖 → 入背包 → 用于英雄培养 - 完成抽卡,等于把 hero-system + bag-system + protocol + persistence 都跑通了 - 后续养成系统的"测试数据"也可以直接靠抽卡获得,省去 GM 命令 **风险**: - [伪随机种子可预测] → 用 `RandomNumberGenerator.GetInt32`(CSPRNG)或 Random + 高熵 seed - [抽卡接口被刷] → 服务端加冷却限速 + 异常 IP/UID 自动报警 ### D10: 分阶段交付计划 - **UI 后置,先服务端 + 日志 + GM 验证** **选择**:**业务骨架先行、UI 集中后期补**。P1-P3 不做任何 YIUI 面板,只产出"服务端业务 + 协议 + GM 命令 + 日志"。所有 UI 集中在 P4 一次性补齐。 | 阶段 | 内容 | 验证方式 | 周期 | |---|---|---|---| | **P0** 基础设施 | DBComponent + 协议工具链 + Luban + **日志框架** + **GM 框架** + 跑通登录 | 单元测试 + Demo 命令 | 3-5 天 | | **P1** 抽卡 + 通用 Item + 英雄基础 | **服务端**:通用 Item、抽卡算法、英雄实体、招募/升级/技能升级;**零 UI** | GM 命令 + 业务日志 + 单元测试 | 1-1.5 周 | | **P2** 背包 + 装备穿戴 | **服务端**:背包增减、6 大类、装备穿脱;**零 UI** | GM 命令 + 业务日志 | 1 周 | | **P3** 高级养成 | **服务端**:升星、觉醒、天赋、锻造;**零 UI** | GM 命令 + 业务日志 | 1-2 周 | | **P4** UI 集中实现 | YIUI 把 P1-P3 的所有面板一次性补齐 | 手动 + 自动化 UI 测试 | 2-3 周 | **为什么改成 UI 后置**: 1. **业务逻辑稳定性优先**:先把服务端真权威 + 协议 + DB 持久化跑稳,再做 UI 不会反复返工 2. **UI 不阻塞后端开发**:UI 是消费者,业务接口确定了 UI 才好做,先做 UI 反而互相阻塞 3. **日志 + GM 完全可替代 UI 验证**:所有业务正确性都能用 GM 命令触发 + 日志检查,效率比手动点 UI 快 5-10 倍 4. **集中做 UI 质量更好**:分散在每个阶段做 UI 容易抄近路、视觉风格不统一;集中做能整体规划 5. **避免 UI 改动反复影响后端**:业务字段变了 UI 要改、UI 设计变了协议要改,等业务定型再做 UI 把这类返工降到 0 **风险与缓解**: - **[业务定义错了到 P4 才发现]** → 用 GM 命令 + 日志强校验,每个 RPC 都有正反例测试;具体在 `gm-tools` 与 `observability-logging` capability 中定义 - **[P4 UI 工作量大]** → P0 就把 YIUI 模板 + `CommonHeader`/`BagItemUICommon` 准备好;P1-P3 也产出 UI 字段需求文档,让 P4 不需要返查 - **[策划/产品要看效果]** → 给策划提供 GM Web 控制台(基础版即可,复用 ET 现有 Watcher),可视化展示玩家数据 **P1 第一个落地的还是抽卡**:抽卡仍然是端到端验证最佳工具,但**验证方式从 UI 改为 GM 命令 + jsonl 日志**。 **关键约束**:P1-P3 阶段**绝对不写 YIUI Panel 代码**。任何"我需要个简单界面看看效果"的需求都通过 GM 命令 + 日志解决。 ### D11: 日志策略 - **基于 ET 自带 Log 扩展**(不重写) **前置事实**:`cn.etetet.core` 已有 `Log` 静态类(Debug/Info/Warning/Error/Trace/Console),`cn.etetet.loader` 已集成 NLog 后端,可通过 `Fiber.Instance.Log` 接口替换实现。**我们要做的是扩展,不是新框架**。 **选择**:**三层日志体系(扩展 ET Log)** | 层级 | 用途 | 输出 | 保留 | |---|---|---|---| | **L1 业务日志** | 每个 RPC 入口/出口、参数、玩家ID、结果 | `Logs/Business/{yyyy-MM-dd}.log` | 30 天 | | **L2 审计日志** | 抽卡、付费、GM 操作、关键资源变动 | `Logs/Audit/{module}/{yyyy-MM-dd}.jsonl` | 永久 | | **L3 错误日志** | 异常、错误码返回、慢查询 | `Logs/Error/{yyyy-MM-dd}.log` + 实时告警 | 90 天 | **结构化 JSON 行格式**(jsonl,便于 grep/jq/ELK): ```json {"ts":"2026-05-27T11:30:01.234","level":"INFO","module":"Gacha","playerId":1001,"action":"Draw","poolId":2,"drawType":"Multi10","costItem":2,"costCount":9,"rewards":[{"itemId":10001,"count":1,"rare":4},...],"guaranteeRemain":85,"reqId":"abc123"} ``` **关键约定**: 1. **每个 RPC Handler 入口必埋点**:playerId + actionName + params 摘要 2. **关键操作(消耗/发奖/抽卡/升星)必写 L2 审计**:单独 jsonl 不混在普通日志里,便于运营/客服/数据分析读取 3. **每个 ErrorCode 返回必写 L3 错误日志**:含 playerId / errorCode / 调用栈/ 上下文 4. **客户端日志桥接**:客户端的 `Log.Error` 关键错误可选上报到服务端(默认开启,可配置关闭) 5. **reqId 串联**:客户端发送 RPC 带 reqId,服务端日志全程透传,便于跨服务追查 6. **日志分模块**:`[Hero] [Bag] [Gacha] [Equip] [DB] [Login]` 前缀,便于 `grep -F '[Gacha]'` 7. **慢查询告警**:DB 查询 > 100ms、Handler 执行 > 500ms 自动写 L3 告警 **ET 框架集成(明确边界)**: - **保留**:`Log.Debug/Info/Warning/Error` 全部入口不变,业务代码继续用 - **扩展**:新建 `cn.etetet.logging` 包,加扩展方法 `Log.Audit(module, action, payload)` / `Log.Slow(module, action, durationMs)` / `Log.Module(tag, level, msg)` - **底层**:复用现有 NLog(`Packages/cn.etetet.loader/Scripts/Loader/Server/NLog.config`),加 3 个 target 分别输出到 `Logs/Business/` `Logs/Audit/{module}/` `Logs/Error/` - **Handler 自动埋点**:抽 `RpcMessageHandler` 基类,在 OnRun 前后自动写 L1(入口/出口 + 耗时) - **慢操作**:包装 `DBComponent` / `MessageDispatcher` 的入口方法,超阈值自动 Log.Slow - **reqId 透传**:在所有 C2G/C2R 协议基类加 `reqId` 字段,Handler 基类把 reqId 放到 `AsyncLocal`,供下游 Log 自动取 **风险与缓解**: - [日志写盘 IO 性能] → NLog 异步队列 + 批量刷盘,关键审计日志走同步落盘保证不丢 - [日志爆盘] → L1 按天滚动 + 30 天清理,L3 90 天清理,L2 转 OSS 永久归档 - [敏感字段泄露] → 玩家个人信息(手机号/邮箱)必须脱敏,工具自动扫描 ### D12: GM 验证策略 - **直接复用 yiuigm + console 现成包**(不重写框架) **前置事实**: - `cn.etetet.yiuigm` 已有 `[GM]` 属性 + `IGMCommand` 接口 + 完整 GMPanel UI(含参数 Enum/String/Bool/Float/Int/Long 6 类) - `cn.etetet.console` 已有 `[ConsoleHandler]` 属性 + `IConsoleHandler` 接口 + 服务端 stdin REPL(启动时自动监听控制台输入) - 两套都已纳入热更编译 **结论**:**完全不需要新建 GM 框架**。我们要做的只是用这两个属性写业务 GM 类。 **选择**:**GM 命令是 P1-P3 阶段唯一的验证入口**(在 UI 出来之前),必须覆盖所有写操作 + 提供读取/快照/模拟工具 **GM 双轨(基于现成包)**: 1. **服务端 GM Console**(`cn.etetet.console` 现成) - 注册方式:`[ConsoleHandler("AddItem")] public class C_AddItem : IConsoleHandler { public async ETTask Run(Fiber fiber, ModeContex contex, string content) { ... } }` - 直接登录服务端 shell 执行 - 适合数据校验、批量操作、压测 2. **客户端 GM Panel**(`cn.etetet.yiuigm` 现成) - 注册方式:`[GM(EGMType.Test, 1, "发放道具", "给当前玩家发放指定道具")] public class GM_AddItem : IGMCommand { public List GetParams() { ... } public async ETTask Run(Scene scene, GMParamVo vo) { ... } }` - 仅 Debug 包启用(`#if ENABLE_GM`) - 适合开发期手动测试 **GM 命令分类**: | 类别 | 命令示例 | 用途 | |---|---|---| | **资源发放** | `gm AddItem ` / `AddCurrency ` | 给自己/指定玩家发资源 | | **英雄操作** | `AddHero ` / `SetHeroLevel ` / `SetHeroStar ` | 创建/调整英雄 | | **背包操作** | `ClearBag` / `ShowBag` / `SetItemCount ` | 背包状态调整与查看 | | **抽卡操作** | `Gacha ` / `ResetGachaGuarantee ` / `SetGachaCount ` | 抽卡 + 保底操作 | | **数据查询** | `ShowHeroes` / `ShowBag` / `ShowGachaStats` / `DumpPlayer ` | 输出当前状态 jsonl | | **数据快照** | `Snapshot ` / `Restore ` | 保存/恢复整个玩家状态 | | **批量模拟** | `SimulateGacha ` 服务端循环 N 次抽卡输出概率统计 | 概率审计 | | **数据校验** | `VerifyConsistency ` 校验玩家数据完整性 | 发现脏数据 | | **服务管理** | `ReloadConfig` / `FlushDirty` / `Kick ` | 运维操作 | **GM 操作必写审计日志**(D11 的 L2 层): ```json {"ts":"...","level":"WARN","module":"GM","operator":"admin","action":"AddItem","targetPlayer":1001,"args":{"itemId":1,"count":1000000}} ``` **关键测试场景(用 GM 验证)**: | 场景 | GM 命令组合 | 期望日志 | |---|---|---| | 抽卡正确扣消耗 | `AddCurrency 2 10` → `Gacha 1 1` → `ShowBag` | Bag 钻石 -1,奖励入包 | | 抽卡保底触发 | `SetGachaCount 1 89` → `Gacha 1 1` 10 次 | 第一次抽到必出保底品质 | | 抽卡概率正确 | `SimulateGacha 1 10000` | 输出概率分布,与配置误差 < 1% | | 英雄升级正确 | `AddHero 10001` → `AddItem <经验书ID> 100` → 调升级 | Hero level 提升,经验书减少 | | 数据持久化 | `Snapshot before` → 一系列操作 → `FlushDirty` → 重启 → `DumpPlayer` 对比 | 数据完全一致 | | 通用 Item 不漏 | `ShowBag` 在做了大量操作后金币/钻石/碎片都显示正确 | 没有"零钻石"等漏算 | **风险与缓解**: - [GM 命令被滥用上线包带出] → 编译条件 `#if !RELEASE` 包裹 GM 注册,Release 包不含 GM 命令 - [GM 操作绕过业务校验导致数据不一致] → GM 直接调 `BagComponentSystem.AddItems` 等业务接口,不允许绕过 ### D13: 强制复用原则 - **避免重复造轮子** **背景**:本次工程已有 38 个 ET 包,覆盖 UI / 协议 / 资源热更 / GM / 服务端 console / 网络 / 数学 / 序列化等大部分基础设施。详见 `Doc/ET-Packages-Audit.md`。 **强制原则**(每次新功能落地前必须自检): 1. **第一步查表**:开 `Doc/ET-Packages-Audit.md` 速查表,确认是否有现成 ET 包能用 2. **能扩展不重写**:若现有包能力不全,**优先在现有包加扩展方法或新 Component**,禁止重写 3. **业务功能建新包**:完全新的业务(hero/bag/gacha)建独立 `cn.etetet.xxx` 包,**禁止塞进** `core` / `login` 等基础包 4. **设计文档必须标注复用**:每个 D 决策必须明确"复用了哪些包 + 扩展点 + 新增点" **本次明确的复用清单**: | 能力 | 状态 | 复用方式 | |---|---|---| | 日志输出 | 复用 `cn.etetet.core/Log.cs` | 新建 `cn.etetet.logging` 加 `Log.Audit/Log.Slow/Log.Module` 扩展方法 | | 客户端 GM 面板 | 完全复用 `cn.etetet.yiuigm` | 新增 `[GM]` 业务类即可 | | 服务端 GM Console | 完全复用 `cn.etetet.console` | 新增 `[ConsoleHandler]` 业务类即可 | | BSON 序列化 | 完全复用 `cn.etetet.core/MongoHelper` | 不重写 | | MongoDB Driver | 完全复用 `com.etetet.init/Plugins/MongoDB` | 不重写 | | 协议生成 | 完全复用 `cn.etetet.proto` + `ET/Proto/Proto2CS` 菜单 | 新增 `Proto/*.proto` 文件即可 | | 资源热更 | 完全复用 `cn.etetet.yooassets` + `cn.etetet.hybridclr` | 不动 | | YIUI 框架 | 完全复用 `cn.etetet.yiuiframework` 等 9 个子包 | 业务 Panel 用 YIUI 自动化工具生成 | | 登录链路 | 扩展 `cn.etetet.login` | 给 Player 挂业务 Component,不改 Account 字段 | | ExcelExporter | **不用**(保留供 StartConfig 等基础设施用) | 新业务表全部走 Luban | **本次明确的新增清单**: | 新增包 | 职责 | |---|---| | `cn.etetet.db` | DBComponent 业务封装(基于 MongoHelper) | | `cn.etetet.logging` | Audit/Slow/Module 扩展(基于 ET Log) | | `cn.etetet.config` | Luban Tables + ConfigComponent | | `cn.etetet.hero` | 英雄系统 | | `cn.etetet.bag` | 背包/装备系统 | | `cn.etetet.gacha` | 抽卡系统 | ## Risks / Trade-offs - **[移植工作量大]** → 分 4 阶段交付,每阶段独立验收 - **[ROK 业务规则细节多]** → 在 specs.md 里列清楚 scenario,写测试覆盖 - **[服务端单进程性能瓶颈]** → 玩家少(< 1000 同时在线)时不会到瓶颈,需要时再拆 Scene - **[ET 框架学习曲线]** → 团队需要熟悉 Entity/Component/System 模式,跟 PureMVC 思路差距不小;建议先吃透 Login Demo 再开干 - **[DB 字段变更迁移]** → 在每个 Entity 加 dataVersion 字段 + DBMigrationComponent 自动迁移 - **[ROK 协议是 sproto,转 protobuf 字段细节可能丢失]** → 不直接转换,按业务需求重新设计 proto;只参考字段语义不参考字段顺序 - **[OctoberStudio 与 ET 双重启动复杂]** → 在 Loader 添加"开发模式快速跳过登录"开关 - **[HybridCLR AOT 元数据问题]** → 业务代码全部放热更层 Hotfix,泛型扫描通过 `ET/HybridCLR/Generate/All` 一键解决 - **[Luban 学习曲线]** → P0 阶段做 ItemConfig 一张示例表跑通全链路 + 写文档 - **[UI 后置导致策划/老板看不到效果]** → P0 起步就有 GM Web Console(基于 ET Watcher),日志 + 数据可视化即时反馈 - **[GM 漏覆盖某个业务点]** → 每个新增 RPC Handler MUST 同时新增对应 GM 命令(在 spec 中强制约束) - **[Login Demo 没有密码校验]** → P0 阶段补一个简单的账号密码校验(hash 入库),生产再升级 ## Migration Plan ### P0 之前必须先完成的"基础设施" 1. 执行 `ET/Loader/Compile (F6)` 生成客户端热更 DLL 2. `dotnet build ET.sln -c Debug` 生成 `Bin/ET.App.dll` 3. 跑通 Login Demo(Init.unity → 输入账号 → 登录成功) 4. **新建 `cn.etetet.db` 包**,封装 `IDBComponent.Save/Query/Delete`(基于 MongoHelper + MongoDB.Driver);支持 Memory/MongoDB 双模式 5. **新建 `cn.etetet.logging` 包**,加 `Log.Audit/Log.Slow/Log.Module` 扩展方法 + NLog 三 target 配置 6. **接入 ROK 配置迁移工具链**(参考 `gmd/Tools/export_hero_json_from_configs_data.py`) 7. 跑通"登录 → 拉空 HeroInfoList → 服务端日志验证(暂无 UI)"链路 ### 部署顺序 1. 服务端先升级(DB schema 兼容) 2. 客户端再发版(向后兼容旧服务端) ### Rollback 策略 - 客户端:YooAsset 资源回滚到上一版本 manifest - 服务端:dotnet build 之前打 tag,回滚到上版本 commit + 重启 - 数据:DB schema 通过 dataVersion 向前兼容,理论上无需回滚 DB ## Open Questions 1. **Q: 玩家创建账号流程在哪做?** Login Demo 没账号注册,是允许任意账号自动注册,还是要单独 RegisterPanel? _倾向:P0 允许任意账号自动注册(首次登录创建 Player),生产再加严格注册_ 2. **Q: OctoberStudio 现有金币 (CurrencySave) 和 ET 服务端金币如何统一?** _倾向:保留两份。OctoberStudio 金币用于局内(关卡内不需要联网),服务端金币用于英雄养成消耗。两者通过"局外结算时同步"_ 3. **Q: 离线作战是否支持?** _倾向:P0-P3 阶段都要求联网(联网才能拉数据/校验操作)。后续可加"离线模式"用本地缓存作为兜底_ 4. **Q: 多语言怎么做?** _倾向:P0-P3 阶段只支持中文。预留 L10N 接口,需要时再补_ 5. **Q: HeroInfo 是否需要包含战斗属性快照?** _倾向:HeroInfo 不存战斗属性(攻防血),客户端根据 level + star + skills + talents + equips 实时算。这样字段少、推送数据小_ 6. **Q: 是否需要"友好名"分页(如把所有 6 大类合并成 4 个 Tab)?** _倾向:UI 分页用枚举 BagItemType 直接 6 个 Tab,简单清晰_ 7. **Q: 抽卡保底配置是否做"软保底"(概率随抽数提升)?** _倾向:P1 阶段只做"硬保底"(90 抽必出),软保底(如 70 抽后概率从 0.6% 渐升到 1.5%)放到 P3 优化_ 8. **Q: 抽卡历史是否需要持久化到 DB(供玩家查询)?** _倾向:保留**最近 100 次**抽卡记录到 GachaComponent,超过的自动清理。完整历史只在服务端日志_ 9. **Q: 一次性发奖列表(如十连)传 10 个 Item_ItemInfo 还是合并?** _倾向:服务端在十连 R2C_GachaDraw 响应里把奖励聚合后一次发 `repeated RewardEntry rewards`,**不再单独推 Item_ItemInfo**;客户端从 rewards 列表自己更新本地 BagComponent_ 10. **Q: 金币/钻石变化是否要走 BagComponent?还是单独的 ResourceComponent?** _倾向:**严格按 D4.1 通用 Item 原则**,金币/钻石作为 itemId=1/2 存在 BagComponent 里。否则就破坏了"全游戏 Item 统一"的核心约定_