35 KiB
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 档)。
normalvsante: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 |
| 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 的可用桶已改为 10x
1000x(桶 717):4x/6.5x 桶整局预算只有 1~3.5 倍底注, 演不出"15 旋里十来旋都在消 + 炸弹累计",玩家看到的一直是空转动画。 改动点:JavaOlympusModeType.getOrnamentRange()的BUY_FS→{7,17}; pyMODE_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),所以兜底必须是 概率递增而不是硬上限。 为什么要改(上一版):旧写法97%)。修完同一份预算下:买 FS 有消除 4.2 → 4.8 旋/局、最长连空 ≥7 旋的局 61% → 2%、单局最长 29 → 11 旋、平均最长连空 9.6 → 4.5 旋。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 - 物理上限(调生成器前必须知道):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 Listruoyi: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 体系)。