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

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 回合(每箱 4955 / 7688 个 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)只出 28 的小球 ——大球一合并,后面每次消除都被放大几十倍,低倍档预算立刻见底;高倍档(≥100x)用全量 21000, 所以"大炸弹"照样存在,只是它出现的那些局自然落在高倍桶。 每屏最多 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 的可用桶已改为 10x1000x(桶 717):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/总局数,到点必消),虽然没有长空尾,但间隔被 压在 15 旋、87% 的局"最长连空正好 4 旋",玩家几局就看穿"到第 4 旋肯定消"。随机化后 相邻两次消除的间隔散成 17 旋(35/20/14/11/8/8/3),每局最长连空 18 旋散开(单值最高 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 更高(9697%)。修完同一份预算下:买 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) 用 28 的小球(预算买得起多次"消除+爆"),目标 ≥100 用全量 21000。

    想给低倍桶更多消除不能靠"把球做小":球小确实省钱,但低倍桶变成"小球多消"后赔付地板抬高、 命中率下降(实测 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):

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)。

# 建表 + 写入饰品桶种子
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 110、ante 1120、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 体系)。