麻将胡了

This commit is contained in:
2026-09-22 11:37:24 +08:00
parent 2ed29ddb90
commit 8f8d2e042c
3 changed files with 770 additions and 2618 deletions
-1748
View File
File diff suppressed because it is too large Load Diff
-707
View File
@@ -1,707 +0,0 @@
# 玫瑰女郎 用户端 接口文档
---
## 一、功能说明
玫瑰女郎是一款**选箱下注 → 旋转 → 必得饰品**的电子游戏。核心产品逻辑:
| 规则 | 说明 |
|------|------|
| 三种玩法模式 | `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)。
File diff suppressed because it is too large Load Diff