193 lines
9.2 KiB
Markdown
193 lines
9.2 KiB
Markdown
## 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** 进入该卡池后红点不消失(仍持续提示)
|