## ADDED Requirements ### Requirement: 卡池配置 系统 MUST 提供卡池配置 `GachaPoolConfig`,每个卡池含:id、name、单抽消耗(itemId + count)、十连消耗(itemId + count,通常有折扣)、保底次数(每 N 抽必出某品质)、保底品质等级。所有数值由 Excel 配置,运行时通过 `GachaPoolConfigCategory.Instance.Get(poolId)` 获取。 #### Scenario: 加载卡池配置 - **WHEN** 服务端启动加载 ExcelExporter 生成的配置 - **THEN** `GachaPoolConfigCategory` 包含所有配置的卡池 - **AND** 每个卡池可查询单抽/十连消耗及保底参数 #### Scenario: 卡池关闭 - **WHEN** `GachaPoolConfig.openTime > 现在时间` 或 `closeTime < 现在时间` - **THEN** 服务端拒绝该卡池的抽卡请求,返回 `ERR_GACHA_POOL_CLOSED` ### Requirement: 卡池权重表 系统 MUST 为每个卡池配置权重表 `GachaWeightConfig`,每条记录含:poolId、entryIndex、itemId(奖励道具)、count(数量)、weight(权重)、rare(品质 1-5)。抽卡随机算法 MUST 按权重总和归一化抽取。 #### Scenario: 权重归一化抽取 - **WHEN** 卡池 A 有 3 条记录权重分别为 70 / 25 / 5(总和 100) - **AND** 服务端生成随机数 r ∈ [0, 100) - **THEN** r < 70 → 选第 1 条;70 ≤ r < 95 → 选第 2 条;95 ≤ r < 100 → 选第 3 条 #### Scenario: 权重表为空 - **WHEN** 卡池配置中无任何 weight 记录 - **THEN** 服务端启动时 MUST 报错并拒绝该卡池上线 ### Requirement: 抽卡组件数据模型 服务端 MUST 为每个玩家维护 `GachaComponent`,记录:每个卡池的累计抽数(用于保底统计)、当前距离保底的抽数、最后一次抽卡时间。`GachaComponent` MUST 持久化到 DB。 #### Scenario: GachaComponent 初始化 - **WHEN** 新玩家首次登录 - **THEN** 创建空的 `GachaComponent`,所有 poolStats 为初始值 #### Scenario: GachaComponent 同步 - **WHEN** 玩家登录后 - **THEN** 服务端 MUST 推送 `Gacha_GachaInfo` 含所有 poolStats - **AND** 客户端缓存到 `GachaComponent` ### Requirement: 单抽 玩家 MUST 能对指定卡池发起单抽。服务端 MUST 严格按顺序执行: 1. 校验玩家拥有 `GachaPoolConfig.singleCostItem` 数量 `>= singleCostCount` 2. 扣除消耗 3. 按权重表随机抽取 1 个奖励项 4. 调用 `BagComponentSystem.AddItems(rewards)` 发放奖励 5. 更新 `GachaComponent.poolStats[poolId]`(累计 +1、距保底 -1 或重置) 6. 推送 `R2C_GachaDraw { rewards }` + `Item_ItemInfo*` + `Gacha_GachaInfo` #### Scenario: 单抽成功 - **WHEN** 玩家拥有 1 个钻石(卡池消耗 1 钻石) - **AND** 发送 `C2G_GachaDraw { poolId, drawType: Single }` - **THEN** 服务端扣 1 钻石、抽取 1 个奖励、发放到背包 - **AND** 返回 `R2C_GachaDraw { error: 0, rewards: [(itemId, count, isNewHero)] }` - **AND** 推送 `Item_ItemInfo`(钻石变化)+ 奖励道具 #### Scenario: 消耗不足 - **WHEN** 玩家消耗道具不足 - **THEN** 服务端拒绝并返回 `ERR_GACHA_COST_NOT_ENOUGH` - **AND** 不扣消耗、不抽奖、不更新状态 ### Requirement: 十连抽 玩家 MUST 能对指定卡池发起十连抽。服务端 MUST 一次性独立抽取 10 个奖励(每次独立抽奖,互不影响),并应用十连专属消耗(通常优惠 10%-20%)。 #### Scenario: 十连成功 - **WHEN** 玩家拥有 10 个钻石(十连消耗 9 钻石优惠) - **AND** 发送 `C2G_GachaDraw { poolId, drawType: Multi10 }` - **THEN** 服务端扣 9 钻石 - **AND** 独立抽取 10 个奖励(每次独立随机,可能重复也可能不重复) - **AND** 累计抽数 +10,保底计数 -10(或触发保底) - **AND** 返回 10 个奖励的列表 #### Scenario: 十连必出保底品质 - **WHEN** 卡池配置 `multiGuaranteeRare = 4`(十连必出至少 1 个 4 星) - **AND** 玩家发起十连 - **AND** 自然抽取的 10 个结果中无 rare ≥ 4 - **THEN** 服务端 MUST 把最低品质的 1 个替换为该卡池权重表中任意 rare ≥ 4 的奖励 - **AND** 替换在响应给客户端之前完成 ### Requirement: 保底机制 每个卡池 MUST 支持累计保底:玩家在该卡池累计抽到 `GachaPoolConfig.guaranteeCount` 次(如 90 次)仍未出 `GachaPoolConfig.guaranteeRare`(如 5 星)品质,**第 N 次 MUST 强制出该品质**。一旦出 ≥ 该品质,保底计数器重置。 #### Scenario: 触发保底 - **WHEN** 玩家在卡池 A 已抽 89 次未出 5 星 - **AND** 玩家发起第 90 抽 - **AND** 自然抽取结果 rare < 5 - **THEN** 服务端 MUST 强制把奖励替换为权重表中任意 rare ≥ 5 的项 - **AND** 重置该玩家在卡池 A 的距离保底计数为 0 #### Scenario: 自然出货重置保底 - **WHEN** 玩家在卡池 A 累计抽 30 次 - **AND** 第 31 抽自然抽到 rare = 5 的奖励 - **THEN** 服务端 MUST 重置该玩家在卡池 A 的"距离保底"计数为 0 - **AND** 累计抽数继续累加(用于历史统计) #### Scenario: 不同卡池独立保底 - **WHEN** 玩家在卡池 A 累计 50 次、卡池 B 累计 80 次 - **THEN** 两个卡池的保底进度独立计算 - **AND** 抽卡池 A 不影响卡池 B 的保底进度 ### Requirement: 随机算法 服务端随机算法 MUST 使用密码学安全的随机源或种子可记录的伪随机(如 `System.Random` 配 GUID seed),且 MUST 在服务端**独立完成**,不接受客户端任何随机性输入。 #### Scenario: 服务端单边随机 - **WHEN** 客户端发送 `C2G_GachaDraw { poolId, drawType }` - **THEN** 服务端使用自身随机数生成器抽奖 - **AND** 客户端 payload 中任何 "randSeed" / "luck" 等字段 MUST 被忽略 #### Scenario: 抽卡日志可审计 - **WHEN** 任何一次抽卡完成 - **THEN** 服务端 MUST 记录日志:玩家 Id、卡池 Id、抽取时间、消耗、奖励列表、抽数序号、保底计数 - **AND** 日志格式便于后续 SQL 分析(防欺诈、概率审计) ### Requirement: 抽卡奖励发放 抽卡奖励 MUST 通过统一接口 `BagComponentSystem.AddItems(player, rewards)` 发放。**抽卡系统自身 MUST NOT 直接操作背包**。 #### Scenario: 抽到道具 - **WHEN** 抽卡结果为 itemId=2001(升星石)count=10 - **THEN** 服务端调用 `BagComponentSystem.AddItem(player, 2001, 10)` - **AND** `Item_ItemInfo` 自动推送给客户端 #### Scenario: 抽到英雄碎片 - **WHEN** 抽卡结果为 itemId=10001(某英雄碎片)count=1 - **AND** 该英雄碎片足够招募(满足 HeroConfig.getItemNum) - **THEN** 服务端发放碎片到背包 - **AND** 响应中 `isCanSummon = true` 标记,客户端展示"碎片满,可招募"动效 #### Scenario: 抽到完整英雄(首次) - **WHEN** 抽卡奖励是 itemId 对应"直接发英雄"(非碎片) - **AND** 玩家尚未拥有该英雄 - **THEN** 服务端直接创建 Hero Entity(等价 SummonHero 流程) - **AND** 响应中 `isNewHero = true` 标记 #### Scenario: 抽到完整英雄(已拥有) - **WHEN** 抽卡奖励是"直接发英雄"但玩家已拥有 - **THEN** 服务端 MUST 按 `ItemConfig.exchangeWhenOwned` 配置转化为碎片或其他奖励 - **AND** 响应中标记为"转换奖励" ### Requirement: 客户端抽卡 UI 客户端 MUST 提供 `GachaPanel`,包含: - 卡池切换栏(横向列表,显示所有 open 状态卡池) - 当前卡池信息(名称、关闭时间倒计时、单抽/十连消耗) - 保底进度条(显示"距离保底还需 N 抽") - 单抽 / 十连按钮(点击发送 `C2G_GachaDraw`) - 历史奖励预览(最近 N 抽的简略列表) #### Scenario: 切换卡池 - **WHEN** 玩家点击卡池切换按钮 - **THEN** UI 刷新显示新卡池的所有信息 - **AND** 保底进度条按新卡池数据更新 #### Scenario: 抽卡过程动画 - **WHEN** 玩家点击十连按钮且消耗足够 - **THEN** UI 立刻禁用按钮防重复点击 - **AND** 发送 `C2G_GachaDraw` - **AND** 收到响应后播放抽卡动画(约 2-3 秒) - **AND** 动画完毕弹出 `GachaResultPanel` 展示所有奖励 ### Requirement: 抽卡结果展示 `GachaResultPanel` MUST 一次性展示一次抽卡的所有奖励(单抽 1 个、十连 10 个),按品质升序排列展示(高品质放最后突出显示)。新英雄 MUST 有特殊"亮相"动画。 #### Scenario: 十连结果展示 - **WHEN** 收到十连响应含 10 个奖励 - **THEN** UI 按 rare 升序排列展示(低品在前、高品在后) - **AND** 含新英雄的项额外播放 `NewHeroBanner` 动画 - **AND** 玩家点击"再抽十连"按钮 → 直接发起下一轮(不关闭面板) #### Scenario: 跳过动画 - **WHEN** 玩家点击屏幕任意位置 - **THEN** 直接快进到结果展示完毕状态 - **AND** 跳过动画不影响奖励发放(奖励已在 `R2C_GachaDraw` 响应到达时入背包) ### Requirement: 抽卡红点 系统 MUST 提供以下红点: - 卡池新开放(首次开启或恢复开启时显示红点,进 GachaPanel 后清除) - 免费单抽可用(部分卡池每 N 小时提供 1 次免费) - 接近保底(剩余抽数 ≤ 5 时显示红点提示) #### Scenario: 接近保底红点 - **WHEN** 玩家在卡池 A 累计 85 次未出保底 - **AND** 卡池 A 保底阈值 90 次 - **THEN** `GachaPanel` 上卡池 A 标签显示红点 - **AND** 进入该卡池后红点不消失(仍持续提示)