操作日志
This commit is contained in:
@@ -56,3 +56,7 @@ nbdist/
|
|||||||
######################################################################
|
######################################################################
|
||||||
# Docker
|
# Docker
|
||||||
docker/backup/
|
docker/backup/
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
# workbuddy
|
||||||
|
.workbuddy
|
||||||
+2
-2
@@ -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 q "select count(*) from sys_oper_log"
|
||||||
// node bin/logtool.js clear-operlog 清理操作日志(先备份到 bin/backup/)
|
// node bin/logtool.js clear-operlog 清理操作日志(先备份到 bin/backup/)
|
||||||
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFileSync } = require('child_process');
|
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) {
|
function sql(statement) {
|
||||||
return execFileSync('docker', [...MYSQL, '-N', '-B', '-e', statement],
|
return execFileSync('docker', [...MYSQL, '-N', '-B', '-e', statement],
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
1. 创建 MySQL 数据卷:
|
1. 创建 MySQL 数据卷:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker volume create changzhou_mysql_data
|
docker volume create henan_mysql_data
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 启动容器(⚠️ 必须先 cd 到 compose 目录,且必须带两个 -f 参数):
|
2. 启动容器(⚠️ 必须先 cd 到 compose 目录,且必须带两个 -f 参数):
|
||||||
|
|
||||||
```powershell
|
```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
|
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
|
```powershell
|
||||||
docker ps
|
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 已启动且带密码)
|
# 返回 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 |
|
| Redis | 127.0.0.1:26379 | 6379 | 密码见 redis.dev.conf |
|
||||||
| RabbitMQ | 127.0.0.1:25672 | 5672 | skins/mk,vhost /skins |
|
| RabbitMQ | 127.0.0.1:25672 | 5672 | skins/mk,vhost /skins |
|
||||||
| RabbitMQ 管理台 | http://127.0.0.1:35672 | 15672 | skins/mk |
|
| RabbitMQ 管理台 | http://127.0.0.1:35672 | 15672 | skins/mk |
|
||||||
@@ -49,7 +49,7 @@ docker exec redis_cache redis-cli ping
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 都在 docker\compose 目录下执行
|
# 都在 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
|
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 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
|
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`,重建即可:
|
启动命令少了 `-f docker-compose.dev.yml`,重建即可:
|
||||||
|
|
||||||
```powershell
|
```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
|
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
|
```powershell
|
||||||
docker volume create changzhou_mysql_data
|
docker volume create henan_mysql_data
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 提示 `cannot find the file specified`
|
### 3. 提示 `cannot find the file specified`
|
||||||
|
|
||||||
没有 cd 到 `docker\compose` 目录就执行了命令,`-f` 的相对路径按当前目录解析。
|
没有 cd 到 `docker\compose` 目录就执行了命令,`-f` 的相对路径按当前目录解析。
|
||||||
|
|
||||||
### 4. 和 chengdu 项目切换
|
### 4. 和 hunan 项目切换
|
||||||
|
|
||||||
端口可能冲突,先到另一个项目的 compose 目录执行 `docker-compose stop`,再启动本项目。
|
端口可能冲突,先到另一个项目的 compose 目录执行 `docker-compose stop`,再启动本项目。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# 自动创建 external 数据卷(首次部署时)
|
# 自动创建 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
|
docker volume inspect "$vol" >/dev/null 2>&1 || docker volume create "$vol" >/dev/null
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ services:
|
|||||||
args:
|
args:
|
||||||
MYSQL_BASE_IMAGE: mysql:8.0.24
|
MYSQL_BASE_IMAGE: mysql:8.0.24
|
||||||
MYSQL_CONF: my.cnf.16c32g
|
MYSQL_CONF: my.cnf.16c32g
|
||||||
container_name: mysql_db
|
container_name: henan_mysql_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
MYSQL_ROOT_PASSWORD: 123456
|
MYSQL_ROOT_PASSWORD: 123456
|
||||||
MYSQL_DATABASE: changzhou
|
MYSQL_DATABASE: henan
|
||||||
MYSQL_USER: changzhou
|
MYSQL_USER: henan
|
||||||
MYSQL_PASSWORD: iTSJSPPZM3LSGAPC
|
MYSQL_PASSWORD: iTSJSPPZM3LSGAPC
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:23306:3306"
|
- "127.0.0.1:23306:3306"
|
||||||
@@ -31,12 +31,12 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- changzhou_app_network
|
- henan_app_network
|
||||||
|
|
||||||
# Redis 服务
|
# Redis 服务
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
container_name: redis_cache
|
container_name: henan_redis_cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server /usr/local/etc/redis/redis.conf
|
command: redis-server /usr/local/etc/redis/redis.conf
|
||||||
ports:
|
ports:
|
||||||
@@ -47,12 +47,12 @@ services:
|
|||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
networks:
|
networks:
|
||||||
- changzhou_app_network
|
- henan_app_network
|
||||||
|
|
||||||
# RabbitMQ 3.9 服务
|
# RabbitMQ 3.9 服务
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.9-management
|
image: rabbitmq:3.9-management
|
||||||
container_name: rabbitmq_server
|
container_name: henan_rabbitmq_server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: skins
|
RABBITMQ_DEFAULT_USER: skins
|
||||||
@@ -75,15 +75,15 @@ services:
|
|||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
networks:
|
networks:
|
||||||
- changzhou_app_network
|
- henan_app_network
|
||||||
|
|
||||||
# 定义数据卷(external 固定卷名,不随目录变化,防止换目录丢数据)
|
# 定义数据卷(external 固定卷名,不随目录变化,防止换目录丢数据)
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
external: true
|
external: true
|
||||||
name: changzhou_mysql_data
|
name: henan_mysql_data
|
||||||
|
|
||||||
# 定义网络
|
# 定义网络
|
||||||
networks:
|
networks:
|
||||||
changzhou_app_network:
|
henan_app_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# MySQL 数据卷迁移脚本
|
# MySQL 数据卷迁移脚本
|
||||||
# 将旧自动命名卷的数据复制到 external 卷 changzhou_mysql_data
|
# 将旧自动命名卷的数据复制到 external 卷 henan_mysql_data
|
||||||
# 用法:bash migrate-mysql-volume.sh [旧卷名]
|
# 用法: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
|
set -e
|
||||||
|
|
||||||
NEW_VOL="changzhou_mysql_data"
|
NEW_VOL="henan_mysql_data"
|
||||||
IMAGE="mysql:8.0.24"
|
IMAGE="mysql:8.0.24"
|
||||||
|
|
||||||
# 自动检测旧卷名
|
# 自动检测旧卷名
|
||||||
if [ -n "$1" ]; then
|
if [ -n "$1" ]; then
|
||||||
OLD_VOL="$1"
|
OLD_VOL="$1"
|
||||||
else
|
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
|
if docker volume inspect "$candidate" >/dev/null 2>&1; then
|
||||||
OLD_VOL="$candidate"
|
OLD_VOL="$candidate"
|
||||||
break
|
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"
|
echo "旧卷 $OLD_VOL 数据量: $OLD_SIZE"
|
||||||
|
|
||||||
# 停掉 MySQL 容器(如果在运行)
|
# 停掉 MySQL 容器(如果在运行)
|
||||||
if docker inspect mysql_db >/dev/null 2>&1; then
|
if docker inspect henan_mysql_db >/dev/null 2>&1; then
|
||||||
echo "停止 MySQL 容器..."
|
echo "停止 MySQL 容器..."
|
||||||
docker stop mysql_db 2>/dev/null || true
|
docker stop henan_mysql_db 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 清空新卷(避免数据叠加)
|
# 清空新卷(避免数据叠加)
|
||||||
|
|||||||
@@ -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` 待定。
|
||||||
@@ -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
@@ -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.')
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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]()
|
|
||||||
@@ -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())
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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分钟未结算记录并补发金币/饰品');
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""Olympus tests."""
|
|
||||||
@@ -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()
|
|
||||||
@@ -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:
|
druid:
|
||||||
# 主库数据源
|
# 主库数据源
|
||||||
master:
|
master:
|
||||||
url: jdbc:mysql://127.0.0.1:23306/changzhou?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
url: jdbc:mysql://127.0.0.1:23306/henan?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||||
username: changzhou
|
username: henan
|
||||||
password: iTSJSPPZM3LSGAPC
|
password: iTSJSPPZM3LSGAPC
|
||||||
# 从库数据源
|
# 从库数据源
|
||||||
slave:
|
slave:
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ spring:
|
|||||||
druid:
|
druid:
|
||||||
# 主库数据源
|
# 主库数据源
|
||||||
master:
|
master:
|
||||||
url: jdbc:mysql://127.0.0.1:23306/changzhou?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
url: jdbc:mysql://127.0.0.1:23306/henan?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||||
username: changzhou
|
username: henan
|
||||||
password: iTSJSPPZM3LSGAPC
|
password: iTSJSPPZM3LSGAPC
|
||||||
# 从库数据源
|
# 从库数据源
|
||||||
slave:
|
slave:
|
||||||
|
|||||||
Reference in New Issue
Block a user