193 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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** 进入该卡池后红点不消失仍持续提示