Files
henan-backend/docs/玫瑰女郎接口文档.md
T
2026-09-21 19:21:35 +08:00

708 lines
40 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.
# 玫瑰女郎 用户端 接口文档
---
## 一、功能说明
玫瑰女郎是一款**选箱下注 → 旋转 → 必得饰品**的电子游戏。核心产品逻辑:
| 规则 | 说明 |
|------|------|
| 三种玩法模式 | `normal` 普通旋转 / `ante` 底注 / `buy_fs` 购买免费旋转,互斥 |
| 下注即得 | **开局即结算**:玩家扣费后,本轮结果与奖品在开局那一刻就已确定,饰品**直接写入背包**;前端播放动画仅是"揭晓" |
| 无损模型 | 中途刷新 / 退出 / 断线,饰品已在背包,不会丢奖 |
| 按身份差异化 | `common` 普通用户 / `anchor` 主播 / `robot` 机器人 / `white` 白号,各自独立的一套饰品桶分布与 RTP |
| 1000x 封顶 | 总倍率 ≥ 1000x 视为已封顶,`maxWinReached=true`;**单局累计到账达 1000 倍即截断终止**,保证动画总赢额 = 实际到账 |
| 自动分解 | 命中饰品默认写入背包;末帧由**前端**给玩家两个选择「放入背包 / 立即分解」。"自动分解"是前端自己的开关,开启后前端在末帧替玩家调一次分解接口即可,**后端不参与、也不回传该状态**;调不动(断网/超时)也只是饰品留在背包,玩家随时能自己分解,不会丢奖、不会重复入账 |
| 消除规则 | 棋盘 5 行 × 6 列共 30 格;**同一符号满 8 个即消除**,按 8-9 / 10-11 / 12-30 三档赔付 |
| 翻滚级联 | 消除 → 上方掉落 → 补位,可在同一旋内连锁多步,每步返回 `gridState`/`symbolLinks`(累积)/`dropData`/`fillData` |
| 炸弹(乘数球) | **只在免费旋转内出现**(normal/ante 的基础旋、含进 FS 的触发盘一律没有炸弹);每屏最多 1 颗,乘数值 2~1000(见 `multiplierDetail`),FS 内跨旋累加 |
| 免费旋转 | 基础盘 4+ SCATTER 触发,赠 15 旋(4/5/6 个 SCATTER 赔 3x/5x/100x);FS 内 3+ SCATTER 重触发 +5 旋,总上限 30 旋(到上限后不再落 3+ 个 SCATTER);**三种模式进 FS 后规则完全一致** |
| 1000x 截断 | 单局累计到账达 1000 倍即截断终止(保证动画总赢额 = 实际到账) |
> 前端只需保证:**首次用 `boxId` 开局,之后不断调用 `继续` 逐步揭晓,直到 `state === "FINISH"`。**
---
## 二、通用约定
- **Base URL**:`{host}/api/olympus`
- **请求方式**:全部为 `POST`
- **Content-Type**:`application/json`
- **鉴权**:需登录态,请求头携带 token(`Authorization: Bearer xxx`)。`userId` 由后端从 token 解析,**客户端不要传 userId**。
### 统一响应结构
```json
{
"code": 200,
"msg": "操作成功",
"data": {}
}
```
| code | 含义 |
|------|------|
| 200 | 成功 |
| 500 | 业务失败,提示语见 `msg`(直接向玩家展示) |
| 701 | 未实名认证,客户端统一弹实名认证窗口(**玫瑰女郎接口不会返回该码**,统一约定保留,便于前端全局处理) |
| 702 | 账户余额不足(`msg`:`账户余额不足`) |
| 703 | 玩家未充值,不能游玩任何游戏(`msg`:`玩家未充值不能游玩任何游戏`) |
> 客户端判定成功以 `code === 200` 为准;失败时直接展示 `msg`。
### 玩法模式(boxType)
| boxType | modeName | 成本倍数 costMultiplier | 备注 |
|---------|----------|------------------------|------|
| `normal` | 普通旋转 | 1x | 标准玩法,饰品桶 0x-150x;基础旋不出炸弹,4+ SCATTER 自然进 FS(进 FS 后与买 FS 一致) |
| `ante` | 底注 | 2x | SCATTER 概率提升(FS 触发 0.5%→1%),饰品桶 0x-200x;基础旋同样不出炸弹 |
| `buy_fs` | 购买免费旋转 | 50x | 保证直接进入免费旋转,饰品桶 10x-1000x |
> 其中 `price`(单次下注实际扣费)= 箱子基础下注 `basicBet` × 该模式成本倍数。
---
## 三、符号与面值对照表
`gridState`、`symbolLinks`、`dropData`、`fillData` 中的符号一律是**短码**(单字母),前端按本节映射图标与文案。
### 3.1 普通符号(可消除)
| 短码 | 符号 code | 中文名 | 8-9 个 | 10-11 个 | 12-30 个 | 出现权重 |
|------|-----------|--------|--------|----------|----------|----------|
| `A` | CROWN | 皇冠 | 10x | 25x | 50x | 5 |
| `B` | HOURGLASS | 沙漏 | 2.5x | 10x | 25x | 6 |
| `C` | RING | 环 | 2x | 5x | 15x | 7 |
| `D` | CHALICE | 杯赛 | 1.5x | 2x | 12x | 8 |
| `E` | RED_GEM | 红宝石 | 1x | 1.5x | 10x | 9 |
| `F` | PURPLE_GEM | 紫宝石 | 0.8x | 1.2x | 8x | 10 |
| `G` | YELLOW_GEM | 黄宝石 | 0.5x | 1x | 5x | 10 |
| `H` | GREEN_GEM | 绿宝石 | 0.4x | 0.9x | 4x | 10 |
| `I` | BLUE_GEM | 蓝宝石 | 0.25x | 0.75x | 2x | 12 |
> 赔付以 **1 倍基础下注**为基准(接口里返回的金额字段均已再 ×bet);同一符号不足 8 个不成奖,最多计 30 个。
### 3.2 特殊符号
| 短码 | 符号 | 说明 |
|------|------|------|
| `S` | SCATTER | 不参与消除、不计入 `symbolLinks` 的消除统计;基础盘 4+ 触发免费旋转(4/5/6 个赔 3x/5x/100x,即 `scatterTrigger.scatterWin`),免费旋转内 3+ 重触发 +5 旋(总上限 30 旋,到上限后不再落 3+ 个 S) |
| `M` | BOMB | 炸弹(乘数球),**乘数值不在短码里**,见 `multiplierDetail`;不参与消除、跨 cascade 保留,免费旋转内跨旋累加(`accumulator`)。**基础旋(`spinNum=0`,含触发盘)不会出现 `M`**;进 FS 后三种模式一致,每屏最多 1 颗 |
炸弹品质码(`multiplierDetail[].quality`):
| quality | 颜色 | 等级 | 乘数值 |
|---------|------|------|--------|
| `GREEN` | 绿 | 1 | 2 / 3 / 4 / 5 / 6 |
| `BLUE` | 蓝 | 2 | 8 / 10 / 12 / 15 |
| `PURPLE` | 紫 | 3 | 20 / 25 / 50 |
| `RED` | 红 | 4 | 100 / 250 |
| `RAINBOW` | 彩虹 | 5 | 500 / 1000 |
### 3.3 位置索引约定
`multiplierDetail[].pos`、`symbolLinks[].loc[]`、`dropData[].from`/`.to`、`fillData[].pos` 全部是**从 1 开始的格位号**,共 30 格:
```
pos = 行序号(0 起) × 6 + 列序号(0 起) + 1 // 1 ~ 30,行=5、列=6
```
---
## 四、接口清单
| # | 接口 | 路径 | 用途 |
|---|------|------|------|
| 1 | 获取箱子列表 | `POST /api/olympus/boxes` | 进入游戏页拉取可选箱子 |
| 2 | 执行旋转(开始/继续) | `POST /api/olympus/dospin` | 开局扣费并返回第一步 / 继续揭晓下一步 |
| 3 | 查询游戏记录(我的/全服,分页) | `POST /api/olympus/records` | `scope=1` 我的战绩 / `scope=0` 全服记录,均分页;**只含已结束的局** |
| 4 | 查询进行中的游戏 | `POST /api/olympus/hasActive` | 断线重连时恢复动画 |
| 5 | 背包分解(关联) | `POST /api/userPackSack/decompose` | 末帧「立即分解」(含前端自动分解) |
---
## 五、接口详情
### 1. 获取箱子列表
**`POST /api/olympus/boxes`**
进入游戏页时调用,返回当前可选的全部箱子(后端只返回启用 `status=1` 的箱子)。
#### 请求参数
无,body 可不传(或传空对象 `{}`)。
#### 响应 data 结构
```json
{
"list": [
{
"boxId": 1001,
"boxName": "新手箱",
"basicBet": 10.00,
"price": 10.00,
"boxType": "normal",
"modeName": "普通旋转"
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| boxId | Long | 箱子 ID,开局时作为 `boxId` 传入 dospin |
| boxName | String | 箱子名称 |
| basicBet | Decimal | 基础下注金额 |
| price | Decimal | 单次实际扣费 = basicBet × 模式成本倍数 |
| boxType | String | `normal`/`ante`/`buy_fs` |
| modeName | String | 模式中文名(普通旋转/底注/购买免费旋转) |
---
### 2. 执行旋转(开始 / 继续)
**`POST /api/olympus/dospin`**
这是**核心接口**,有两种用法:
- **开始新游戏**:body 携带非空 `boxId` → 后端校验余额/充值/身份 → 扣费 → 选回合 → 返回**第一步**动画数据。
- **继续游戏**:body 不带 `boxId`(传 `{}` 或 `{"boxId": null}`)→ 返回**下一步**动画数据,直到 `state === "FINISH"`。
> ⚠️ 继续时必须传一个 JSON body(至少 `{}`)。若完全不传 body,后端可能因读取不到 boxId 而报错。
#### 请求参数
```json
{
"boxId": 1001
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| boxId | Long | 否 | **非空**=开始新游戏;**为空/null/不传**=继续当前游戏 |
#### 响应 data 结构(SpinResponse)
完整响应体(`@JsonInclude(NON_NULL)`,未出现的字段即为 `null`/未返回)。下面是**真实数据**的一步(box1 普通旋转 · 自然触发的免费旋转**第 6 旋 · 第 2 波**消除,屏上还留着一颗 2 倍炸弹;回合 `round 66551`,整局实付 125.45 倍底注、30 旋、42 步):
```json
{
"state": "PLAYING",
"spinNum": 6,
"tumbleNum": 2,
"globalStep": 10,
"globalTotalSteps": 42,
"gridState": [
["C","G","F","I","A","F"],
["F","M","C","D","F","F"],
["F","H","E","I","F","D"],
["I","C","I","E","F","H"],
["H","H","H","I","A","E"]
],
"multiplierDetail": [{"pos": 8, "val": 2, "quality": "GREEN"}],
"symbolLinks": [
{"symbol": "G", "count": 8, "loc": [3,5,10,13,18,25,26,30], "base_score": 0.50, "tumble": 1},
{"symbol": "F", "count": 8, "loc": [3,6,7,11,12,13,17,23], "base_score": 0.80, "tumble": 2}
],
"dropData": [
{"from": 19, "to": 25, "symbol": "H"},
{"from": 7, "to": 19, "symbol": "I"},
{"from": 1, "to": 13, "symbol": "F"},
{"from": 20, "to": 26, "symbol": "H"},
{"from": 14, "to": 20, "symbol": "C"},
{"from": 8, "to": 14, "symbol": "H"},
{"from": 2, "to": 8, "symbol": "M"},
{"from": 4, "to": 10, "symbol": "D"},
{"from": 24, "to": 30, "symbol": "E"},
{"from": 12, "to": 24, "symbol": "H"},
{"from": 6, "to": 18, "symbol": "D"}
],
"fillData": [
{"pos": 1, "symbol": "C"}, {"pos": 2, "symbol": "G"}, {"pos": 3, "symbol": "F"},
{"pos": 4, "symbol": "I"}, {"pos": 5, "symbol": "A"}, {"pos": 6, "symbol": "F"},
{"pos": 7, "symbol": "F"}, {"pos": 12, "symbol": "F"}
],
"winAmount": 1.30,
"spinWin": 14.55
}
```
读法(这一步):
- 触发盘(`spinNum: 0`)**不会出现 `M`**:这一局基础旋摇出 4 个 S,白送 3.00 并进 FS(见下面买 FS 示例①)
- `symbolLinks` 有 **2 条** —— `tumble:1` 是第 1 波(G 符号 8 连)、`tumble:2` 是第 2 波(F 符号 8 连);**本步新增的只有 `tumble === tumbleNum(2)` 那条**,另一条是给面板累加用的
- `winAmount = 1.30` 是**本旋(第 6 旋)累计到本步的赢额**(= 0.50 + 0.80,中间波只记基础分),**不是本步增量**;**本旋末帧才把累计倍数乘上去** —— 这一旋末帧 `winAmount` 会跳到 11.70(本旋基础分 1.30 × (累计 7 + 本旋炸弹 2))
- `spinWin = 14.55` 是**本局累计到本步的赢额**(触发盘 3.00 + 前 5 旋 10.25 + 本旋 1.30)
- `dropData` 里 `{"from":2,"to":8,"symbol":"M"}`:**炸弹也随重力下落**(第 2 格 → 第 8 格),本旋末帧统一结算后才并入累计器(7 → 9)
- `globalStep/globalTotalSteps` 由服务端按整局排帧给出
> 已删除的冗余字段:`accumulatedSymbols`、`spinWinners`、`removedPositions`、`baseScore` —— 替代算法见字段表下方的说明。
---
**下面用同一局(买FS bet=1、15 旋、整局实付 66.00 倍底注、共 27 步,回合 `round 93485`)看三个典型阶段**(真实数据):
##### ① 起始:本局第 1 步(触发盘,4 个 S 白送 3 倍)
```json
{
"state": "PLAYING",
"spinNum": 0,
"tumbleNum": 1,
"globalStep": 1,
"globalTotalSteps": 27,
"gridState": [
["B","G","S","C","G","I"],
["I","E","H","E","G","S"],
["D","B","G","I","S","C"],
["G","H","E","B","F","S"],
["B","C","E","A","C","G"]
],
"multiplierDetail": [],
"symbolLinks": [],
"dropData": [],
"fillData": [],
"scatterTrigger": {"scatterCount": 4, "freeSpins": 15, "scatterWin": 3.00},
"winAmount": 3.00,
"spinWin": 3.00
}
```
- `spinNum: 0` = 触发盘(买FS 必出 4 个 S,这一盘没有任何符号连线和炸弹)
- `winAmount = spinWin = 3.00`:触发盘这一旋的总额,也是本局累计的起点
- `state` 仍是 `PLAYING` —— **本局还有 26 步**;`scatterTrigger` 出现在**本旋的末帧**,不是本局的末帧
##### ② 中间:第 4 旋 · 第 2 波(这一旋连消多波,屏上还留着一颗 2 倍炸弹)
```json
{
"state": "PLAYING",
"spinNum": 4,
"tumbleNum": 2,
"globalStep": 10,
"globalTotalSteps": 27,
"gridState": [
["G","A","G","G","G","G"],
["I","E","G","I","C","G"],
["D","G","D","G","I","G"],
["I","E","I","C","M","E"],
["D","I","E","C","B","E"]
],
"multiplierDetail": [{"pos": 23, "val": 2, "quality": "GREEN"}],
"symbolLinks": [
{"symbol": "G", "count": 14, "loc": [2,6,8,9,12,13,14,15,16,17,18,22,27,28], "base_score": 5.00, "tumble": 1},
{"symbol": "G", "count": 10, "loc": [1,3,4,5,6,9,12,14,16,18], "base_score": 1.00, "tumble": 2}
],
"dropData": [
{"from": 7, "to": 13, "symbol": "D"},
{"from": 1, "to": 7, "symbol": "I"},
{"from": 21, "to": 27, "symbol": "E"},
{"from": 3, "to": 21, "symbol": "I"},
{"from": 10, "to": 28, "symbol": "C"},
{"from": 4, "to": 22, "symbol": "C"},
{"from": 11, "to": 17, "symbol": "I"},
{"from": 5, "to": 11, "symbol": "C"}
],
"fillData": [
{"pos": 1, "symbol": "G"}, {"pos": 2, "symbol": "A"}, {"pos": 3, "symbol": "G"},
{"pos": 4, "symbol": "G"}, {"pos": 5, "symbol": "G"}, {"pos": 6, "symbol": "G"},
{"pos": 8, "symbol": "E"}, {"pos": 9, "symbol": "G"}, {"pos": 10, "symbol": "I"},
{"pos": 12, "symbol": "G"}, {"pos": 14, "symbol": "G"}, {"pos": 15, "symbol": "D"},
{"pos": 16, "symbol": "G"}, {"pos": 18, "symbol": "G"}
],
"winAmount": 6.00,
"spinWin": 19.25
}
```
- 本步新增的连线只有 `tumble === 2` 那条(G 10 连);`tumble: 1` 那条是这一旋第 1 波的(G 14 连,5.00),继续列在这里供"获胜者面板"累加
- `dropData` 里那条 `{"from":...,"to":...,"symbol":"M"}` 会体现炸弹随重力下落,末帧才结算倍数(本例炸弹在 `gridState` 第 23 格)
- `winAmount = 6.00` 是**第 4 旋累计**(5.00 + 1.00),`spinWin = 19.25` 是本局累计;这一旋末帧会跳到 `12.00`(基础分 6.00 × (累计 0 + 本旋炸弹 2))
##### ③ 最后一旋:本局第 27 步(`state = FINISH`,可结算展示)
```json
{
"state": "FINISH",
"spinNum": 15,
"tumbleNum": 1,
"globalStep": 27,
"globalTotalSteps": 27,
"gridState": [
["M","C","D","E","D","A"],
["F","C","D","C","C","G"],
["E","C","I","E","F","B"],
["F","F","G","H","C","I"],
["H","F","A","C","I","A"]
],
"multiplierDetail": [{"pos": 1, "val": 2, "quality": "GREEN"}],
"symbolLinks": [],
"dropData": [],
"fillData": [],
"winAmount": 0.00,
"spinWin": 66.00,
"totalWin": 66.00,
"totalMul": 66.00,
"prizeInfo": {
"id": 4,
"name": "M4A4 | 皇家圣骑士",
"imageUrl": "/profile/upload/2026/07/02/TTRBNCB8IFJveWFsIFBhbGFkaW4gKEZhY3RvcnkgTmV3KQ==_20260702034203A004.png",
"grade": 4,
"exteriorName": "破损不堪",
"isFallback": false
},
"boxRecordId": 556677
}
```
- 第 15 旋**没有消成**:`symbolLinks` 空、`winAmount = 0`(屏上那颗 2 倍炸弹随本旋作废,累计器不变)
- `spinWin = 66.00` = **整局实付**,与 `totalWin`/`totalMul` 一致(各旋末帧 `winAmount` 相加也等于它)
- `prizeInfo` = 本局命中的饰品(按整局赢额落区间查表,见 3.4);`boxRecordId` = 服务端开局时就写入背包的记录 ID
- 1000 倍封顶时才会多两个字段:`maxWinReached: true` + `spinsRemaining: 0`;本局没触顶,所以都不返回(`spinsRemaining` 平时**每帧都在**,封顶时被强制成 0)
- 本局第 12 旋单旋赢了 22.75 倍(≥ 买 FS 的"小"阈值 20),那一旋的末帧会带 `bigWinLevel: "small"`;最后这一旋没消成(`winAmount = 0`),所以本步没有该字段 —— 该字段按**单旋**赢额判档、只在**免费旋转**里给,达标的那一旋末帧才出现
##### 字段说明
| 字段 | 类型 | 出现 | 说明 |
|------|------|------|------|
| state | String | 每步 | `PLAYING`(本局还有后续步骤)/ `FINISH`(**本局最后一步**,可结算展示)。注意:**本旋的末帧不一定是 FINISH**(只有整局最后一步才是),本旋是否结束看下一步 `spinNum` 是否变化 |
| spinNum | Integer | 每步 | 免费旋转序号,**0 = 基础旋转** |
| tumbleNum | Integer | 每步 | 本旋内的翻滚/消除序号 |
| globalStep | Integer | 每步 | 全局第几步(从 1 开始) |
| globalTotalSteps | Integer | 每步 | 本局总步数 |
| spinsRemaining | Integer | 每步 | **剩余免费旋转次数**(已发放 − 已播完旋数):已发放 = 触发帧 `scatterTrigger.freeSpins`(15) + 已播步里的 `retriggerInfo.extraSpins`(5),封顶 30。**前端直接显示这个值倒计即可,不用自己加减**(口径见下方「剩余免费旋转」)。例:第 2 旋消完出 3 个 S → 15−2+5 = **18**。基础旋/无 FS 的局恒 0;1000x 封顶时强制 0 |
| gridState | Array\[5\]\[6\] | 每步 | 棋盘短码矩阵(5 行 × 6 列,含 `S`/`M`);**每一步都是完整的 30 格,不会有空格/null**,是渲染的权威数据(约定见本节末尾) |
| multiplierDetail | Array | 每步 | 炸弹明细 `[{"pos":1起,"val":乘数值,"quality":"GREEN/…"}]`,**未 × bet**(quality 见 3.2) |
| symbolLinks | Array | 每步 | **本旋累积**的成奖连线 `[{"symbol","count","loc":[1起格位],"base_score","tumble"}]`;`base_score` 已 × bet,`tumble` = 这条连线属于本旋内第几波消除(= 本步 `tumbleNum`)。**本波新增的连线 = `tumble` 等于本步 `tumbleNum` 的那些**;获胜者面板按 `symbol` 汇总即可(原来单独的 `spinWinners` 字段已删) |
| dropData | Array | 每步 | 掉落数据 `[{"from":1起,"to":1起,"symbol"}]`:只列**位置变了**的符号,`symbol` 是它落到目的地后的符号;新旋第一波为空数组 |
| fillData | Array | 每步 | 补位数据 `[{"pos":1起,"symbol"}]`,**只含普通符号**(不含炸弹/SCATTER);正好盖住下落之后空出来的格子,新旋第一波为空数组 |
| scatterTrigger | Object | 每步 | 基础盘触发免费旋转时返回 `{"scatterCount":4,"freeSpins":15,"scatterWin":3.00}`:`freeSpins` 恒 15 = **赠送量**(不是剩余,剩余看 `spinsRemaining`);`scatterWin` = 本盘 SCATTER 的**现金赔付**(已 × bet,4/5/6 个 S 分别赔 3x/5x/100x,已计入本旋 `winAmount`/`spinWin`),无则 null |
| winAmount | Decimal | 每步 | **本旋累计到本步的赢额**(已 × bet):该旋**末帧(`isFinal=1`)的值就是这一旋的总额**(含炸弹倍数与 SCATTER),中间步是这一旋的累计进度;**不是本步增量**(本步增量 = 与上一步相减) |
| spinWin | Decimal | 每步 | **本局累计到本步的赢额**(已 × bet):前端"总赢额跳字"直接用这个;**最后一步 = 整局实付 = `totalWin`** |
| accumulator | Decimal | 每步 | **本步后的炸弹累计倍数**(未 × bet):免费旋转内跨旋传递的累计器(基础旋恒为 0);**断线重连时用 `lastStep.accumulator` 接回累计值**,其余时候前端可按 `multiplierDetail` 自行累计 |
| retriggerInfo | Object | 每步 | 免费旋转内重触发 `{"scatterCount":3,"extraSpins":5,"scatterWin":0.00}`:`extraSpins` 恒 5 = **追加旋数**(30 旋封顶后仍会报 5 但实际不加,所以别拿它自己加减,直接用 `spinsRemaining`);`scatterWin` = 本旋 SCATTER 的**现金赔付**(已 × bet,4/5/6 个 S 分别赔 3x/5x/100x,**3 个 S 赔 0**,已计入本旋 `winAmount`/`spinWin`),无则 null |
| bigWinLevel | String | 每步 | 爆奖档位(小/中/大):`small` / `middle` / `big`,无则 null。**只有免费旋转的旋末帧才可能有值**(normal/ante 的自然 FS、buy_fs 的每一旋),基础旋(`spinNum=0`)恒为 null;判档用该旋末帧的 `winAmount`,阈值见本节末尾「爆奖档位 `bigWinLevel` 阈值」 |
| **totalWin** | Decimal | 仅 FINISH | 本局总赢额 |
| **totalMul** | Decimal | 仅 FINISH | 本局总倍率 |
| **maxWinReached** | Boolean | 仅 FINISH | 是否已达 1000x 封顶 |
| **prizeInfo** | Object | 仅 FINISH | 命中饰品详情(见下) |
| **boxRecordId** | Long | 仅 FINISH | 背包记录 ID,用于末帧「放入背包」或「立即分解」(分解时作为 `packSackIds` 传入) |
`prizeInfo` 结构:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Long | 饰品(奖品)ID |
| name | String | 饰品名称 |
| imageUrl | String | 饰品图片(后台「奖品管理」的"饰品图片") |
| levelImageUrl | String | **饰品等级/品质图片**(后台「奖品管理」的"品质图片",取 `tt_ornaments_level.level_img`,按饰品 `grade` 关联);前端结果弹窗的品质图标用它(可空) |
| grade | Integer | 等级(= `tt_ornaments_level.id`) |
| exteriorName | String | 外观名(可空) |
| isFallback | Boolean | 是否命中兜底饰品(无精确区间时的 fallback) |
> 说明:`accumulator`(本步后炸弹累计倍数,未 × bet)为**跨旋传递的累计器**,基础旋恒为 `0`。
> 前端按 `multiplierDetail` 自行累计即可;**唯一必须依赖它的是断线重连**——`hasActive` 只回最后一步,
> 前几旋攒下的累计倍数不在 `lastStep.multiplierDetail` 里,必须用 `lastStep.accumulator` 把累计值接回来,
> 否则重连后显示的累计倍数会从这次重连的那一旋重新起算(少掉之前累计的倍数)。
>
> **剩余免费旋转 `spinsRemaining`(每帧都返回,前端直接显示,不用自己算)**:
> 进 FS 后从 **15** 起倒计,**每播完一旋 −1**(同一旋的中间波帧不变,到该旋**末帧**才减);
> **那一旋出 3+ 个 S 时在同一帧 +5**(`retriggerInfo.extraSpins`;出 3/4/5/6 个 S 都是 +5,多出来的只是现金 `scatterWin` 3x/5x/100x,3 个 S 为 0)。
> 所以"出 3 个 S"那一帧是 **−1 和 +5 一起结算 = 净 +4**:
>
> | 时刻 | 已发放 | 已播完 | `spinsRemaining` |
> |---|---|---|---|
> | 触发帧播完(进 FS) | 15 | 0 | **15** |
> | 第 1 旋播完 | 15 | 1 | **14** |
> | 第 2 旋·第 1/2 波(本旋还没播完) | 15 | 1 | 14(不变) |
> | **第 2 旋末帧,这旋出 3 个 S** | 15+5 = 20 | 2 | **18**(= 14−1+5) |
> | 第 3 旋播完 | 20 | 3 | **17** |
> | 第 3 旋又出 4 个 S | 20+5 = 25 | 3 | **22** |
>
> 旋数上限 **30**(15 起、每次重触发 +5、最多加 3 次)—— 生成端已加闸门:**到 30 旋后不再落 3+ 个 S**,
> 所以一局最多 3 次重触发(旧池子里还残留 650 个"报了 +5 但没有旋可加"的旧帧,重跑池子后会消失;
> 后端的 `spinsRemaining` 按旋数夹 30,碰上旧帧也不会算错)。
> 整局 1000x 封顶时强制 `spinsRemaining = 0`;无 FS 的局(基础旋空转)恒 0。
>
> **金额与面板字段怎么用**(都在 `steps[]` 的每一步上,金额已 × bet):
>
> | 想看什么 | 用哪个 |
> |---|---|
> | 这一步加了多少(本步增量) | `winAmount` 与上一步相减(第一步直接取 `winAmount`) |
> | **这一旋一共赢了多少** | 该旋**末帧**的 `winAmount`(不用自己求和) |
> | **本局一共赢了/正在累计多少** | `spinWin`(滚动跳字);最后一步的值 = `totalWin` |
> | 本步新消出来的连线(画连线动画) | `symbolLinks` 里 `tumble` == 本步 `tumbleNum` 的那些 |
> | 获胜者面板(本旋逐波的 符号×数量×赢额) | `symbolLinks` 按 `symbol` 汇总(`count` 累加、`base_score` 累加)即可,**已无单独的 `spinWinners` 字段** |
> | 爆奖动画(small/middle/big 小/中/大) | `bigWinLevel`(后端按**单旋赢额**判好档,直接透传,无则 null;只在免费旋转里给) |
> | 炸弹与倍数累计显示 | `multiplierDetail`(每屏炸弹 `pos/val/quality`),倍数按旋自行累加;断线重连后用 `lastStep.accumulator` 接回累计值 |
>
> 一致性:各旋末帧 `winAmount` 相加 = 整局实付 = 最后一步 `spinWin` = `totalWin`。
> 已删除的字段(都是可从别的字段推导的冗余项):`baseScore`(= 本步 `symbolLinks[].base_score` 之和)、
> `removedPositions`(= 上一步 `symbolLinks[].loc` 的并集)、`accumulatedSymbols`(= 本旋 `symbolLinks` 按 symbol 汇总)、
> `spinWinners`(= 本旋 `symbolLinks` 按 symbol 汇总)。
> **盘面与动画数据的约定**(每一步都返回完整盘面,前端不用自己推算):
>
> 1. `gridState` 永远是**权威盘面**:5 行 × 6 列 = 30 格,格子里只有 `A`~`I` / `S` / `M`,**不会出现空格或 null**(全库 355,902 步已逐一校验:空白格 0、非法符号 0、复刻结果与实际盘面 100% 一致)。
> 2. 同一旋内,第 N+1 波可以**精确**复刻:`上一波盘面` −(上一波 `symbolLinks` 里 `tumble == 上一波 tumbleNum` 的格子)→ 按 `dropData` 下落 → 按 `fillData` 补位 = `gridState`(`dropData` 与重力、`fillData` 覆盖的格子也逐条校验过)。
> 3. `dropData[].symbol` / `fillData[].symbol` 一律是**这一波结束后**该位置的符号(与 `gridState` 同位置一致),所以按第 2 条落地后不会出现错符号。
> 4. `dropData` 只列**位置发生变化**的符号(原地不动的格子不在里面,前端保留原样即可);`fillData` 正好覆盖"下落之后空出来的格子",不会漏格。
> 5. **新旋的第一波**(`tumbleNum = 1`,`spinNum` 递增或进入本局第 1 步)`dropData` / `fillData` 都是空数组 —— 这一波是**整盘重发**,前端直接整盘替换/重新发牌,不要按下落补位处理。
> 6. 免费旋转的"保底消除"让 8 个**刚落下/刚补位**的格子凑成一组消除:这些格子的新符号就在 `dropData` / `fillData` 里(见第 3 条),所以整条动画链能完整复刻。前端每一步结束都以 `gridState` 为准渲染,永远不会出现空格。
> **免费旋转的累计倍数**(生成端规则,前端显示不受影响):
> 免费旋转内,**每一旋都可能带炸弹**(每屏最多 1 颗;低倍档局只出 2~8 的小球、高倍档局可出到 1000),
> 有消除的旋会把本旋炸弹并入累计器,**本旋赢额 = 基础分 × max(1, 累计值)**,之后累计值 += 本旋炸弹和
> —— 也就是"滚雪球":上一旋攒的倍数会作用到后面每一次消除。
> 因为累计倍数会放大后续每一次消除,**低倍档局(10x~25x,占买FS 抽中率 76%)大约只能演 5 次左右的
> "消除+爆"**(单个球越小、能买的次数越多);要 10 次以上的连爆,那一局本身就得是 40 倍以上的档位。
> 表现上:15 旋里约 5 旋有消除、约 4 次炸弹并入、局末累计倍数常见 15~25(大奖局 100+)。
>
> **normal/ante 的自然免费旋转**:基础旋(`spinNum=0`)不出炸弹,摇出 4~6 个 SCATTER 才进 FS,
> 进 FS 后走的是**和买 FS 完全相同**的规则(同样的炸弹密度、同样的累计器、同样的每旋保底消除),
> 这样一局实测约 80~120 倍底注、5 旋左右有消除。因为 15 旋带炸弹的整局必然是这个量级,
> **这两个模式的 100x / 150x / 200x 饰品桶就是靠"自然触发 FS"的回合来装的** ——
> 前端不需要区分,`spinNum` 从 0 变成 1 就是进了 FS。
**爆奖档位 `bigWinLevel` 阈值**(小/中/大三档,按**单旋**赢额 ÷ 基础下注判档,达标即在该旋末帧返回、同档一局可多次;**只判免费旋转**,基础旋恒为 null):
| 情况 | 模式 | `small`(小) | `middle`(中) | `big`(大) |
|------|------|------|------|------|
| ① 自然免费旋转 | `normal` / `ante` | ≥5 | ≥25 | ≥100 |
| ② 购买免费旋转 | `buy_fs` | ≥20 | ≥60 | ≥200 |
- 判档取值:该旋末帧的 `winAmount`(单位 = 基础下注倍数,与前端看到的一致)。
- 前端逻辑:**只在 `bigWinLevel` 非空时播对应动画**,播完继续下一步即可;一个 FS 局里三档可以按旋依次出现。
- 为什么两套阈值:normal/ante 的自然 FS 整局约 80~130 倍(预算/旋数完全一致,共用一套);`buy_fs` 花 50 倍买来的赢额分散在 15+ 旋里,单旋阈值整体下调。
- 实测出现频率(当前池子 30 箱逐局统计):① 自然 FS(normal 519 局 / ante 818 局)—— "小" 91.7% / 93.5% 的局会出现(每局 2.3~2.5 次)、"中" 49.3% / 48.8%(0.6 次)、"大" 31.4% / 39.0%(0.3~0.4 次);② 买 FS(12,205 局)—— "小" 19.5% 的局出现(0.47 次)、"中" 6.3%(0.13 次)、"大" **1.5%**(0.02 次)。
##### 错误码 / 业务提示
| code | msg | 触发场景 | 前端处理 |
|------|-----|---------|---------|
| 702 | 账户余额不足 | 余额 < 单次扣费 | 提示并引导充值 |
| 703 | 玩家未充值不能游玩任何游戏 | 从未充值 | 提示先充值 |
| 500 | 网站维护中...... | 全站维护开启 | 提示维护 |
| 500 | 玫瑰女郎维护中...... | 玫瑰女郎维护开启 | 提示维护 |
| 500 | 请求频繁,请稍后再试 | 短时重复请求(Redis 锁) | 稍后重试 |
| 500 | 箱子不可用 | boxId 不存在或下线 | 刷新箱子列表 |
| 500 | 该箱子暂无可用回合 | 该身份该箱无可用回合 | 提示稍后再试 |
| 500 | 游戏已结束 | 局已播完仍继续调用 | 忽略/返回首页 |
| 500 | boxId 不能为空 | 无进行中对局,且 body 未带 `boxId` | 回到选箱页重新开局 |
> **本接口不会返回 701**(实名认证由支付等其它模块返回,游戏链路不做实名校验)。
> **维护只拦"新开一局"**:当 `websiteMaintenance`/`olympusMaintenance` 开启时,只要**携带非空 `boxId`(开新局)**就会返回维护提示;**不带 `boxId`(继续当前对局)不受维护影响**,可正常播完。进行中的对局不会被强制结算。
---
### 3. 查询游戏记录(我的 / 全服,均分页)
**`POST /api/olympus/records`**
一个接口通过 `scope` 区分「我的记录」与「全服记录」,**两者均支持分页**。
- `scope=1`(默认):**我的记录**,只返回当前登录玩家已结束(播完)的记录。
- `scope=0`:**全服记录**,返回全站玩家已结束(播完)的记录(倒序)。
- **白号记录在两种 scope 下都会出现(可上榜)**;全服口径仅排除机器人(user_type=03)、封禁(status=1)与已删除(del_flag!=0)用户。
#### 请求参数
```json
{
"scope": 0,
"pageNum": 1,
"pageSize": 10
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| scope | Integer | 否 | `1`=我的记录(默认)/ `0`=全服记录 |
| pageNum | Integer | 否 | 页码,默认 `1` |
| pageSize | Integer | 否 | 每页条数,默认 `10` |
> 请求体为 **`RecordPageRequest` 实体类**(POST + JSON),三个字段均可选;body 也可不传(或传空对象 `{}`),此时等价于 `scope=1, pageNum=1, pageSize=10`(我的记录)。
#### 响应 data 结构
```json
{
"rows": [
{
"nickName": "玩家昵称",
"avatar": "https://.../avatar.png",
"cost": 10.00,
"settledWin": 88.00,
"prizeImageUrl": "https://.../rose.png",
"prizeName": "黄金玫瑰 (久经沙场)",
"createTime": "2024-01-01 12:00:00"
}
],
"total": 57
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| nickName | String | 玩家昵称(我的记录=自己,全服记录=中奖玩家) |
| avatar | String | 玩家头像 |
| cost | Decimal | 用户此次消费价格(本局扣费) |
| settledWin | Decimal | 此次获得饰品的价格(本局结算金额) |
| prizeImageUrl | String | 饰品图片 |
| prizeName | String | 饰品全名:`名称 (磨损品质)`,无品质时仅名称(如 `黄金玫瑰 (久经沙场)`) |
| createTime | String | 时间(yyyy-MM-dd HH:mm:ss,东八区) |
> 列表项**只返回上述 7 个字段**(我的记录/全服记录同一结构):`userId`/`boxId`/`roundId`/`settled`/`modeType`/`rewardType`/`prizeId`/`prizeLevelImg`/`settleSource`/`boxName`/`basicBet` 等字段已不再返回,客户端无需判空。
>
> **只返回已结束的局**:开局即结算(`settled=1`)但客户端还在播动画的那一局不出现在列表里,播完末帧后(服务端把该记录 `settle_source` 由 `start` 改为 `finish`)才进入记录;中途退出/断线未播完的局,按单局缓存 TTL **30 分钟**兜底,超时后照常展示,战绩不会丢。
>
> `total` 为**该 scope 下的真实总数**(可用于分页计算)。
>
> 早期版本曾有 `autoDismantle` 字段,现已彻底移除(自动分解由前端开关 + 前端调分解接口,后端不记录、不返回);库里 `game_olympus_record.auto_dismantle` 列作为历史列保留,无代码读写。
---
### 4. 查询进行中的游戏(断线重连)
**`POST /api/olympus/hasActive`**
用户刷新页面/重连时调用,判断是否有未播完的局,若有则返回当前进度与最后一步,供前端恢复动画。**维护期间不强制结算**,进行中的对局可正常重连并播完。
#### 请求参数
无,body 可不传(或传空对象 `{}`)。
#### 响应 data 结构
**无进行中游戏:**
```json
{ "hasActive": false }
```
**有进行中游戏:**
```json
{
"hasActive": true,
"boxId": 1001,
"boxType": "normal",
"bet": 10.00,
"cost": 10.00,
"stepIndex": 3,
"totalSteps": 8,
"freeSpinCount": 0,
"totalWin": 25.00,
"roundId": 1234567,
"lastStep": { "...": "与 dospin 的 PLAYING 响应同构,为最后已播的一步" }
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| hasActive | Boolean | 是否有进行中局 |
| boxId | Long | 箱子 ID |
| boxType | String | 模式 |
| bet | Decimal | 基础下注 |
| cost | Decimal | 本局扣费(已扣) |
| stepIndex | Integer | 当前已播步数 |
| totalSteps | Integer | 总步数 |
| freeSpinCount | Integer | 本局免费旋转**总**次数(15 + 重触发×5,封顶 30;1000x 截断局是实际执行数)。**不是剩余** |
| spinsRemaining | Integer | **剩余免费旋转次数**(倒计值,= 最后已播一步的 `lastStep.spinsRemaining`,口径见「2. 执行旋转」的字段说明),无 FS 的局为 0 |
| totalWin | Decimal | 已播步骤累计赢额 |
| roundId | Long | 回合 ID |
| lastStep | Object | 最后已播一步的完整动画数据(PLAYING 同构);**仅当 `stepIndex ≥ 1` 时返回**,`stepIndex=0` 时该字段不出现。`lastStep.accumulator` = 这步之后的炸弹累计倍数,重连时用它接回累计 |
> 前端恢复策略:先播 `lastStep` 的最后一帧,然后**把本地炸弹累计倍数重置为 `lastStep.accumulator`**(字段不出现时按 0),再继续调用 `dospin`(不传 boxId)走完剩余步骤,直至 FINISH。少了这一步重置,重连后累计倍数会从重连那一旋重新起算。
---
### 5. 背包分解(关联接口)
**`POST /api/userPackSack/decompose`**
玫瑰女郎的命中饰品默认以 `ornament` 形式写入背包(`tt_box_records.source = OLYMPUS_BOX(13)`)。末帧玩家点「立即分解」——或前端"自动分解"开关开启时代替玩家自动调用——都调这个通用背包接口把饰品兑换成金币/积分;接口没调到也只是饰品留在背包,无资金影响。
#### 请求参数
```json
{
"packSackIds": [556677],
"isAll": false,
"decomposeType": "game"
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| packSackIds | Array<Long> | 否 | 待分解的背包记录 ID 列表(单个即 `[boxRecordId]`) |
| isAll | Boolean | 是 | 是否全部分解(`false`=按 ids) |
| decomposeType | String | 否 | `backpack`=背包分解(默认)/ `game`=游戏结果页立即分解。**后端当前不读取该字段**(分解结算一律按饰品来源判定),传与不传结果相同,保留仅为兼容前端调用 |
#### 响应
```json
{
"code": 200,
"msg": "成功分解1件饰品",
"data": null
}
```
> **分解流水口径**:分解结算按**饰品来源**自动分组记流水(`tt_box_records.source` → 对应游戏奖励流水),与前端传来的 `decomposeType` 无关(该字段后端不读取,传与不传结果相同)。玫瑰女郎饰品来源 `OLYMPUS_BOX(13)` → **`OLYMPUS_REWARD(61) 玫瑰女郎奖励`**:无论玩家「放入背包后再分解」还是末帧「立即分解」(含前端自动分解),入账流水一律是玫瑰女郎奖励。
---
## 六、关键流程(前端友好版)
### 6.1 进入游戏页
1. `POST /api/olympus/boxes` → 拿到箱子列表,展示箱子和价格。
2. 玩家选箱子 → 记录 `boxId`。
### 6.2 开始新游戏
1. `POST /api/olympus/dospin`,body `{"boxId": X}`。
2. 返回第一步(`PLAYING` 或直接 `FINISH`,若该局只有一步)。
### 6.3 逐步揭晓动画
- 对每步 `PLAYING` 响应,播放对应动画(`gridState`/`symbolLinks` 里 `tumble === tumbleNum` 的新增连线/`dropData`/`fillData`/`multiplierDetail`)。
`winAmount` 是该旋累计(末帧即该旋总额),`spinWin` 是本局累计,可直接用于跳字。
- 再调用 `POST /api/olympus/dospin`,body `{}`(不带 boxId),得到下一步。
- 重复直止 `state === "FINISH"`。
### 6.4 末帧结算展示
1. 读到 `totalWin/totalMul/prizeInfo/boxRecordId`。
2. **饰品已写入背包**,无需等待。
3. 展示中奖弹窗,给玩家两个选择:
- **放入背包**:什么都不用调,饰品已在背包(`boxRecordId`)。
- **立即分解**:调 `POST /api/userPackSack/decompose`,`packSackIds:[boxRecordId], isAll:false`,入账流水为玫瑰女郎奖励(61)。
4. 前端"自动分解"开关为开时:末帧直接按第 3 步的「立即分解」调用,等价于替玩家点了一下,**后端无需任何额外字段或开关**。
5. 这次调用失败(断网、超时、玩家直接关页面)都不影响对局结果:饰品本来就在背包里,等于玩家选了「放入背包」,之后在背包页随时可再分解。重试也不会重复加钱(同一饰品只会被结算一次)。
6. **结算站内信在"这一局播完"时才发**(多步局=末帧 `FINISH`,单步局=开局):开局就发会在动画播完前把结果剧透给玩家。因此中途退出/断线再没回来的局不发站内信,饰品仍在背包、战绩仍按 30 分钟兜底展示。
### 6.5 重连 / 刷新
- 调 `POST /api/olympus/hasActive`。
- `hasActive=false` → 正常回首页/列表。
- `hasActive=true` → 先播 `lastStep`,再继续 `dospin`(不带 boxId)走完剩余步骤。
### 6.6 维护行为
- **开启维护(`websiteMaintenance`/`olympusMaintenance`)= 只拦"新开一局"**。
- 玩家携带非空 `boxId` 调 `dospin`(开新局)→ 返回维护提示(`网站维护中......` / `玫瑰女郎维护中......`)。
- **进行中的对局不受影响**:不带 `boxId` 调 `dospin`(继续)可正常播完;`hasActive` 也能正常恢复动画,不会被强制结算。
---
## 七、数据口径补充
- **扣费来源流水**:开局扣费写入账户流水 `source = 60`(`TtAccountRecordSource.OLYMPUS_SPIN`,玫瑰女郎消费)。
- **奖励流水**:`source = 61`(`TtAccountRecordSource.OLYMPUS_REWARD`,玫瑰女郎奖励)。**只在玩家分解饰品时入账**:无论「放入背包后再分解」还是末帧「立即分解」(含前端自动分解),口径完全一致;开局本身不发奖励流水。不会重复入账——同一饰品记录只结算一次(`status IN (0,2)` 状态机 + `user:decompose:lock` 锁)。
- **背包记录来源**:命中饰品写入 `tt_box_records`,`source = OLYMPUS_BOX(13)`,`box_name = "OLYMPUS"`,`status=0`(我的库存)。
- **身份维度(audience)**:`common` 普通 / `anchor` 主播(user_type=01) / `robot` 机器人(user_type=03) / `white` 白号(is_blank_account=1)。白号优先级最高,压过 user_type。
- **注意**:白号一般为主播测试/展示用,其玩局不注水、不扣水、不受水位约束,前端无需特殊处理。
- **1000x 封顶**:单局总倍率 ≥ 1000 即 `maxWinReached=true`,`spinsRemaining=0`;到账同样按 1000 倍截断。
- **游戏参数(速查)**:棋盘 5×6=30 格;同符号 ≥8 消除(8-9/10-11/12-30 三档);**炸弹只在免费旋转内出现**(每屏最多 1 颗,2~1000 倍,绿/蓝/紫/红/彩虹 5 档品质;normal/ante 的基础旋无炸弹);基础盘 4+ SCATTER 触发 15 旋免费旋转(三模式进 FS 后规则一致),FS 内 3+ 再 +5 旋,上限 30 旋(到上限后不再落 3+ 个 S)。