操作日志

This commit is contained in:
2026-09-22 10:01:40 +08:00
parent 6c944c4ffd
commit 414529265c
29 changed files with 70 additions and 17960 deletions
+4
View File
@@ -56,3 +56,7 @@ nbdist/
######################################################################
# Docker
docker/backup/
######################################################################
# workbuddy
.workbuddy
+2 -2
View File
@@ -1,4 +1,4 @@
// 只读查询/受控清库小工具(连 docker 里的 mysql_db)
// 只读查询/受控清库小工具(连 docker 里的 henan_mysql_db)
// 用法:
// node bin/logtool.js q "select count(*) from sys_oper_log"
// node bin/logtool.js clear-operlog 清理操作日志(先备份到 bin/backup/)
@@ -7,7 +7,7 @@ const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const MYSQL = ['exec', 'mysql_db', 'mysql', '-uchangzhou', '-piTSJSPPZM3LSGAPC', '--default-character-set=utf8mb4', '-D', 'changzhou'];
const MYSQL = ['exec', 'henan_mysql_db', 'mysql', '-uhenan', '-piTSJSPPZM3LSGAPC', '--default-character-set=utf8mb4', '-D', 'henan'];
function sql(statement) {
return execFileSync('docker', [...MYSQL, '-N', '-B', '-e', statement],
+11 -11
View File
@@ -11,13 +11,13 @@
1. 创建 MySQL 数据卷:
```powershell
docker volume create changzhou_mysql_data
docker volume create henan_mysql_data
```
2. 启动容器(⚠️ 必须先 cd 到 compose 目录,且必须带两个 -f 参数):
```powershell
cd d:\changzhou_app\changzhou_backend\docker\compose
cd D:\henan_background\henan-backend\docker\compose
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
@@ -30,9 +30,9 @@ docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```powershell
docker ps
# 三个容器都应为 Up 状态:mysql_db / redis_cache / rabbitmq_server
# 三个容器都应为 Up 状态:henan_mysql_db / henan_redis_cache / henan_rabbitmq_server
docker exec redis_cache redis-cli ping
docker exec henan_redis_cache redis-cli ping
# 返回 NOAUTH Authentication required. 是正常的(Redis 已启动且带密码)
```
@@ -40,7 +40,7 @@ docker exec redis_cache redis-cli ping
| 服务 | 本机地址 | 容器端口 | 账号信息 |
|------|---------|---------|---------|
| MySQL | 127.0.0.1:23306 | 3306 | root/123456,业务库 changzhou |
| MySQL | 127.0.0.1:23306 | 3306 | root/123456,业务库 henan |
| Redis | 127.0.0.1:26379 | 6379 | 密码见 redis.dev.conf |
| RabbitMQ | 127.0.0.1:25672 | 5672 | skins/mk,vhost /skins |
| RabbitMQ 管理台 | http://127.0.0.1:35672 | 15672 | skins/mk |
@@ -49,7 +49,7 @@ docker exec redis_cache redis-cli ping
```powershell
# 都在 docker\compose 目录下执行
cd d:\changzhou_app\changzhou_backend\docker\compose
cd D:\henan_background\henan-backend\docker\compose
# 启动
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
@@ -61,7 +61,7 @@ docker-compose -f docker-compose.yml -f docker-compose.dev.yml stop
docker ps
# 查看某个容器日志
docker logs redis_cache --tail 50
docker logs henan_redis_cache --tail 50
# 重建某个容器(比如改了配置)
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate redis
@@ -74,20 +74,20 @@ docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-rec
启动命令少了 `-f docker-compose.dev.yml`,重建即可:
```powershell
cd d:\changzhou_app\changzhou_backend\docker\compose
cd D:\henan_background\henan-backend\docker\compose
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate redis
```
### 2. 提示 `external volume "changzhou_mysql_data" not found`
### 2. 提示 `external volume "henan_mysql_data" not found`
```powershell
docker volume create changzhou_mysql_data
docker volume create henan_mysql_data
```
### 3. 提示 `cannot find the file specified`
没有 cd 到 `docker\compose` 目录就执行了命令,`-f` 的相对路径按当前目录解析。
### 4. 和 chengdu 项目切换
### 4. 和 hunan 项目切换
端口可能冲突,先到另一个项目的 compose 目录执行 `docker-compose stop`,再启动本项目。
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
# 自动创建 external 数据卷(首次部署时)
for vol in changzhou_mysql_data; do
for vol in henan_mysql_data; do
docker volume inspect "$vol" >/dev/null 2>&1 || docker volume create "$vol" >/dev/null
done
+10 -10
View File
@@ -7,13 +7,13 @@ services:
args:
MYSQL_BASE_IMAGE: mysql:8.0.24
MYSQL_CONF: my.cnf.16c32g
container_name: mysql_db
container_name: henan_mysql_db
restart: unless-stopped
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: changzhou
MYSQL_USER: changzhou
MYSQL_DATABASE: henan
MYSQL_USER: henan
MYSQL_PASSWORD: iTSJSPPZM3LSGAPC
ports:
- "127.0.0.1:23306:3306"
@@ -31,12 +31,12 @@ services:
retries: 5
start_period: 30s
networks:
- changzhou_app_network
- henan_app_network
# Redis 服务
redis:
image: redis:latest
container_name: redis_cache
container_name: henan_redis_cache
restart: unless-stopped
command: redis-server /usr/local/etc/redis/redis.conf
ports:
@@ -47,12 +47,12 @@ services:
max-size: "50m"
max-file: "3"
networks:
- changzhou_app_network
- henan_app_network
# RabbitMQ 3.9 服务
rabbitmq:
image: rabbitmq:3.9-management
container_name: rabbitmq_server
container_name: henan_rabbitmq_server
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: skins
@@ -75,15 +75,15 @@ services:
max-size: "50m"
max-file: "3"
networks:
- changzhou_app_network
- henan_app_network
# 定义数据卷(external 固定卷名,不随目录变化,防止换目录丢数据)
volumes:
mysql_data:
external: true
name: changzhou_mysql_data
name: henan_mysql_data
# 定义网络
networks:
changzhou_app_network:
henan_app_network:
driver: bridge
+6 -6
View File
@@ -1,19 +1,19 @@
#!/bin/bash
# MySQL 数据卷迁移脚本
# 将旧自动命名卷的数据复制到 external 卷 changzhou_mysql_data
# 将旧自动命名卷的数据复制到 external 卷 henan_mysql_data
# 用法:bash migrate-mysql-volume.sh [旧卷名]
# 不传参数时自动检测 docker_changzhou_mysql_data 和 docker-cn_changzhou_mysql_data
# 不传参数时自动检测 docker_henan_mysql_data 和 docker-cn_henan_mysql_data
set -e
NEW_VOL="changzhou_mysql_data"
NEW_VOL="henan_mysql_data"
IMAGE="mysql:8.0.24"
# 自动检测旧卷名
if [ -n "$1" ]; then
OLD_VOL="$1"
else
for candidate in docker_changzhou_mysql_data docker-cn_changzhou_mysql_data; do
for candidate in docker_henan_mysql_data docker-cn_henan_mysql_data; do
if docker volume inspect "$candidate" >/dev/null 2>&1; then
OLD_VOL="$candidate"
break
@@ -39,9 +39,9 @@ OLD_SIZE=$(docker run --rm -v "${OLD_VOL}:/data" "$IMAGE" sh -c "du -sh /data" 2
echo "旧卷 $OLD_VOL 数据量: $OLD_SIZE"
# 停掉 MySQL 容器(如果在运行)
if docker inspect mysql_db >/dev/null 2>&1; then
if docker inspect henan_mysql_db >/dev/null 2>&1; then
echo "停止 MySQL 容器..."
docker stop mysql_db 2>/dev/null || true
docker stop henan_mysql_db 2>/dev/null || true
fi
# 清空新卷(避免数据叠加)
+32
View File
@@ -0,0 +1,32 @@
# 修改记录
> **只记大改动**:一个需求 / 一个模块算一条;同一件事的多轮微调合并成一条,不逐轮记。
> **排序:最新在最上面。**
> 展开细节看 `docs/updata_sql.md`(数据库改动,含完整可执行 SQL 与回滚)与 `.workbuddy/memory/YYYY-MM-DD.md`(过程与踩坑)。
> **维护**:完成一个大改动后,在顶部按日期加一条;同一天追加在该日期块末尾。
---
## 2026-09-22
- **docker 配置命名统一:`changzhou` → `henan`(全套 `henan_` 前缀)**
**起因**:`docker/` 下的编排文件里到处是 `changzhou`,是**从同源项目拷贝模板时的命名残留**。查实本机 `docker volume ls` / `docker ps -a`:**从未跑过 changzhou 这套**(只存在 hunan 项目的容器与卷 `hunan_mysql_db` / `hunan_redis_cache` / `hunan_rabbitmq_server`,3 个月前创建、均已 Exited)⇒ 改名**无数据迁移包袱**。统一为 `henan` 全套前缀,与实跑的 hunan 命名风格对齐。
**改动 7 个文件**:
1. `docker/compose/docker-compose.yml` —— `MYSQL_DATABASE` / `MYSQL_USER` → `henan`;三个 `container_name` → `henan_mysql_db` / `henan_redis_cache` / `henan_rabbitmq_server`;网络 → `henan_app_network`;external 卷 → `henan_mysql_data`
2. `docker/compose/deploy.sh` —— 自动建卷的卷名
3. `docker/compose/migrate-mysql-volume.sh` —— `NEW_VOL` 与旧卷候选列表;`docker stop henan_mysql_db`
4. `docker/WINDOWS_QUICKSTART.md` —— 卷名 / 库名 / 容器名;顺带修正 3 处**本身就是错的**路径 `d:\changzhou_app\changzhou_backend\...` → `D:\henan_background\henan-backend\docker\compose`;第 4 节「和 chengdu 项目切换」→ hunan
5. `ruoyi-admin/src/main/resources/application-dev.yml` —— jdbc url 库名 + `username`
6. `ruoyi-admin/src/main/resources/application-pro.yml` —— 同上
7. `bin/logtool.js` —— `-uhenan`、`-D henan`、容器名 `henan_mysql_db`
**验证**:`docker compose -f docker-compose.yml config` 解析通过、零报错,`name` 段全部已生效(库 `henan`、三个 `henan_*` 容器名、网络 `henan_app_network`、external 卷 `henan_mysql_data`)。**零 SQL 变更、零后端代码改动。**
**刻意没改**:① `chengduhipay` —— 第三方**支付通道名**不是地名,`application-dev/pro.yml` 与 `updata_sql.md` 里几十处,动了支付会挂;② `docs/updata_sql.md` 里历史条目的 `USE changzhou;` —— 台账回改会失真,需要留痕就**新加一条**;③ 各 `target/` `dist*/` `.lst` `stats.html` —— 里面是 `D:/changzhou_app/...` 的**编译期绝对路径**,属产物,重新构建自然消失;④ `docs/DEPLOYMENT.md`、`docs/README.md`、`docs/麻将胡了设计方案.md`(`D:\changzhou-cocos\...` 是客户端仓库路径)—— 旧项目文档。
**上线前必做两件事**:① 卷是 `external: true`,**必须先 `docker volume create henan_mysql_data`**,否则报 `external volume not found` 直接起不来(`deploy.sh up -d` 会自动建,手敲 `docker-compose up` 不会);② 新卷首次初始化会建出**空**的 `henan` 库 —— `MYSQL_DATABASE` / `MYSQL_USER` / `MYSQL_PASSWORD` **只在数据卷首次初始化时生效**,原有数据需另行导入,否则应用连上的是空库。
**遗留待定**:网络没固定 `name:`,实际名会随执行目录变(在 `docker/compose` 下执行时是 `compose_henan_app_network`)。卷已用 `external` + 固定 `name` 规避「换目录丢数据」,网络是否同样钉死成 `henan_app_network` 待定。
-516
View File
@@ -1,516 +0,0 @@
# 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 体系)。
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
"""建奥林匹斯奖池调整日志表 game_olympus_reward_log(幂等,IF NOT EXISTS)。
用法: python _create_reward_log.py
"""
import olympus_db as db
DDL = """
CREATE TABLE IF NOT EXISTS game_olympus_reward_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
box_name VARCHAR(100) DEFAULT NULL COMMENT '箱子名/汇总描述',
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份 common/anchor/robot/white',
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
operator VARCHAR(64) DEFAULT NULL COMMENT '操作人',
remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_create (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus奖池水位调整日志';
"""
with db.get_conn() as conn:
conn.cursor().execute(DDL)
print('game_olympus_reward_log ready.')
-71
View File
@@ -1,71 +0,0 @@
"""给每箱 × 每身份建"默认配比"模板并置为生效。
为什么需要:箱子的一个 cycle 抽满后(drawn ≥ N),Java 会
cycleRepository.resetCycle() + distributionService.refillFromConfig(boxId, audience)
而 refillFromConfig 是**按 (boxId, audience) 查生效模板**(is_active=1,取 id 最大的一条):
- 有生效模板 → 按模板 items_json 重写 count(writeDistribution),并按固定池反推回写 total_ornaments
- 没有模板 → 只把 used 归零(配比不变)
所以要让"抽完按模板重填",必须给每个 (box, audience) 都有一条生效模板。
用法:
python _make_templates.py --dry # 只看会写什么
python _make_templates.py # 写入
"""
import argparse
import json
from collections import defaultdict
import olympus_db as db
from olympus.config import AUDIENCES
TEMPLATE_NAME = '默认配比'
def build_rows():
rows = []
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('SELECT box_id, box_type, bet, rtp FROM game_olympus_box ORDER BY box_id')
boxes = cur.fetchall()
cur.execute('''SELECT box_id, ornament_id, audience, count
FROM game_olympus_box_ornament ORDER BY box_id, audience, ornament_id''')
cfg = defaultdict(dict)
for box_id, orn, aud, cnt in cur.fetchall():
cfg[(int(box_id), aud)][int(orn)] = int(cnt)
for box_id, box_type, bet, rtp in boxes:
for aud in AUDIENCES:
items = cfg.get((int(box_id), aud), {})
if not items:
print('!! box%s %s 无配比,跳过' % (box_id, aud))
continue
items_json = json.dumps(
[{'ornamentId': k, 'count': v} for k, v in sorted(items.items())],
ensure_ascii=False, separators=(',', ':'))
rows.append((int(box_id), aud, TEMPLATE_NAME, sum(items.values()), items_json))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--dry', action='store_true')
args = ap.parse_args()
rows = build_rows()
print('待写模板 %d 条(%d 箱 × %d 身份)' % (len(rows), len(rows) // len(AUDIENCES), len(AUDIENCES)))
if args.dry:
print('样例:', rows[0][:4], rows[0][4][:80], '...')
return
with db.get_conn() as conn:
cur = conn.cursor()
# 先取消原有生效模板,再 upsert(表上有 uk_box_aud_name 唯一键)
cur.execute('''UPDATE game_olympus_box_template SET is_active=0''')
cur.executemany('''INSERT INTO game_olympus_box_template
(box_id, audience, name, total_rounds, items_json, is_active, create_time)
VALUES (%s,%s,%s,%s,%s,1,NOW())
ON DUPLICATE KEY UPDATE total_rounds=VALUES(total_rounds),
items_json=VALUES(items_json), is_active=1, create_time=NOW()''', rows)
cur.execute('''SELECT COUNT(*), SUM(is_active) FROM game_olympus_box_template''')
print('模板表: 总行数/生效行数 =', cur.fetchone())
if __name__ == '__main__':
main()
-183
View File
@@ -1,183 +0,0 @@
"""复刻后端 OlympusDistributionService.calcRounds + reset(),重算某模式各箱的桶配比。
为什么需要:`OlympusModeType.getOrnamentRange()` 改了(buy_fs 从 5~17 改成 7~17)之后,
后台要「一键重算」才会按新范围重配 count。这个脚本把同一套算法跑在 py 侧,
用来在没有后台操作的场合先把配比算出来(结果与后端一键重算一致)。
算法移植自 Java(calcRounds):基础权重 30×0.6^k → 二分 power 命中目标均值 →
按权重分配固定池 → 单调约束 count[i] ≥ count[i+1] → 只在最大的 3 个桶间微调逼近目标。
用法:
python _recalc_boxes.py --mode buy_fs --dry # 只看结果
python _recalc_boxes.py --mode buy_fs # 写库(count 覆盖、used 归零)
"""
import argparse
import olympus_db as db
from olympus.config import MODES, MODE_ORNAMENT_IDS, ORNAMENTS, payout_expect
# 回退用的"桶期望":桶 17 展示 1000x 但封顶发放,按实测值算(见 config.PAYOUT_EXPECT_OVERRIDE)
_ORN_MUL = {o['id']: payout_expect(o['id']) for o in ORNAMENTS}
# 该桶池子里一条回合都没有时才回退到 _ORN_MUL(名义/封顶修正值)。
# 只要池子里有回合,就用它的实际均值:箱子发的是**自己这批固定回合**,它的均值就是这个箱子的真实期望。
MIN_POOL_SAMPLE = 1
def load_pool_expect(box_id, mode_code):
"""该箱池子里每个桶的**实际**均值(AVG(actual_mul))——配比按"这个箱子真正会赔出去多少"算,
而不是按桶区间中点:桶内均值偏差(实测 -18%~+11%)和每箱池子的抽样差异会直接变成 RTP 偏差。"""
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT ornament_id, COUNT(*), AVG(actual_mul) FROM game_olympus_round
WHERE box_id=%s GROUP BY ornament_id''', (box_id,))
rows = cur.fetchall()
exp = {i: _ORN_MUL[i] for i in MODE_ORNAMENT_IDS[mode_code]}
for oid, n, avg in rows:
if int(oid) in exp and int(n) >= MIN_POOL_SAMPLE and avg and float(avg) > 0:
exp[int(oid)] = float(avg)
return exp
def derive_total_rounds(fixed_rounds, cr):
cr = min(max(cr, 0.0), 0.9)
return int(fixed_rounds / (1.0 - cr) + 0.5)
def derive_fixed_rounds(total_rounds, cr):
cr = min(max(cr, 0.0), 0.9)
return int(total_rounds * (1.0 - cr) + 0.5)
def calc_rounds(mode_code, total_rounds, target_rtp, expect=None):
"""返回 {ornament_id: count},合计 = total_rounds(对齐 Java calcRounds)
expect:桶 → 该桶"实际赔出去多少"的期望;不传则用名义值(含桶 17 封顶修正)。"""
mul = expect or _ORN_MUL
cm = float(MODES[mode_code].cost_multiplier)
target_avg = target_rtp * cm
ids = sorted(MODE_ORNAMENT_IDS[mode_code])
n = len(ids)
if total_rounds <= n:
return {i: 1 for i in ids}
base_w = [30.0 * (0.6 ** i) for i in range(n)]
remaining = total_rounds - n
guaranteed = sum(mul[i] for i in ids)
rem_target_avg = (target_avg * total_rounds - guaranteed) / remaining
power, plo, phi = 1.0, 0.2, 10.0
for _ in range(30):
ws = [w ** power for w in base_w]
sw = sum(ws)
avg = sum(w * mul[i] for w, i in zip(ws, ids)) / sw if sw > 0 else 0.0
if abs(avg - rem_target_avg) < max(rem_target_avg * 0.01, 0.001):
break
if avg < rem_target_avg:
phi = power
else:
plo = power
power = (plo + phi) / 2
ws = [w ** power for w in base_w]
tw = sum(ws)
result = {i: 1 for i in ids}
rem = remaining
for i, w in zip(ids, ws):
extra = int(w / tw * remaining + 0.5) if tw > 0 else 0
put = min(extra, rem)
if put > 0:
result[i] += put
rem -= put
if rem > 0:
result[ids[0]] += rem
for k in range(n - 2, -1, -1): # 单调 count[i] >= count[i+1]
if result[ids[k]] < result[ids[k + 1]]:
result[ids[k]] = result[ids[k + 1]]
diff = total_rounds - sum(result.values())
if diff:
result[ids[0]] += diff
for _ in range(400): # 微调:任取两个桶搬 1 个(保持单调),只要更接近目标
cur_mul = sum(result[i] * mul[i] for i in ids)
delta = target_avg * total_rounds - cur_mul
if abs(delta) < 0.01:
break
best, best_diff = None, abs(delta)
for lo in ids:
if result[lo] <= 1:
continue
for hi in ids:
if lo == hi:
continue
change = mul[hi] - mul[lo]
if abs(change) < 0.001:
continue
nd = abs(delta - change)
if nd < best_diff:
ok = True
ca, idx_lo = result[lo] - 1, ids.index(lo)
if idx_lo < n - 1 and ca < result[ids[idx_lo + 1]]:
ok = False
ca2, idx_hi = result[hi] + 1, ids.index(hi)
if idx_hi > 0 and ca2 > result[ids[idx_hi - 1]]:
ok = False
if ids.index(lo) < ids.index(hi) and ca < ca2:
ok = False
if ok:
best, best_diff = (lo, hi), nd
if best is None:
break
result[best[0]] -= 1
result[best[1]] += 1
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--mode', default='buy_fs')
ap.add_argument('--dry', action='store_true')
args = ap.parse_args()
from olympus.config import AUDIENCES
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT box_id, rtp, rtp_converge_ratio, total_ornaments
FROM game_olympus_box WHERE box_type=%s ORDER BY box_id''', (args.mode,))
boxes = cur.fetchall()
print('%s 共 %d 箱,桶范围 %s;池内实际均值按**每个箱子自己的池子**算(样本<%d的桶回退名义值)'
% (args.mode, len(boxes), sorted(MODE_ORNAMENT_IDS[args.mode]), MIN_POOL_SAMPLE))
for box_id, rtp, cr, total in boxes:
rtp = float(rtp or 0.8)
cr = float(cr if cr is not None else 0.15)
fixed = derive_fixed_rounds(int(total or 1000), cr)
pool_expect = load_pool_expect(box_id, args.mode)
counts = calc_rounds(args.mode, fixed, rtp, pool_expect)
s = sum(counts.values())
cm = float(MODES[args.mode].cost_multiplier)
pay_pool = sum(counts[i] * pool_expect[i] for i in counts)
pay_nominal = sum(counts[i] * _ORN_MUL[i] for i in counts)
print('box%-3d 固定池%d 配比 %s' % (
box_id, s, {k: v for k, v in sorted(counts.items()) if v > 0}))
print(' 目标RTP(本箱池内实际口径) %.2f%% 名义口径 %.2f%%'
% (100.0 * pay_pool / s / cm, 100.0 * pay_nominal / s / cm))
if args.dry:
continue
n_cycle = derive_total_rounds(s, cr)
with db.get_conn() as conn:
cur = conn.cursor()
for aud in AUDIENCES:
cur.execute('DELETE FROM game_olympus_box_ornament WHERE box_id=%s AND audience=%s',
(box_id, aud))
cur.executemany('''INSERT INTO game_olympus_box_ornament
(box_id, ornament_id, audience, count, used) VALUES (%s,%s,%s,%s,0)''',
[(box_id, i, aud, c) for i, c in sorted(counts.items()) if c > 0])
cur.execute('UPDATE game_olympus_box SET total_ornaments=%s WHERE box_id=%s',
(n_cycle, box_id))
if not args.dry:
print('已写库(count 覆盖、used 归零、total_ornaments=一轮抽数)。')
print('提醒:本脚本只动库里的 used,**不会清 Redis 的本轮账本**'
'(ruoyi:olympus:cycle:{boxId}:{audience});不清的话后台「实时RTP」会把'
'重置前的旧抽数和新抽数混在一起显示。'
'清账本:docker exec redis_cache redis-cli -a <pwd> DEL ruoyi:olympus:cycle:<boxId>:<audience>,'
'或在后台对这几个箱点一次「一键重算分布」(它会同时清账本)。')
print('记得再跑 python _make_templates.py 刷新模板。')
if __name__ == '__main__':
main()
-329
View File
@@ -1,329 +0,0 @@
"""重新生成回合数据前的检查 / 快照 / 清空 / 还原(一次性脚本,用完可删)。
python _regen.py check # 查看箱配置、桶配比、回合池构成
python _regen.py snapshot # 备份 box / box_ornament / box_template 到 JSON
python _regen.py clear # 清空 game_olympus_step + game_olympus_round
python _regen.py restore # 从备份还原 box_ornament 的 count(used 归 0)
python _regen.py verify # 校验:buy_fs 触发盘 4S0M、FS 内仍有炸弹、池子每桶 77
python _regen.py fsstats [box_id] # 免费旋转观感统计:每局几次消除、几次炸弹并入、累计倍数
"""
import json
import sys
from datetime import datetime
from pathlib import Path
import olympus_db as db
SNAP = Path(__file__).resolve().parent / '_backup_box_config.json'
def check():
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT box_id, box_type, bet, total_ornaments, rtp
FROM game_olympus_box ORDER BY box_id''')
boxes = cur.fetchall()
print('boxes=%d' % len(boxes))
for b in boxes[:3] + boxes[-2:]:
print(' ', b)
cur.execute('''SELECT audience, COUNT(DISTINCT box_id), SUM(count), SUM(used)
FROM game_olympus_box_ornament GROUP BY audience''')
print('box_ornament:', cur.fetchall())
cur.execute('''SELECT box_id, audience, COUNT(*) n, SUM(count) total, SUM(used) used
FROM game_olympus_box_ornament WHERE box_id IN (1,21,30)
GROUP BY box_id, audience ORDER BY box_id''')
for r in cur.fetchall():
print(' cfg', r)
for box_id in (1, 21, 30):
cur.execute('''SELECT ornament_id, COUNT(*) FROM game_olympus_round
WHERE box_id=%s GROUP BY ornament_id ORDER BY ornament_id''',
(box_id,))
print(' pool box%d:' % box_id, cur.fetchall())
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
WHERE box_id=21 AND audience='common' ORDER BY ornament_id''')
print(' cfg box21 common:', cur.fetchall())
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
WHERE box_id=30 AND audience='common' ORDER BY ornament_id''')
print(' cfg box30 common:', cur.fetchall())
def snapshot():
data = {'ts': datetime.now().isoformat(timespec='seconds')}
with db.get_conn() as conn:
cur = conn.cursor(db.pymysql.cursors.DictCursor)
for t in ('game_olympus_box', 'game_olympus_box_ornament', 'game_olympus_box_template'):
cur.execute('SELECT * FROM ' + t)
data[t] = [{k: (str(v) if v is not None else None) for k, v in row.items()}
for row in cur.fetchall()]
SNAP.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding='utf-8')
print('snapshot ->', SNAP,
{k: len(v) for k, v in data.items() if k != 'ts'})
def clear():
with db.get_conn() as conn:
cur = conn.cursor()
for t in ('game_olympus_step', 'game_olympus_round'):
cur.execute('TRUNCATE TABLE ' + t)
print('truncated', t)
print('max_round_id =', db.get_max_round_id(), 'max_step_id =', db.get_max_step_id())
def restore():
data = json.loads(SNAP.read_text(encoding='utf-8'))
rows = data['game_olympus_box_ornament']
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('SELECT COUNT(*) FROM game_olympus_box_ornament')
before = cur.fetchone()[0]
cur.execute('DELETE FROM game_olympus_box_ornament')
cur.executemany('''INSERT INTO game_olympus_box_ornament
(box_id, ornament_id, audience, count, used) VALUES (%s,%s,%s,%s,0)''',
[(int(r['box_id']), int(r['ornament_id']), r['audience'],
int(r['count'])) for r in rows])
print('box_ornament: %d -> %d rows (used=0)' % (before, len(rows)))
# box 行对比:库里现值 vs 备份值
dc = conn.cursor(db.pymysql.cursors.DictCursor)
dc.execute('''SELECT box_id, box_type, bet, total_ornaments, rtp, price, status, sort
FROM game_olympus_box ORDER BY box_id''')
old = {int(r['box_id']): r for r in data['game_olympus_box']}
for r in dc.fetchall():
o = old.get(int(r['box_id']))
if o is None:
print(' box %s: 新增' % r['box_id'])
continue
diff = {k: (o[k], str(r[k])) for k in r
if str(o[k]) != str(r[k])}
if diff:
print(' box %s 变化:' % r['box_id'], diff)
def verify():
"""grid_state 里数 '"S"'/'"M"' 出现次数(短码是独立 JSON 字符串,不会误匹配符号名)。"""
cnt = ("(CHAR_LENGTH(s.grid_state)-CHAR_LENGTH(REPLACE(s.grid_state,'\"%s\"','')))/3")
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('SELECT COUNT(*) FROM game_olympus_round')
rounds = cur.fetchone()[0]
cur.execute('SELECT COUNT(*) FROM game_olympus_step')
steps = cur.fetchone()[0]
cur.execute('SELECT box_id, COUNT(*) FROM game_olympus_round GROUP BY box_id ORDER BY box_id')
per_box = {r[0]: r[1] for r in cur.fetchall()}
print('rounds=%d steps=%d 每箱回合=%s' % (rounds, steps, sorted(set(per_box.values()))))
cur.execute('''SELECT COUNT(*) FROM game_olympus_round WHERE box_id NOT BETWEEN 1 AND 30''')
print('越界 box_id 回合:', cur.fetchone()[0])
# buy_fs 触发盘:每步 4 个 S、0 个 M
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'S'}), SUM({cnt % 'M'}), MAX(s.cascade_seq)
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
WHERE r.box_id BETWEEN 21 AND 30 AND s.free_spin_id=0''')
n, sc, bm, maxseq = cur.fetchone()
print('buy_fs 触发盘: 步数=%s S总数=%s(应=%s) M总数=%s(应=0) 最大级联页=%s'
% (n, sc, 4 * (n or 0), bm, maxseq))
# 免费旋转内仍然有炸弹(累计器机制没被误伤)
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
JOIN game_olympus_round r USING(round_id)
WHERE s.free_spin_id > 0''')
fn, fm = map(float, cur.fetchone())
fm = fm or 0.0
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
JOIN game_olympus_round r USING(round_id)
WHERE r.box_id <= 10 AND s.free_spin_id = 0''')
nn, nm = map(float, cur.fetchone())
nm = nm or 0.0
print('FS 步数=%s 带炸弹格=%s (%.1f%%)' % (fn, fm, 100.0 * (fm or 0) / (30 * fn)))
print('normal 基础盘步数=%s 带炸弹格=%s (%.1f%%)' % (nn, nm, 100.0 * (nm or 0) / (30 * nn)))
# ante 基础旋同样不许有炸弹;normal/ante 的 100x+ 桶(基础旋无炸弹赔不出来)应全是 FS 回合
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
JOIN game_olympus_round r USING(round_id)
WHERE r.box_id BETWEEN 11 AND 20 AND s.free_spin_id = 0''')
an, am = map(float, cur.fetchone())
am = am or 0.0
print('ante 基础盘步数=%s 带炸弹格=%s (%.1f%%)' % (an, am, 100.0 * am / (30 * an)))
cur.execute('''SELECT r.ornament_id, COUNT(*) 回合, SUM(r.free_spin_count>0) FS回合
FROM game_olympus_round r WHERE r.box_id <= 20 AND r.ornament_id >= 12
GROUP BY r.ornament_id ORDER BY r.ornament_id''')
print('normal/ante 100x+ 桶(应 回合=FS回合):', cur.fetchall())
cur.execute('''SELECT r.box_id, COUNT(*) 回合, SUM(r.free_spin_count>0) FS回合
FROM game_olympus_round r WHERE r.box_id <= 20
GROUP BY r.box_id ORDER BY r.box_id''')
print('normal/ante 每箱 FS 回合数:', cur.fetchall())
# 爆奖档位:只有小/中/大,且只落在免费旋转的旋末帧
cur.execute('''SELECT bigwin_level, COUNT(*) FROM game_olympus_step
GROUP BY bigwin_level ORDER BY bigwin_level''')
print('爆奖档位分布:', cur.fetchall())
cur.execute('''SELECT COUNT(*) FROM game_olympus_step
WHERE bigwin_level IS NOT NULL AND (is_final <> 1 OR free_spin_id = 0)''')
print('爆奖档位落在基础旋/非末帧的行数(应=0):', cur.fetchone()[0])
# 盘面绝不能有空格(前端会显示成空白格子)
cur.execute('''SELECT COUNT(*) FROM game_olympus_step WHERE grid_state LIKE '%""%' ''')
print('盘面空白格行数(应=0):', cur.fetchone()[0])
# 池子构成(每桶应≈77)
cur.execute('''SELECT box_id, MIN(c), MAX(c) FROM
(SELECT box_id, ornament_id, COUNT(*) c FROM game_olympus_round
GROUP BY box_id, ornament_id) t GROUP BY box_id ORDER BY box_id''')
print('每箱桶内回合数 min/max:', sorted({(r[1], r[2]) for r in cur.fetchall()}))
# 配比是否为后台调好的曲线(非均分)
cur.execute('''SELECT box_id, SUM(count) FROM game_olympus_box_ornament
GROUP BY box_id, audience ORDER BY box_id LIMIT 3''')
print('box_ornament 前3箱×(身份) sum(count):', cur.fetchall())
def fsstats():
"""免费旋转观感统计(按桶):每局"有消除的旋数"、"炸弹并入累计器的次数"、局末累计倍数中位数。
玩家真正感受到的是"按后台配比加权"后的那一行 —— 低倍桶占比高时观感就被低倍桶决定。"""
import statistics
from collections import defaultdict, Counter
from olympus.config import ORNAMENTS
_orn = {o['id']: o['name'] for o in ORNAMENTS}
box_id = int(sys.argv[2]) if len(sys.argv) > 2 else 21
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT r.round_id, r.ornament_id, s.free_spin_id, s.base_score,
s.accumulator_before, s.accumulator_after
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
WHERE r.box_id=%s AND s.free_spin_id>0 ORDER BY s.round_id, s.id''', (box_id,))
rows = cur.fetchall()
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
WHERE box_id=%s AND audience=%s''', (box_id, 'common'))
cfg = {int(a): int(b) for a, b in cur.fetchall()}
cur.execute('SELECT ornament_id, AVG(actual_mul) FROM game_olympus_round WHERE box_id=%s GROUP BY ornament_id', (box_id,))
avg = {int(a): float(b) for a, b in cur.fetchall()}
spins = {}
for rid, orn, fs, base, ab, aa in rows:
d = spins.setdefault((rid, orn, fs), {'elim': False, 'ab': 0.0, 'aa': 0.0})
if float(base or 0) > 0:
d['elim'] = True
d['ab'] = float(ab or 0)
d['aa'] = float(aa or 0)
per = defaultdict(Counter)
rounds_of = defaultdict(set)
acc_of = {}
for (rid, orn, fs), d in spins.items():
c = per[orn]
c['spins'] += 1
rounds_of[orn].add(rid)
acc_of[rid] = max(acc_of.get(rid, 0.0), d['aa'])
if d['elim']:
c['elim'] += 1
if d['aa'] > d['ab']:
c['merges'] += 1
total = sum(cfg.values()) or 1
print('box%d 免费旋转观感(桶 抽中率 | 每局消除旋数 | 每局炸弹并入 | 局末累计中位 | 该桶实际均值)' % box_id)
w_elim = w_merges = 0.0
for orn in sorted(per):
c = per[orn]
n = len(rounds_of[orn]) or 1
elim_per, merge_per = c['elim'] / n, c['merges'] / n
accs = [acc_of[r] for r in rounds_of[orn]]
w = cfg.get(orn, 0) / total
w_elim += w * elim_per
w_merges += w * merge_per
print(' %2d %6sx %5.1f%% | %6.1f 旋 | %6.2f 次 | %8.0f | %8.2f' % (
orn, _orn.get(orn, '?'), 100 * w, elim_per, merge_per,
statistics.median(accs) if accs else 0, avg.get(orn, 0)))
print(' ── 玩家加权(按配比):每局 %.1f 旋有消除、%.2f 次炸弹并入' % (w_elim, w_merges))
def fsdry():
"""免费旋转"连续空转"统计:一局里最长连续几旋一个符号都没消(玩家观感=一直白转)。
dry = 该旋所有步的 base_score 都是 0(scatter 赔付不算消除:S 不参与消除)。
只统计有 FS 的回合;1000x 封顶截断的回合里被丢掉的旋按缺失处理,不参与连击计数。
"""
from collections import Counter, defaultdict
from olympus.config import ORNAMENTS
_orn = {o['id']: o['name'] for o in ORNAMENTS}
box_id = int(sys.argv[2]) if len(sys.argv) > 2 else None
sql = '''SELECT r.round_id, r.box_id, r.ornament_id, s.free_spin_id,
SUM(s.base_score > 0) elim
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
WHERE s.free_spin_id > 0'''
args = ()
if box_id:
sql += ' AND r.box_id=%s'
args = (box_id,)
sql += ''' GROUP BY r.round_id, r.box_id, r.ornament_id, s.free_spin_id
ORDER BY r.round_id, s.free_spin_id'''
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, args)
rows = cur.fetchall()
cur.execute('SELECT box_id, box_type FROM game_olympus_box')
btype = {int(a): str(b) for a, b in cur.fetchall()}
cur.execute("""SELECT box_id, ornament_id, count FROM game_olympus_box_ornament
WHERE audience='common'""")
cfg = defaultdict(dict)
for b, o, c in cur.fetchall():
cfg[int(b)][int(o)] = int(c)
per_round, meta = defaultdict(list), {}
for rid, b, o, fs, elim in rows:
per_round[int(rid)].append((int(fs), float(elim or 0) > 0))
meta[int(rid)] = (int(b), int(o))
# rid -> (box, orn, 旋数, 有消除旋数, 最长连空)
stat = {}
for rid, seq in per_round.items():
run = mx = 0
for _, e in sorted(seq):
run = 0 if e else run + 1
mx = max(mx, run)
stat[rid] = (meta[rid][0], meta[rid][1], len(seq), sum(e for _, e in seq), mx)
def show(tag, rids, weight_by_cfg=False):
"""weight_by_cfg:≥4 连空的概率按后台配比 count 加权(= 玩家实际遇到的概率)"""
if not rids:
return []
dist, wsum, p4 = Counter(), 0.0, 0.0
spins = elim = 0
worst = []
for rid in rids:
b, o, n, e, mx = stat[rid]
w = (cfg.get(b, {}).get(o, 0) or 1) if weight_by_cfg else 1
wsum += w
spins += n
elim += e
dist[min(mx, 6)] += 1 # 6 及以上并作一格,否则 >6 的局不进任何格、占比会莫名少一截
if mx >= 4:
p4 += w
worst.append((mx, rid))
n = len(rids)
worst.sort(reverse=True)
print('%-12s 局=%5d 旋/局=%5.1f 有消除=%4.1f/局(%4.1f%%) | 最长连空 0~6+ : %s | ≥4连空 %5.1f%%'
% (tag, n, spins / n, elim / n, 100.0 * elim / max(1, spins),
' '.join('%d%s:%.0f%%' % (k, '+' if k == 6 else '', 100.0 * dist[k] / n)
for k in range(7)),
100.0 * p4 / wsum))
return worst
by_mode = defaultdict(list)
for rid, (b, o) in meta.items():
by_mode[btype.get(b, '?')].append(rid)
print('=== 免费旋转"连续空转"(dry = 该旋 base_score 全 0;≥4连空 一列买 FS 按配比加权)===')
all_worst = []
for mode in ('normal', 'ante', 'buy_fs'):
rids = by_mode.get(mode) or []
if not rids:
continue
all_worst += [(mx, rid, mode, meta[rid]) for mx, rid in show(mode, rids)[:5]]
by_orn = defaultdict(list) # 同一模式的 10 个箱合并看桶
for rid in rids:
by_orn[meta[rid][1]].append(rid)
for o in sorted(by_orn):
show(' %s %s' % (mode, _orn.get(o, o)), by_orn[o], weight_by_cfg=True)
all_worst.sort(reverse=True)
print('--- 最差局(最长连空 / round_id / 模式 / 桶)---')
for mx, rid, mode, (b, o) in all_worst[:12]:
print(' 连空%d旋 round=%d %s box%d 桶%s' % (mx, rid, mode, b, _orn.get(o, o)))
if __name__ == '__main__':
cmd = sys.argv[1] if len(sys.argv) > 1 else 'check'
globals()[cmd]()
-21
View File
@@ -1,21 +0,0 @@
"""一次性重置:清空箱子相关数据(box_id 改从 1 起、总回合调整时用)。
保留 game_olympus_ornament(桶定义) 与 game_olympus_prize_*(返奖饰品)。
用法: python _reset_boxes.py
"""
import olympus_db as db
# 子表在前(无外键,仅为清晰)
TABLES = [
'game_olympus_step',
'game_olympus_round',
'game_olympus_box_ornament',
'game_olympus_box_template',
'game_olympus_box',
]
with db.get_conn() as conn:
cur = conn.cursor()
for t in TABLES:
cur.execute(f'TRUNCATE TABLE {t}')
print('truncated', t)
print('reset done. max_round_id =', db.get_max_round_id())
-295
View File
@@ -1,295 +0,0 @@
"""
Olympus 回合生成运行入口 — 只负责回合库存的多样性
职责说明:
- Python 端: 为模式允许的每个饰品桶预生成足够数量的回合(库存),保证多样性
- 后台端: 通过配置各桶的数量分布(game_olympus_box_ornament.count)控制箱子
抽完的实时 RTP 是否达到目标,本脚本不做 RTP 收敛
用法:
python generate_rounds.py # 干跑: 每模式每桶生成20个验证多样性
python generate_rounds.py --all --per-box 200 --write # 全部模式×档位建箱写库(box_id从1,每箱总回合200)
python generate_rounds.py --all --alloc config --write # 池子按后台桶配比分配(推荐):每桶 = 配比 ×1.5
python generate_rounds.py --box-type normal --bet 1 --per-box 200 --write # 单箱写库
"""
import argparse
import time
from collections import Counter
from olympus.config import MODES, MODE_ORNAMENT_IDS, ORNAMENTS, RTP, AUDIENCES
from olympus_simulator import fill_distribution
_ORN = {o['id']: o for o in ORNAMENTS}
ALLOC_K = 1.5 # --alloc config 时,每桶池子 = 配比 × 该系数(留余量给"不重复发")
# 每桶池子下限。只对"配比 × ALLOC_K < 下限"的桶起作用,也就是**配比 ≤13 的桶(6.5x 以上)**:
# 这些桶条数少、盘面又显眼(有赢奖/大奖动画),是玩家真正会"看着眼熟"的那几个,所以 20 → 60
# 把它们的复现间隔拉长 3 倍(实测 10x 桶 5,455 抽 → 16,364 抽、100x 桶 6 万 → 18 万抽),
# 而 0x~4x 这些"量大但没人记住"的桶走 ALLOC_K、完全不受影响 —— 池子总量只 +8%。
ALLOC_FLOOR = 60 # 每桶池子下限
# 并行写库用:round_id 按"该箱池子的实际条数"连续切段(池子条数由配比唯一决定,能事前算准,见 planned_rounds);
# 步数取决于随机级联、事前算不准,只能给每箱预留一段固定 id 空间。
# 注意要按**最重的箱**留:buy_fs 每箱 ~84,000 步(每局都是 15~30 旋的 FS),normal/ante 只有 ~1.1w 步(大多 1~2 步的基础旋)。
BOX_STEP_BAND = 200_000
def read_config_counts(box_id):
"""读该箱现有桶配比(各身份取最大值)。没有配置时返回 {}。"""
import olympus_db as db
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT ornament_id, MAX(count) FROM game_olympus_box_ornament
WHERE box_id=%s GROUP BY ornament_id''', (box_id,))
return {int(a): int(b) for a, b in cur.fetchall()}
def build_counts_from_config(mode_code, box_id, k=ALLOC_K):
"""按后台桶配比分配池子:每桶回合数 = max(下限, 配比 × k)。
池子 ≥ 配比 → 一个 cycle 内每个回合最多发一次(配合 Java 侧"不重复发")。"""
cfg = read_config_counts(box_id)
counts = {}
for i in MODE_ORNAMENT_IDS[mode_code]:
c = cfg.get(i, 0)
if c > 0:
counts[i] = max(ALLOC_FLOOR, int(round(c * k)))
return counts
def build_counts(mode_code, per_bucket):
"""每个可用桶生成相同数量的回合库存,后台再按需配比"""
return {i: per_bucket for i in MODE_ORNAMENT_IDS[mode_code]}
def build_counts_total(mode_code, total):
"""每箱固定总回合数,跨该模式可用桶均分(余数分给靠前的桶),保证每桶≥1"""
ids = list(MODE_ORNAMENT_IDS[mode_code])
n = len(ids)
base, rem = divmod(total, n)
return {i: base + (1 if idx < rem else 0) for idx, i in enumerate(ids)}
def seq_box_id(mode_code, bet):
"""全局顺序 box_id(从 1 开始):按模式顺序×档位顺序编号"""
idx = 0
for mc, mode in MODES.items():
for b in mode.base_bets:
idx += 1
if mc == mode_code and b == bet:
return idx
return idx
def report(mode_code, counts, rounds):
total_target = sum(counts.values())
dist = Counter(rd['ornament_id'] for rd, _ in rounds)
print(f'[{mode_code}] rounds={len(rounds)}/{total_target}')
for i in sorted(counts):
hits = [rd for rd, _ in rounds if rd['ornament_id'] == i]
if hits:
muls = [rd['actual_mul'] for rd in hits]
casc = [rd['total_cascades'] for rd in hits]
fs = sum(1 for rd in hits if rd['free_spin_count'] > 0)
extra = (f'倍率{min(muls):.2f}~{max(muls):.2f} '
f'步数{min(casc)}~{max(casc)} FS回合{fs}')
else:
extra = ''
print(f' 桶{i:>2} {_ORN[i]["name"]:>6}: {dist.get(i, 0):>5}/{counts[i]:>5} {extra}')
capped = sum(1 for _, ss in rounds if any(s['win_capped'] for s in ss))
overmax = sum(1 for rd, _ in rounds if rd['actual_mul'] > 1000)
print(f' 封顶截断={capped} 超1000={overmax}')
def dry_run(per_bucket=20):
for mode_code in MODES:
counts = build_counts(mode_code, per_bucket)
t0 = time.time()
rounds, _ = fill_distribution({'box_id': 0, 'box_type': mode_code}, counts)
report(mode_code, counts, rounds)
print(f' 耗时 {time.time() - t0:.1f}s\n')
def derive_cycle_rounds(box_id, fixed_sum, default_cr=0.15):
"""一轮抽多少抽 N = round(固定池 /(1-收敛比例)),对齐 Java deriveTotalRounds。
box.total_ornaments 的语义是"一轮总抽数",不是池子大小(抽完补池时 Java 也会回写这个值)。"""
import olympus_db as db
cr = default_cr
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('SELECT rtp_converge_ratio FROM game_olympus_box WHERE box_id=%s', (box_id,))
row = cur.fetchone()
if row and row[0] is not None:
cr = float(row[0])
cr = min(max(cr, 0.0), 0.9)
return int(round(fixed_sum / (1.0 - cr)))
def write_box(mode_code, bet, box_id, per_box, alloc='flat', round_base=None, step_base=None, planned=None):
import olympus_db as db
mode = MODES[mode_code]
if bet not in mode.base_bets:
raise SystemExit(f'模式 {mode_code} 不支持档位 {bet},可选: {mode.base_bets}')
price = float(mode.price_for(bet))
counts = build_counts_from_config(mode_code, box_id) if alloc == 'config' else {}
if not counts:
counts = build_counts_total(mode_code, per_box)
total_rounds = sum(counts.values())
config_counts = read_config_counts(box_id) or dict(counts)
box = {'box_id': box_id, 'box_type': mode_code, 'bet': bet,
'name': f'{mode.name}-bet{bet}', 'price': price,
'total_ornaments': derive_cycle_rounds(box_id, sum(config_counts.values())),
'rtp': RTP}
print(f'生成 box={box_id} {box["name"]} price={price} '
f'分配方式={alloc} 池子{total_rounds}条 / 一轮{box["total_ornaments"]}抽 ...')
t0 = time.time()
rounds, gen = fill_distribution(box, counts)
report(mode_code, counts, rounds)
if planned is not None and len(rounds) != planned:
raise SystemExit(f'box={box_id}: 实际生成 {len(rounds)} 条 ≠ 预留 id 段 {planned} 条,'
f'id 段会错位 —— 重跑一次(配比刚改过就先 --clear)')
round_base = db.get_max_round_id() if round_base is None else round_base
step_base = db.get_max_step_id() if step_base is None else step_base
rounds_data, steps_data = [], []
rid, sid = round_base, step_base
for rd, steps in rounds:
rid += 1
rd['round_id'] = rid
rounds_data.append(rd)
for s in steps:
sid += 1
s['id'] = sid
s['round_id'] = rid
steps_data.append(s)
if len(steps_data) > BOX_STEP_BAND:
raise SystemExit(f'box={box_id}: {len(steps_data)} 步超出每箱预留段 {BOX_STEP_BAND},'
f'把 BOX_STEP_BAND 调大再跑')
db.insert_box(box)
# box_ornament:已有后台配比则原样写回(池子形状 ≠ 配比,别把配比覆盖成池子形状);
# 首次建箱才用实际生成量做种子
keep = read_config_counts(box_id) or dict(gen)
db.write_box_distributions(box_id, {aud: dict(keep) for aud in AUDIENCES})
db.write_rounds_and_steps(box_id, rounds_data, steps_data)
print(f'写库完成: {len(rounds_data)} rounds / {len(steps_data)} steps, '
f'耗时 {time.time() - t0:.1f}s\n')
def all_boxes():
"""(box_id, mode_code, bet) 顺序编号,与旧 --all 完全一致(1..30)"""
boxes = []
box_id = 0
for mode_code, mode in MODES.items():
for bet in mode.base_bets:
box_id += 1
boxes.append((box_id, mode_code, bet))
return boxes
def _box_task(task):
"""worker 入口:跑一箱(id 段由任务自带,互不重叠)"""
box_id, mode_code, bet, per_box, alloc, r_base, s_base, planned = task
write_box(mode_code, bet, box_id, per_box, alloc, r_base, s_base, planned)
return box_id
def planned_rounds(mode_code, box_id, per_box, alloc):
"""这箱这一轮会生成多少条回合:池子条数 = Σ max(ALLOC_FLOOR, 配比×ALLOC_K),由配比唯一决定。
用它给每箱切一段**连续**的 round_id(并行写库不会撞主键,id 也不会有空洞)。"""
counts = build_counts_from_config(mode_code, box_id) if alloc == 'config' else {}
if not counts:
counts = build_counts_total(mode_code, per_box)
return sum(counts.values())
def clear_pool_tables():
"""TRUNCATE 回合池两张表:清空全部 30 箱的旧回合,id 从 1 重排。
注意 game_olympus_record(战绩)里的 round_id 会因此指向新池子里的同号回合 —— 战绩金额本身不受影响。"""
import olympus_db as db
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('TRUNCATE TABLE game_olympus_step')
cur.execute('TRUNCATE TABLE game_olympus_round')
print('已 TRUNCATE game_olympus_step / game_olympus_round(旧池子清空,round_id 从 1 重排)')
def _existing_rounds(box_id):
import olympus_db as db
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('SELECT COUNT(*) FROM game_olympus_round WHERE box_id=%s', (box_id,))
return int(cur.fetchone()[0])
def run_all(alloc, per_box, workers, clear=False, resume=False):
"""--all:顺序(workers=1)或按箱并行写库。
生成是纯 CPU 活(多线程没用,要进程);各箱 round_id 段连续不重叠,所以并行也不会撞主键。
--resume:跳过已经写好的箱(条数 == 计划条数),只补剩下的 —— id 段仍按整轮 30 箱排,所以布局和整轮跑一致。
进程数:本机只有 4 个物理核(8 逻辑),而且 VS Code/IDEA/Docker 常驻,建议 1~2。"""
import olympus_db as db
if clear:
clear_pool_tables()
cursor, step_base0 = 0, 0
else:
cursor, step_base0 = db.get_max_round_id(), db.get_max_step_id()
tasks = []
for bid, mc, bet in all_boxes():
n = planned_rounds(mc, bid, per_box, alloc)
tasks.append((bid, mc, bet, per_box, alloc,
cursor, step_base0 + bid * BOX_STEP_BAND, n))
cursor += n
if resume:
left = [t for t in tasks if _existing_rounds(t[0]) != t[7]]
print(f'--resume:已写好 {len(tasks) - len(left)} 箱,待生成 {len(left)} 箱')
tasks = left
if not tasks:
print('没有需要生成的箱')
return
print(f'共 {len(tasks)} 箱,round_id 段 {tasks[0][5] + 1} ~ {cursor}(连续、不重叠)')
if workers <= 1:
t0 = time.time()
for done, t in enumerate(tasks, 1):
_box_task(t)
print(f'--- [{done}/{len(tasks)}] box={t[0]} 完成 累计 {time.time() - t0:.1f}s')
return
import multiprocessing as mp
t0 = time.time()
print(f'并行 {workers} 进程 ...')
with mp.Pool(processes=workers) as pool:
for done, bid in enumerate(pool.imap_unordered(_box_task, tasks), 1):
print(f'--- [{done}/{len(tasks)}] box={bid} 完成 累计 {time.time() - t0:.1f}s')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--box-type', choices=list(MODES), help='模式')
ap.add_argument('--bet', type=int, help='基础下注档位')
ap.add_argument('--per-bucket', type=int, default=20, help='干跑每桶回合数(仅 dry-run 用)')
ap.add_argument('--per-box', type=int, default=200, help='每箱总回合数(--alloc flat 用,跨桶均分)')
ap.add_argument('--alloc', choices=['flat', 'config'], default='flat',
help='flat=跨桶均分(旧行为);config=按后台桶配比×1.5 分配(池子≥配比)')
ap.add_argument('--write', action='store_true', help='写入数据库')
ap.add_argument('--all', action='store_true', help='全部模式×档位,box_id 从 1 顺序编号')
ap.add_argument('--workers', type=int, default=1,
help='并行进程数(生成是纯 CPU 活,多线程没用;建议 ≤3,DB 连接数限制)')
ap.add_argument('--clear', action='store_true',
help='先 TRUNCATE 回合池两张表(全部箱),round_id 从 1 重排;只配合 --all 用')
ap.add_argument('--resume', action='store_true',
help='只补还没写好的箱(跳过已写完的),id 段仍按整轮 30 箱排')
args = ap.parse_args()
if args.clear and not args.all:
raise SystemExit('--clear 会清空全部箱的回合池,只配合 --all 用')
if not args.write:
dry_run(min(args.per_bucket, 20))
return
if args.all:
run_all(args.alloc, args.per_box, args.workers, args.clear, args.resume)
elif args.box_type and args.bet:
write_box(args.box_type, args.bet, seq_box_id(args.box_type, args.bet),
args.per_box, args.alloc)
else:
raise SystemExit('--write 需要 --box-type + --bet,或 --all')
if __name__ == '__main__':
main()
-36
View File
@@ -1,36 +0,0 @@
"""Olympus 电子游戏规则核心 (0.1.0)。"""
from .config import (
SYMBOL_RULES,
SCATTER_CODE,
SCATTER_PAYOUTS,
MODES,
DEFAULT_MODE,
MAX_WIN_MULTIPLIER,
GameMode,
)
__version__ = "0.1.0"
__all__ = [
# config
"SYMBOL_RULES",
"SCATTER_CODE",
"SCATTER_PAYOUTS",
"MODES",
"DEFAULT_MODE",
"MAX_WIN_MULTIPLIER",
"GameMode",
# engine
"OlympusEngine",
"RuleViolation",
# models
"Bomb",
"CascadeResult",
"Elimination",
"SessionState",
"SpinResult",
]
from .engine import OlympusEngine, RuleViolation
from .models import Bomb, CascadeResult, Elimination, SessionState, SpinResult
-311
View File
@@ -1,311 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Mapping, Tuple
@dataclass(frozen=True)
class SymbolRule:
code: str
name: str
payouts: Mapping[int, Decimal]
def payout_for(self, count: int) -> Decimal:
for threshold in sorted(self.payouts, reverse=True):
if count >= threshold:
return self.payouts[threshold]
return Decimal("0")
# 赔付倍数以 1 倍下注为基准,数值对齐官方赔付表(8-9 / 10-11 / 12-30 三档)
SYMBOL_RULES = {
"CROWN": SymbolRule("CROWN", "皇冠", {12: Decimal("50"), 10: Decimal("25"), 8: Decimal("10")}),
"HOURGLASS": SymbolRule("HOURGLASS", "沙漏", {12: Decimal("25"), 10: Decimal("10"), 8: Decimal("2.5")}),
"RING": SymbolRule("RING", "环", {12: Decimal("15"), 10: Decimal("5"), 8: Decimal("2")}),
"CHALICE": SymbolRule("CHALICE", "杯赛", {12: Decimal("12"), 10: Decimal("2"), 8: Decimal("1.5")}),
"RED_GEM": SymbolRule("RED_GEM", "红宝石", {12: Decimal("10"), 10: Decimal("1.5"), 8: Decimal("1")}),
"PURPLE_GEM": SymbolRule("PURPLE_GEM", "紫宝石", {12: Decimal("8"), 10: Decimal("1.2"), 8: Decimal("0.8")}),
"YELLOW_GEM": SymbolRule("YELLOW_GEM", "黄宝石", {12: Decimal("5"), 10: Decimal("1"), 8: Decimal("0.5")}),
"GREEN_GEM": SymbolRule("GREEN_GEM", "绿宝石", {12: Decimal("4"), 10: Decimal("0.9"), 8: Decimal("0.4")}),
"BLUE_GEM": SymbolRule("BLUE_GEM", "蓝宝石", {12: Decimal("2"), 10: Decimal("0.75"), 8: Decimal("0.25")}),
}
SCATTER_CODE = "SCATTER"
# 6/5/4 个 SCATTER 的赔付倍数(1 倍下注基准,与皇冠系列量表同源)
SCATTER_PAYOUTS = {
6: Decimal("100"),
5: Decimal("5"),
4: Decimal("3"),
}
SCATTER_FREE_SPIN_TRIGGER = 4 # 基础盘 4+ SCATTER 触发免费旋转
SCATTER_RETRIGGER_TRIGGER = 3 # 免费旋转中 3+ SCATTER 触发重触发
FREE_SPINS_AWARDED = 15 # 首次触发赠送次数
FREE_SPIN_RETRIGGER_COUNT = 5 # 每次重触发追加次数
MULTIPLIER_VALUES = frozenset({
2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 50, 100, 250, 500, 1000,
})
MIN_WIN_COUNT = 8
MAX_SYMBOL_COUNT = 30
# 单局最高赢奖封顶:1000 倍基础下注(最高 1000x 饰品)
MAX_WIN_MULTIPLIER = Decimal("1000")
# ============================================================
# 游戏模式:互斥规则
# - 底注(ante)开启时禁用购买免费旋转;购买免费旋转(buy_fs)时禁用底注效果
# - 只有一个购买免费旋转档(无 super 模式)
# ============================================================
@dataclass(frozen=True)
class GameMode:
code: str
name: str
cost_multiplier: int # 实际扣费 = 基础下注 × cost_multiplier
base_bets: Tuple[int, ...] # 可选基础下注档位
ante_boost: bool # 底注效果(SCATTER 概率提升)
guaranteed_free_spins: bool # 保证直接进入免费旋转
can_buy_free_spins: bool # 是否允许购买免费旋转
def price_for(self, base_bet: int) -> Decimal:
if base_bet not in self.base_bets:
raise ValueError(f"模式 {self.code} 不支持下注档位 {base_bet}")
return Decimal(base_bet) * self.cost_multiplier
_NORMAL_BETS = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300)
_BUY_FS_BETS = _NORMAL_BETS # 10 档,实际价格 50 ~ 15000
MODES = {
# 普通旋转:实际扣费 = 1 3 5 10 20 30 50 100 200 300
"normal": GameMode(
code="normal", name="普通旋转", cost_multiplier=1,
base_bets=_NORMAL_BETS,
ante_boost=False, guaranteed_free_spins=False, can_buy_free_spins=True,
),
# 底注:实际扣费 = 2 6 10 20 40 60 100 200 400 600;禁止购买免费旋转
"ante": GameMode(
code="ante", name="底注", cost_multiplier=2,
base_bets=_NORMAL_BETS,
ante_boost=True, guaranteed_free_spins=False, can_buy_free_spins=False,
),
# 购买免费旋转:实际扣费 = 50 150 250 500 1000 1500 2500 5000 10000 15000;无底注效果
# 售价 50 倍(原 20 倍):局均赔付 = 售价 × RTP = 40 倍底注 → 后台一键重算会把配比压到
# 25x/40x/60x 桶(FS 预算 22~57),每局才演得出 6~10 次消除 + 4~6 次炸弹累计;
# 20 倍时局均只有 16 倍,配比被迫压在 10x/16x 桶(预算 7~13),动画"钱不够"。
"buy_fs": GameMode(
code="buy_fs", name="购买免费旋转", cost_multiplier=50,
base_bets=_BUY_FS_BETS,
ante_boost=False, guaranteed_free_spins=True, can_buy_free_spins=False,
),
}
DEFAULT_MODE = "normal"
# ============================================================
# 饰品桶定义(DB 写入用):最高 1000x,对齐封顶规则
# 设计原则:区间连续无缝覆盖 0~1370(封顶前理论值域);
# expect_mul 严格等于 (range_low + range_high) / 2;
# 低倍段密集(高频小奖)、高倍段递宽。
# 注:顶桶 id=17 区间 630~1370(中点=期望=1000),但实际发放受封顶约束:
# 超过 1000 倍的部分不再给奖,一律按 1000 倍发放(引擎 MAX_WIN_MULTIPLIER 截断)。
# ============================================================
ORNAMENTS = [
{'id': 1, 'name': '0x', 'expect_mul': 0.0, 'range_low': 0.00, 'range_high': 0.00},
{'id': 2, 'name': '0.5x', 'expect_mul': 0.5, 'range_low': 0.01, 'range_high': 1.00},
{'id': 3, 'name': '1.5x', 'expect_mul': 1.5, 'range_low': 1.00, 'range_high': 2.00},
{'id': 4, 'name': '2.5x', 'expect_mul': 2.5, 'range_low': 2.00, 'range_high': 3.00},
{'id': 5, 'name': '4x', 'expect_mul': 4.0, 'range_low': 3.00, 'range_high': 5.00},
{'id': 6, 'name': '6.5x', 'expect_mul': 6.5, 'range_low': 5.00, 'range_high': 8.00},
{'id': 7, 'name': '10x', 'expect_mul': 10.0, 'range_low': 8.00, 'range_high': 12.00},
{'id': 8, 'name': '16x', 'expect_mul': 16.0, 'range_low': 12.00, 'range_high': 20.00},
{'id': 9, 'name': '25x', 'expect_mul': 25.0, 'range_low': 20.00, 'range_high': 30.00},
{'id': 10, 'name': '40x', 'expect_mul': 40.0, 'range_low': 30.00, 'range_high': 50.00},
{'id': 11, 'name': '60x', 'expect_mul': 60.0, 'range_low': 50.00, 'range_high': 70.00},
{'id': 12, 'name': '100x', 'expect_mul': 100.0, 'range_low': 70.00, 'range_high': 130.00},
{'id': 13, 'name': '150x', 'expect_mul': 150.0, 'range_low': 130.00, 'range_high': 170.00},
{'id': 14, 'name': '200x', 'expect_mul': 200.0, 'range_low': 170.00, 'range_high': 230.00},
{'id': 15, 'name': '300x', 'expect_mul': 300.0, 'range_low': 230.00, 'range_high': 370.00},
{'id': 16, 'name': '500x', 'expect_mul': 500.0, 'range_low': 370.00, 'range_high': 630.00},
{'id': 17, 'name': '1000x', 'expect_mul': 1000.0, 'range_low': 630.00, 'range_high': 1370.00},
]
# ============================================================
# 回合生成器参数
# ============================================================
GRID_ROWS = 5
GRID_COLS = 6
GRID_SIZE = GRID_ROWS * GRID_COLS # 30
# 短码映射(网格/步骤 JSON 存储用,对齐 sweet 风格)
SYMBOL_SHORT = {
'CROWN': 'A', 'HOURGLASS': 'B', 'RING': 'C', 'CHALICE': 'D', 'RED_GEM': 'E',
'PURPLE_GEM': 'F', 'YELLOW_GEM': 'G', 'GREEN_GEM': 'H', 'BLUE_GEM': 'I',
}
SHORT_TO_SYMBOL = {v: k for k, v in SYMBOL_SHORT.items()}
SCATTER_SHORT = 'S'
BOMB_SHORT = 'M'
# 普通符号随机权重(赔率越高权重越低)
SYMBOL_WEIGHTS = {'A': 5, 'B': 6, 'C': 7, 'D': 8, 'E': 9, 'F': 10, 'G': 10, 'H': 10, 'I': 12}
# 炸弹(乘数符号)参数
BOMB_CELL_PROBABILITY = 0.04 # 每格出现炸弹概率 ≈1个/屏
BOMB_VALUES = sorted(MULTIPLIER_VALUES)
BOMB_WEIGHT_POWER = 0.5 # 值越大权重越低: w = 1/v^power
# 炸弹品质(按乘数值分 5 档,对应绿/蓝/紫/红/彩虹 5 种球体)
BOMB_QUALITIES = [
# (quality_code, 名称, 等级, 包含乘数值)
('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)),
]
# value -> quality_code 快速查表
BOMB_VALUE_QUALITY = {v: code for code, _, _, vals in BOMB_QUALITIES for v in vals}
def bomb_quality(value: int) -> str:
"""炸弹乘数值 → 品质编码(GREEN/BLUE/PURPLE/RED/RAINBOW)"""
return BOMB_VALUE_QUALITY[value]
# 免费旋转触发/散布(底注概率翻倍,互斥规则:buy_fs 保证触发)
BASE_FS_TRIGGER_PROB = {'normal': 0.005, 'ante': 0.010}
BASE_NEARMISS_SCATTER = {
'normal': {0: 0.88, 1: 0.08, 2: 0.03, 3: 0.01},
'ante': {0: 0.74, 1: 0.16, 2: 0.07, 3: 0.03},
}
FS_TRIGGER_SCATTER = {4: 0.80, 5: 0.15, 6: 0.05} # 触发盘落 S 数
FREESPIN_SCATTER = { # FS 每旋落 S 数(≥3 → +5)
'default': {0: 0.70, 1: 0.15, 2: 0.07, 3: 0.05, 4: 0.025, 5: 0.005},
'buy_fs': {0: 0.78, 1: 0.13, 2: 0.06, 3: 0.02, 4: 0.008, 5: 0.002},
}
MAX_FREE_SPINS = 30 # 重触发总次数上限
# ============================================================
# 免费旋转"每旋保底消除"(仅 buy_fs)
# 动机:15 旋原来 86.8% 的旋完全空转(45% 强制空转 + 自然填充 82.9% 凑不出 8 连)
# 做法:本旋没消成时按概率补一组最小档消除(8 连低价值符号 = 0.25 倍底注),消完再按概率续链
# 预算:补桶时按桶目标反推本局 FS 预算 —— 按剩余旋数自平衡,花完即停
# 注:normal/ante 的自然触发盘不走这套(仍按原 45% 空转 + 自然),避免打穿 0x~2.5x 低倍桶
# ============================================================
FS_SPIN_WIN_PROB = 0.60 # 自然阶段:每旋"至少消一波"的概率
# 有预算时(buy_fs / 高倍桶补桶)不再按"剩余预算÷剩余旋数"算每旋概率 —— 扣预算扣的是**已被累计
# 倍数放大**的赢额,累计器一涨几旋就把预算烧穿 → 后半局 p=0 一直空转到结束(实测买 FS 有 88% 的局
# 出现 ≥4 旋连空、51.7% 的局尾巴一直空转)。改成:一局能买几次消除由预算反推
# (k ≈ √(预算 / FS_GUARANTEE_COST)),每次消除后**重抽下一次的位置**(间隔随机,见
# `olympus_simulator._fs_next_target`)—— 整局消除次数不变,但不再"每 4 旋必消"。
FS_GUARANTEE_COST = 0.45 # 每多一次"保底消除+炸弹累计"的边际赔付(实测 ≈0.45×k)
# 随机间隔的兜底:连续空转越久,越可能被强制补一次保底消除(**软**上限,不是硬闸)——
# 硬上限(到第 N 旋必消)会留下新的固定天花板(实测硬顶 6 时有 42% 的局"最长连空正好 6"),
# 所以按概率递增:dry=5/6 时 0.5/0.85,dry≥8 必补。配额提前用完时靠它兜住长空尾。
# 实测(box21 整池):最长连空 2~8 旋散开、单值最高 42%(等距版是 87% 都卡在 4),≥7 旋占 2.5%。
FS_DRY_FORCE_PROB = {5: 0.50, 6: 0.85}
FS_DRY_FORCE_HARD = 8 # 到这个旋数无条件补(概率表兜不到时)
# 连消概率:越低,同一份预算能覆盖的"有消除的旋"越多(0.45 时预算集中在少数旋上,
# 一局只剩 1~2 旋有消除;0.10 时同样的钱能铺到 4~10 旋)
FS_CASCADE_CONTINUE_PROB = 0.10
FS_CASCADE_MAX = 5 # 单旋最大消除波数
FS_GUARANTEE_COMBOS = ( # 保底消除组合 (符号, 个数, 赔付倍数@1倍底注),最小档为主
('I', 8, 0.25), ('I', 8, 0.25), ('H', 8, 0.40), ('G', 8, 0.50),
)
# 免费旋转里的炸弹(buy_fs):**每一旋都可能出现、每屏最多 1 颗**。
# ① 每一旋都可能出现:前面几旋的消除盘面上也看得见炸弹,倍数从第 1~2 旋就开始涨;
# ② 每屏最多 1 颗:一屏堆 2~4 颗会"一次消除好几颗一起爆",看不出"消一次爆一颗"的节奏;
# ③ 炸弹**有大有小**,但按该局的档位配:低倍档(< FS_BIG_BOMB_TARGET)用 2~8 的小球
# (累计倍数作用到每一次消除:0.4×2 + 0.5×4 = 2.8 这种滚雪球,大球一合并后面每次消除
# 都被放大几十倍,低倍档的预算立刻见底),高倍档(≥100x)用全量 2~1000 —— 所以
# "大炸弹"照样存在,只是它出现的那些局自然落在高倍桶。
FS_SCREEN_BOMB_PROB = 0.08
FS_SMALL_BOMB_MAX = 8
FS_BIG_BOMB_TARGET = 100
FS_MAX_BOMBS_PER_SPIN = 1
# "只能靠免费旋转赔出来"的目标倍数下限:normal/ante 的基础旋不出炸弹,最多只能凑到
# A12(50) + B10(10) ≈ 60x(自然多组 75x 已属极罕见),所以目标 ≥80x 的桶(100x/150x/200x)
# 只能生成"自然触发 FS"的回合来装 —— 这也是这两个模式里玩家能进 FS 的现实来源。
FS_ONLY_TARGET_MUL = 80.0
# 目标 RTP 与各模式可用饰品桶
RTP = 0.8
MODE_ORNAMENT_IDS = {
'normal': list(range(1, 14)), # 0x ~ 150x
'ante': list(range(1, 15)), # 0x ~ 200x
'buy_fs': list(range(7, 18)), # 10x ~ 1000x(保证进 FS;原 5 起=4x 起,低倍桶预算装不下 FS 动画)
}
# 桶"实际发出期望"的**回退值**:桶 17 展示 1000x(区间 630~1370),但单局封顶 1000 →
# 真正发出去的是 min(原始分, 1000):
# · 理论值(假设原始分在 630~1370 均匀分布):E[min(x,1000)] = 907.5
# · 实测值(buy_fs 10 箱 × 桶17 全部回合 = 200 个):均值 834.6、中位 821.3、27.5% 触顶 1000
# —— 生成端种的大奖目标集中在 1000 附近、不是均匀分布,所以实测低于理论值。
# 注意:配比(calcRounds)与后台「目标RTP」现在优先用**该箱池内 AVG(actual_mul)**(见
# _recalc_boxes.load_pool_expect / Java loadPoolExpect),这个覆盖值只在"池子里一条回合都没有"
# 时兜底 —— 它比名义 1000 诚实(高估约 0.5~1.6 个百分点)。
PAYOUT_EXPECT_OVERRIDE = {17: 835.0}
_ORN_EXPECT = {o['id']: float(o['expect_mul']) for o in ORNAMENTS}
def payout_expect(orn_id: int) -> float:
"""桶的"实际发出期望"回退值:受封顶影响的桶用实测覆盖值,其余用名义 expect_mul。
用于:池子里还没有该桶回合时的兜底、以及模拟/报表里的名义口径对比。
**抽回合时的 payoutCap 过滤仍用名义值**(Java OlympusRoundPicker 读 DB 的 expect_mul,
偏保守,保留安全余量)。"""
return PAYOUT_EXPECT_OVERRIDE.get(orn_id, _ORN_EXPECT.get(orn_id, 0.0))
# ============================================================
# 多身份爆率(audience):每种身份一套独立的饰品桶数量分布,实现差异化 RTP。
# 回合库存池(round/step)完全共享,仅“抽哪个桶”的概率按身份区分。
# 与现有宝箱 tt_user 对齐:user_type 01主播 / 02普通 / 03机器人;is_blank_account=1 白号。
# 身份解析优先级(Java 开局):白号最高优先,压过 user_type:
# is_blank_account==1 → white;user_type=01 → anchor;03 → robot;其余 → common。
# 白号池(white):独立 count/used,抽取只递减自己的 used、独立补桶,
# 永不消耗 common/anchor/robot 库存;白号 bet/win 不计水位、赢奖不进真实经济。
# 机器人暂不细分幸运/非幸运档(后续视 2v2 需求再扩展 robot_unlucky)。
# ============================================================
AUDIENCE_COMMON = 'common' # 普通用户(对齐 user_type=02 及招商/卡商/超管等其他)
AUDIENCE_ANCHOR = 'anchor' # 主播(user_type=01)
AUDIENCE_ROBOT = 'robot' # 机器人(user_type=03)
AUDIENCE_WHITE = 'white' # 白号(is_blank_account=1,压过 user_type)
AUDIENCES = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_ROBOT, AUDIENCE_WHITE)
DEFAULT_AUDIENCE = AUDIENCE_COMMON
# ============================================================
# 爆奖动画(小/中/大 3 档):**只在免费旋转里判档**,按"单旋赢额"独立判档,
# 达标即在该旋末帧盖章,同档一局可多次;normal/ante 的基础旋一律 null(不播大奖动画)。
# 两种情况两套阈值(单位:单旋赢额 ÷ 基础下注):
# ① 自然免费旋转(normal/ante 摇出 4+ S 进入):5 / 25 / 100
# 两个模式的 FS 预算与旋数完全一致 → 共用一套;实测 FS 内约 15% 的旋 ≥5、
# 约 4% 的旋 ≥25、约 1.7% 的旋 ≥100(约 1/4 的 FS 局能见到"大")
# ② 购买免费旋转(buy_fs):20 / 60 / 200
# 买 FS 的赢额分散在 15+ 旋里,单旋阈值整体下调;实测约 3.5% 的旋 ≥20、
# 约 0.9% 的旋 ≥60、约 0.1% 的旋 ≥200(约 2% 的买 FS 局能见到"大")
# 编码 small < middle < big:前端按这三个值播小/中/大动画,null 表示不播
# ============================================================
BIGWIN_LEVELS = ('small', 'middle', 'big')
BIGWIN_TIERS = {
'normal': (5, 25, 100),
'ante': (5, 25, 100), # 与 normal 同一口径:都是"自然触发的 FS",整局规模相同
'buy_fs': (20, 60, 200),
}
def bigwin_level(mode_code: str, spin_mul: float):
"""单旋赢额(基础注倍数)→ 爆奖档位编码 small/middle/big;未达最低档返回 None"""
tiers = BIGWIN_TIERS[mode_code]
level = None
for name, threshold in zip(BIGWIN_LEVELS, tiers):
if spin_mul >= threshold:
level = name
return level
-169
View File
@@ -1,169 +0,0 @@
from __future__ import annotations
from collections import Counter
from decimal import Decimal
from typing import Iterable, Mapping, Sequence
from .config import (
DEFAULT_MODE,
MAX_SYMBOL_COUNT,
MAX_WIN_MULTIPLIER,
MIN_WIN_COUNT,
MODES,
MULTIPLIER_VALUES,
SYMBOL_RULES,
GameMode,
)
from .models import Bomb, CascadeResult, Elimination, SessionState, SpinResult
class RuleViolation(ValueError):
pass
class OlympusEngine:
def __init__(
self,
bet: Decimal | int | str = Decimal("1"),
mode: str = DEFAULT_MODE,
) -> None:
if mode not in MODES:
raise RuleViolation(f"未知模式: {mode},可选: {sorted(MODES)}")
self.mode: GameMode = MODES[mode]
self.bet = Decimal(str(bet))
if self.bet <= 0:
raise RuleViolation("bet 必须大于 0")
# 档位校验:整数档位必须在模式允许列表内(非整数 bet 仅用于单测/模拟,不校验)
if self.bet == self.bet.to_integral_value() and int(self.bet) not in self.mode.base_bets:
raise RuleViolation(
f"模式 {self.mode.code} 不支持下注档位 {int(self.bet)},可选: {self.mode.base_bets}"
)
self.cost = self.bet * self.mode.cost_multiplier # 实际扣费
self.max_win = self.bet * MAX_WIN_MULTIPLIER # 单局封顶 1000x 基础下注
self.state = SessionState()
self._cascades: list[CascadeResult] | None = None
self._symbol_counts: Counter[str] = Counter()
self._spin_base_win = Decimal("0")
self._bombs: dict[str, Bomb] = {}
self._is_free_spin = False
def buy_free_spins_allowed(self) -> bool:
"""当前模式是否允许购买免费旋转(底注与购买互斥)。"""
return self.mode.can_buy_free_spins
def ante_boost_active(self) -> bool:
"""当前模式是否启用底注效果(购买免费旋转模式下强制关闭)。"""
return self.mode.ante_boost
def begin_spin(self, is_free_spin: bool = False) -> int:
"""is_free_spin=True 表示免费旋转:炸弹进入累计器;基础旋转炸弹仅对本旋生效。"""
if self._cascades is not None:
raise RuleViolation("当前回合尚未结束")
self._cascades = []
self._symbol_counts.clear()
self._spin_base_win = Decimal("0")
self._bombs.clear()
self._is_free_spin = is_free_spin
return self.state.next_spin_index
def record_cascade(
self,
symbols: Mapping[str, Sequence[int]] | None = None,
bombs: Iterable[Bomb] = (),
) -> CascadeResult:
if self._cascades is None:
raise RuleViolation("请先调用 begin_spin")
eliminations = []
cascade_base_win = Decimal("0")
for symbol, raw_positions in (symbols or {}).items():
if symbol not in SYMBOL_RULES:
raise RuleViolation(f"未知符号: {symbol}")
positions = tuple(raw_positions)
count = len(positions)
if count < MIN_WIN_COUNT or count > MAX_SYMBOL_COUNT:
raise RuleViolation(f"{symbol} 消除数量必须在 {MIN_WIN_COUNT}-{MAX_SYMBOL_COUNT} 之间")
if len(set(positions)) != count:
raise RuleViolation(f"{symbol} 的消除位置不能重复")
payout_multiplier = SYMBOL_RULES[symbol].payout_for(count)
elimination = Elimination(
symbol=symbol,
count=count,
positions=positions,
payout_multiplier=payout_multiplier,
)
eliminations.append(elimination)
cascade_base_win += payout_multiplier * self.bet
self._symbol_counts[symbol] += count
current_bombs = []
for bomb in bombs:
if bomb.value not in MULTIPLIER_VALUES:
raise RuleViolation(f"不支持的炸弹倍数: {bomb.value}")
existing = self._bombs.get(bomb.bomb_id)
if existing is not None and existing != bomb:
raise RuleViolation(f"炸弹 {bomb.bomb_id} 的数据前后不一致")
if existing is None:
self._bombs[bomb.bomb_id] = bomb
current_bombs.append(bomb)
self._spin_base_win += cascade_base_win
result = CascadeResult(
cascade_index=len(self._cascades) + 1,
eliminations=tuple(eliminations),
bombs=tuple(current_bombs),
base_win=cascade_base_win,
accumulated_symbols=dict(self._symbol_counts),
)
self._cascades.append(result)
return result
def end_spin(self) -> SpinResult:
if self._cascades is None:
raise RuleViolation("请先调用 begin_spin")
multiplier_before = self.state.accumulated_multiplier
has_win = self._spin_base_win > 0
bombs_added = sum(bomb.value for bomb in self._bombs.values()) if has_win else 0
if self._is_free_spin:
# 免费旋转:炸弹并入累计器,赢额乘“累计+本旋新炸弹”
multiplier_after = multiplier_before + bombs_added
effective_multiplier = max(1, multiplier_after) if has_win else 0
else:
# 基础旋转:无累计器,炸弹仅乘本旋赢额,不留存
multiplier_after = multiplier_before
effective_multiplier = max(1, bombs_added) if has_win else 0
total_win = self._spin_base_win * effective_multiplier
# 封顶:整局累计赢奖不超过 1000 倍基础下注(最高 1000x 饰品)
remaining = self.max_win - self.state.total_win
if remaining < 0:
remaining = Decimal("0")
capped = total_win > remaining
if capped:
total_win = remaining
result = SpinResult(
spin_index=self.state.next_spin_index,
cascades=tuple(self._cascades),
base_win=self._spin_base_win,
multiplier_before=multiplier_before,
bombs_added=bombs_added,
multiplier_after=multiplier_after,
effective_multiplier=effective_multiplier,
total_win=total_win,
accumulated_symbols=dict(self._symbol_counts),
win_capped=capped,
)
self.state.spins.append(result)
self.state.next_spin_index += 1
self.state.accumulated_multiplier = multiplier_after
self.state.total_win += total_win
self._cascades = None
return result
def reset_session(self) -> None:
if self._cascades is not None:
raise RuleViolation("当前回合尚未结束")
self.state.reset()
-57
View File
@@ -1,57 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Dict, List, Tuple
@dataclass(frozen=True)
class Bomb:
bomb_id: str
value: int
position: int | None = None
@dataclass(frozen=True)
class Elimination:
symbol: str
count: int
positions: Tuple[int, ...]
payout_multiplier: Decimal
@dataclass(frozen=True)
class CascadeResult:
cascade_index: int
eliminations: Tuple[Elimination, ...]
bombs: Tuple[Bomb, ...]
base_win: Decimal
accumulated_symbols: Dict[str, int]
@dataclass(frozen=True)
class SpinResult:
spin_index: int
cascades: Tuple[CascadeResult, ...]
base_win: Decimal
multiplier_before: int
bombs_added: int
multiplier_after: int
effective_multiplier: int
total_win: Decimal
accumulated_symbols: Dict[str, int]
win_capped: bool = False # 本回合赢奖因 1000x 封顶被截断
@dataclass
class SessionState:
next_spin_index: int = 1
accumulated_multiplier: int = 0
total_win: Decimal = Decimal("0")
spins: List[SpinResult] = field(default_factory=list)
def reset(self) -> None:
self.next_spin_index = 1
self.accumulated_multiplier = 0
self.total_win = Decimal("0")
self.spins.clear()
-229
View File
@@ -1,229 +0,0 @@
"""
Olympus 数据库操作模块
用法:
python olympus_db.py # 建表 + 写入饰品桶种子数据
连接参数可用环境变量覆盖: OLY_DB_HOST / OLY_DB_PORT / OLY_DB_USER / OLY_DB_PASSWORD / OLY_DB_NAME
默认对齐本地开发库 (127.0.0.1:23306 changzhou,见 docker/compose/docker-compose.yml)。
"""
import json
import os
import time
from contextlib import contextmanager
from pathlib import Path
import pymysql
from olympus.config import ORNAMENTS
DB_CONFIG = {
'host': os.environ.get('OLY_DB_HOST', '127.0.0.1'),
'port': int(os.environ.get('OLY_DB_PORT', '23306')),
'user': os.environ.get('OLY_DB_USER', 'changzhou'),
'password': os.environ.get('OLY_DB_PASSWORD', 'iTSJSPPZM3LSGAPC'),
'database': os.environ.get('OLY_DB_NAME', 'changzhou'),
'charset': 'utf8mb4',
'local_infile': True,
}
SCHEMA_FILE = Path(__file__).resolve().parent / 'schema.sql'
@contextmanager
def get_conn():
conn = pymysql.connect(**DB_CONFIG)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ============================================================
# 建表(执行 schema.sql 中所有语句,注释行自动跳过)
# ============================================================
def _load_schema_statements():
text = SCHEMA_FILE.read_text(encoding='utf-8')
lines = [ln for ln in text.splitlines() if not ln.strip().startswith('--')]
statements = [s.strip() for s in '\n'.join(lines).split(';') if s.strip()]
return statements
def create_tables():
"""建表 + 写入饰品桶种子数据(幂等,可重复执行)"""
stmts = _load_schema_statements()
with get_conn() as conn:
cur = conn.cursor()
for sql in stmts:
cur.execute(sql)
print(f'OK: executed {len(stmts)} statements (tables + ornament seed).')
def verify_tables():
"""校验所有 olympus 表已存在并输出行数"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute("SHOW TABLES LIKE 'game_olympus_%'")
tables = [row[0] for row in cur.fetchall()]
for t in sorted(tables):
cur.execute(f"SELECT COUNT(*) FROM {t}")
print(f'{t}: {cur.fetchone()[0]} rows')
return tables
# ============================================================
# Ornament 操作
# ============================================================
def insert_ornaments():
"""按 config.ORNAMENTS 覆盖写入饰品桶(与 schema.sql 种子一致)"""
with get_conn() as conn:
cur = conn.cursor()
for o in ORNAMENTS:
cur.execute("""
INSERT INTO game_olympus_ornament (id, game_type, expect_mul, name, range_low, range_high)
VALUES (%s,'OLYMPUS',%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
range_low=VALUES(range_low), range_high=VALUES(range_high)
""", (o['id'], o['expect_mul'], o['name'], o['range_low'], o['range_high']))
# ============================================================
# Box 操作
# ============================================================
def insert_box(box):
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""
INSERT INTO game_olympus_box (box_id, game_type, box_type, bet, name, price,
total_ornaments, rtp, sort, status)
VALUES (%s,'OLYMPUS',%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE box_type=VALUES(box_type),
bet=VALUES(bet), name=VALUES(name),
price=VALUES(price), total_ornaments=VALUES(total_ornaments),
rtp=VALUES(rtp)
""", (box['box_id'], box['box_type'], box['bet'], box['name'],
box['price'], box['total_ornaments'], box['rtp'],
box.get('sort', 0), box.get('status', '1')))
def query_boxes():
with get_conn() as conn:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute("SELECT * FROM game_olympus_box WHERE status='1' ORDER BY box_id")
return cur.fetchall()
# ============================================================
# Round / Step 批量写入(单连接清空+写入,死锁自动重试)
# ============================================================
def get_max_round_id():
with get_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT COALESCE(MAX(round_id), 0) FROM game_olympus_round")
return cur.fetchone()[0]
def get_max_step_id():
with get_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT COALESCE(MAX(id), 0) FROM game_olympus_step")
return cur.fetchone()[0]
def _j(v, default='[]'):
if v is None:
return default
return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
def write_box_distribution(box_id, counts, audience='common'):
"""单连接:按身份清空并写入饰品桶分布(只动该 audience,不影响其他身份)"""
for attempt in range(5):
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"DELETE FROM game_olympus_box_ornament WHERE box_id=%s AND audience=%s",
(box_id, audience))
if counts:
sql = ("INSERT INTO game_olympus_box_ornament"
" (box_id, ornament_id, audience, count, used)"
" VALUES (%s,%s,%s,%s,0)")
rows = [(box_id, oid, audience, cnt)
for oid, cnt in sorted(counts.items()) if cnt > 0]
cur.executemany(sql, rows)
return
except Exception as e:
if 'Deadlock' in str(e) and attempt < 4:
time.sleep(0.5 * (attempt + 1))
continue
raise
def write_box_distributions(box_id, counts_by_audience):
"""一次写入多套身份分布。counts_by_audience: {audience: {ornament_id: count}}"""
for audience, counts in counts_by_audience.items():
write_box_distribution(box_id, counts, audience)
def write_rounds_and_steps(box_id, rounds_data, steps_data):
"""单连接:清空旧回合+步骤,批量写入新数据"""
for attempt in range(5):
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute("""DELETE FROM game_olympus_step WHERE round_id IN
(SELECT round_id FROM game_olympus_round WHERE box_id=%s)""", (box_id,))
cur.execute("DELETE FROM game_olympus_round WHERE box_id=%s", (box_id,))
if rounds_data:
sql_r = """INSERT INTO game_olympus_round
(round_id, box_id, ornament_id, actual_mul, free_spin_count, total_cascades)
VALUES (%s,%s,%s,%s,%s,%s)"""
rows_r = [(rd['round_id'], rd['box_id'], rd['ornament_id'],
rd['actual_mul'], rd['free_spin_count'], rd['total_cascades'])
for rd in rounds_data]
cur.executemany(sql_r, rows_r)
if steps_data:
sql_s = """INSERT INTO game_olympus_step
(id, round_id, free_spin_id, cascade_seq, grid_state,
multiplier_detail, symbol_links, accumulated_symbols, spin_winners,
removed_positions, drop_data, fill_data,
scatter_trigger, retrigger_info, base_score, win_amount,
accumulator_before, accumulator_after, spin_win, win_capped,
bigwin_level, is_final)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"""
rows_s = [(s['id'], s['round_id'], s['free_spin_id'], s['cascade_seq'],
_j(s['grid_state']),
_j(s.get('multiplier_detail')),
_j(s.get('symbol_links')),
_j(s.get('accumulated_symbols'), default='{}'),
_j(s.get('spin_winners')),
_j(s.get('removed_positions')),
_j(s.get('drop_data')),
_j(s.get('fill_data')),
_j(s.get('scatter_trigger'), default=None),
_j(s.get('retrigger_info'), default=None),
s['base_score'], s['win_amount'],
s['accumulator_before'], s['accumulator_after'],
s['spin_win'], s.get('win_capped', 0),
s.get('bigwin_level'), s['is_final'])
for s in steps_data]
cur.executemany(sql_s, rows_s)
return
except Exception as e:
if 'Deadlock' in str(e) and attempt < 4:
time.sleep(0.5 * (attempt + 1))
continue
raise
if __name__ == '__main__':
create_tables()
verify_tables()
-805
View File
@@ -1,805 +0,0 @@
"""
Olympus 回合生成器核心 — 正向生成
流程: 生成 cascade → 自然累计 → 总分落桶
核心规则(与 olympus/engine.py 对齐):
1. 一旋转内多次消除,中间步只记基础分,末帧统一引爆炸弹
2. 炸弹倍数跨旋转累计(accumulator 贯穿整个回合:基础旋转 + 免费旋转)
3. 每步 accumulated_symbols 记录本旋转累计消除符号
4. 落桶用理论总分(最高 1370),发放封顶 1000(末步截断 + win_capped)
"""
import json
import random
from collections import Counter
from olympus.config import (
SYMBOL_RULES, SHORT_TO_SYMBOL, SCATTER_SHORT, BOMB_SHORT,
SYMBOL_WEIGHTS, BOMB_CELL_PROBABILITY, BOMB_VALUES, BOMB_WEIGHT_POWER,
GRID_ROWS, GRID_COLS, GRID_SIZE, MIN_WIN_COUNT,
SCATTER_PAYOUTS, SCATTER_FREE_SPIN_TRIGGER, SCATTER_RETRIGGER_TRIGGER,
FREE_SPINS_AWARDED, FREE_SPIN_RETRIGGER_COUNT, MAX_FREE_SPINS,
BASE_FS_TRIGGER_PROB, BASE_NEARMISS_SCATTER, FS_TRIGGER_SCATTER, FREESPIN_SCATTER,
FS_SPIN_WIN_PROB, FS_CASCADE_CONTINUE_PROB, FS_CASCADE_MAX, FS_GUARANTEE_COMBOS,
FS_GUARANTEE_COST, FS_DRY_FORCE_PROB, FS_DRY_FORCE_HARD,
FS_SMALL_BOMB_MAX, FS_BIG_BOMB_TARGET, FS_SCREEN_BOMB_PROB, FS_MAX_BOMBS_PER_SPIN,
FS_ONLY_TARGET_MUL,
ORNAMENTS, MAX_WIN_MULTIPLIER, bomb_quality, bigwin_level,
)
REGULAR_SHORTS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
MAX_WIN = float(MAX_WIN_MULTIPLIER) # 发放封顶 1000
BUCKET_CEIL = max(o['range_high'] for o in ORNAMENTS) # 落桶理论上限 1370
# 保底消除预算(1 倍底注口径):最小档 0.25,连消后期望 1/(1-p) 波
FS_FLOOR_UNIT = min(c[2] for c in FS_GUARANTEE_COMBOS)
FS_FLOOR_SPEND = FS_FLOOR_UNIT / (1.0 - FS_CASCADE_CONTINUE_PROB) # ≈0.45/旋
FS_FLOOR_BUDGET_CAP = FREE_SPINS_AWARDED * FS_FLOOR_SPEND # ≈6.8/局
# ====================================================================
# 工具
# ====================================================================
def _payout(short, cnt):
return float(SYMBOL_RULES[SHORT_TO_SYMBOL[short]].payout_for(cnt))
def _weighted_sym(exclude=()):
syms = [s for s in REGULAR_SHORTS if s not in exclude]
wts = [SYMBOL_WEIGHTS[s] for s in syms]
r = random.uniform(0, sum(wts))
a = 0.0
for s, w in zip(syms, wts):
a += w
if r <= a:
return s
return syms[-1]
def _weighted_choice(dist):
r = random.random()
cum, last = 0.0, None
for k, p in dist.items():
last = k
cum += p
if r <= cum:
return k
return last
def _bucket(score):
"""理论总分落桶(score 可到 1370+,超出归顶桶)"""
if score <= 0:
return 1
if score > BUCKET_CEIL:
return ORNAMENTS[-1]['id']
for o in ORNAMENTS:
if o['range_low'] <= score <= o['range_high']:
return o['id']
for o in ORNAMENTS[1:]:
if score <= o['range_high']:
return o['id']
return ORNAMENTS[-1]['id']
def _gen_bomb_value(max_val=None):
opts = [v for v in BOMB_VALUES if max_val is None or v <= max_val]
wts = [1.0 / (v ** BOMB_WEIGHT_POWER) for v in opts]
r = random.uniform(0, sum(wts))
a = 0.0
for v, w in zip(opts, wts):
a += w
if r <= a:
return v
return opts[-1]
def _bomb_combos(target):
"""列出 ≤3 颗炸弹、和最接近 target 的所有组合:(误差, 组合),按误差升序"""
out = []
vals = BOMB_VALUES
for a in vals:
out.append((abs(a - target), [a]))
if a > target * 1.5:
break
for a in vals:
if a > target:
break
for b in vals:
s = a + b
if s > target * 1.5:
break
out.append((abs(s - target), [a, b]))
for a in vals:
if a > target:
break
for b in vals:
if a + b > target:
break
for c in vals:
s = a + b + c
if s > target * 1.5:
break
out.append((abs(s - target), [a, b, c]))
out.sort(key=lambda x: (x[0], len(x[1])))
return out
def _best_bombs(target):
"""确定性最优(只用于评估方案误差)"""
combos = _bomb_combos(target)
return combos[0][1] if combos else [2]
def _decompose_best(target):
"""在"接近最优"的组合里随机挑一个,并强烈偏向单颗 —— 一屏一颗,
玩家才看得出"消一次爆一颗、倍数一颗一颗涨";改前每次都返回同一套
(比如某些目标永远是 [3] 或 [3,6]),看起来"消除必定带 3 和 6 倍炸弹"。"""
combos = _bomb_combos(target)
if not combos:
return [2]
best_err = combos[0][0]
pool = [c for e, c in combos if e <= best_err + max(0.5, best_err * 0.15)]
weights = [1.0 / (len(c) ** 3) for c in pool] # 1 颗 : 2 颗 : 3 颗 = 1 : 1/8 : 1/27
return random.choices(pool, weights=weights)[0]
# ====================================================================
# 网格
# ====================================================================
def _empty_grid():
return [[''] * GRID_COLS for _ in range(GRID_ROWS)]
def _count(grid):
c = Counter()
for row in grid:
for cell in row:
if cell:
c[cell] += 1
return c
def _winners(grid):
w = {}
for sym, cnt in _count(grid).items():
if sym in (BOMB_SHORT, SCATTER_SHORT, ''):
continue
if cnt >= MIN_WIN_COUNT:
pay = _payout(sym, cnt)
if pay > 0:
w[sym] = (cnt, pay)
return w
def _no_extra(grid, allowed):
"""打散计划外的 ≥8 同符号组"""
backup = [s for s in REGULAR_SHORTS if s not in allowed] or ['I']
for _ in range(50):
dirty = False
for sym, cnt in _count(grid).items():
if sym in (BOMB_SHORT, SCATTER_SHORT, '') or sym in allowed:
continue
if cnt >= MIN_WIN_COUNT:
excess = cnt - (MIN_WIN_COUNT - 1)
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
if grid[r][c] == sym and excess > 0:
grid[r][c] = random.choice(backup)
excess -= 1
dirty = True
if not dirty:
break
def _fill(grid, mul_map, allow_bomb, track=False, exclude_syms=(),
bomb_prob=BOMB_CELL_PROBABILITY, bomb_max=None, max_bombs=None):
"""填充空格。allow_bomb 时按 bomb_prob 概率放炸弹(bomb_max 限制炸弹值、max_bombs 限制每屏颗数)。
track 返回填充数据(仅普通符号)"""
filled = [] if track else None
placed = len(mul_map)
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
if grid[r][c] == '':
pos = r * GRID_COLS + c
if allow_bomb and random.random() < bomb_prob \
and (max_bombs is None or placed < max_bombs):
grid[r][c] = BOMB_SHORT
mul_map[pos] = _gen_bomb_value(bomb_max)
placed += 1
else:
sym = _weighted_sym(exclude_syms)
grid[r][c] = sym
if track:
filled.append({"pos": pos + 1, "symbol": sym})
return filled
def _collapse(grid, remove, mul_map):
"""消除指定位置(0-based),重力下落。炸弹/散布随位置移动,mul_map 同步重映射"""
rs = set(remove)
drops = []
ng = _empty_grid()
pos_remap = {}
for c in range(GRID_COLS):
surviving = []
for r in range(GRID_ROWS - 1, -1, -1):
p = r * GRID_COLS + c
if p not in rs:
surviving.append((p, grid[r][c]))
for i, (old_pos, sym) in enumerate(surviving):
new_r = GRID_ROWS - 1 - i
new_pos = new_r * GRID_COLS + c
ng[new_r][c] = sym
pos_remap[old_pos] = new_pos
if new_pos != old_pos:
drops.append({"from": old_pos + 1, "to": new_pos + 1, "symbol": sym})
new_mul = {pos_remap[p]: v for p, v in mul_map.items() if p in pos_remap}
return ng, drops, new_mul
def _place_scatter(grid, count, protect=()):
cand = [(r, c) for r in range(GRID_ROWS) for c in range(GRID_COLS)
if grid[r][c] not in ({SCATTER_SHORT, BOMB_SHORT} | set(protect))]
for r, c in random.sample(cand, min(count, len(cand))):
grid[r][c] = SCATTER_SHORT
def _count_scatter(grid):
return sum(1 for row in grid for cell in row if cell == SCATTER_SHORT)
def _force_small_win(grid, prefer=()):
"""就地补一组最小档消除(默认 8 连低价值符号),不覆盖已有 S/M 格。
prefer = 这一波"刚落下 / 刚补位"的格子(0 基)。保底消除优先种在这些格子上:
它们本来就有 drop/fill 记录,前端按动画数据落地就能与 grid_state 完全一致;
种在**没移动过**的格子上则没有任何动画记录,前端只能靠 grid_state 兜底。"""
sym, cnt, _ = random.choice(FS_GUARANTEE_COMBOS)
free = [p for p in range(GRID_SIZE)
if grid[p // GRID_COLS][p % GRID_COLS] not in (SCATTER_SHORT, BOMB_SHORT)]
if len(free) < cnt:
return
fresh = [p for p in prefer if p in set(free)]
if len(fresh) >= cnt:
picks = random.sample(fresh, cnt)
else:
rest = [p for p in free if p not in set(fresh)]
picks = fresh + random.sample(rest, cnt - len(fresh))
for p in picks:
grid[p // GRID_COLS][p % GRID_COLS] = sym
_no_extra(grid, {sym})
def _scatter_payout(count):
if count >= SCATTER_FREE_SPIN_TRIGGER:
return float(SCATTER_PAYOUTS.get(min(count, 6), 0))
return 0.0
# ====================================================================
# 强制中奖(bias 补桶用)
# ====================================================================
# (基础分, 组合) 按基础分降序
_BASE_OPTIONS = [
(60.0, [('A', 12), ('B', 10)]),
(50.0, [('A', 12)]),
(35.0, [('A', 10), ('B', 10)]),
(25.0, [('A', 10)]),
(12.5, [('A', 8), ('B', 8)]),
(10.0, [('A', 8)]),
(5.0, [('C', 10)]),
(3.5, [('C', 8), ('D', 8)]),
(2.5, [('B', 8)]),
(2.0, [('C', 8)]),
(1.5, [('D', 8)]),
(1.0, [('E', 8)]),
(0.8, [('F', 8)]),
(0.5, [('G', 8)]),
(0.4, [('H', 8)]),
(0.25, [('I', 8)]),
]
def _plan_for(target):
"""全局搜索 (基础组合, 炸弹列表),使 base × max(1, Σ炸弹) 最接近 target。
在"误差在最优 +5 个百分点以内"的若干方案里随机挑一个 —— 改前永远返回同一套最优解,
所以同一档位的回合盘面/炸弹老是那几个("某个符号消除必定带着 3 和 6 倍炸弹")。"""
if target <= 0:
return [], []
plans = []
for base, combo in _BASE_OPTIONS:
plans.append((abs(base - target) / target, combo, None)) # 方案A:无炸弹
m = target / base
if m >= 2: # 方案B:配炸弹
achieved = base * sum(_best_bombs(m))
plans.append((abs(achieved - target) / target, combo, m))
plans.sort(key=lambda p: p[0])
best_err = plans[0][0]
pool = [p for p in plans if p[0] <= best_err + 0.05]
_, combo, m = random.choice(pool)
return combo, ([] if m is None else _decompose_best(m))
def _plan_base_only(target):
"""只挑基础组合(不植炸弹):在接近最优的组合里随机。acc 已经提供倍数时用这个。"""
if target <= 0:
return [('I', 8)]
cands = sorted((abs(base - target) / target, combo) for base, combo in _BASE_OPTIONS)
best = cands[0][0]
pool = [c for e, c in cands if e <= best + 0.05]
return random.choice(pool)
def _force_win(grid, target, mul_map, acc=0.0, allow_bomb=True):
"""把 target 当"本旋最终赢额"来造奖:先按当前累计倍数 acc 折算需要的基础分。
买单旋赢额 = 基础分 × max(1, acc + 本旋炸弹),所以:
· acc 已经 >1 时只挑基础分、**不再植炸弹**(否则 acc 和炸弹的倍数会被算两次,
实测把 10 倍目标打成 40~50 倍);
· 该模式不允许炸弹(普通旋转/底注)时同理只挑基础分;
· 其余情况(buy_fs 且 acc=0)按原逻辑植炸弹,大奖局(100 倍以上)靠这一步才能到。"""
need_base = target / max(1.0, acc) if acc > 0 else target
if need_base >= 0.25:
if acc > 1 or not allow_bomb:
combo, bombs = _plan_base_only(need_base), []
else:
combo, bombs = _plan_for(need_base)
else:
combo, bombs = [('I', 8)], [] # acc 本身已超过目标:只给最小档,不再加炸弹
used = set()
win_syms = set()
for sym, cnt in combo:
win_syms.add(sym)
free = [p for p in range(GRID_SIZE) if p not in used]
for p in random.sample(free, min(cnt, len(free))):
r, c = divmod(p, GRID_COLS)
grid[r][c] = sym
used.add(p)
free = [p for p in range(GRID_SIZE) if p not in used]
for v in bombs:
if not free:
break
p = free.pop(random.randrange(len(free)))
r, c = divmod(p, GRID_COLS)
grid[r][c] = BOMB_SHORT
mul_map[p] = v
used.add(p)
for p in range(GRID_SIZE):
r, c = divmod(p, GRID_COLS)
if grid[r][c] == '':
grid[r][c] = _weighted_sym(exclude=win_syms)
_no_extra(grid, win_syms)
return win_syms
# ====================================================================
# 核心:一次旋转的 cascade 链
# ====================================================================
def _spin(mode_code, bias=None, acc=0, is_free_spin=False, target_scatter=0,
allow_bomb=True, win_prob=0.0, continue_prob=0.0, cascade_max=0,
bomb_prob=None, bomb_max=None, max_bombs=None):
"""
一次旋转(含多次 cascade)。
炸弹跨 cascade 保留不消除;末帧统一结算: total = base_sum * max(1, acc+bomb_sum)
acc 只在本旋转有消除时累加炸弹(与 engine.end_spin 一致)。
allow_bomb=False 时初始盘也不出炸弹;级联补位一律不出。
**炸弹只存在于免费旋转内**:普通旋转/底注的基础旋(含进 FS 的那张触发盘)一律不出炸弹;
三个模式进 FS 后规则完全一致(炸弹 + 累计器 + 每旋保底消除)。
win_prob/continue_prob:本盘凑不出消除时按概率补最小档消除(首盘/级联各自取一个),
cascade_max 限制单旋最大波数 —— 让 FS 里"每旋都在消"。
返回 (steps, spin_total, acc_out, scatter_count)
"""
if not is_free_spin:
allow_bomb = False
steps = []
seq = 0
mul_map = {} # {pos0: bomb_val} 跨 cascade 累积
if bomb_prob is None:
bomb_prob = BOMB_CELL_PROBABILITY
# 本旋是不是"大奖旋"(补桶植入目标额的那一旋):只有它按累计倍数结算
plant_spin = isinstance(bias, (int, float)) and bias > 0
grid = None
prev_win_pos = []
sym_acc = Counter() # 本旋转累计消除符号
spin_winners = [] # 落库留档用(接口已不下发,面板由 symbol_links 汇总)
spin_links = [] # 本旋累积连线上报:[{symbol,count,loc,base_score,tumble}],新旋清空
total_base = 0.0
recorded = 0.0 # 已记入前置步骤的赢额
removed, drops, fills = [], [], []
acc_before_spin = acc
while True:
seq += 1
if seq == 1:
grid = _empty_grid()
if isinstance(bias, (int, float)) and bias > 0:
win_syms = _force_win(grid, float(bias), mul_map, acc, allow_bomb)
else:
_fill(grid, mul_map, allow_bomb, bomb_prob=bomb_prob, bomb_max=bomb_max,
max_bombs=max_bombs)
if bias == 'zero':
_no_extra(grid, set())
if target_scatter > 0:
_place_scatter(grid, target_scatter, protect=set(_winners(grid)))
removed, drops, fills = [], [], []
else:
removed = [p + 1 for p in prev_win_pos]
grid, drops, mul_map = _collapse(grid, prev_win_pos, mul_map)
fills = _fill(grid, mul_map, allow_bomb=False, track=True)
# 保底消除:本盘自然凑不出 8 连时,按概率补一组最小档(首盘用 win_prob,级联用 continue_prob)
prob = win_prob if seq == 1 else continue_prob
if prob > 0 and (not cascade_max or seq < cascade_max) \
and random.random() < prob and not _winners(grid):
_force_small_win(grid, prefer=[f['pos'] - 1 for f in fills])
# 保底消除 / 打散会覆盖"刚落下"或"刚补位"的格子 —— 这两份动画数据里的 symbol 按**最终盘面**
# 回填,前端按"消→落→补"重建出来的盘面才与 grid_state 完全一致(否则会画出不对的符号)。
if drops:
drops = [dict(d, symbol=grid[(d['to'] - 1) // GRID_COLS][(d['to'] - 1) % GRID_COLS])
for d in drops]
if fills:
fills = [dict(f, symbol=grid[(f['pos'] - 1) // GRID_COLS][(f['pos'] - 1) % GRID_COLS])
for f in fills]
w = _winners(grid)
is_final = (len(w) == 0)
base = 0.0
win_pos = []
mul_det = [{"pos": p + 1, "val": v, "quality": bomb_quality(v)}
for p, v in sorted(mul_map.items())]
bomb_sum = sum(mul_map.values())
# symbol_links = **本旋累积**的连线上报(每波消除往上追加,tumble 标明是旋内第几波)——
# 它同时充当"获胜者面板"(前端按 symbol 汇总即可),所以 spin_winners 字段已删。
for sym, (cnt, pay) in sorted(w.items()):
pos_list = [r * GRID_COLS + c for r in range(GRID_ROWS)
for c in range(GRID_COLS) if grid[r][c] == sym][:cnt]
base += pay
win_pos.extend(pos_list)
sym_acc[sym] += cnt
spin_links.append({
'symbol': sym, 'count': cnt,
'loc': [p + 1 for p in pos_list],
'base_score': round(pay, 4),
'tumble': seq, # 本旋第几波消除(= 本步 tumbleNum)
})
total_base += base
# 末帧:统一引爆(一旋转内不管几次消除,消完才爆)
# 累计器仅免费旋转有效;基础旋转炸弹只乘本旋赢额、不留存
scatter_win = 0.0
scatter_count = 0
acc_after = acc
if is_final:
scatter_count = _count_scatter(grid)
scatter_win = _scatter_payout(scatter_count)
if total_base > 0:
if is_free_spin:
acc_after = acc + bomb_sum # FS:炸弹并入累计器,跨旋传递
# 本旋赢额 = 基础分 × max(1, 累计 + 本旋炸弹) —— 累计倍数作用到每一次消除
# (玩家要的就是这个滚雪球:0.4×2 + 0.5×4 = 2.8)
effective = max(1, acc_after)
else:
acc_after = acc # 基础旋:无累计器
effective = max(1, bomb_sum)
win_amt = total_base * effective - recorded + scatter_win
else:
win_amt = scatter_win # 无消除→炸弹作废、累计不变
else:
win_amt = base # 中间步只记基础分
recorded += win_amt if not is_final else 0.0
# scatter 触发/重触发(仅末帧)
scatter_trigger, retrigger_info = None, None
if is_final:
if is_free_spin and scatter_count >= SCATTER_RETRIGGER_TRIGGER:
retrigger_info = {'scatterCount': scatter_count,
'extraSpins': FREE_SPIN_RETRIGGER_COUNT,
'scatterWin': round(scatter_win, 2)}
elif not is_free_spin and scatter_count >= SCATTER_FREE_SPIN_TRIGGER:
scatter_trigger = {'scatterCount': scatter_count,
'freeSpins': FREE_SPINS_AWARDED,
'scatterWin': round(scatter_win, 2)}
steps.append({
'cascade_seq': seq, 'free_spin_id': 0,
'grid_state': [row[:] for row in grid],
'multiplier_detail': json.dumps(mul_det),
'symbol_links': json.dumps(spin_links, ensure_ascii=False), # 本旋累积(含 tumble)
'accumulated_symbols': json.dumps(dict(sym_acc)), # 仅落库留档,不下发
'spin_winners': json.dumps(spin_winners, ensure_ascii=False), # 仅落库留档,不下发
'removed_positions': json.dumps(removed), # 仅落库留档,不下发
'drop_data': json.dumps(drops),
'fill_data': json.dumps(fills if fills else []),
'scatter_trigger': json.dumps(scatter_trigger) if scatter_trigger else None,
'retrigger_info': json.dumps(retrigger_info) if retrigger_info else None,
'base_score': round(base, 2), # 仅落库留档,不下发
'win_amount': round(win_amt, 2),
'accumulator_before': round(acc_before_spin, 2),
'accumulator_after': round(acc_after if is_final else acc, 2),
'spin_win': 0, 'win_capped': 0, 'bigwin_level': None,
'is_final': 1 if is_final else 0,
})
if is_final:
total = round(sum(s['win_amount'] for s in steps), 2)
return steps, total, acc_after, scatter_count
prev_win_pos = win_pos
# ====================================================================
# 免费旋转会话(15 次起,≥3S 重触发 +5,上限 30)
# ====================================================================
def _fs_next_target(spin_no, wins, total_spins, quota):
"""下一旋"该保底消除"的位置:间隔随机,但均值 = 剩余旋数 / 剩余配额。
为什么要随机:等距铺(每 quota/总局数 旋必消一次)会让玩家几局就看穿"到第 4 旋肯定消"——
实测等距版本有 87% 的局"最长连空正好 4 旋"。这里改成每次消除后重抽下一次的位置:
间隔 = round(均值 + δ),δ ∈ {-2..2} 权重 {1,2,3,2,1}(均值 0,所以整局的消除次数不变),
玩家看到的空转长度在 1~5 旋之间跳,而不是恒定 4。剩余配额为 0 时返回 None(后面只靠自然消)。
"""
left_spins = total_spins - spin_no
left_quota = quota - wins
if left_spins <= 0 or left_quota <= 0:
return None
delta = random.choices((-2, -1, 0, 1, 2), weights=(1, 2, 3, 2, 1))[0]
return spin_no + max(1, int(round(left_spins / left_quota + delta)))
def _free_spin_session(mode_code, slot_target=None, acc=0, fs_budget=None):
"""免费旋转会话(15 次起,≥3S 重触发 +5,上限 30)。
**三个模式同一套规则**(炸弹 + 累计器 + 每旋保底消除),区别只在"这一局值多少钱":
· buy_fs:价格换来桶目标,拆成「保底消除预算 fs_budget + 一旋大奖 slot_target」;
· normal/ante 自然触发:没有价格可拆 → 只给一份同档保底预算、不植大奖旋,
整局赔付落在哪由自然结果决定。
fs_budget 按"配额 + 随机间隔"花:预算能买 k 次消除(k ≈ √(预算/FS_GUARANTEE_COST)),
每次消除后重抽下一次的位置(间隔随机、均值 = 剩余旋数/剩余配额),自然消除也算一次进度;
空转太久再按 FS_DRY_FORCE_PROB 软兜底 —— 旧写法按"剩余预算÷剩余旋数"算每旋概率,累计倍数
一涨就把预算烧穿,后半局十几旋连空;等距铺法又会让玩家看穿"到第 4 旋肯定消"(见 _fs_next_target)。
slot_target 只作用于其中随机一旋(它按累计倍数结算,不占保底配额)。
"""
total_spins = FREE_SPINS_AWARDED
fs_index = 0
all_steps = []
fs_total = 0.0
sc_dist = FREESPIN_SCATTER.get(mode_code, FREESPIN_SCATTER['default'])
budget = fs_budget
# 炸弹大小按档位配:低倍局用小球(预算买得起多次"消除+爆"),高倍局用全量(2~1000 的大球)
target_mul = float(slot_target) if isinstance(slot_target, (int, float)) else 0.0
spin_bomb_max = None if target_mul >= FS_BIG_BOMB_TARGET else FS_SMALL_BOMB_MAX
bias_slot = random.randrange(FREE_SPINS_AWARDED) if slot_target else -1
# 保底配额:预算 ÷ 单次消除边际赔付再开方 = 这一局"买得起"几次消除
quota = None if budget is None else max(1, int((max(0.0, budget) / FS_GUARANTEE_COST) ** 0.5))
wins = 0 # 已消除的旋数(自然 + 保底)
dry = 0 # 已经连续空转了几旋(兜底用)
target = None if quota is None else _fs_next_target(0, 0, total_spins, quota)
while fs_index < total_spins:
fs_index += 1
is_target_slot = (fs_index - 1) == bias_slot
spin_bias = slot_target if is_target_slot else None
forced = False
if spin_bias is None and quota is not None:
# 到点了 / 空转太久被软兜底抽中 → 本旋必补一次保底消除
forced = target is not None and fs_index >= target
if not forced and dry >= FS_DRY_FORCE_HARD:
forced = True
elif not forced and dry in FS_DRY_FORCE_PROB:
forced = random.random() < FS_DRY_FORCE_PROB[dry]
p = 1.0 if forced else 0.0
else:
p = FS_SPIN_WIN_PROB
sc = _weighted_choice(sc_dist)
# 已到 30 旋上限:这一旋不再落 3+ 个 S —— 否则会写出"extraSpins=5 但没有旋可加"的
# retrigger_info(玩家白看一次 +5,前端还得自己夹 30)。上限之前的那一旋照常触发第 3 次 +5。
if total_spins >= MAX_FREE_SPINS:
sc = min(sc, SCATTER_RETRIGGER_TRIGGER - 1)
spin_steps, spin_score, acc, sc_count = _spin(
mode_code, spin_bias, acc, is_free_spin=True, target_scatter=sc,
win_prob=p, continue_prob=FS_CASCADE_CONTINUE_PROB,
cascade_max=FS_CASCADE_MAX,
bomb_prob=FS_SCREEN_BOMB_PROB, bomb_max=spin_bomb_max,
max_bombs=FS_MAX_BOMBS_PER_SPIN)
if sc_count >= SCATTER_RETRIGGER_TRIGGER and total_spins < MAX_FREE_SPINS:
total_spins += FREE_SPIN_RETRIGGER_COUNT
# 目标大奖旋的花费由目标额单独控制,不占用保底预算
if budget is not None and not is_target_slot:
budget -= spin_score
for s in spin_steps:
s['free_spin_id'] = fs_index
fs_total += spin_score
all_steps.extend(spin_steps)
# 进度:消了(不管自然还是保底)就重新抽下一次的位置 —— 整局消除次数仍由配额决定
if spin_bias is None and quota is not None:
if any(float(s['base_score']) > 0 for s in spin_steps):
wins += 1
dry = 0
target = _fs_next_target(fs_index, wins, total_spins, quota)
else:
dry += 1
return all_steps, fs_total
# ====================================================================
# 主入口
# ====================================================================
def generate_round(box, bias=None):
"""
生成一个完整回合。box: {'box_id','box_type',...} bias: None/'zero'/目标倍率(float)
返回 (round_data, steps, orn_id)
"""
mode_code = box['box_type']
buy_fs = mode_code == 'buy_fs'
# 目标高于"基础旋(无炸弹)能赔到的上限"时,只能生成自然触发 FS 的回合来装这个桶:
# normal/ante 的 100x/150x/200x 桶没有别的来源(基础旋不出炸弹,最多 ~60x)。
must_free_spins = isinstance(bias, (int, float)) and not buy_fs \
and float(bias) >= FS_ONLY_TARGET_MUL
is_fs = False
if buy_fs:
is_fs = True # 购买模式保证进 FS(互斥:无底注效果)
elif must_free_spins:
is_fs = True
elif mode_code in BASE_FS_TRIGGER_PROB and bias is None:
is_fs = random.random() < BASE_FS_TRIGGER_PROB[mode_code]
if is_fs:
# 触发盘:三模式都固定/摇出 SCATTER,且**不出炸弹**(炸弹只存在于免费旋转内)
trigger_sc = 4 if buy_fs else _weighted_choice(FS_TRIGGER_SCATTER)
base_steps, base_total, _, _ = _spin(
mode_code, None, 0, is_free_spin=False, target_scatter=trigger_sc,
allow_bomb=False)
# FS 预算:买 FS / 高倍桶(bias=桶目标)拆成「保底消除预算 + 一旋大奖」;
# normal/ante 自然摇出来的 FS(bias=None)没有价格可拆 → 只给同档保底预算、不植大奖旋,
# 整局赔付由自然消除 + 炸弹累计决定(实测约 80x、5 旋有消除,落在模式自己的桶里)。
fs_budget, slot_target = None, None
if isinstance(bias, (int, float)) and bias > 0:
budget = max(0.0, float(bias) - float(base_total))
if budget <= FS_FLOOR_BUDGET_CAP:
fs_budget = budget # 低倍局:全靠保底小消
else:
fs_budget = FS_FLOOR_BUDGET_CAP # 保底小消是平价结算(×1),预算可以给足
slot_target = budget - fs_budget # 其余给"大奖旋"(它按累计倍数结算)
elif not buy_fs:
fs_budget = FS_FLOOR_BUDGET_CAP
# 炸弹累计器仅免费旋转内生效:FS 会话从 0 起累,触发盘炸弹不带入
fs_steps, fs_total = _free_spin_session(
mode_code, slot_target=slot_target, acc=0, fs_budget=fs_budget)
steps = base_steps + fs_steps
else:
nearmiss = _weighted_choice(BASE_NEARMISS_SCATTER.get(mode_code, {0: 1.0}))
steps, _, _, _ = _spin(mode_code, bias, 0,
is_free_spin=False, target_scatter=nearmiss)
# 理论总分(落桶用,可到 1370+)
raw_total = round(sum(s['win_amount'] for s in steps), 2)
orn_id = _bucket(raw_total)
# 发放截断 + 口径重写:累计到 1000 即终止,动画=到账零偏差。
# win_amount = **本旋累计到本步的赢额**(该旋末步就是这一旋的总额);
# spin_win = **本局累计到本步的赢额**(最后一步 = 整局实付 = totalWin)
# 前端因此不需要自己相加:本步增量 = 与上一步相减,本旋总额 = 本旋末步的 win_amount。
raw_increments = [s['win_amount'] for s in steps]
cumul, spin_cumul, capped_steps, hit = 0.0, {}, [], False
for s, inc in zip(steps, raw_increments):
if cumul + inc >= MAX_WIN: # 封顶:本步补足到 1000 并终止
inc = round(MAX_WIN - cumul, 2)
s['win_capped'] = 1
s['is_final'] = 1
hit = True
key = s['free_spin_id']
spin_cumul[key] = round(spin_cumul.get(key, 0.0) + inc, 2)
cumul = round(cumul + inc, 2)
s['win_amount'] = spin_cumul[key] # 本旋累计(末步即该旋总额)
s['spin_win'] = cumul # 本局累计
capped_steps.append(s)
if hit:
break
if hit:
steps = capped_steps
paid_total = round(cumul, 2)
# 爆奖动画(小/中/大):**只有免费旋转里才判档** —— normal/ante 靠自然触发 FS、
# buy_fs 本身即 FS;基础旋(free_spin_id=0)一律 null,不播大奖动画。
# 单旋赢额 = 该旋末帧的 win_amount;旋与旋互不影响,同档一局可多次播放。
for s in steps:
if s['is_final'] and s['free_spin_id'] > 0:
s['bigwin_level'] = bigwin_level(mode_code, float(s['win_amount']))
fs_cnt = len({s['free_spin_id'] for s in steps if s['free_spin_id'] > 0})
rd = {
'box_id': box['box_id'], 'ornament_id': orn_id,
'actual_mul': round(paid_total, 4), # 实付倍率 ≤1000
'free_spin_count': fs_cnt,
'total_cascades': len(steps),
}
return rd, steps, orn_id
# ====================================================================
# 分布补偿:两阶段填桶
# ====================================================================
def _bias_for(orn_id):
if orn_id == 1:
return 'zero'
o = next(x for x in ORNAMENTS if x['id'] == orn_id)
return float(o['expect_mul'])
def _calibrate_bias(box, orn_id, samples=8):
"""给某个桶在线找一个"更容易撞上"的 bias(只改传给生成器的目标值,不动任何规则)。
为什么需要:`_bias_for` 用的是桶的名义 expect_mul,但 buy_fs 的整局赔付被 FS 会话抬高了 ——
实测 bias=10(桶 7 名义 10x)时实际赔付 min 14 / 中位 58,**永远撞不进 8~12**,命中率仅 3%,
stage2 只能白跑几万次(单箱 5~10 分钟)。这里拿几个候选值各试几局,挑命中率最高的那个。"""
target = _bias_for(orn_id)
best, best_hit = target, -1
for mult in (1.0, 0.5, 0.3, 0.15, 0.08):
b = target * mult
hit = 0
for _ in range(samples):
_, _, oid = generate_round(box, b)
hit += (oid == orn_id)
if hit > best_hit:
best, best_hit = b, hit
if hit == samples: # 已经全中,不用再试更小/更大的
break
return best
def fill_distribution(box, target_counts, max_rounds=None):
"""阶段1 自由生成积累常见桶;阶段2 按缺额 bias 补桶。返回 (rounds, 实际分布)
阶段2 的尝试上限按 total×60:保底消除改成"随机间隔"之后,低倍桶(10x/16x)的命中率大约减半
(同样的钱摊到整局、摊到累计倍数高的旋上更贵),尝试数不够会填不满池子(实测 ×30 时
10x 桶只填到 178/297,×60 满池)。
某个桶饿死时(试了 4 倍于它条数的次数还没填满)会重新校准它的 bias —— 只对慢桶生效,
命中率本来就高的桶走原来的名义值,不额外花钱。"""
gen = Counter()
rounds = []
total_target = sum(target_counts.values())
bias_use = {} # orn_id -> 校准后的 bias
tries = Counter() # orn_id -> 已尝试次数
def _accept(oid):
if oid in target_counts and gen[oid] < target_counts[oid]:
gen[oid] += 1
return True
return False
free_budget = min(total_target * 3, 12000)
for _ in range(free_budget):
if sum(gen.values()) >= total_target:
break
rd, steps, oid = generate_round(box, None)
if _accept(oid):
rounds.append((rd, steps))
max_att = (max_rounds or total_target * 60)
for _ in range(max_att):
deficit = [(oid, target_counts[oid] - gen[oid])
for oid in target_counts if gen[oid] < target_counts[oid]]
if not deficit:
break
# 按缺口加权随机选桶,避免“最难桶”饿死其他桶
need = random.choices([d[0] for d in deficit],
weights=[d[1] for d in deficit])[0]
tries[need] += 1
if need not in bias_use and tries[need] > target_counts[need] * 4:
bias_use[need] = _calibrate_bias(box, need)
rd, steps, oid = generate_round(box, bias_use.get(need, _bias_for(need)))
if _accept(oid):
rounds.append((rd, steps))
return rounds, dict(gen)
-16
View File
@@ -1,16 +0,0 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "olympus"
version = "0.1.0"
description = "Olympus match-3 game engine with bomb accumulator rules."
readme = "README.md"
requires-python = ">=3.9"
license = {text = "Proprietary"}
[tool.setuptools.packages.find]
where = ["."]
include = ["olympus*"]
namespaces = false
-240
View File
@@ -1,240 +0,0 @@
-- ============================================================
-- Olympus 电子游戏模块 全量建表 DDL
-- 表前缀: game_olympus_ 游戏类型: OLYMPUS
-- 设计对齐 sweet 体系,差异点:
-- 1. 饰品桶最高 1000x(单局封顶 1000 倍基础下注)
-- 2. step 表自带 accumulator_before/after(跨回合炸弹累计)与 win_capped
-- 3. record 表自带结算/补发字段(settled 等),无需后期 ALTER
-- 4. 模式仅三种: normal / ante / buy_fs(互斥,无 super 模式)
-- ============================================================
-- 1. 箱子配置(模式 × 下注档位)
CREATE TABLE IF NOT EXISTS game_olympus_box (
box_id BIGINT NOT NULL COMMENT 'PK',
game_type VARCHAR(16) NOT NULL DEFAULT 'OLYMPUS',
box_type VARCHAR(32) NOT NULL COMMENT 'normal|ante|buy_fs',
bet DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '基础下注',
name VARCHAR(50) DEFAULT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '实际扣费 = bet × CM (normal=1/ante=2/buy_fs=20)',
total_ornaments INT NOT NULL DEFAULT '0' COMMENT '总回合数',
rtp DECIMAL(5,4) NOT NULL DEFAULT '0.8000' COMMENT '目标返奖率',
rtp_converge_ratio DECIMAL(4,3) NULL COMMENT '一轮收敛回合占比,空=默认0.15',
sort INT DEFAULT '0',
status VARCHAR(1) DEFAULT '1' COMMENT '1启用 0禁用',
del_flag VARCHAR(1) DEFAULT '0',
remark VARCHAR(200) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (box_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus箱子配置';
-- 2. 饰品桶定义(0x ~ 1000x,共17档)
CREATE TABLE IF NOT EXISTS game_olympus_ornament (
id BIGINT NOT NULL COMMENT 'PK 1-17',
game_type VARCHAR(16) NOT NULL DEFAULT 'OLYMPUS',
expect_mul DECIMAL(8,2) NOT NULL COMMENT '期望倍率(最高1000)',
name VARCHAR(50) DEFAULT NULL,
range_low DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间下限(含)',
range_high DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间上限(含)',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus饰品桶定义';
-- 3. 箱子-饰品桶分布(库存配额,剩余 = count - used)
-- audience 多身份爆率:每种身份(common/anchor/robot)一套独立分布,回合库存池共享
CREATE TABLE IF NOT EXISTS game_olympus_box_ornament (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT NOT NULL,
ornament_id INT NOT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份维度 common普通/anchor主播/robot机器人',
count INT NOT NULL DEFAULT '0' COMMENT '分配回合数',
used INT NOT NULL DEFAULT '0' COMMENT '已消耗回合数,剩余=count-used',
PRIMARY KEY (id),
UNIQUE KEY uk_box_ornament_aud (box_id, ornament_id, audience)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus箱子饰品分布(多身份)';
-- 4. 预生成回合
CREATE TABLE IF NOT EXISTS game_olympus_round (
round_id BIGINT NOT NULL COMMENT 'PK',
box_id BIGINT NOT NULL,
ornament_id INT NOT NULL COMMENT '落入的饰品桶 1-17',
actual_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '实际总倍率(≤1000)',
free_spin_count INT NOT NULL DEFAULT '0' COMMENT '免费旋转总次数(15+重触发×5)',
total_cascades INT NOT NULL DEFAULT '0' COMMENT '总步数',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (round_id),
INDEX idx_box_ornament (box_id, ornament_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus预生成回合';
-- 5. 回合步骤(级联/翻滚明细)
CREATE TABLE IF NOT EXISTS game_olympus_step (
id BIGINT NOT NULL COMMENT 'PK',
round_id BIGINT NOT NULL,
free_spin_id INT NOT NULL DEFAULT '0' COMMENT '0=基础旋转, >0=第N次免费旋转',
cascade_seq INT NOT NULL DEFAULT '0' COMMENT '本旋转内第几次翻滚',
grid_state JSON DEFAULT NULL COMMENT '6×5网格最终状态',
multiplier_detail JSON DEFAULT NULL COMMENT '炸弹符号 [{"pos":int,"val":int},...]',
symbol_links JSON DEFAULT NULL COMMENT '中奖明细 [{"symbol","count","loc",...}]',
accumulated_symbols JSON DEFAULT NULL COMMENT '本旋转累计消除符号 {"CROWN":9,...}',
spin_winners JSON DEFAULT NULL COMMENT '获胜者面板:本旋逐波追加 [{"symbol","count","win"},...] 新旋清空',
removed_positions JSON DEFAULT NULL COMMENT '消除位置 [pos,...]',
drop_data JSON DEFAULT NULL COMMENT '下落数据 [{"from":int,"to":int,"symbol":"A"},...]',
fill_data JSON DEFAULT NULL COMMENT '填充数据 [{"pos":int,"symbol":"A"},...]',
scatter_trigger JSON DEFAULT NULL COMMENT '基础盘触发免费旋转 {scatterCount,freeSpins(15),scatterWin}',
retrigger_info JSON DEFAULT NULL COMMENT '免费旋转重触发 {scatterCount,extraSpins(5),scatterWin}',
base_score DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步基础分(未乘炸弹)',
win_amount DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步赢额(回合末帧含炸弹结算)',
accumulator_before DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步前累计炸弹倍数',
accumulator_after DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步后累计炸弹倍数(跨回合传递)',
spin_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '整局累计赢额',
win_capped TINYINT NOT NULL DEFAULT '0' COMMENT '本步赢额被1000x封顶截断 0否1是',
bigwin_level VARCHAR(16) DEFAULT NULL COMMENT '爆奖档位(小/中/大,只在免费旋转里判档,按单旋赢额盖在该旋末帧,同档可多次): small/middle/big',
is_final TINYINT DEFAULT '0' COMMENT '本旋转末帧',
PRIMARY KEY (id),
INDEX idx_round (round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus回合步骤';
-- 6. 游戏记录(含结算/补发字段,建表即带全)
CREATE TABLE IF NOT EXISTS game_olympus_record (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
user_id BIGINT NOT NULL,
box_id BIGINT NOT NULL,
round_id BIGINT NOT NULL,
cost DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '扣费 = bet × CM',
total_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '总赢额(结算时填,≤bet×1000)',
settled TINYINT NOT NULL DEFAULT '0' COMMENT '是否已结算 0未1已(幂等守卫)',
mode_type VARCHAR(20) DEFAULT NULL COMMENT '游戏模式 normal/ante/buy_fs',
auto_dismantle TINYINT DEFAULT 0 COMMENT '开局自动分解快照 0关1开',
reward_type VARCHAR(10) DEFAULT NULL COMMENT '发放类型 gold/ornament',
settled_win DECIMAL(10,2) DEFAULT 0.00 COMMENT '本局价值(金币金额=饰品价格)',
prize_id BIGINT DEFAULT NULL COMMENT '饰品ID game_olympus_prize_ornament.id',
prize_name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
prize_image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
prize_level_img VARCHAR(500) DEFAULT NULL COMMENT '品质图片URL',
settle_source VARCHAR(20) DEFAULT NULL COMMENT '结算来源 start=开局已结算(动画还在播) / finish=末帧播完(已结束,进战绩列表)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_user_time (user_id, create_time),
INDEX idx_box_time (box_id, create_time),
INDEX idx_settled_time (settled, create_time)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus游戏记录';
-- 7. 用户统计
CREATE TABLE IF NOT EXISTS game_olympus_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
user_id BIGINT NOT NULL,
total_spins BIGINT NOT NULL DEFAULT '0',
total_cost DECIMAL(12,2) NOT NULL DEFAULT '0.00',
total_win DECIMAL(12,2) NOT NULL DEFAULT '0.00',
total_free_spins INT NOT NULL DEFAULT '0',
max_win_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '历史最高倍率(≤1000)',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user (user_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus用户统计';
-- 8. 每日盈亏
CREATE TABLE IF NOT EXISTS game_olympus_win (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
date DATE NOT NULL,
total_spins BIGINT NOT NULL DEFAULT '0',
total_cost DECIMAL(14,2) NOT NULL DEFAULT '0.00',
total_win DECIMAL(14,2) NOT NULL DEFAULT '0.00',
net_profit DECIMAL(14,2) NOT NULL DEFAULT '0.00',
player_count INT NOT NULL DEFAULT '0',
PRIMARY KEY (id),
UNIQUE KEY uk_date (date)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus每日盈亏';
-- 9. 返奖饰品定义(展示用饰品,按赢额区间匹配)
CREATE TABLE IF NOT EXISTS game_olympus_prize_ornament (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
game_type VARCHAR(16) NOT NULL DEFAULT 'OLYMPUS',
name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
grade INT DEFAULT NULL COMMENT '品质等级 1-5',
exterior_name VARCHAR(100) DEFAULT NULL COMMENT '品质名称 崭新出厂/略有磨损/久经沙场/破损不堪/战痕累累',
sort INT DEFAULT '0',
status VARCHAR(1) DEFAULT '1',
is_fallback TINYINT DEFAULT '0' COMMENT '兜底饰品 1是(仅展示不覆盖金额) 0否',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus返奖饰品';
-- 10. 返奖饰品区间(与 prize_ornament 一一对应,区间连续覆盖)
CREATE TABLE IF NOT EXISTS game_olympus_prize_range (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
prize_id BIGINT NOT NULL COMMENT 'game_olympus_prize_ornament.id',
win_low DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间下限(含)',
win_high DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间上限(含)',
PRIMARY KEY (id),
UNIQUE KEY uk_prize (prize_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus返奖区间';
-- 11. 箱子饰品分布模板(抽干补桶依据,按身份区分)
CREATE TABLE IF NOT EXISTS game_olympus_box_template (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT NOT NULL COMMENT '所属箱子ID',
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份维度 common/anchor/robot',
name VARCHAR(100) NOT NULL COMMENT '模板名称(同一箱子+身份内唯一)',
total_rounds INT NOT NULL DEFAULT 0 COMMENT '总回合数 = items 各 count 之和',
items_json TEXT NOT NULL COMMENT '饰品分布JSON: [{"ornamentId":1,"count":100},...]',
is_active TINYINT NOT NULL DEFAULT 0 COMMENT '是否生效模板(抽干补桶依据) 1是 0否',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_box_aud_name (box_id, audience, name),
KEY idx_box_aud_active (box_id, audience, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus箱子饰品分布模板(多身份)';
-- 12. 奖池水位调整日志(注水/抽水/重置审计)
CREATE TABLE IF NOT EXISTS game_olympus_reward_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
box_name VARCHAR(100) DEFAULT NULL COMMENT '箱子名/汇总描述',
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份 common/anchor/robot/white',
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
operator VARCHAR(64) DEFAULT NULL COMMENT '操作人',
remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_create (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus奖池水位调整日志';
-- ============================================================
-- 种子数据:饰品桶 1-17(0x ~ 1000x)
-- ============================================================
INSERT INTO game_olympus_ornament (id, game_type, expect_mul, name, range_low, range_high) VALUES
(1, 'OLYMPUS', 0.00, '0x', 0.00, 0.00),
(2, 'OLYMPUS', 0.50, '0.5x', 0.01, 1.00),
(3, 'OLYMPUS', 1.50, '1.5x', 1.00, 2.00),
(4, 'OLYMPUS', 2.50, '2.5x', 2.00, 3.00),
(5, 'OLYMPUS', 4.00, '4x', 3.00, 5.00),
(6, 'OLYMPUS', 6.50, '6.5x', 5.00, 8.00),
(7, 'OLYMPUS', 10.00, '10x', 8.00, 12.00),
(8, 'OLYMPUS', 16.00, '16x', 12.00, 20.00),
(9, 'OLYMPUS', 25.00, '25x', 20.00, 30.00),
(10, 'OLYMPUS', 40.00, '40x', 30.00, 50.00),
(11, 'OLYMPUS', 60.00, '60x', 50.00, 70.00),
(12, 'OLYMPUS', 100.00, '100x', 70.00, 130.00),
(13, 'OLYMPUS', 150.00, '150x', 130.00, 170.00),
(14, 'OLYMPUS', 200.00, '200x', 170.00, 230.00),
(15, 'OLYMPUS', 300.00, '300x', 230.00, 370.00),
(16, 'OLYMPUS', 500.00, '500x', 370.00, 630.00),
(17, 'OLYMPUS', 1000.00, '1000x', 630.00, 1370.00)
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
range_low=VALUES(range_low), range_high=VALUES(range_high);
-- ============================================================
-- 附:Java 服务上线时执行(先不执行,接入后台时再放开)
-- ============================================================
-- 维护开关
-- INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
-- VALUES ('Olympus维护', 'olympusMaintenance', '0', 'Y', 'admin', NOW(), 'Olympus维护开关 0正常 1维护');
-- 未结算兜底补发定时任务(系统监控>定时任务)
-- INSERT INTO sys_job (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
-- VALUES ('Olympus未结算补发', 'OLYMPUS', 'olympusSettleTask.scheduledSettle()', '0 */10 * * * ?', '1', '0', '0', 'admin', NOW(), '每10分钟扫描超35分钟未结算记录并补发金币/饰品');
-176
View File
@@ -1,176 +0,0 @@
"""复刻 Java 抽回合链路,模拟每个箱子抽完一个 cycle 的实时 RTP。
复刻的 Java 逻辑(OlympusRoundPicker / OlympusGameService / OlympusDistributionService):
1. 固定池 fixedSum = Σ 配比 count(按身份),N = round(fixedSum/(1-收敛比例)),
fixedRounds = round(N×(1-收敛比例));固定阶段 drawn < fixedRounds
2. 固定阶段:在"remaining>0 且 expect_mul×bet ≤ payoutCap"的桶里按 remaining 加权抽;
桶内取 40 条候选,优先取 actual_mul×bet ≤ payoutCap 的(取不到就退回候选第一条);该桶 used+1
3. 收敛阶段:desiredNextMul = (N×目标倍率 − 已派彩) / (N − drawn),
取期望最接近 desired 的 3 个桶(随机其一),桶内取 actual_mul 最接近 min(desired, capMul) 的一条
(优先 ≤ capMul),不消耗 used
4. 水位:payoutCap = max(0, 水位 + 本局成本) × rtp;水位 += 成本 − 派彩(白号不参与水位 → 无上限)
5. drawn ≥ N → cycle 结束:resetCycle + refillFromConfig(有生效模板则按模板重写 count,否则 used 归零)
用法:
python simulate_cycle.py # 全部 30 箱 × common 身份,跑 3 个 cycle
python simulate_cycle.py --cycles 5 --audience white
python simulate_cycle.py --box 21
"""
import argparse
import random
from collections import defaultdict
import olympus_db as db
from olympus.config import ORNAMENTS, MODES, payout_expect
# 抽回合的 payoutCap 过滤用**名义** expect_mul(对齐 Java OlympusRoundPicker,偏保守);
# 统计里的"名义 RTP"用 payout_expect(对齐后台「目标RTP」列的封顶修正口径)。
_ORN_MUL = {o['id']: o['expect_mul'] for o in ORNAMENTS}
_ORN_PAY = {o['id']: payout_expect(o['id']) for o in ORNAMENTS}
CANDIDATE_LIMIT = 40
def load_boxes(box_id=None):
with db.get_conn() as conn:
cur = conn.cursor()
sql = 'SELECT box_id, box_type, bet, rtp, rtp_converge_ratio, total_ornaments FROM game_olympus_box'
if box_id:
sql += ' WHERE box_id=%s' % int(box_id)
cur.execute(sql + ' ORDER BY box_id')
boxes = [dict(zip(('box_id', 'box_type', 'bet', 'rtp', 'cr', 'total'), r)) for r in cur.fetchall()]
cur.execute('SELECT box_id, ornament_id, audience, count FROM game_olympus_box_ornament')
cfg = defaultdict(dict)
for b, o, a, c in cur.fetchall():
cfg[(int(b), a)][int(o)] = int(c)
cur.execute('SELECT box_id, ornament_id, actual_mul FROM game_olympus_round')
rounds = defaultdict(lambda: defaultdict(list))
for b, o, m in cur.fetchall():
rounds[int(b)][int(o)].append(float(m))
# 生效模板(抽完重填的依据)
cur.execute('''SELECT box_id, audience, items_json FROM game_olympus_box_template
WHERE is_active=1 ORDER BY id''')
tpl = {}
import json
for b, a, items in cur.fetchall():
tpl[(int(b), a)] = {int(i['ornamentId']): int(i['count'])
for i in json.loads(items) if int(i.get('count', 0)) > 0}
for b in boxes:
b['cm'] = float(MODES[b['box_type']].cost_multiplier)
return boxes, cfg, rounds, tpl
def _weighted(cands, weights):
total = sum(weights)
r = random.uniform(0, total)
acc = 0.0
for c, w in zip(cands, weights):
acc += w
if r <= acc:
return c
return cands[-1]
def simulate(box, cfg, rounds, tpl, audience, cycles=3, cr_override=None):
cm, rtp = box['cm'], float(box['rtp'])
cr = float(cr_override if cr_override is not None else (box['cr'] or 0.15))
fixed_sum = sum(cfg.values())
n_total = round(fixed_sum / (1.0 - cr))
fixed_rounds = round(n_total * (1.0 - cr))
target_mul = rtp * cm
capped = (audience != 'white') # 白号 payoutCap=null
stats = []
counts = dict(cfg)
cumulative = {'drawn': 0, 'mul': 0.0}
balance = 0.0 # 箱子水位:跨 cycle 持久(= Σ投注 − Σ返奖)
for cyc in range(1, cycles + 1):
used = defaultdict(int)
drawn, mul_sum = 0, 0.0
rtp_fixed = None
while drawn < n_total:
cap_mul = max(0.0, balance + cm) * rtp if capped else float('inf')
remaining = {o: max(0, c - used[o]) for o, c in counts.items()}
if drawn < fixed_rounds and sum(remaining.values()) > 0:
elig = [o for o, r in remaining.items()
if r > 0 and _ORN_MUL[o] <= cap_mul] or \
[o for o, r in remaining.items() if r > 0]
orn = _weighted(elig, [remaining[o] for o in elig])
pool = rounds[box['box_id']].get(orn) or [0.0]
cands = random.sample(pool, min(CANDIDATE_LIMIT, len(pool)))
mul = next((m for m in cands if m <= cap_mul), cands[0])
used[orn] += 1
else:
desired = max(0.0, (n_total * target_mul - mul_sum) / max(1, n_total - drawn))
allowed = [o for o, c in counts.items() if _ORN_MUL[o] <= cap_mul] or list(counts)
allowed.sort(key=lambda o: abs(_ORN_MUL[o] - desired))
orn = random.choice(allowed[:3])
pool = rounds[box['box_id']].get(orn) or [0.0]
eff = min(desired, cap_mul)
mul = sorted(pool, key=lambda m: (0 if m <= cap_mul else 1, abs(m - eff)))[0]
drawn += 1
mul_sum += mul
balance += cm - mul
cumulative['drawn'] += 1
cumulative['mul'] += mul
if drawn == fixed_rounds:
rtp_fixed = mul_sum / drawn / cm
# cycle 结束 → 按模板重填(无模板则 used 归零、count 不变)
tpl_counts = tpl.get((box['box_id'], audience))
refilled = '模板' if tpl_counts else 'used归零'
if tpl_counts:
counts = {o: c for o, c in tpl_counts.items() if c > 0}
stats.append({
'cycle': cyc,
'rtp_fixed': rtp_fixed,
'rtp_end': mul_sum / drawn / cm,
'balance_end': balance,
'refill': refilled,
})
return {
'box_id': box['box_id'], 'box_type': box['box_type'], 'audience': audience,
'rtp': rtp, 'cr': cr, 'n_total': n_total, 'fixed_rounds': fixed_rounds,
'fixed_sum': fixed_sum, 'nominal': sum(c * _ORN_PAY.get(o, 0) for o, c in cfg.items()) / fixed_sum / cm,
'cycles': stats,
'cum_rtp': cumulative['mul'] / cumulative['drawn'] / cm,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--box', type=int)
ap.add_argument('--audience', default='common')
ap.add_argument('--cycles', type=int, default=3)
ap.add_argument('--cr', type=float, default=None, help='覆盖收敛比例(测敏感度用,不写库)')
ap.add_argument('--seed', type=int, default=2026)
args = ap.parse_args()
random.seed(args.seed)
boxes, cfg, rounds, tpl = load_boxes(args.box)
print('身份=%s cycle 数=%d seed=%d 收敛比例=%s' % (
args.audience, args.cycles, args.seed,
('箱配置' if args.cr is None else args.cr)))
print('箱 模式 配比sum N 固定池 名义RTP 各cycle(固定池末→整轮末) 累计RTP')
rows = []
for b in boxes:
c = cfg.get((b['box_id'], args.audience), {})
if not c:
continue
r = simulate(b, c, rounds, tpl, args.audience, args.cycles, args.cr)
rows.append(r)
per = ' '.join('%.1f→%.1f' % (s['rtp_fixed'] * 100, s['rtp_end'] * 100) for s in r['cycles'])
flag = '✓' if abs(r['cum_rtp'] - r['rtp']) < 0.03 else '✗'
print('%2d %-7s %5d %4d %5d %6.1f%% %-34s %6.1f%% %s' % (
r['box_id'], r['box_type'], r['fixed_sum'], r['n_total'], r['fixed_rounds'],
r['nominal'] * 100, per, r['cum_rtp'] * 100, flag))
bad = [r for r in rows if abs(r['cum_rtp'] - r['rtp']) >= 0.03]
print('\n达标(±3个百分点): %d/%d' % (len(rows) - len(bad), len(rows)))
if bad:
print('未达标:', [(r['box_id'], round(r['cum_rtp'] * 100, 1), round(r['rtp'] * 100, 1)) for r in bad])
if rows:
r = rows[0]
print('\n样例 box%d 逐 cycle 明细(%s):' % (r['box_id'], r['audience']))
for s in r['cycles']:
print(' cycle%d: 固定池结束 %.1f%% 整轮结束 %.1f%% 抽完水位=%.0f 倍底注 重填=%s' % (
s['cycle'], s['rtp_fixed'] * 100, s['rtp_end'] * 100, s['balance_end'], s['refill']))
if __name__ == '__main__':
main()
-1
View File
@@ -1 +0,0 @@
"""Olympus tests."""
-292
View File
@@ -1,292 +0,0 @@
"""Olympus 引擎单元测试(unittest)。不依赖第三方库,可直接 `python tests/test_engine.py` 运行。"""
from __future__ import annotations
import sys
import unittest
from decimal import Decimal
from pathlib import Path
# 支持直接 `python tests/test_engine.py` 运行(无需先安装为包)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from olympus import OlympusEngine, RuleViolation
from olympus.config import MODES, SYMBOL_RULES
from olympus.models import Bomb
class TestSymbolRule(unittest.TestCase):
"""符号赔付校验:8/10/12 三档取值正确。"""
def test_symbol_payout_tiers(self) -> None:
crown = SYMBOL_RULES["CROWN"]
self.assertEqual(crown.payout_for(7), Decimal("0"))
self.assertEqual(crown.payout_for(8), Decimal("10"))
self.assertEqual(crown.payout_for(9), Decimal("10"))
self.assertEqual(crown.payout_for(10), Decimal("25"))
self.assertEqual(crown.payout_for(11), Decimal("25"))
self.assertEqual(crown.payout_for(12), Decimal("50"))
self.assertEqual(crown.payout_for(30), Decimal("50"))
class TestSingleCascade(unittest.TestCase):
"""单回合单次消除:无炸弹时有效倍数为 1。"""
def test_one_cascade_no_bomb(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(symbols={"BLUE_GEM": list(range(8))}, bombs=[])
spin = engine.end_spin()
self.assertEqual(spin.base_win, Decimal("0.25"))
self.assertEqual(spin.effective_multiplier, 1)
self.assertAlmostEqual(float(spin.total_win), 0.25, places=2)
def test_one_cascade_with_bomb(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(
symbols={"CROWN": [0, 1, 2, 7, 8, 9, 14, 15, 16]}, # 9 个 → 10x
bombs=[Bomb("b1", 2)],
)
spin = engine.end_spin()
self.assertEqual(spin.base_win, Decimal("10"))
self.assertEqual(spin.effective_multiplier, 2)
self.assertEqual(spin.total_win, Decimal("20"))
class TestMultiCascadeWithinSpin(unittest.TestCase):
"""一回合多次消除:炸弹只在整回合结束后统一引爆,本回合中间不逐次乘算。"""
def test_bomb_only_applies_after_all_cascades(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
c1 = engine.record_cascade(
symbols={"BLUE_GEM": list(range(8))}, bombs=[Bomb("m1", 3)]
)
c2 = engine.record_cascade(
symbols={"GREEN_GEM": list(range(8, 16))}, bombs=[]
)
# 级联阶段自身的 base_win 不应被乘数放大
self.assertEqual(c1.base_win, Decimal("0.25"))
self.assertEqual(c2.base_win, Decimal("0.4"))
spin = engine.end_spin()
# 回合基础总赢额 = 两次消除之和(未乘算)
self.assertEqual(spin.base_win, Decimal("0.65"))
# 炸弹在回合结束后才统一生效
self.assertEqual(spin.effective_multiplier, 3)
self.assertEqual(spin.total_win, Decimal("1.95"))
def test_symbols_are_accumulated_across_cascades(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(symbols={"BLUE_GEM": list(range(8))}, bombs=[Bomb("m1", 3)])
c2 = engine.record_cascade(symbols={"BLUE_GEM": list(range(8, 16))}, bombs=[])
# 同一符号在本回合内跨级联累计记录(8 + 8 = 16)
self.assertEqual(c2.accumulated_symbols.get("BLUE_GEM"), 16)
spin = engine.end_spin()
self.assertEqual(spin.accumulated_symbols.get("BLUE_GEM"), 16)
class TestCrossSpinAccumulator(unittest.TestCase):
"""炸弹累计器:仅免费旋转生效;基础旋转炸弹只乘本旋、不留存。"""
def test_base_spin_does_not_accumulate(self) -> None:
"""基础旋转:炸弹乘本旋赢额,但不进累计器。"""
engine = OlympusEngine(bet="1")
engine.begin_spin() # 默认基础旋转
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
s1 = engine.end_spin()
self.assertEqual(s1.effective_multiplier, 5) # 本旋生效
self.assertEqual(s1.total_win, Decimal("5")) # 1 * 5
self.assertEqual(s1.multiplier_after, 0) # 不留存
# 第二次基础旋转:只吃自己的炸弹
engine.begin_spin()
engine.record_cascade(symbols={"PURPLE_GEM": list(range(8, 16))}, bombs=[Bomb("m2", 3)])
s2 = engine.end_spin()
self.assertEqual(s2.multiplier_before, 0)
self.assertEqual(s2.effective_multiplier, 3)
self.assertEqual(s2.total_win, Decimal("2.4")) # 0.8 * 3
def test_accumulator_carries_across_free_spins(self) -> None:
"""免费旋转:炸弹并入累计器并跨旋传递。"""
engine = OlympusEngine(bet="1")
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
s1 = engine.end_spin()
self.assertEqual(s1.multiplier_before, 0)
self.assertEqual(s1.multiplier_after, 5)
self.assertEqual(s1.total_win, Decimal("5")) # 1 * 5
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"PURPLE_GEM": list(range(8, 16))}, bombs=[Bomb("m2", 3)])
s2 = engine.end_spin()
self.assertEqual(s2.multiplier_before, 5)
self.assertEqual(s2.multiplier_after, 8)
self.assertEqual(s2.total_win, Decimal("6.4")) # PURPLE_GEM 8个=0.8x, 0.8 * 8 = 6.4
def test_fs_spin_without_win_keeps_accumulator_unchanged(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
engine.end_spin()
# 第二次 FS 旋转没有任何消除(即使传入炸弹也无效,不计入累计)
engine.begin_spin(is_free_spin=True)
s2 = engine.end_spin()
self.assertEqual(s2.base_win, Decimal("0"))
self.assertEqual(s2.effective_multiplier, 0)
self.assertEqual(s2.multiplier_after, 5) # 累计值保持不变
def test_bomb_discarded_when_cascade_has_no_win(self) -> None:
"""无消除的旋转即使出现炸弹,也不应计入累计(消除是炸弹生效的前提)。"""
engine = OlympusEngine(bet="1")
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={}, bombs=[Bomb("m1", 10)])
spin = engine.end_spin()
self.assertEqual(spin.base_win, Decimal("0"))
self.assertEqual(spin.multiplier_after, 0)
class TestStateGuards(unittest.TestCase):
"""状态机误用与非法输入的防御性校验。"""
def test_non_positive_bet_raises(self) -> None:
with self.assertRaises(RuleViolation):
OlympusEngine(bet="0")
def test_record_cascade_without_begin_raises(self) -> None:
engine = OlympusEngine(bet="1")
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": list(range(8))})
def test_begin_spin_twice_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.begin_spin()
def test_invalid_symbol_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"UNKNOWN": list(range(8))})
def test_below_min_win_count_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": list(range(7))})
def test_duplicate_positions_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": [0, 0, 1, 2, 3, 4, 5, 6]})
def test_invalid_bomb_value_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 7)])
def test_inconsistent_bomb_redefinition_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 5)])
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={}, bombs=[Bomb("m1", 10)])
def test_reset_session_while_spin_active_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.reset_session()
class TestGameModes(unittest.TestCase):
"""模式互斥与下注档位。"""
def test_mode_bet_tiers_and_prices(self) -> None:
# 普通:1 3 5 10 20 30 50 100 200 300(扣费=下注)
normal = MODES["normal"]
self.assertEqual(normal.base_bets, (1, 3, 5, 10, 20, 30, 50, 100, 200, 300))
self.assertEqual(normal.price_for(20), Decimal("20"))
# 底注:扣费 = 2 6 10 20 40 60 100 200 400 600
ante = MODES["ante"]
self.assertEqual(
[int(ante.price_for(b)) for b in ante.base_bets],
[2, 6, 10, 20, 40, 60, 100, 200, 400, 600],
)
# 购买免费旋转:扣费 = 50 150 250 500 1000 1500 2500 5000 10000 15000(10 档,50 倍)
buy = MODES["buy_fs"]
self.assertEqual(
[int(buy.price_for(b)) for b in buy.base_bets],
[50, 150, 250, 500, 1000, 1500, 2500, 5000, 10000, 15000],
)
def test_mode_mutual_exclusion(self) -> None:
# 底注模式禁止购买免费旋转
ante_engine = OlympusEngine(bet="1", mode="ante")
self.assertFalse(ante_engine.buy_free_spins_allowed())
self.assertTrue(ante_engine.ante_boost_active())
# 购买免费旋转模式禁用底注效果
buy_engine = OlympusEngine(bet="1", mode="buy_fs")
self.assertFalse(buy_engine.ante_boost_active())
self.assertFalse(buy_engine.buy_free_spins_allowed())
self.assertTrue(buy_engine.mode.guaranteed_free_spins)
# 普通模式允许购买
normal_engine = OlympusEngine(bet="1", mode="normal")
self.assertTrue(normal_engine.buy_free_spins_allowed())
self.assertFalse(normal_engine.ante_boost_active())
def test_engine_cost_uses_mode_multiplier(self) -> None:
self.assertEqual(OlympusEngine(bet="20", mode="normal").cost, Decimal("20"))
self.assertEqual(OlympusEngine(bet="20", mode="ante").cost, Decimal("40"))
self.assertEqual(OlympusEngine(bet="20", mode="buy_fs").cost, Decimal("1000"))
def test_invalid_mode_raises(self) -> None:
with self.assertRaises(RuleViolation):
OlympusEngine(bet="1", mode="buy_super_fs")
def test_bet_not_in_mode_tiers_raises(self) -> None:
# normal 没有 7 档
with self.assertRaises(RuleViolation):
OlympusEngine(bet="7", mode="normal")
# buy_fs 没有 40 档
with self.assertRaises(RuleViolation):
OlympusEngine(bet="40", mode="buy_fs")
class TestMaxWinCap(unittest.TestCase):
"""单局赢奖封顶 1000 倍基础下注。"""
def test_win_capped_at_1000x(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
# CROWN 12个=50x,配 1000 倍炸弹 → 未封顶前 50000x
engine.record_cascade(
symbols={"CROWN": list(range(12))}, bombs=[Bomb("m1", 1000)]
)
spin = engine.end_spin()
self.assertTrue(spin.win_capped)
self.assertEqual(spin.total_win, Decimal("1000"))
self.assertEqual(engine.state.total_win, Decimal("1000"))
def test_cap_applies_to_session_cumulative(self) -> None:
engine = OlympusEngine(bet="1")
# 第 1 次 FS 旋转拿 900x(基础 10x × 炸弹 90),累计器 → 90
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 50), Bomb("m2", 25), Bomb("m3", 15)])
s1 = engine.end_spin()
self.assertEqual(s1.total_win, Decimal("900"))
self.assertFalse(s1.win_capped)
# 第 2 次 FS 旋转再赢(无新炸弹,吃累计 90)→ 只能再拿 100x,整局顶格 1000x
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[])
s2 = engine.end_spin()
self.assertTrue(s2.win_capped)
self.assertEqual(engine.state.total_win, Decimal("1000"))
if __name__ == "__main__":
unittest.main()
-263
View File
@@ -1,263 +0,0 @@
"""回合生成器规则校验(unittest)。不依赖第三方库,可直接 `python tests/test_simulator.py` 运行。"""
from __future__ import annotations
import json
import random
import sys
import unittest
from collections import Counter
from pathlib import Path
# 支持直接 `python tests/test_simulator.py` 运行(无需先安装为包)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from olympus.config import BIGWIN_LEVELS, BIGWIN_TIERS, BOMB_SHORT, SCATTER_SHORT
import olympus_simulator as sim
from olympus_simulator import generate_round
def _cells(step):
return [c for row in step['grid_state'] for c in row]
class TestBuyFsTriggerSpin(unittest.TestCase):
"""buy_fs 触发盘:首盘固定 4 个 SCATTER,且整盘不出炸弹(炸弹只在免费旋转内生效)。"""
def test_trigger_pages(self) -> None:
for _ in range(100):
_, steps, _ = generate_round({'box_id': 0, 'box_type': 'buy_fs'})
trigger = [s for s in steps if s['free_spin_id'] == 0]
self.assertTrue(trigger)
for s in trigger:
cells = _cells(s)
self.assertEqual(cells.count(SCATTER_SHORT), 4)
self.assertEqual(cells.count(BOMB_SHORT), 0)
class TestBuyFsFreeSpinHits(unittest.TestCase):
"""buy_fs 免费旋转"每旋保底消除":自然阶段命中率应远高于旧实现的 ~13%(旧版 45% 强制空转)。"""
def test_natural_phase_hit_rate(self) -> None:
random.seed(12345)
box = {'box_id': 0, 'box_type': 'buy_fs'}
total = hit = 0
for _ in range(60):
_, steps, _ = generate_round(box, None)
spins = Counter(s['free_spin_id'] for s in steps if s['free_spin_id'] > 0)
total += len(spins)
hit += sum(1 for v in spins.values() if v > 1)
self.assertGreater(hit / total, 0.45)
class TestBombOnlyInFreeSpins(unittest.TestCase):
"""炸弹只存在于免费旋转内:
· normal/ante 的基础旋(含进 FS 的触发盘、含补桶植入)一律不出炸弹;
· 三个模式一旦进 FS 规则一致(炸弹 + 累计器),normal/ante 靠它演 100x/150x/200x 桶。"""
def test_normal_ante_base_spin_never_emit_bomb(self) -> None:
random.seed(20260822)
for mode in ('normal', 'ante'):
box = {'box_id': 0, 'box_type': mode}
bombs = 0
for bias in (None, 'zero', 0.5, 10.0, 150.0):
for _ in range(400):
_, steps, _ = generate_round(box, bias)
bombs += sum(_cells(s).count(BOMB_SHORT)
for s in steps if s['free_spin_id'] == 0)
self.assertEqual(bombs, 0, '%s 基础旋不该出炸弹' % mode)
def test_normal_ante_free_spins_have_bomb(self) -> None:
random.seed(11)
for mode in ('normal', 'ante'):
box = {'box_id': 0, 'box_type': mode}
grids = bombs = 0
for _ in range(400):
# 目标 150x ≥ FS_ONLY_TARGET_MUL → 该模式的 150x 桶只能靠自然触发 FS 的回合来装
_, steps, _ = generate_round(box, 150.0)
for s in steps:
if s['free_spin_id'] > 0:
grids += 1
bombs += _cells(s).count(BOMB_SHORT)
self.assertGreater(grids, 0, '%s 高倍桶应该是 FS 回合' % mode)
self.assertGreater(bombs / (30.0 * grids), 0.02,
'%s 的 FS 盘面应该有炸弹' % mode)
def test_high_target_round_enters_free_spins(self) -> None:
"""目标高到基础旋赔不出来(≥80x)时,生成器必须改用自然触发 FS 的回合。"""
for mode in ('normal', 'ante'):
_, steps, _ = generate_round({'box_id': 0, 'box_type': mode}, 150.0)
self.assertTrue(any(s['free_spin_id'] > 0 for s in steps))
trigger = [s for s in steps if s['free_spin_id'] == 0][-1]
self.assertGreaterEqual(_cells(trigger).count(SCATTER_SHORT), 4)
self.assertEqual(_cells(trigger).count(BOMB_SHORT), 0)
def test_buy_fs_still_has_bomb_in_free_spins(self) -> None:
random.seed(7)
box = {'box_id': 0, 'box_type': 'buy_fs'}
bombs = 0
for _ in range(200):
_, steps, _ = generate_round(box, None)
bombs += sum(_cells(s).count(BOMB_SHORT) for s in steps if s['free_spin_id'] > 0)
self.assertGreater(bombs, 0)
class TestBigWinLevels(unittest.TestCase):
"""爆奖动画只有小/中/大 3 档,而且只在免费旋转里判档:
· 基础旋(含触发盘,`free_spin_id=0`)恒为 null —— 这两个模式的普通旋转不播大奖动画;
· FS 旋的档位 = 该旋末帧 winAmount 与新阈值(两种情况两套)比对的结果。"""
def test_tiers_are_three_and_two_cases(self) -> None:
self.assertEqual(BIGWIN_LEVELS, ('small', 'middle', 'big'))
self.assertEqual(BIGWIN_TIERS['normal'], BIGWIN_TIERS['ante']) # 自然 FS 共用一套
self.assertNotEqual(BIGWIN_TIERS['normal'], BIGWIN_TIERS['buy_fs'])
for tiers in BIGWIN_TIERS.values():
self.assertEqual(len(tiers), len(BIGWIN_LEVELS))
def test_only_free_spin_final_frames_get_level(self) -> None:
random.seed(4242)
cases = (('normal', None), ('ante', None), ('buy_fs', None),
('normal', 150.0), ('ante', 200.0))
for mode, bias in cases:
box = {'box_id': 0, 'box_type': mode}
for _ in range(120):
_, steps, _ = generate_round(box, bias)
for s in steps:
if s['free_spin_id'] == 0 or not s['is_final']:
self.assertIsNone(s['bigwin_level'],
'%s 基础旋/非末帧不该有爆奖档' % mode)
continue
want = None
for name, threshold in zip(BIGWIN_LEVELS, BIGWIN_TIERS[mode]):
if float(s['win_amount']) >= threshold:
want = name
self.assertEqual(s['bigwin_level'], want)
class TestFreeSpinFloorSpread(unittest.TestCase):
"""保底消除"随机间隔":既不能出现长空尾,也不能变成固定节奏。
口径:dry = 该旋所有 cascade 步 `base_score` 全 0(scatter 赔付不算消除,S 不参与消除)。
两版历史(都别退回去):
· 最初按"剩余预算÷剩余旋数"算概率、扣预算扣的是被累计倍数放大后的赢额 → 买 FS 87.9% 的局
≥4 旋连空、51.7% 的局尾巴一直空转、单局最长 29 旋;
· 修成"等距铺"后虽然没长空尾,但 87% 的局"最长连空正好 4 旋",玩家几局就看穿"到第 4 旋肯定消"。"""
@staticmethod
def _max_dry(steps) -> int:
per = {}
for s in steps:
if s['free_spin_id'] > 0:
per[s['free_spin_id']] = per.get(s['free_spin_id'], 0.0) + float(s['base_score'])
run = mx = 0
for k in sorted(per):
run = 0 if per[k] > 0 else run + 1
mx = max(mx, run)
return mx
def test_dry_run_stays_short(self) -> None:
random.seed(20260916)
for bias in (25.0, None):
runs = [self._max_dry(generate_round({'box_id': 0, 'box_type': 'buy_fs'}, bias)[1])
for _ in range(80)]
avg, worst = sum(runs) / len(runs), max(runs)
self.assertLess(avg, 7, 'bias=%s 平均最长连空 %.1f 太长(旧实现 9.6)' % (bias, avg))
self.assertLessEqual(worst, 9, 'bias=%s 出现 %d 旋死尾巴(旧实现最长 29)' % (bias, worst))
def test_gap_is_not_a_fixed_cadence(self) -> None:
"""最长连空的取值必须散开:等距版 87% 的局都卡在同一个值。"""
random.seed(4242)
runs = [self._max_dry(generate_round({'box_id': 0, 'box_type': 'buy_fs'}, 25.0)[1])
for _ in range(120)]
top, cnt = Counter(runs).most_common(1)[0]
self.assertGreaterEqual(len(set(runs)), 3, '最长连空只有 %s,节奏太固定' % sorted(set(runs)))
self.assertLess(cnt / len(runs), 0.7, '最长连空过于集中:%.0f%% 都是 %d 旋' % (100 * cnt / len(runs), top))
class TestTumbleGeometry(unittest.TestCase):
"""每一步都要能按"消掉上一步的连线 → 重力下落 → 补位"复刻出 `grid_state`:
前端据此播动画,任何一格对不上/漏掉都会表现成画错符号或空白格子。"""
def _check_round(self, steps) -> None:
rows, cols = 5, 6
for i in range(1, len(steps)):
prev, cur = steps[i - 1], steps[i]
drops = json.loads(cur['drop_data'] or '[]')
fills = json.loads(cur['fill_data'] or '[]')
if prev['free_spin_id'] != cur['free_spin_id']:
# 新的一旋:整盘重发,不该带下落/补位
self.assertEqual(drops, [])
self.assertEqual(fills, [])
continue
links = json.loads(prev['symbol_links'] or '[]')
removed = {p - 1 for l in links if l.get('tumble') == prev['cascade_seq']
for p in l['loc']}
old = prev['grid_state']
board = [['' if (r * cols + c) in removed else old[r][c] for c in range(cols)]
for r in range(rows)]
nb = [[''] * cols for _ in range(rows)]
exp_drops = []
for c in range(cols):
surv = [(r, board[r][c]) for r in range(rows - 1, -1, -1) if board[r][c] != '']
for k, (r, sym) in enumerate(surv):
nr = rows - 1 - k
nb[nr][c] = sym
if nr != r:
exp_drops.append({'from': r * cols + c + 1, 'to': nr * cols + c + 1,
'symbol': sym})
key = lambda d: (d['from'], d['to'])
self.assertEqual(sorted(exp_drops, key=key), sorted(drops, key=key))
# 下落目标的 symbol 必须是"这一波结束后"的符号(= grid_state 同位置)
for d in drops:
p = d['to'] - 1
self.assertEqual(d['symbol'], cur['grid_state'][p // cols][p % cols])
# 补位必须正好盖住所有空位,且符号与最终盘面一致
empty = sorted(r * cols + c for c in range(cols) for r in range(rows)
if nb[r][c] == '')
self.assertEqual(sorted(f['pos'] - 1 for f in fills), empty)
for f in fills:
p = f['pos'] - 1
self.assertEqual(f['symbol'], cur['grid_state'][p // cols][p % cols])
nb[p // cols][p % cols] = f['symbol']
self.assertEqual(nb, cur['grid_state'], '复刻盘面 ≠ grid_state')
def test_all_modes(self) -> None:
random.seed(31337)
for mode, biases in (('normal', (None, 'zero', 20.0, 150.0)),
('ante', (None, 100.0, 200.0)),
('buy_fs', (None, 25.0, 500.0))):
for bias in biases:
for _ in range(60):
_, steps, _ = generate_round({'box_id': 0, 'box_type': mode}, bias)
self._check_round(steps)
class TestFreeSpinCap(unittest.TestCase):
"""30 旋上限(15 + 5×3):到上限之后不得再落 3+ 个 S。
旧实现只在"加旋"那行有闸门,写 retrigger_info 的那行没有 —— 到 30 旋后照样摇出 3+ 个 S、
照样写 extraSpins=5,玩家白看 "+5" 提示(全库 650 个这种帧)。这里用"每旋必落 5 个 S"的
极端分布把上限逼出来:应恰好 30 旋、恰好 3 次重触发,上限之后的旋 S 数 ≤2。
"""
def test_no_retrigger_beyond_cap(self) -> None:
random.seed(20260919)
original = sim.FREESPIN_SCATTER
sim.FREESPIN_SCATTER = {'default': {5: 1.0}, 'buy_fs': {5: 1.0}}
try:
for mode in ('normal', 'ante', 'buy_fs'):
steps, _ = sim._free_spin_session(mode)
spins = {s['free_spin_id'] for s in steps}
retrigger = [s for s in steps if s.get('retrigger_info')]
self.assertEqual(len(spins), 30, f'{mode}: 旋数应为 30,实际 {len(spins)}')
self.assertEqual(len(retrigger), 3, f'{mode}: 重触发应为 3 次,实际 {len(retrigger)}')
for s in steps:
if s['is_final'] and s['free_spin_id'] > 3:
self.assertLessEqual(_cells(s).count(SCATTER_SHORT), 2,
f'{mode}: 上限之后(第 {s["free_spin_id"]} 旋)又落了 3+ 个 S')
finally:
sim.FREESPIN_SCATTER = original
if __name__ == '__main__':
unittest.main()
@@ -33,8 +33,8 @@ spring:
druid:
# 主库数据源
master:
url: jdbc:mysql://127.0.0.1:23306/changzhou?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: changzhou
url: jdbc:mysql://127.0.0.1:23306/henan?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: henan
password: iTSJSPPZM3LSGAPC
# 从库数据源
slave:
@@ -33,8 +33,8 @@ spring:
druid:
# 主库数据源
master:
url: jdbc:mysql://127.0.0.1:23306/changzhou?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: changzhou
url: jdbc:mysql://127.0.0.1:23306/henan?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: henan
password: iTSJSPPZM3LSGAPC
# 从库数据源
slave: