# 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 体系)。