Files
henan-backend/olympus-py/README.md
T
2026-09-21 19:21:35 +08:00

517 lines
35 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.
# Olympus 电子游戏 — Python 端结构文档
> match-3 消除类电子游戏(皇冠/宝石主题)的**规则核心 + 离线回合生成器**。
> Python 端负责:可执行的规则规范(引擎)、回合数据预生成、落库。
> 在线播放接口、结算发奖由后续的 Java 服务端消费本模块产出的 `round/step` 数据实现。
---
## 一、目录结构
```
olympus-py/
├── olympus/ 规则核心包(纯逻辑,无第三方依赖)
│ ├── __init__.py 包导出:引擎/模型/关键配置
│ ├── config.py 全部规则配置 + 3 个查询函数
│ ├── engine.py OlympusEngine 结算状态机(规则的可执行规范)
│ └── models.py 不可变数据模型(Bomb/Elimination/Cascade/Spin/Session)
├── tests/
│ ├── __init__.py
│ └── test_engine.py 25 项单元测试(规则回归)
├── olympus_simulator.py 回合生成器核心(网格/级联/炸弹/免费旋转/落桶/截断)
├── generate_rounds.py 生成入口(--per-box 每箱总回合 / box_id 从1顺序)
├── olympus_db.py 建表 + 批量写库(连接参数走环境变量)
├── schema.sql 全量 DDL(12 张表)+ 饰品桶种子数据
└── pyproject.toml 包元信息(name=olympus, py>=3.9)
```
> 辅助脚本:`_reset_boxes.py`(重置——清空 box/box_ornament/round/step/box_template)、`_create_reward_log.py`(建奖池调整日志表 game_olympus_reward_log)。
分两层:
- **规则核心 `olympus/`**:定义"游戏应该怎么算",被引擎和生成器共同依赖,是唯一事实来源。
- **生成/落库工具(根目录)**:调用规则核心批量产出回合数据并写入 MySQL。
---
## 二、核心游戏规则总览
| 规则 | 值 / 说明 |
|---|---|
| 网格 | 5 行 × 6 列 = 30 格 |
| 中奖门槛 | 屏幕任意位置同符号 **≥8 个**即赔付(pay-anywhere) |
| 普通符号 | 9 种:皇冠/沙漏/环/杯赛/红/紫/黄/绿/蓝宝石,各有 **8-9 / 10-11 / 12-30** 三档区间赔率(12 个及以上至全屏 30 格归最高档) |
| SCATTER | 4/5/6 个赔 3x/5x/100x,且 **≥4 个触发免费旋转** |
| 免费旋转 | 首次赠送 **15 次**;FS 中 **≥3 个 SCATTER 重触发 +5 次**(4/5/6 个同时赔 3x/5x/100x 并入本旋赢额;恰好3个只+5不赔),上限 30,**到上限后不再落 3+ 个 SCATTER**(否则会写出"extraSpins=5 但加不上旋"的 retrigger_info) |
| 炸弹(乘数符号) | 值 2x~1000x,分 5 种品质色;**只在免费旋转内出现**(normal/ante 的基础旋、含进 FS 的触发盘一律不出) |
| 消除→引爆 | 一次旋转内多波消除,**全部消完后末帧统一引爆炸弹** |
| 单局封顶 | 累计赢奖 **≤ 1000 倍**基础下注,超出截断 |
---
## 三、三种游戏模式(互斥)
| 模式 | code | 扣费倍率 | 实际扣费档位 | 底注效果 | 可购买 FS | 保证进 FS |
|---|---|---|---|---|---|---|
| 普通旋转 | `normal` | 1× | 1,3,5,10,20,30,50,100,200,300 | ✗ | ✓ | ✗ |
| 底注 | `ante` | 2× | 2,6,10,20,40,60,100,200,400,600 | ✓ | ✗ | ✗ |
| 购买免费旋转 | `buy_fs` | 50× | 50,150,250,500,1000,1500,2500,5000,10000,15000 | ✗ | ✗ | ✓ |
**互斥关系**(双向):底注开启 → 禁止购买免费旋转;购买免费旋转 → 强制关闭底注效果。三种模式基础注档位完全一致(均 10 档)。
- `normal` vs `ante`:ante 花双倍钱,换 **FS 触发概率翻倍**(0.5%→1%)+ SCATTER 出现率更高 + 桶顶更高(150x→200x)。
- `buy_fs`:花 50 倍直接进 15 次免费旋转,是累计器滚雪球的主要场景。**首盘固定 4 个 SCATTER**(直接赔 3x),且该盘不出炸弹(炸弹只在免费旋转内出现,触发盘炸弹本来也不带入累计器)。
- **normal/ante 也能进 FS**:基础旋摇出 4~6 个 SCATTER 就免费进 15 旋,进 FS 后与 buy_fs **完全同一套规则**(炸弹、累计器、每旋保底消除),见第三点五 / 第四节。
---
## 三点五、基础旋为什么没有炸弹(normal/ante)
> 规则:**炸弹只存在于免费旋转内**。`_spin()` 里 `if not is_free_spin: allow_bomb = False` —— 基础旋(含进 FS 的触发盘、含补桶植入的大奖旋)整盘不会有 `M`。
随之而来的物理限制:没有炸弹,基础旋最多只能凑到 `A12(50) + B10(10) ≈ 60x`(自然多组 12 连到 75x 已属极罕见),**100x / 150x / 200x 这些桶基础旋根本赔不出来**。所以:
| 目标倍率 | 谁来装这个桶 |
|---|---|
| < 80x(`FS_ONLY_TARGET_MUL`) | 基础旋(`--alloc config` 补桶时用 `_plan_base_only` 植基础组合) |
| ≥ 80x(100x/150x/200x 桶) | **自然触发 FS 的回合**:`generate_round(box, bias≥80)` 直接生成"4~6 S 触发盘 + 15 旋 FS",FS 预算 `= 桶目标 − 触发盘赢额` |
这正是这两个模式里"能进免费旋转"的现实来源:玩家在后台配比里抽中 100x/150x(/200x) 桶时,看到的就是一次免费旋转(box1:桶 12/13 的池子 100% 是 FS 回合)。
实测(2026-09 用当前生成器重跑全部 30 箱之后;全库 39,431 回合 / 358,234 步):
| 检查项 | 结果 |
|---|---|
| 盘面 | 5×6、30 格全满、只有 A~I/S/M —— **空白格 0、非法格 0** |
| 掉落复刻 | 每一步都能用「上一波盘面 − 上一波新增连线 → `drop_data` 下落 → `fill_data` 补位」**精确**复刻(不符 0 条) |
| 金额 | 连线 `base_score` = 赔付表、单旋赢额 = 基础分 × max(1, 累计+本旋炸弹) + scatter、累计器、`spin_win` 连续 —— 全对(只有 2 步在 1000x 封顶截断局里 `accumulator_after` 记的是未并入值,该字段已下发为接口 `accumulator`) |
| 免费旋转多样性 | 13,542 个 FS 回合,**整局盘面完全重复 = 0** |
| 基础旋炸弹 | normal/ante 基础旋炸弹格 **0**;触发盘 S 数:normal 519 局 / ante 818 局都是 4~6,买 FS 12,205 局固定 4 |
| 100x+ 桶 | normal/ante 的桶 12/13/14 池子 200/200/200 条**全部**是 FS 回合(每箱 49~55 / 76~88 个 FS 回合) |
| RTP | 30 个箱**后台配比反推 = 池内实际口径 = 79.99%~80.03%**(见第六点五;重算后不再有 ±1.7pt 偏差) |
| 池子覆盖率 | 池内回合数 / 配比 count 最小 **1.36x**(≥1 → 一个 cycle 内不会重复发) |
| 抽回合模拟 | `simulate_cycle.py` 复刻 Java 链路(含 payoutCap/水位/抽干按模板重填)各 3~4 个 cycle 都收敛到 **80.0%** |
---
## 四、炸弹累计器(关键机制)
> **累计器只存在于免费旋转模式**,基础旋转没有累计器。三种模式只要进入 FS(normal/ante 摇出 4+S 或 buy_fs 购买)均启用累计器,与模式无关。
| 场景 | 炸弹行为 | 累计器 |
|---|---|---|
| 基础旋转(normal/ante 未进 FS) | **整盘不出炸弹**(`allow_bomb=False`) | 不存在(`multiplier_after` 不变) |
| 免费旋转(三模式一致:≥4 S 进入 / buy_fs 购买) | 每屏最多 1 颗(`FS_SCREEN_BOMB_PROB=0.08` 每空格);有消除时并入累计器,跨旋传递 | **从 0 起累**,永不归零直到 FS 结束 |
**计算公式**(`engine.end_spin`):
- 基础旋转:`本旋赢额 = 基础分 × max(1, 本旋炸弹和)`
- 免费旋转:`本旋赢额 = 基础分 × max(1, 累计值 + 本旋新炸弹)`,之后 `累计值 += 本旋炸弹和`
(**累计倍数作用到每一次消除** —— 0.4×2 + 0.5×4 = 2.8 这种滚雪球,这是这个玩法的核心爽点)
三条性质:① 免费旋转内累计器只涨不跌;② 无消除的旋转炸弹作废、累计值保持;③ 触发盘(进 FS 那一旋)的炸弹**不带入** FS 累计器。
> **推论(决定了"每局能演几次消除")**:n 次"消除+炸弹"的累计花费 ≈ `0.125 × 平均炸弹值 × n²` 倍底注,
> 所以**炸弹越小,买得起的消除次数越多**(单个球值 b、n 次连爆 ≈ 0.125·b·n² ≤ 该局 FS 预算)。
> 实测(买FS 售价 50 倍、RTP 80%、桶 7~17):
>
> | 桶(抽中率) | FS 预算 | 每局消除旋数 | 每局炸弹并入 | 局末累计中位 |
> |---|---|---|---|---|
> | 10x(35.6%) | 7 | 5.3 旋 | 3.92 次 | 15 |
> | 16x(23.4%) | 13 | 5.2 旋 | 3.97 次 | 16 |
> | 25x(16.1%) | 22 | 5.3 旋 | 4.21 次 | 20 |
> | 40x(9.2%) | 37 | 4.9 旋 | 3.62 次 | 16 |
> | 100x(3.8%) | 97 | 4.7 旋 | 3.44 次 | 17 |
> | 150x~1000x(4.5%) | 147+ | 3~6 旋 | 2~5 次 | 26~258 |
>
> **炸弹值按档位配**(`FS_SMALL_BOMB_MAX=8` / `FS_BIG_BOMB_TARGET=100`):低倍档(<100x)只出 2~8 的小球
> ——大球一合并,后面每次消除都被放大几十倍,低倍档预算立刻见底;高倍档(≥100x)用全量 2~1000,
> 所以"大炸弹"照样存在,只是它出现的那些局自然落在高倍桶。
> 每屏最多 1 颗(`FS_MAX_BOMBS_PER_SPIN`),玩家才看得出"消一次爆一颗、倍数一颗颗涨"。
> > 2026-09 试过一版"按桶分档"(低倍局只用 2~4、自然路径放开到 1000):低倍桶消除 3.65→4.29 次/局,
> > 但高倍桶出现大球后**消除次数反而掉**(1000x 桶 13→8.7 次/局)—— 一局赔付 ≈ 系数×k²、桶目标固定,
> > 大球把后面每次消除都放大,同样的钱只够买更少的消除。用户定调"消除多优先",故已回退;
> > 实测数据与折中做法(如"每局最多一颗大球""大球只给 300x+")留在 `docs/玫瑰女郎炸弹倍数分档方案.md`。
**推论(改生成器时必须知道)**:累计器只在"本旋有消除"时才吃炸弹,而炸弹均值 ≈25 倍(`w = 1/√v` 权重)→ **一次带炸弹的消除就把累计倍数推到几十上百**,此后任何一次小消除都被放大成 7.5~50。所以"某局 FS 能容纳多少次消除"完全由该局的原始分预算决定:
| buy_fs 桶 | 触发盘白送 | FS 可用预算 | 保底上限 |
|---|---|---|---|
| 4x(3.00~5.00) | 3.00 | ~1.0 | 约 2~4 次消除 |
| 6.5x(5.00~8.00) | 3.00 | ~3.5 | 约 8~14 次 |
| 10x(8.00~12.00) | 3.00 | ~7.0 | 每旋都能消 + 连消 |
> **buy_fs 的可用桶已改为 10x~1000x(桶 7~17)**:4x/6.5x 桶整局预算只有 1~3.5 倍底注,
> 演不出"15 旋里十来旋都在消 + 炸弹累计",玩家看到的一直是空转动画。
> 改动点:Java `OlympusModeType.getOrnamentRange()` 的 `BUY_FS` → `{7,17}`;
> py `MODE_ORNAMENT_IDS['buy_fs']` → `range(7,18)`;后台「一键重算」重配 count
> (py 侧可跑 `python _recalc_boxes.py --mode buy_fs` 复刻同一算法)。
> 实测观感(box21,玩家加权):每局 **4.5 旋**有消除、**1.16 次**炸弹并入累计器、低倍局累计倍数中位 **9**(改前分别约 3.2 旋 / 0.4 次 / 0)。
> 注意:新配比下 buy_fs 的实际 RTP 会落在 **~81.5%**(10x 桶占 69%,该桶实际均值 10.97 > 名义 10.0);要压回 80% 就把这 10 个箱的 rtp 设成 0.785 再重算。
> **(已修正,见第六节"配比口径")** 现在配比不再按名义 expect_mul 算,而是按每个箱子池内 `AVG(actual_mul)` 算,
> 「设 80% → 实际就赔 80%」,不再需要手改 rtp 这种补偿手段。
---
## 四点五、免费旋转"每旋保底消除"(三模式统一)
> 动机:旧实现 30 格里自然凑出 8 连的概率只有 **17%**,再叠 45% 强制空转,导致 **86.8% 的 FS 旋完全空转**(一局 15 旋里平均只有 2.4 旋有消除),观感"每轮都不消"。
> 现在 normal/ante/buy_fs **只要进了 FS 走的就是同一条代码路径**(`_free_spin_session`),区别只在"这一局值多少钱"。
| 参数 | 值 | 作用 |
|---|---|---|
| `FS_SPIN_WIN_PROB` | 0.60 | 不限额时每旋"至少消一波"的基础概率 |
| `FS_CASCADE_CONTINUE_PROB` | 0.10 | 消完一波后继续连消的概率(越低,同样预算能铺到越多旋) |
| `FS_CASCADE_MAX` | 5 | 单旋最大消除波数 |
| `FS_GUARANTEE_COMBOS` | 8 连 I/H/G | 保底用最小档消除(0.25/0.4/0.5 倍底注),不覆盖已有 S/M 格 |
| `FS_GUARANTEE_COST` | 0.45 | 每多一次"保底消除+炸弹累计"的边际赔付(≈0.45×k,见下) |
| `FS_DRY_FORCE_PROB` | {5:0.5, 6:0.85} | 连续空转到第 5/6 旋时"按概率补保底"(软兜底,避免长空尾) |
| `FS_DRY_FORCE_HARD` | 8 | 连续空转到这个旋数无条件补 |
| `FS_FLOOR_SPEND` | ≈0.28 | 保底预算的换算口径(0.25 / (1−0.10)) |
| `FS_FLOOR_BUDGET_CAP` | ≈4.17 | 一局的保底预算上限(15 旋 × 0.28) |
**预算规则**(`generate_round`):
| 场景 | 预算 | 说明 |
|---|---|---|
| buy_fs / 高倍桶补桶(`bias` = 桶目标) | `保底预算 = min(上限, 目标 − 触发盘赢额)`,差额给"一旋大奖"(`_force_win`) | 保证每旋在消又不打穿低倍桶 |
| buy_fs 自然阶段(bias=None) | 不限额 → 每旋 60% 保底 | 15 旋全在消(累计器会把整局抬到几百倍,所以只出现在高倍桶) |
| **normal/ante 自然触发 FS** | 固定 `FS_FLOOR_BUDGET_CAP`(无大奖旋) | 免费摇出来的没有价格可拆,保底预算 + 炸弹累计自然结算 |
- **保底消除"配额 + 随机间隔"(2026-09 修正)**:一局能买几次消除由预算反推
`k = max(1, √(保底预算 / FS_GUARANTEE_COST))`;每次消除(自然或保底)后**重抽下一次的位置**
(`_fs_next_target`:间隔 = `round(剩余旋数/剩余配额 + δ)`,δ∈{-2..2} 权重 {1,2,3,2,1},
均值 0 → 整局消除次数不变、位置随机);空转太久再按 `FS_DRY_FORCE_PROB` 软兜底(第 5/6 旋
0.5/0.85,第 8 旋必补),防止配额提前用完留下长空尾。
> **为什么要改成随机**:先是"等距铺"(`due += k/总局数`,到点必消),虽然没有长空尾,但间隔被
> 压在 1~5 旋、**87% 的局"最长连空正好 4 旋"**,玩家几局就看穿"到第 4 旋肯定消"。随机化后
> 相邻两次消除的间隔散成 1~7 旋(35/20/14/11/8/8/3),每局最长连空 1~8 旋散开(单值最高 41%);
> 硬闸版本(到第 N 旋必消)会留下新的固定天花板(硬顶 6 时 42% 的局正好卡在 6),所以兜底必须是
> **概率递增**而不是硬上限。
> **为什么要改(上一版)**:旧写法 `p = min(1, 剩余预算 / (FS_FLOOR_SPEND × 剩余旋数))` 里扣预算
> 扣的是**已被累计倍数放大后的赢额**(`win = 基础分 × max(1, 累计+本旋炸弹)`),累计器一涨,几次
> 保底就把预算烧穿 → `p = 0` → 后半局再也不会消。全库实测(`python _regen.py fsdry`):买 FS
> **87.9%** 的局出现 ≥4 旋连空、**51.7%** 的局一直空转到结束、单局最长 **29 旋**;natural 触发
> FS 更高(96~97%)。修完同一份预算下:买 FS 有消除 **4.2 → 4.8 旋/局**、最长连空 ≥7 旋的局
> **61% → 2%**、单局最长 29 → 11 旋、平均最长连空 9.6 → 4.5 旋。
- **物理上限(调生成器前必须知道)**:k 次消除的整局赔付 ≈ `FS_GUARANTEE_COST × k²`(每次消除都把炸弹并进
累计器,倍数作用到之后每一次消除 → 平方增长)。所以 10x 桶(预算≈7)只买得起 **~4 次消除**;
想再多消就得靠"少出炸弹",但那样赔付地板会抬高、这些回合不再落在低倍桶(实测 10x 桶直接填不满)。
- **炸弹值按档位配**(`spin_bomb_max`):目标 < `FS_BIG_BOMB_TARGET`(100) 用 2~8 的小球(预算买得起多次"消除+爆"),目标 ≥100 用全量 2~1000。
> 想给低倍桶更多消除**不能靠"把球做小"**:球小确实省钱,但低倍桶变成"小球多消"后赔付地板抬高、
> 命中率下降(实测 10x 桶只填到 117/292,且高倍桶一旦出大球消除次数反掉)—— 详见 `docs/玫瑰女郎炸弹倍数分档方案.md`。
- **保底消除种在"刚落下/刚补位"的格子上**(`_force_small_win(grid, prefer=补位格)`):这些格子本来就有 `drop_data`/`fill_data` 记录,前端按动画数据落地就能与 `grid_state` 完全一致;`drop_data`/`fill_data` 里的 `symbol` 也统一按**最终盘面**回填(`_spin` 里补位、保底之后统一同步一次)。
> 改前的老数据里,保底消除会随机改写**没移动过**的格子 → 那类格子没有任何动画记录,前端只能靠 `grid_state` 兜底(不会出现空格,但符号要等这一步渲染完才对齐)。全库体检:`grid_state` 空白格 0、`fill_data` 漏格 0、`drop_data` 与重力不符 0、drop/fill 符号与盘面不符 0;老数据剩 10,603 格属于上述"原地改写"。
**验收口径**:`python _regen.py fsdry [box_id]` 打印每模式/每桶的"有消除旋数、最长连空分布、≥4 连空占比"
(买 FS 的 ≥4 连空一列按后台配比加权 = 玩家实际遇到的概率),并列出最差局;单测
`tests/test_simulator.py::TestFreeSpinFloorSpread` 锁住"平均最长连空 < 8、不出现十几旋死尾巴"。
实测(box1:natural 触发 FS 池内 49 个回合,`_regen.py fsstats 1`):每局 **5.1~5.5 旋**有消除、**3.9~4.0 次**炸弹并入累计器、局末累计倍数中位 **15~16**,落桶与名义值基本贴合(桶 12 实测 115.3 vs 名义 100、桶 13 实测 147.8 vs 150)。
买 FS(`_regen.py fsstats 21`,按配比加权):每局 **4.6 旋**有消除、**3.16 次**炸弹并入;连空体检 `_regen.py fsdry` 见四点五。
---
## 五、爆奖动画(小/中/大 3 档)
> 判定口径:**只在免费旋转里判档**(normal/ante 靠自然触发 FS、buy_fs 本身即 FS),
> **按单旋赢额独立判档**,达标即在该旋末帧盖章,旋与旋互不影响,**同一档一局可多次触发**;
> 基础旋(`spinNum=0`)永远是 `null`,不播大奖动画。
档位编码:`small < middle < big`(小 / 中 / 大)。
阈值表(单位:单旋赢额 ÷ 基础下注,配置于 `BIGWIN_TIERS`)——**两种情况两套阈值**:
| 情况 | 模式 | small | middle | big |
|---|---|---|---|---|
| ① 自然免费旋转 | `normal` / `ante` | ≥5 | ≥25 | ≥100 |
| ② 购买免费旋转 | `buy_fs` | ≥20 | ≥60 | ≥200 |
为什么两套:normal/ante 的自然 FS 预算与旋数完全一致(整局约 80~130x),所以共用一套;
buy_fs 花 50 倍买来的赢额分散在 15+ 旋里,单旋阈值整体下调(否则连"小"都很难出现)。
实测出现频率(2026-09 重跑后的池子,逐局统计):
| 情况 | small | middle | big |
|---|---|---|---|
| ① 自然 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(12205 局) | 19.5% 的局出现(每局 0.47 次) | 6.3%(每局 0.13 次) | **1.5%**(每局 0.02 次) |
> 买 FS 的"小"只覆盖 19.5% 的局:它门槛是"单旋 ≥20 倍"(= 售价的 0.4 倍),而 10x/16x 桶整局才 10~20 倍,所以那些局不会有任何爆奖动画。
> 想让"小"更常见就把 `BIGWIN_TIERS['buy_fs']` 的第一档调低(比如 10),改完重跑数据即可。
---
## 六、饰品桶(17 档)与匹配/发放分离
`ORNAMENTS` 定义 17 个倍率桶(0x ~ 1000x),`expect_mul` 严格 = `(range_low + range_high) / 2`,区间连续无缝。
**匹配 vs 发放分离**(顶桶特例):
- 17 号桶(展示名 `1000x`)匹配区间 **630~1370**,中点/期望 = 1000;
- 但实际发放**封顶 1000 倍**:回合理论总分落在 1000~1370 仍归此桶,发钱时截断到 1000 并标记 `win_capped`。
- 即:**落桶用理论分(≤1370),发钱用截断值(≤1000)**,两层各司其职。
### 六点五、配比口径:按"池内实际会赔多少"算(2026-09 修正)
> 以前的配比(`count`)是按**名义** `expect_mul`(区间中点)反推的,于是「设 80% → 实际赔 81.8%」这种系统性偏差:
> 桶内实际均值跟区间中点差很多(实测 **-18% ~ +11%**),桶 17 又受 1000x 封顶影响实际只有 **~835**(不是 1000)。
现在 Java `OlympusDistributionService` 与 py `_recalc_boxes.py` 都改成:
| 环节 | 口径 |
|---|---|
| 配比 `calcRounds` | 期望值取 **该箱池内 `AVG(actual_mul)`**(`loadPoolExpect(boxId)`;某桶池子里一条回合都没有时才回退名义值) |
| 后台「目标RTP」列 | 同一个口径反推 = `Σ(count × 池内均值) / Σcount / cm` —— 也就是**这个箱子真实会赔多少** |
| 抽回合的 payoutCap 过滤 | 仍用名义 `expect_mul`(`OlympusRoundPicker` 读 DB,偏保守,保留安全余量) |
| 微调 | 从"只在最大的 3 个桶之间搬 1 个"改成**任意两个桶搬 1 个**(保持单调),否则池内均值口径下根本走不到目标,会差 0.5~1 个百分点 |
效果(30 个箱全部重算后实测):每个箱的期望赔付 **79.97% ~ 80.02%**(目标 0.8),`simulate_cycle.py` 整轮收敛 80.0%。
注意:池子重新生成过之后要**再跑一次重算**(配比是跟着池子里的这批回合走的);重算会把 `used` 归零,
Redis 的本轮账本要一起清(后台「一键重算」会清;py 侧 `_recalc_boxes.py` 只动库,跑完手动清,
`ruoyi:olympus:cycle:{boxId}:{audience}`),否则「实时RTP」显示的是旧抽数+新抽数混在一起的值。
---
## 七、多身份爆率(audience)
参照现有宝箱的"普通/主播/机器人"爆率,olympus 同样支持多身份差异化 RTP。但实现方式不同:
| | 现有宝箱 | olympus |
|---|---|---|
| 爆率载体 | 每饰品配多列 odds(real/anchor/robot) | 每饰品桶的 `count`(按 audience 分套) |
| 开奖方式 | 开箱实时按身份 roll 饰品 | 开局按身份桶分布抽一个**预生成回合** |
**核心:回合库存池(round/step)完全共享,仅“抽哪个桶”的概率按身份区分。** 四档身份(对齐 `tt_user`):
| audience | 对应身份 | 识别字段 |
|---|---|---|
| `common` | 普通用户(及招商/卡商/超管等) | user_type=02 及其他 |
| `anchor` | 主播 | user_type=01 |
| `robot` | 机器人 | user_type=03 |
| `white` | 白号 | is_blank_account=1(**压过 user_type**) |
**身份解析优先级(Java 开局)**:白号最高优先——`is_blank_account==1 → white`(不管哪个 user_type);否则 01→anchor / 03→robot / 其余→common。
实现:
- `game_olympus_box_ornament` 带 `audience` 列,唯一键 `(box_id, ornament_id, audience)`,每身份一套 `count`/`used` 分布;
- 生成器写库时四档各写一套(首版相同,后台可差异化调各桶 count);
- Java 开局时按优先级选 audience 分布抽回合(回合池共享,无需重生)。
**白号池(white)专项规则**(消耗隔离——白号不碰其他人奖池):
- 独立有限库存:白号抽取只递减 `white` 自己的 `used`、抽干独立补桶,**永不消耗 common/anchor/robot 库存**(由 audience 维度天然隔离);
- white 桶范围与 common 一致,默认分布相同,运营可单独调;
- 水位隔离:白号 bet/win **不计入水位**(对齐 bullet);
- 白号赢奖不进真实经济(提货虚假成功、不计充值/主播收益,与现有白号语义一致)。
- 机器人暂不细分幸运/非幸运,后续视 2v2 需求再扩展。
---
## 八、模块 API 说明
### 8.1 `olympus/config.py` — 规则配置(唯一事实来源)
| 名称 | 类型 | 说明 |
|---|---|---|
| `SymbolRule` | dataclass | 符号赔付规则,`payout_for(count)` 按 8/10/12 档取倍率 |
| `SYMBOL_RULES` | dict | 9 种普通符号赔付表 |
| `SCATTER_PAYOUTS` | dict | `{6:100, 5:5, 4:3}` |
| `SCATTER_FREE_SPIN_TRIGGER` / `SCATTER_RETRIGGER_TRIGGER` | int | 4 / 3 |
| `FREE_SPINS_AWARDED` / `FREE_SPIN_RETRIGGER_COUNT` / `MAX_FREE_SPINS` | int | 15 / 5 / 60 |
| `MULTIPLIER_VALUES` | frozenset | 16 个合法炸弹倍数 |
| `MAX_WIN_MULTIPLIER` | Decimal | 1000(封顶) |
| `GameMode` / `MODES` / `DEFAULT_MODE` | dataclass/dict | 三模式定义,`price_for(bet)` 算扣费 |
| `ORNAMENTS` | list | 17 档饰品桶 |
| `GRID_ROWS/COLS/SIZE` | int | 5 / 6 / 30 |
| `SYMBOL_SHORT` / `SHORT_TO_SYMBOL` | dict | 符号↔短码(A~I, S, M)互转 |
| `SYMBOL_WEIGHTS` / `BOMB_*` | - | 生成器随机权重、炸弹概率/品质 |
| `BOMB_QUALITIES` / `bomb_quality(v)` | list/func | 炸弹 5 档品质,值→品质编码 |
| `BASE_FS_TRIGGER_PROB` / `*_SCATTER` | dict | FS 触发与 SCATTER 散布分布 |
| `MODE_ORNAMENT_IDS` | dict | 各模式可用桶范围 |
| `BIGWIN_TIERS` / `bigwin_level(mode, mul)` | dict/func | 爆奖小/中/大 3 档阈值与判档(两种情况两套阈值) |
### 8.2 `olympus/models.py` — 数据模型(全部 frozen dataclass)
| 模型 | 关键字段 |
|---|---|
| `Bomb` | `bomb_id, value, position` |
| `Elimination` | `symbol, count, positions, payout_multiplier` |
| `CascadeResult` | `cascade_index, eliminations, bombs, base_win, accumulated_symbols` |
| `SpinResult` | `spin_index, cascades, base_win, multiplier_before/after, bombs_added, effective_multiplier, total_win, accumulated_symbols, win_capped` |
| `SessionState` | `next_spin_index, accumulated_multiplier, total_win, spins`;`reset()` |
### 8.3 `olympus/engine.py` — `OlympusEngine` 结算状态机
规则的**可执行规范**(一局 = 一次付费;一 spin 内可多次 cascade):
```python
engine = OlympusEngine(bet=10, mode="normal") # 校验模式与档位
engine.begin_spin(is_free_spin=False) # 开旋(FS 需传 True 才启用累计器)
engine.record_cascade(symbols={"CROWN": [...]}, bombs=[Bomb("m1", 5)]) # 逐波消除
result = engine.end_spin() # 末帧统一引爆 + 封顶 → SpinResult
```
关键方法:`buy_free_spins_allowed()` / `ante_boost_active()` / `reset_session()`;非法操作抛 `RuleViolation`。
---
## 九、数据库表(前缀 `game_olympus_`,共 12 张)
| 表 | 用途 |
|---|---|
| `box` | 箱子配置(模式×档位,`box_type/bet/price`) |
| `ornament` | 17 档饰品桶定义(种子随 schema 写入) |
| `box_ornament` | 箱子-桶数量分布(`count/used`,后台调此控 RTP) |
| `round` | 预生成回合(`ornament_id/actual_mul/free_spin_count/total_cascades`) |
| `step` | 级联步骤明细(见下方字段) |
| `record` | 玩家游戏记录(自带 `settled/结算/补发`字段) |
| `user` / `win` | 用户统计 / 每日盈亏 |
| `prize_ornament` / `prize_range` | 返奖饰品定义与区间 |
| `box_template` | 箱子分布模板(抽干补桶依据) |
| `reward_log` | 奖池水位调整日志(注水/抽水/重置审计,含操作后总池余额) |
**`step` 表核心字段**(前端播放 + 审计):
| 字段 | 含义 |
|---|---|
| `grid_state` | 本步 6×5 网格最终状态 |
| `multiplier_detail` | 炸弹 `[{pos,val,quality}]` |
| `symbol_links` | **本旋累积**连线 `[{symbol,count,loc,base_score,tumble}]`;`tumble` = 旋内第几波,**接口只下发这个字段**(本波新增 = `tumble` 等于本步 `cascade_seq` 的那些;获胜者面板按 symbol 汇总) |
| `drop_data/fill_data` | 下落/填充动画数据 |
| `scatter_trigger/retrigger_info` | 触发/重触发免费旋转信息(仅末帧) |
| `accumulator_before/after` | 炸弹累计器前后值(跨旋传递);`after` **已下发**为接口字段 `accumulator`(未 ×bet,断线重连接回累计用),`before` 仍仅落库留档 |
| `win_amount` | **本旋累计到本步的赢额**(该旋末帧即这一旋的总额;不是本步增量) |
| `spin_win` | **本局累计到本步的赢额**(最后一步 = 整局实付 = 回合 `actual_mul`) |
| `win_capped` | 本步是否被 1000x 截断 |
| `bigwin_level` | 爆奖档位 `small/middle/big`(按单旋赢额,只在免费旋转里判档,盖在该旋末帧;基础旋恒为 null) |
| `is_final` | 本旋末帧标记 |
| `spin_winners` / `accumulated_symbols` / `removed_positions` / `base_score` | **仅落库留档,接口已不下发**(都能由 `symbol_links` 推出:面板=按 symbol 汇总、消除格位=上一步 `loc` 并集、基础分=`base_score` 之和) |
---
## 十、回合生成器(`olympus_simulator.py` + `generate_rounds.py`)
流程:`generate_round(box, bias)` → 生成 cascade 链 → 自然累计 → 落桶 → 发放截断。
- **职责边界**:生成器只负责"库存多样性"(每桶预生成足量各异回合),**不做 RTP 收敛**——RTP 由后台配 `box_ornament.count` 控制。
- `fill_distribution(box, counts)`:两阶段填桶(自由生成 + 缺额加权 bias 补桶),保证每桶命中目标数量。
- 关键实现:炸弹位置随重力下落同步重映射;免费旋转会话 15 起、≥3S 重触发 +5;`bias ≥ FS_ONLY_TARGET_MUL(80x)` 时改用"自然触发 FS"的回合来装该桶(见第三点五)。
---
## 十一、运行方式
> 环境:Python ≥ 3.9,依赖 `pymysql`(仅写库时需要)。DB 连接走环境变量 `OLY_DB_*`,默认对齐 `docker-compose.yml`(`127.0.0.1:23306 / changzhou`)。
```bash
# 建表 + 写入饰品桶种子
python olympus_db.py
# 干跑:三模式各桶抽样,验证多样性/封顶(不写库)
python generate_rounds.py --per-bucket 20
# 全部 30 箱写库(推荐:池子按后台配比×1.5 分配,见下节"池子怎么分配")
python generate_rounds.py --all --alloc config --write
# 备用:按每箱固定总回合跨桶均分(旧行为,正式发布可用 --per-box 2000)
python generate_rounds.py --all --per-box 2000 --write
# 单箱写库
python generate_rounds.py --box-type normal --bet 1 --alloc config --write
# 重跑前的安全流程(快照→清空→生成→校验;--alloc config 会自动保留配比,快照只作兜底)
python _regen.py snapshot && python _regen.py clear
python generate_rounds.py --all --alloc config --write # 保底消除改"随机间隔"后低倍桶命中率约减半,
# fill_distribution 尝试上限已从 ×30 提到 ×60,
# 全库重跑耗时约翻倍(30 箱 ≈45 分钟~1 小时)
python _regen.py verify
python _regen.py fsdry # 免费旋转连空体检(见四点五)
python _recalc_boxes.py --mode normal && python _recalc_boxes.py --mode ante && python _recalc_boxes.py --mode buy_fs
# 重算只动库里的 used,不清 Redis 账本 → 手动清 ruoyi:olympus:cycle:{boxId}:{audience},
# 或后台对每个箱点一次「一键重算分布」;再跑 python _make_templates.py 刷新模板
# 重置(改 box_id 方案前用):清空 box/box_ornament/round/step/box_template
python _reset_boxes.py
# 建奖池水位调整日志表(对齐 bullet 审计)
python _create_reward_log.py
# 规则回归测试(25 项)
python tests/test_engine.py
```
**box_id 规则**:`--all` 按模式顺序×档位顺序**从 1 顺序编号**(normal 1~10、ante 11~20、buy_fs 21~30,共 30 箱)。
**池子怎么分配(重要)**:用 `--alloc config` —— **每桶池子 = 后台配比 × 1.5**(下限 20 条),而不是跨桶均分:
| 方式 | 效果 |
|---|---|
| `--per-box N`(旧,均分) | 每桶 N/桶数 条;normal 0x 桶只有 77 条,而配比一个 cycle 要发 548 次 → 同一条回合被反复发给玩家(**7.1×**) |
| `--alloc config`(推荐) | 每桶 ≥ 配比;normal 0x 桶 837 条(配比 563)、buy_fs 低倍桶 447/280/174 条(配比 297/186/118)→ 每个回合一个 cycle 内最多发一次 |
`--alloc config` 还会把**现有配比原样写回**(不会把池子形状覆盖成 count),所以不需要再手工还原配比。
池子形状与配比的对应关系写死在 `generate_rounds.py` 的 `ALLOC_K=1.5` / `ALLOC_FLOOR=60`,要调就改这两个常量。
> **为什么下限是 60(2026-09 调)**:池子 = `max(ALLOC_FLOOR, 配比×1.5)`,下限只作用在**配比 ≤13 的桶(6.5x 以上)**。
> 这几个桶条数少、盘面又显眼(有赢奖/大奖动画),是玩家真正会"看着眼熟"的;0x~4x 虽然复现间隔最短
> (约 4,500 抽一次),但空转/小赔的盘面没人记得住,所以不值得为它们整体加大池子(那就得动 `ALLOC_K`,
> 池子 +64%、重跑时间 ×1.7)。下限 20→60 只让池子 +8%,把 6.5x 以上的复现间隔拉长 3 倍:
> 10x 桶 5,455→16,364 抽、100x 桶 6 万→18 万抽(按全服每天 5,000 抽算,10x 从"几乎每天重"变成"三天重一次")。
> 复现间隔 = `池子条数 ÷ 该桶配比 × 一轮抽数`,调这两个常量前建议照这个式子算一遍。
> 端侧配合:Java `OlympusRoundPicker` 已改为**同一玩家最近 30 条回合不再重发**(Redis List `ruoyi:olympus:seen:{userId}:{boxId}`,`LPUSH`+`LTRIM`),抽回合时从 40 条候选里过滤。
> 两者叠加的效果:玩家在池子耗完前**不会看到重复盘面**;只靠加大池子做不到(要 ~1.4GB 数据且仍有 30% 重复)。
### 抽完补池(refill)与"实时 RTP 是否达标"仿真
一个 cycle 抽满后(`drawn ≥ N = fixedSum/(1−收敛比例)`),Java `OlympusGameService` 会 `resetCycle` + `refillFromConfig(boxId, audience)`:
| 情况 | 行为 |
|---|---|
| 该 (box, audience) 有**生效模板**(`is_active=1`,取 id 最大一条) | 按模板 `items_json` 重写 count(used 归 0),并按固定池反推回写 `total_ornaments` |
| 没有模板 | 只把 `used` 归零(配比不变,等价于"填满"但形状不重置) |
建模板:`python _make_templates.py`(30 箱 × 4 身份 = 120 条生效模板,内容=当前配比;会先把旧的测试模板置为不生效)。
仿真(复刻 Java:固定阶段按 remaining 加权 + 收敛阶段 `desiredNextMul` + 水位 `payoutCap` + 抽完按模板重填):
```
python simulate_cycle.py # 全部 30 箱 × common,3 个 cycle(水位跨 cycle 持久)
python simulate_cycle.py --audience white # 白号(payoutCap=null,不进水位)
python simulate_cycle.py --box 21 --cycles 5
python simulate_cycle.py --cr 0.1 # 试不同收敛比例(只在仿真里覆盖,不写库)
```
实测(rtp=80%、收敛比例=20%,30 箱 × 3 轮 × 1000 抽):
| 收敛比例 | 固定池末偏差 max/均值 | 整轮末偏差 max/均值 | 最差单轮 |
|---|---|---|---|
| 5% | 4.93% / 1.53% | 2.98% / 0.24% | 83.0% |
| 10% | 6.55% / 1.65% | 1.78% / 0.08% | 81.8% |
| 15% | 7.20% / 1.79% | 0.50% / 0.04% | 80.5% |
| **20%(当前)** | 6.36% / 1.58% | **0.13% / 0.03%** | 79.9% |
| 30% | 6.01% / 1.65% | 0.09% / 0.02% | 79.9% |
结论:固定阶段自身 ±6% 波动("配比即爆率"),全靠收敛阶段拉回目标;**收敛比例不要低于 15%**。
水位只在冷启动时起作用(水位 0 → `payoutCap=(0+成本)×rtp`,首轮大奖被压住,导致第 1 轮固定池末偏低 77~78%,但整轮仍达标)。
---
## 十二、端到端数据流
```
config.py(规则配置,唯一事实来源)
│
├─► engine.py 被 test_engine.py 回归验证(规则正确性)
│
└─► olympus_simulator.py 生成 round + step
│
▼
generate_rounds.py 按桶批量生成
│
▼
olympus_db.py → MySQL(game_olympus_round / _step / _box_ornament)
│
▼
(后续)Java 服务端消费步骤数据 → dospin 逐步回放 + 结算发奖
```
两层职责:**Python 预生成回合/步骤数据落库**,**Java 服务端实时回放与结算**(对齐 sweet 体系)。