麻将胡了
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# mahjong-py · 麻将胡了(回合池生成器)
|
||||
|
||||
规则唯一事实来源是 `docs/麻将胡了设计方案.md`;本目录是它的 Python 实现(母本风格照 `D:\changzhou_app\changzhou_backend\olympus-py`,但**引擎是重写的**:麻将从盘面自己判定,母本只记账)。
|
||||
|
||||
## 现在有什么 / 还没有什么
|
||||
|
||||
| 状态 | 内容 |
|
||||
|---|---|
|
||||
| ✅ 已实现 | `mahjong/config.py`(规则参数唯一来源)、`models.py`、`engine.py`(前缀链 / 金色就地转百搭 / 列内重力+补位 / 阶梯 / FS / 500x 封顶)、`tests/test_engine.py`(11 项)、`mahjong_simulator.py`(自然盘面仿真) |
|
||||
| ⛔ 还没有 | `generate_rounds.py`(按桶配额主动摆盘面 + bias 反解 + 落库)、`_recalc_boxes.py`(配比)、`schema.sql`、`mahjong_db.py`、`_make_templates.py`、`simulate_cycle.py`、`_regen.py` |
|
||||
|
||||
## 跑起来
|
||||
|
||||
```bash
|
||||
cd mahjong-py
|
||||
set PYTHONIOENCODING=utf-8 # Windows:中文输出
|
||||
python tests/test_engine.py # 11 项规则单测(无需 pytest)
|
||||
python mahjong_simulator.py --rounds 200000 --seed 2026 # 自然盘面仿真(不含偏置构造)
|
||||
```
|
||||
|
||||
## 引擎的 6 条铁律(写代码时别绕开)
|
||||
|
||||
1. **前缀链**:从第 1 轴起 1→2→3→4→5 连续,断链整笔作废;`ways` 只乘链内轴。
|
||||
2. **金色格只当它自己的自然符号**(不做别的符号的替代);"顶替别人"只有 `W` 会做。
|
||||
3. **金色中奖格就地变 `W`**:不清空、不补牌;几个金格中奖就留几个 `W`(无上限)。金色只镀在第 2/3/4 轴的普通符号上 ⇒ **`W` 永远不落在第 1/5 轴**。
|
||||
4. **`W` 要满足消除链才被消除**;没参与成奖就留到下一波。同一个 `W` 可以同时被多个符号借用。
|
||||
5. **金色标记跟着符号下落**(不是跟着格号)——否则会"传染"给新补的牌。
|
||||
6. **阶梯每旋重置**:基础 `1/2/3/5`、FS `2/4/6/10`;**一屏最多 5 个胡**;FS 上限 36 旋且"发不满就不发"。
|
||||
|
||||
## 与设计文档的已知偏差(文档已同步勘误)
|
||||
|
||||
- §15 第 4 波原本只算两笔奖,漏了"同一个 `W` 也能当白板"→ 白板 3 连。修正后本波 ¥1200、本旋总账 **¥2380**(原 ¥2180)。
|
||||
- §3.9 是新加的空转率结论(65%),依据就是本目录 20 万局的自然仿真数据。
|
||||
|
||||
## ponytail 备注
|
||||
|
||||
- 生成端(配额/偏置)**故意还没写**:它的形状取决于 §3.9 的空转率口径与桶配比,先把引擎钉死再往上堆,避免拿未验证的数学去凑池子。
|
||||
- 仿真只做自然盘面,没有引入任何"目标倍率反解";`play_spin` 已经支持外部传盘面,构造器接上去即可。
|
||||
@@ -0,0 +1,7 @@
|
||||
"""麻将胡了 · 生成器包(规则参数 / 模型 / 引擎)。"""
|
||||
from .config import * # noqa: F401,F403
|
||||
from .engine import ( # noqa: F401
|
||||
assert_no_native_wild, assert_wild_only_middle, count_scatters, new_grid,
|
||||
play_round, play_spin, pos, settle_rotation, ways_wins,
|
||||
)
|
||||
from .models import Round, Rotation, Spin, Win # noqa: F401
|
||||
@@ -0,0 +1,166 @@
|
||||
"""麻将胡了 · 唯一事实来源(规则参数)。
|
||||
|
||||
数值全部取自 docs/麻将胡了设计方案.md(§2 规则 / §3 桶与 RTP / §2.8 投注档)。
|
||||
改规则改这里;生成器、引擎、单测、仿真都从这里读。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Mapping, Tuple
|
||||
|
||||
# ============================================================
|
||||
# 盘面:5 轴 × 4 行 = 20 格,ways = 4^5 = 1024
|
||||
# ============================================================
|
||||
GRID_COLS = 5
|
||||
GRID_ROWS = 4
|
||||
GRID_SIZE = GRID_ROWS * GRID_COLS
|
||||
WAYS = GRID_ROWS ** GRID_COLS
|
||||
|
||||
WILD = "W" # 百搭(元宝):只能由金色符号转化产生,卷轴永不落
|
||||
SCATTER = "S" # 胡(夺宝):只触发免费旋转,不赔现金
|
||||
|
||||
# 格位约定:接口/落库用 1 基、行优先(1~20);引擎内部用 0 基。
|
||||
# 引擎内 index = row * GRID_COLS + col;pos() 负责换算(见 engine.py)。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolRule:
|
||||
code: str
|
||||
name: str
|
||||
payouts: Mapping[int, float] # 连轴数(3/4/5) → 倍投注总额
|
||||
|
||||
|
||||
# 赔付表:原版"每条基础投注点数"已 ÷20 内化,表中数值**直接是倍率**
|
||||
SYMBOL_RULES: Dict[str, SymbolRule] = {
|
||||
"A": SymbolRule("A", "發", {3: 0.75, 4: 3.0, 5: 5.0}),
|
||||
"B": SymbolRule("B", "中", {3: 0.5, 4: 2.0, 5: 4.0}),
|
||||
"C": SymbolRule("C", "白板", {3: 0.4, 4: 1.0, 5: 3.0}),
|
||||
"D": SymbolRule("D", "八萬", {3: 0.3, 4: 0.75, 5: 2.0}),
|
||||
"E": SymbolRule("E", "花筒", {3: 0.2, 4: 0.5, 5: 1.0}),
|
||||
"F": SymbolRule("F", "索红绿", {3: 0.2, 4: 0.5, 5: 1.0}),
|
||||
"G": SymbolRule("G", "筒", {3: 0.1, 4: 0.25, 5: 0.5}),
|
||||
"H": SymbolRule("H", "索绿", {3: 0.1, 4: 0.25, 5: 0.5}),
|
||||
}
|
||||
PAYING: Tuple[str, ...] = ("A", "B", "C", "D", "E", "F", "G", "H")
|
||||
PAYTABLE: Dict[str, Mapping[int, float]] = {code: r.payouts for code, r in SYMBOL_RULES.items()}
|
||||
MIN_CHAIN = 3 # 前缀链至少 3 连才成奖(只有 3/4/5 三档)
|
||||
|
||||
# 卷轴出现权重:只有 8 个付费符号 + 胡(**没有 W**,原生盘面永不落百搭)
|
||||
SYMBOL_WEIGHTS: Dict[str, int] = {
|
||||
"A": 5, "B": 6, "C": 8, "D": 9, "E": 11, "F": 11, "G": 13, "H": 13, "S": 2,
|
||||
}
|
||||
|
||||
# 金色麻将符号:只可能出现在第 2/3/4 轴(0 基列 1..3)
|
||||
GOLDEN_COLS: Tuple[int, ...] = (1, 2, 3)
|
||||
GOLDEN_CELL_PROB = 0.10 # 逐格独立;新补入的符号也可以带金
|
||||
|
||||
# ============================================================
|
||||
# 乘数阶梯(本波阶梯倍率):每旋从第一档重新开始,不跨旋累计
|
||||
# ============================================================
|
||||
LADDER_BASE: Tuple[int, ...] = (1, 2, 3, 5)
|
||||
LADDER_FS: Tuple[int, ...] = (2, 4, 6, 10)
|
||||
|
||||
|
||||
def ladder_mul(rotation_seq: int, is_fs: bool) -> int:
|
||||
"""第 n 波(1 基)的阶梯倍率;第 4 波及以后取最后一档。"""
|
||||
if rotation_seq < 1:
|
||||
raise ValueError("波序从 1 开始")
|
||||
ladder = LADDER_FS if is_fs else LADDER_BASE
|
||||
return ladder[min(rotation_seq, len(ladder)) - 1]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 免费旋转:3+ 胡 → 12 旋 + 每多 1 个 +2;一屏最多 5 个胡;上限 36 旋
|
||||
# "发不满就不发":已发放 + 本次应赠 > 36 时,本旋绝不落 3+ 胡
|
||||
# ============================================================
|
||||
SCATTER_TRIGGER = 3
|
||||
FS_BASE_SPINS = 12
|
||||
FS_EXTRA_PER_SCATTER = 2
|
||||
MAX_SCATTER_PER_SCREEN = 5
|
||||
MAX_FREE_SPINS = 36
|
||||
|
||||
|
||||
def fs_award(scatter_count: int) -> int:
|
||||
return FS_BASE_SPINS + FS_EXTRA_PER_SCATTER * max(0, scatter_count - SCATTER_TRIGGER)
|
||||
|
||||
|
||||
# 单局封顶:总倍率到达即截断,剩余旋作废
|
||||
MAX_WIN_CAP_MUL = 500
|
||||
|
||||
# 悬念盘:基础旋恰好 2 个胡且第 5 轴无胡(生成端配额,客户端播第 5 列放大)
|
||||
NEARMISS_SCATTER_COUNT = 2
|
||||
NEARMISS_2SCATTER_RATE = 0.015
|
||||
|
||||
# ============================================================
|
||||
# 投注档:11 档纯投注档,无投注倍数(betMul 仅预留、服务端只接受 1)
|
||||
# price = cost = box.bet;boxId = 序号(1~11)
|
||||
# ============================================================
|
||||
BET_LADDER: Tuple[int, ...] = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500)
|
||||
BOX_IDS: Tuple[int, ...] = tuple(range(1, len(BET_LADDER) + 1))
|
||||
|
||||
|
||||
def bet_of(box_id: int) -> int:
|
||||
return BET_LADDER[box_id - 1]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 饰品桶(16 档,0x~500x):沿用玫瑰女郎 17 档结构、删掉 1000x 那档
|
||||
# 期望倍率 = (区间下限 + 上限) / 2;区间连续无缝
|
||||
# 桶 16 区间上界故意开到 700 作缓冲,但生成端"先按 500 截断再落桶"
|
||||
# ============================================================
|
||||
@dataclass(frozen=True)
|
||||
class Bucket:
|
||||
id: int
|
||||
name: str
|
||||
expect_mul: float
|
||||
range_low: float
|
||||
range_high: float
|
||||
|
||||
|
||||
BUCKETS: Tuple[Bucket, ...] = (
|
||||
Bucket(1, "0倍饰品", 0.0, 0.0, 0.0),
|
||||
Bucket(2, "0.5倍饰品", 0.5, 0.01, 1.0),
|
||||
Bucket(3, "1.5倍饰品", 1.5, 1.0, 2.0),
|
||||
Bucket(4, "2.5倍饰品", 2.5, 2.0, 3.0),
|
||||
Bucket(5, "4倍饰品", 4.0, 3.0, 5.0),
|
||||
Bucket(6, "6.5倍饰品", 6.5, 5.0, 8.0),
|
||||
Bucket(7, "10倍饰品", 10.0, 8.0, 12.0),
|
||||
Bucket(8, "16倍饰品", 16.0, 12.0, 20.0),
|
||||
Bucket(9, "25倍饰品", 25.0, 20.0, 30.0),
|
||||
Bucket(10, "40倍饰品", 40.0, 30.0, 50.0),
|
||||
Bucket(11, "60倍饰品", 60.0, 50.0, 70.0),
|
||||
Bucket(12, "100倍饰品", 100.0, 70.0, 130.0),
|
||||
Bucket(13, "150倍饰品", 150.0, 130.0, 170.0),
|
||||
Bucket(14, "200倍饰品", 200.0, 170.0, 230.0),
|
||||
Bucket(15, "300倍饰品", 300.0, 230.0, 370.0),
|
||||
Bucket(16, "500倍饰品(封顶)", 500.0, 370.0, 700.0),
|
||||
)
|
||||
FIXED_CAP_BUCKET_ID = 16 # 封顶桶
|
||||
|
||||
|
||||
def bucket_of(mul: float) -> int:
|
||||
"""按**已截断**的倍率落桶(顺序:先 min(mul, 500) 再落桶)。<0.01 视为空转。"""
|
||||
mul = min(float(mul), MAX_WIN_CAP_MUL)
|
||||
if mul < 0.01:
|
||||
return 1
|
||||
for b in BUCKETS:
|
||||
if b.range_low <= mul <= b.range_high:
|
||||
return b.id
|
||||
return FIXED_CAP_BUCKET_ID
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RTP 与身份
|
||||
# ============================================================
|
||||
RTP = 0.80 # 目标返奖率(后台可按箱改)
|
||||
CONVERGE_RATIO = 0.15 # 收敛阶段占比(一轮 N = fixedSum / (1 - cr))
|
||||
AUDIENCE_COMMON = "common" # 其余全部(**含 user_type='03' 机器人**)
|
||||
AUDIENCE_ANCHOR = "anchor" # user_type = '01'
|
||||
AUDIENCE_WHITE = "white" # is_blank_account = 1(最高优先)
|
||||
# 只有 3 个身份维度:robot 维度已按用户要求去掉(2026-09-22),机器人归 common
|
||||
AUDIENCES: Tuple[str, ...] = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_WHITE)
|
||||
|
||||
# 生成器/池子规模(先小规模验证,再放大到线上)
|
||||
ALLOC_K = 1.5 # 池子 = 配比 × 1.5
|
||||
ALLOC_FLOOR = 60 # 每桶保底条数
|
||||
SMOKE_ROUNDS_PER_BUCKET = 20 # `--per-bucket 20` 那种冒烟规模
|
||||
@@ -0,0 +1,279 @@
|
||||
"""麻将胡了 · 规则引擎(服务端权威判定的唯一实现)。
|
||||
|
||||
与母本 olympus/engine.py 的差别(母本只记账、消除由 simulator 决定):
|
||||
本引擎**从盘面自己判定**——1024 路前缀链、金色就地转百搭、列内重力+补位、阶梯、FS、500x 封顶。
|
||||
生成端(mahjong_simulator.py)只负责"把盘面摆成想要的形状",摆完之后一切由这里算。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import replace
|
||||
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from .config import (
|
||||
GOLDEN_CELL_PROB, GOLDEN_COLS, GRID_COLS, GRID_ROWS, MAX_SCATTER_PER_SCREEN,
|
||||
MAX_WIN_CAP_MUL, MIN_CHAIN, NEARMISS_SCATTER_COUNT, PAYING, PAYTABLE, SCATTER,
|
||||
SCATTER_TRIGGER, SYMBOL_WEIGHTS, WILD, fs_award, ladder_mul,
|
||||
)
|
||||
from .models import Round, Rotation, Spin, Win
|
||||
|
||||
Cell = Optional[str] # None = 空洞(结算中间态,必被补位填满)
|
||||
Grid = List[Cell]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 格位换算
|
||||
def pos(row: int, col: int) -> int:
|
||||
"""0 基 (行,列) → 接口/落库用的 1 基格位 1~20(行优先)。"""
|
||||
return row * GRID_COLS + col + 1
|
||||
|
||||
|
||||
def index_of(p: int) -> int:
|
||||
"""1 基格位 → 0 基内部下标。"""
|
||||
return p - 1
|
||||
|
||||
|
||||
def _rc(i: int) -> Tuple[int, int]:
|
||||
return divmod(i, GRID_COLS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 盘面构造
|
||||
def _pick(rng: random.Random) -> str:
|
||||
"""卷轴落一个符号:只从 8 付费符号 + 胡里出(永不落 W)。"""
|
||||
codes = tuple(SYMBOL_WEIGHTS)
|
||||
return rng.choices(codes, weights=[SYMBOL_WEIGHTS[c] for c in codes])[0]
|
||||
|
||||
|
||||
def _mark_golden(rng: random.Random, col: int, sym: str) -> bool:
|
||||
"""金色只在第 2/3/4 轴,且只打在普通符号上(百搭/胡不变金)。"""
|
||||
return (col in GOLDEN_COLS and sym != SCATTER and sym != WILD
|
||||
and rng.random() < GOLDEN_CELL_PROB)
|
||||
|
||||
|
||||
def new_grid(rng: random.Random) -> Tuple[Grid, Set[int]]:
|
||||
"""开一旋:整盘新发 + 第 2/3/4 轴的普通符号按概率打金色。返回 (盘面, 金色格下标集)。"""
|
||||
grid: Grid = []
|
||||
golden: Set[int] = set()
|
||||
for i in range(GRID_ROWS * GRID_COLS):
|
||||
row, col = _rc(i)
|
||||
sym = _pick(rng)
|
||||
grid.append(sym)
|
||||
if _mark_golden(rng, col, sym):
|
||||
golden.add(i)
|
||||
return grid, golden
|
||||
|
||||
|
||||
def assert_no_native_wild(grid: Sequence[Cell]) -> None:
|
||||
"""铁律:原生盘面(未结算)+ 补位都不产出 W,百搭只能由金色转化产生。"""
|
||||
for i, v in enumerate(grid):
|
||||
if v == WILD:
|
||||
raise AssertionError(f"格位 {pos(*_rc(i))} 出现了非转化来源的百搭")
|
||||
|
||||
|
||||
def assert_wild_only_middle(grid: Sequence[Cell]) -> None:
|
||||
"""铁律:金色只在第 2/3/4 轴 ⇒ 百搭只可能在第 2/3/4 轴(第 1 轴永远没有)。"""
|
||||
for i, v in enumerate(grid):
|
||||
if v == WILD and _rc(i)[1] not in GOLDEN_COLS:
|
||||
raise AssertionError(f"格位 {pos(*_rc(i))} 的百搭落在第 1 或第 5 轴,不可能")
|
||||
|
||||
|
||||
def count_scatters(grid: Sequence[Cell]) -> int:
|
||||
return sum(1 for v in grid if v == SCATTER)
|
||||
|
||||
|
||||
def scatters_in_col(grid: Sequence[Cell], col: int) -> int:
|
||||
return sum(1 for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] == SCATTER)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 1024 路判定
|
||||
def _count_in_col(grid: Sequence[Cell], col: int, sym: str) -> int:
|
||||
"""该轴「符号 X 或百搭」的格数(金色格按自然符号计入)。"""
|
||||
n = 0
|
||||
for r in range(GRID_ROWS):
|
||||
v = grid[r * GRID_COLS + col]
|
||||
if v == sym or v == WILD:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def chain_cells(grid: Sequence[Cell], sym: str, run: int) -> Tuple[int, ...]:
|
||||
cells = []
|
||||
for col in range(run):
|
||||
for r in range(GRID_ROWS):
|
||||
if grid[r * GRID_COLS + col] in (sym, WILD):
|
||||
cells.append(pos(r, col))
|
||||
return tuple(cells)
|
||||
|
||||
|
||||
def ways_wins(grid: Sequence[Cell]) -> Tuple[Win, ...]:
|
||||
"""前缀链:第 1 轴起 1→2→3→4→5 连续,一旦断链整笔作废;ways 只乘链内轴。"""
|
||||
wins: List[Win] = []
|
||||
for sym in PAYING:
|
||||
run, ways, counts = 0, 1, []
|
||||
for col in range(GRID_COLS):
|
||||
n = _count_in_col(grid, col, sym)
|
||||
if n == 0:
|
||||
break
|
||||
run += 1
|
||||
ways *= n
|
||||
counts.append(n)
|
||||
if run >= MIN_CHAIN:
|
||||
wins.append(Win(
|
||||
symbol=sym, run=run, counts=tuple(counts), ways=ways,
|
||||
loc=chain_cells(grid, sym, run), pay_base=PAYTABLE[sym][run] * ways,
|
||||
))
|
||||
return tuple(wins)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 重力 + 补位
|
||||
def collapse_fill(grid: Grid, golden: Set[int], rng: random.Random
|
||||
) -> Tuple[Tuple[Tuple[int, int], ...], Tuple[Tuple[int, str], ...]]:
|
||||
"""列内重力:幸存符号(含刚转化的百搭)保持顺序压到底,顶部补新符号(可带金色)。
|
||||
|
||||
金色是**镀在符号上**的:幸存符号下落时,金色标记要跟着它一起走,
|
||||
否则会留在原格"传染"给新补进来的牌(也就不再是"那个金色符号"了)。
|
||||
"""
|
||||
drops: List[Tuple[int, int]] = []
|
||||
fills: List[Tuple[int, str]] = []
|
||||
for col in range(GRID_COLS):
|
||||
survivors = [(r, grid[r * GRID_COLS + col], (r * GRID_COLS + col) in golden)
|
||||
for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] is not None]
|
||||
for r in range(GRID_ROWS):
|
||||
golden.discard(r * GRID_COLS + col) # 清空本列标记,按落点重打
|
||||
new_col: List[Cell] = [None] * GRID_ROWS
|
||||
offset = GRID_ROWS - len(survivors)
|
||||
for k, (r, v, was_golden) in enumerate(survivors):
|
||||
dst_row = offset + k
|
||||
new_col[dst_row] = v
|
||||
if was_golden:
|
||||
golden.add(dst_row * GRID_COLS + col)
|
||||
if dst_row != r:
|
||||
drops.append((pos(r, col), pos(dst_row, col)))
|
||||
for r in range(offset):
|
||||
sym = _pick(rng)
|
||||
new_col[r] = sym
|
||||
fills.append((pos(r, col), sym))
|
||||
if _mark_golden(rng, col, sym):
|
||||
golden.add(r * GRID_COLS + col)
|
||||
for r in range(GRID_ROWS):
|
||||
grid[r * GRID_COLS + col] = new_col[r]
|
||||
return tuple(drops), tuple(fills)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 一波结算
|
||||
def settle_rotation(grid: Grid, golden: Set[int], seq: int, is_fs: bool,
|
||||
rng: random.Random) -> Optional[Rotation]:
|
||||
"""结算一波;无成奖返回 None。会就地改写 grid / golden。"""
|
||||
wins = ways_wins(grid)
|
||||
if not wins:
|
||||
return None
|
||||
|
||||
involved: Set[int] = {index_of(p) for w in wins for p in w.loc}
|
||||
rotation_mul = ladder_mul(seq, is_fs)
|
||||
base_mul = sum(w.pay_base for w in wins)
|
||||
|
||||
# ① 全部中奖格"被消除",但金色格是就地改写:不空洞、不补牌
|
||||
converted: List[Tuple[int, str]] = []
|
||||
for i in sorted(involved):
|
||||
if i in golden:
|
||||
golden.discard(i)
|
||||
converted.append((i, grid[i] or ""))
|
||||
grid[i] = WILD
|
||||
converted_idx = {i for i, _ in converted}
|
||||
for i in involved:
|
||||
if i not in converted_idx:
|
||||
grid[i] = None
|
||||
|
||||
# ② 重力下落 + 顶部补位(刚转化的 W 也是普通盘面符号,一起落)
|
||||
drops, fills = collapse_fill(grid, golden, rng)
|
||||
assert_wild_only_middle(grid)
|
||||
|
||||
return Rotation(
|
||||
seq=seq, rotation_mul=rotation_mul, wins=wins, base_mul=base_mul,
|
||||
win_mul=base_mul * rotation_mul,
|
||||
converted=tuple((pos(*_rc(i)), s) for i, s in converted),
|
||||
drops=drops, fills=fills, grid=tuple(grid),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 一旋 / 一局
|
||||
def _strip_scatters(grid: Grid, golden: Set[int], rng: random.Random,
|
||||
keep: int = SCATTER_TRIGGER - 1) -> None:
|
||||
"""把落定的胡数压到 keep(用于"一屏最多 5 个胡"与"发不满就不发")。"""
|
||||
hits = [i for i, v in enumerate(grid) if v == SCATTER]
|
||||
for i in hits[keep:]:
|
||||
row, col = _rc(i)
|
||||
grid[i] = _pick(rng)
|
||||
golden.discard(i)
|
||||
if _mark_golden(rng, col, grid[i] or ""):
|
||||
golden.add(i)
|
||||
|
||||
|
||||
def play_spin(rng: random.Random, index: int, is_fs: bool,
|
||||
grid: Optional[Grid] = None, golden: Optional[Set[int]] = None) -> Spin:
|
||||
"""播一旋:落定(grid)→ 逐波结算 → 直到本波无成奖。"""
|
||||
if grid is None or golden is None:
|
||||
grid, golden = new_grid(rng)
|
||||
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
|
||||
scatters = count_scatters(grid) # 触发只看**落定盘面**(补位落下的胡不算)
|
||||
|
||||
rotations: List[Rotation] = []
|
||||
seq = 1
|
||||
while True:
|
||||
rot = settle_rotation(grid, golden, seq, is_fs, rng)
|
||||
if rot is None:
|
||||
break
|
||||
rotations.append(rot)
|
||||
seq += 1
|
||||
return Spin(index=index, is_fs=is_fs, rotations=tuple(rotations),
|
||||
mul=sum(r.win_mul for r in rotations), scatters=scatters)
|
||||
|
||||
|
||||
def _is_near_miss(grid: Sequence[Cell]) -> bool:
|
||||
"""恰好 2 个胡、且第 5 轴没有胡(客户端在第 5 列播放大)。"""
|
||||
return (count_scatters(grid) == NEARMISS_SCATTER_COUNT
|
||||
and scatters_in_col(grid, GRID_COLS - 1) == 0)
|
||||
|
||||
|
||||
def play_round(rng: random.Random, box_id: int = 1) -> Round:
|
||||
"""完整一局:基础旋 →(3+ 胡)免费旋转 → 重触发 → 封顶截断。"""
|
||||
round_ = Round(box_id=box_id)
|
||||
total = 0.0
|
||||
|
||||
grid, golden = new_grid(rng)
|
||||
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN) # 一屏最多 5 个胡
|
||||
round_.near_miss = _is_near_miss(grid)
|
||||
|
||||
base = play_spin(rng, index=0, is_fs=False, grid=grid, golden=golden)
|
||||
if base.scatters >= SCATTER_TRIGGER:
|
||||
award = fs_award(base.scatters)
|
||||
base = replace(base, fs_awarded=award)
|
||||
round_.free_spins = award
|
||||
round_.spins.append(base)
|
||||
total += base.mul
|
||||
|
||||
played = 0
|
||||
while played < round_.free_spins:
|
||||
played += 1
|
||||
grid, golden = new_grid(rng)
|
||||
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
|
||||
sc = count_scatters(grid)
|
||||
retrigger = False
|
||||
if sc >= SCATTER_TRIGGER:
|
||||
extra = fs_award(sc)
|
||||
if round_.free_spins + extra <= 36: # "发不满就不发"
|
||||
round_.free_spins += extra
|
||||
retrigger = True
|
||||
else:
|
||||
_strip_scatters(grid, golden, rng) # 本旋就是不落 3+ 胡
|
||||
spin = play_spin(rng, index=played, is_fs=True, grid=grid, golden=golden)
|
||||
if retrigger:
|
||||
spin = replace(spin, retrigger=True)
|
||||
round_.spins.append(spin)
|
||||
total += spin.mul
|
||||
if total >= MAX_WIN_CAP_MUL: # 触顶:剩余旋作废
|
||||
round_.capped = True
|
||||
break
|
||||
|
||||
round_.raw_mul = total
|
||||
round_.actual_mul = min(total, float(MAX_WIN_CAP_MUL))
|
||||
return round_
|
||||
@@ -0,0 +1,58 @@
|
||||
"""麻将胡了 · 数据模型(frozen dataclass,母本 olympus/models.py 同风格)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Win:
|
||||
"""一笔奖 = 某符号的一条前缀链。"""
|
||||
symbol: str
|
||||
run: int # 链长 3/4/5(从第 1 轴起连续的轴数)
|
||||
counts: Tuple[int, ...] # 链内各轴的格数(百搭计入)
|
||||
ways: int # counts 的乘积
|
||||
loc: Tuple[int, ...] # 本笔涉及的全部格位(1 基,含金色格与百搭)
|
||||
pay_base: float # 赔付表[符号][链长] × ways,单位 = 倍投注总额
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rotation:
|
||||
"""一波 = 一次消除结算。"""
|
||||
seq: int # 本旋内第几波(= 阶梯序号)
|
||||
rotation_mul: int # 本波阶梯倍率
|
||||
wins: Tuple[Win, ...]
|
||||
base_mul: float # Σ pay_base
|
||||
win_mul: float # base_mul × rotation_mul
|
||||
converted: Tuple[Tuple[int, str], ...] # (格位1基, 原自然符号):金色就地转百搭
|
||||
drops: Tuple[Tuple[int, int], ...] # (from1基, to1基)
|
||||
fills: Tuple[Tuple[int, str], ...] # (格位1基, 新符号)
|
||||
grid: Tuple[Optional[str], ...] # 本波结算+补位后的盘面(20 格短码)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Spin:
|
||||
"""一旋。"""
|
||||
index: int # 0 = 基础旋(触发盘),>0 = 第 N 次免费旋转
|
||||
is_fs: bool
|
||||
rotations: Tuple[Rotation, ...]
|
||||
mul: float # 本旋倍率
|
||||
scatters: int # 本旋**落定盘面**上的胡数(补位产生的不算触发)
|
||||
fs_awarded: int = 0 # 本旋触发的赠送旋数(0 = 没触发)
|
||||
retrigger: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Round:
|
||||
"""一局 = 基础旋 + 免费旋转(可重触发),受 500x 封顶。"""
|
||||
box_id: int
|
||||
spins: List[Spin] = field(default_factory=list)
|
||||
raw_mul: float = 0.0 # 封顶前的总倍率
|
||||
actual_mul: float = 0.0 # min(raw_mul, 500) —— 落桶/发奖用这个
|
||||
capped: bool = False
|
||||
near_miss: bool = False # 基础旋恰好 2 个胡且第 5 轴无胡
|
||||
free_spins: int = 0 # 本局发放的免费旋数
|
||||
|
||||
@property
|
||||
def has_fs(self) -> bool:
|
||||
return self.free_spins > 0
|
||||
@@ -0,0 +1,99 @@
|
||||
"""麻将胡了 · 自然盘面仿真(回答"空转率/命中率到底是多少、离目标差多远")。
|
||||
|
||||
用法:
|
||||
python mahjong_simulator.py --rounds 200000 --seed 2026
|
||||
|
||||
它只跑**自然落牌 + 无偏构造**的局面 —— 也就是"完全不干预"时游戏长什么样:
|
||||
自然 RTP(= 平均 actual_mul)、基础旋命中率、级联波数分布、FS 自然触发率、悬念盘占比。
|
||||
生成端要做的事(把配比凑到目标 RTP、把高倍桶构造出来)都在这些数字之上做偏置。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from mahjong import config as C # noqa: E402
|
||||
from mahjong.engine import play_round # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--rounds", type=int, default=100000)
|
||||
ap.add_argument("--seed", type=int, default=2026)
|
||||
ap.add_argument("--box", type=int, default=5, help="投注档 boxId,只影响金额不影响倍率")
|
||||
args = ap.parse_args()
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
n = args.rounds
|
||||
base_hit = 0
|
||||
empty_rounds = 0
|
||||
fs_rounds = 0
|
||||
retrigger_rounds = 0
|
||||
near_miss = 0
|
||||
capped = 0
|
||||
rotations = Counter() # 本旋波数(基础旋)
|
||||
fs_award_dist = Counter()
|
||||
bucket_dist = Counter()
|
||||
scatter_dist = Counter()
|
||||
muls = []
|
||||
total = 0.0
|
||||
|
||||
for _ in range(n):
|
||||
r = play_round(rng, box_id=args.box)
|
||||
muls.append(r.actual_mul)
|
||||
total += r.actual_mul
|
||||
base = r.spins[0]
|
||||
if base.mul > 0:
|
||||
base_hit += 1
|
||||
else:
|
||||
empty_rounds += 1
|
||||
rotations[len(base.rotations)] += 1
|
||||
scatter_dist[base.scatters] += 1
|
||||
if r.has_fs:
|
||||
fs_rounds += 1
|
||||
fs_award_dist[r.free_spins] += 1
|
||||
if any(s.retrigger for s in r.spins):
|
||||
retrigger_rounds += 1
|
||||
if r.near_miss:
|
||||
near_miss += 1
|
||||
if r.capped:
|
||||
capped += 1
|
||||
bucket_dist[C.bucket_of(r.actual_mul)] += 1
|
||||
|
||||
muls.sort()
|
||||
|
||||
def pct(p: float) -> float:
|
||||
return muls[min(n - 1, int(n * p))]
|
||||
|
||||
print(f"=== 自然仿真:{n} 局 / seed={args.seed} / 无偏构造 ===")
|
||||
print(f"自然 RTP(平均 actual_mul) : {total / n:.4f}")
|
||||
print(f"基础旋命中率(至少一波成奖) : {base_hit / n:.4%} → 空转率 {empty_rounds / n:.2%}")
|
||||
print(f"基础旋空转局(= 桶 1 占比) : {empty_rounds / n:.4%}")
|
||||
print(f"FS 自然触发率(3+ 胡) : {fs_rounds / n:.4%} (设计目标 ≈0.5%,生成端要压低)")
|
||||
print(f"其中重触发过的局 : {retrigger_rounds / n:.4%}")
|
||||
print(f"悬念盘(2 胡且第 5 轴无胡) : {near_miss / n:.4%} (设计目标 1~2%)")
|
||||
print(f"触 500x 封顶 : {capped / n:.4%}")
|
||||
print(f"actual_mul 中位 / p90 / p99 / max: {pct(0.5):.2f} / {pct(0.9):.2f} / {pct(0.99):.2f} / {muls[-1]:.2f}")
|
||||
print("\n基础旋波数分布(0 = 一波都不成):")
|
||||
for k in sorted(rotations):
|
||||
print(f" {k} 波: {rotations[k] / n:7.3%}")
|
||||
print("\n落定盘面的胡数分布:")
|
||||
for k in sorted(scatter_dist):
|
||||
print(f" {k} 个: {scatter_dist[k] / n:7.3%}")
|
||||
print("\nFS 赠送旋数分布(触发局内):")
|
||||
for k in sorted(fs_award_dist):
|
||||
print(f" {k} 旋: {fs_award_dist[k] / max(1, fs_rounds):7.3%}")
|
||||
print("\n落桶分布(桶: 占比 / 名义期望倍率):")
|
||||
for b in C.BUCKETS:
|
||||
share = bucket_dist[b.id] / n
|
||||
if share:
|
||||
print(f" {b.id:2d} {b.name:<16} {share:7.4%} expect={b.expect_mul}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
-- 步骤 1:插入「麻将胡了」顶级目录(照 olympus menu_id=3130 的结构)
|
||||
INSERT INTO sys_menu
|
||||
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
|
||||
visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
('麻将胡了', 0, 6, 'mahjong', '', NULL, 1, 0, 'M',
|
||||
'0', '0', '', 'star', 'admin', NOW(), '麻将胡了游戏后台管理目录');
|
||||
|
||||
-- 步骤 2:取刚插入的目录 id(LAST_INSERT_ID() 是**会话级**的,
|
||||
-- 所以目录 INSERT 和下面子菜单 INSERT 必须在同一个会话里连着执行,不能拆开跑)
|
||||
SET @mj_dir := LAST_INSERT_ID();
|
||||
|
||||
-- 步骤 3:插入 7 个子菜单(order_num 1..7,与 olympus 同序)
|
||||
INSERT INTO sys_menu
|
||||
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
|
||||
visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
('箱子配置', @mj_dir, 1, 'box', 'game/mahjong/box/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:box', 'shopping', 'admin', NOW(), '麻将胡了·箱子配置(11 档投注,price ≡ bet)'),
|
||||
('饰品列表', @mj_dir, 2, 'bucket', 'game/mahjong/bucket/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:bucket', 'star', 'admin', NOW(), '麻将胡了·16 档饰品桶定义(0x~500x,只读)'),
|
||||
('回合管理', @mj_dir, 3, 'round', 'game/mahjong/round/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:round', 'documentation', 'admin', NOW(), '麻将胡了·预生成回合池(只读)'),
|
||||
('奖池水位', @mj_dir, 4, 'rewardPool', 'game/mahjong/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:pool', 'money', 'admin', NOW(), '麻将胡了·奖池水位(3 身份:普通/主播/白号)'),
|
||||
('玩家记录', @mj_dir, 5, 'record', 'game/mahjong/record/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:record', 'list', 'admin', NOW(), '麻将胡了·玩家游玩记录'),
|
||||
('每日统计', @mj_dir, 6, 'dailywin', 'game/mahjong/dailywin/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:win', 'chart', 'admin', NOW(), '麻将胡了·每日投注/赢奖/RTP 统计'),
|
||||
('奖品管理', @mj_dir, 7, 'prize', 'game/mahjong/prize/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:prize', 'guide', 'admin', NOW(), '麻将胡了·返奖饰品与返奖区间');
|
||||
@@ -0,0 +1,298 @@
|
||||
-- ============================================================
|
||||
-- 麻将胡了(Mahjong)模块 全量建表 DDL
|
||||
-- 表前缀: game_mahjong_ 游戏类型: MAHJONG
|
||||
-- 字符集/排序规则: **utf8mb4 / utf8mb4_0900_ai_ci**(显式写出,不依赖服务器默认;与本库 henan 的默认一致,
|
||||
-- 也与 game_wheel_* 一致。注意本库另有 111 张表是 utf8mb4_general_ci / utf8mb4_unicode_ci —— 只要**不要**拿
|
||||
-- 字符串列去 join 它们(我们的 join 全在 user_id/box_id/round_id 等数值键上),就不会撞 Illegal mix of collations)
|
||||
-- 权威来源: docs/麻将胡了设计方案.md §4(表清单)/ 附录 A(与玫瑰女郎的映射)
|
||||
-- 与玫瑰女郎(game_olympus_*)的差异:
|
||||
-- 1. 饰品桶 16 档(0x~500x,删掉它的 1000x 那档);单局封顶 500 倍
|
||||
-- 2. 无模式倍率:只有 normal,cost = bet(去 mode_type 的 CM 语义)
|
||||
-- 3. 无炸弹:step 的 multiplier_detail / accumulator_* → rotation_mul(本波阶梯,不跨旋)
|
||||
-- 4. 盘面 4 行 × 5 列 = 20 格;step 自带 golden_detail / wild_converted / ways_detail
|
||||
-- 5. record 自带 audience(按箱×身份记水位)与 bet_mul(预留、恒 1)
|
||||
-- 6. box.total_ornaments → total_rounds
|
||||
-- 执行:mysql --default-character-set=utf8mb4 -u<user> -p <db> < schema.sql (可重复执行)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 箱子配置(= 投注档,11 档;price = bet)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_box (
|
||||
box_id BIGINT NOT NULL COMMENT 'PK 1-11',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
|
||||
box_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '只有 normal',
|
||||
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(costMultiplier ≡ 1)',
|
||||
total_rounds 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 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子配置(=投注档)';
|
||||
|
||||
-- 2. 饰品桶定义(0x ~ 500x,共 16 档)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_bucket (
|
||||
id BIGINT NOT NULL COMMENT 'PK 1-16',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
|
||||
expect_mul DECIMAL(8,2) NOT NULL COMMENT '期望倍率(最高 500)',
|
||||
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 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了饰品桶定义';
|
||||
|
||||
-- 3. 箱子-饰品桶分布(库存配额,剩余 = count - used)
|
||||
-- audience 多身份爆率:每种身份一套独立分布;白号(white)独立库存、不进真实经济
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_box_bucket (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
bucket_id INT NOT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common普通/anchor主播/white白号(无 robot 维度)',
|
||||
count INT NOT NULL DEFAULT '0' COMMENT '分配回合数',
|
||||
used INT NOT NULL DEFAULT '0' COMMENT '已消耗回合数,剩余=count-used',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_box_bucket_aud (box_id, bucket_id, audience)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子饰品桶分布(多身份)';
|
||||
|
||||
-- 4. 预生成回合(池里只存倍率,与投注额无关)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_round (
|
||||
round_id BIGINT NOT NULL COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
bucket_id INT NOT NULL COMMENT '落入的饰品桶 1-16',
|
||||
actual_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '实际总倍率(先按 500 截断再落桶)',
|
||||
free_spin_count INT NOT NULL DEFAULT '0' COMMENT '免费旋转总次数(12/14/16 + 重触发)',
|
||||
total_rotations INT NOT NULL DEFAULT '0' COMMENT '整局总波数',
|
||||
has_fs TINYINT NOT NULL DEFAULT '0' COMMENT '是否含免费旋转 0否1是',
|
||||
near_miss TINYINT NOT NULL DEFAULT '0' COMMENT '悬念盘:基础旋恰好2个胡且第5轴无胡',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (round_id),
|
||||
INDEX idx_box_bucket (box_id, bucket_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了预生成回合';
|
||||
|
||||
-- 5. 回合步骤(每帧一行,客户端逐帧续播)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_step (
|
||||
id BIGINT NOT NULL COMMENT 'PK',
|
||||
round_id BIGINT NOT NULL,
|
||||
reel_spin_id INT NOT NULL DEFAULT '0' COMMENT '0=基础旋/触发盘, >0=第N次免费旋转',
|
||||
rotation_seq INT NOT NULL DEFAULT '0' COMMENT '本旋内第几波(= 阶梯序号)',
|
||||
grid_state JSON DEFAULT NULL COMMENT '4行×5列=20格短码矩阵',
|
||||
golden_detail JSON DEFAULT NULL COMMENT '本帧仍为"金色普通符号"的格 [{"pos","symbol"}]',
|
||||
wild_converted JSON DEFAULT NULL COMMENT '本波"金色→百搭"就地转化的格 [{"pos","fromSymbol"}]',
|
||||
ways_detail JSON DEFAULT NULL COMMENT '中奖明细 [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}]',
|
||||
accumulated_symbols JSON DEFAULT NULL COMMENT '本旋累计消除符号 {"A":9,...}',
|
||||
spin_winners JSON DEFAULT NULL COMMENT '获胜者面板:本旋逐波追加,新旋清空',
|
||||
removed_positions JSON DEFAULT NULL COMMENT '消除位置 [pos,...](含金色格)',
|
||||
drop_data JSON DEFAULT NULL COMMENT '下落数据 [{"from","to","symbol"}]',
|
||||
fill_data JSON DEFAULT NULL COMMENT '补位数据 [{"pos","symbol"}]',
|
||||
scatter_trigger JSON DEFAULT NULL COMMENT '触发免费旋转 {"scatterCount":3,"freeSpins":12,"extraSpins":0}',
|
||||
retrigger_info JSON DEFAULT NULL COMMENT '免费旋转内重触发',
|
||||
base_score DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波基础倍率(Σ符号倍率×ways,未乘阶梯)',
|
||||
win_amount DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本旋累计到本步(末帧=本旋总额),不是增量',
|
||||
rotation_mul DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波阶梯倍率(基础 1/2/3/5,FS 2/4/6/10;每旋重置)',
|
||||
spin_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '整局累计赢额(倍率)到本步',
|
||||
win_capped TINYINT NOT NULL DEFAULT '0' COMMENT '本步被 500x 封顶截断 0否1是',
|
||||
bigwin_level VARCHAR(16) DEFAULT NULL COMMENT '爆奖档位 small/middle/big(只在 FS 旋末帧,按单旋赢额)',
|
||||
is_final TINYINT DEFAULT '0' COMMENT '本旋末帧',
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_round (round_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了回合步骤';
|
||||
|
||||
-- 6. 游戏记录(含结算/幂等/发奖字段,建表即带全)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_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',
|
||||
bet_mul DECIMAL(10,2) NOT NULL DEFAULT '1.00' COMMENT '投注倍数(预留字段,恒 1)',
|
||||
total_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '总赢额(倍数×投注额,≤bet×500)',
|
||||
settled TINYINT NOT NULL DEFAULT '0' COMMENT '是否已结算 0未1已(幂等守卫)',
|
||||
mode_type VARCHAR(20) DEFAULT 'normal' COMMENT '只有 normal',
|
||||
reward_type VARCHAR(10) DEFAULT NULL COMMENT '发放类型 gold/ornament',
|
||||
settled_win DECIMAL(10,2) DEFAULT 0.00 COMMENT '本局价值(=命中饰品价格;空转局按饰品实际价值记账)',
|
||||
prize_id BIGINT DEFAULT NULL COMMENT 'game_mahjong_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=末帧播完(进战绩)',
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份:水位按 箱×身份 聚合',
|
||||
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),
|
||||
INDEX idx_box_aud (box_id, audience)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了游戏记录';
|
||||
|
||||
-- 7. 用户统计
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_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',
|
||||
max_win_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '历史最高倍率(≤500)',
|
||||
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 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了用户统计';
|
||||
|
||||
-- 8. 箱子桶分布模板(抽干补桶依据,按身份区分)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_box_template (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common',
|
||||
name VARCHAR(100) NOT NULL COMMENT '模板名称(同箱+身份内唯一)',
|
||||
total_rounds INT NOT NULL DEFAULT 0 COMMENT '= items 各 count 之和',
|
||||
items_json TEXT NOT NULL COMMENT '分布JSON: [{"bucketId":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 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子桶分布模板(多身份)';
|
||||
|
||||
-- 9. 奖池水位调整日志(注水/抽水/重置审计,原因必填由前端保证)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_reward_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
|
||||
box_name VARCHAR(100) DEFAULT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common/anchor/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,
|
||||
remark VARCHAR(200) DEFAULT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_create (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了奖池水位调整日志';
|
||||
|
||||
-- 10. 返奖饰品定义(按赢额金额区间匹配,与游戏无关)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_prize_ornament (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
|
||||
name VARCHAR(100) DEFAULT NULL,
|
||||
image_url VARCHAR(500) DEFAULT NULL,
|
||||
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 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖饰品';
|
||||
|
||||
-- 11. 返奖饰品区间(与 prize_ornament 一一对应,区间连续无缝覆盖 0~250000)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_prize_range (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
prize_id BIGINT NOT NULL COMMENT 'game_mahjong_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),
|
||||
KEY idx_win_low (win_low)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖区间';
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:饰品桶 1-16(0x ~ 500x)
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_bucket (id, game_type, expect_mul, name, range_low, range_high) VALUES
|
||||
(1, 'MAHJONG', 0.00, '0x', 0.00, 0.00),
|
||||
(2, 'MAHJONG', 0.50, '0.5x', 0.01, 1.00),
|
||||
(3, 'MAHJONG', 1.50, '1.5x', 1.00, 2.00),
|
||||
(4, 'MAHJONG', 2.50, '2.5x', 2.00, 3.00),
|
||||
(5, 'MAHJONG', 4.00, '4x', 3.00, 5.00),
|
||||
(6, 'MAHJONG', 6.50, '6.5x', 5.00, 8.00),
|
||||
(7, 'MAHJONG', 10.00, '10x', 8.00, 12.00),
|
||||
(8, 'MAHJONG', 16.00, '16x', 12.00, 20.00),
|
||||
(9, 'MAHJONG', 25.00, '25x', 20.00, 30.00),
|
||||
(10, 'MAHJONG', 40.00, '40x', 30.00, 50.00),
|
||||
(11, 'MAHJONG', 60.00, '60x', 50.00, 70.00),
|
||||
(12, 'MAHJONG', 100.00, '100x', 70.00, 130.00),
|
||||
(13, 'MAHJONG', 150.00, '150x', 130.00, 170.00),
|
||||
(14, 'MAHJONG', 200.00, '200x', 170.00, 230.00),
|
||||
(15, 'MAHJONG', 300.00, '300x', 230.00, 370.00),
|
||||
(16, 'MAHJONG', 500.00, '500x', 370.00, 700.00)
|
||||
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
|
||||
range_low=VALUES(range_low), range_high=VALUES(range_high);
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:11 个箱子(=11 个投注档,price = bet,无投注倍数)
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_box (box_id, game_type, box_type, bet, name, price, rtp, sort, status, remark) VALUES
|
||||
(1, 'MAHJONG', 'normal', 1.00, '1', 1.00, 0.8000, 1, '1', NULL),
|
||||
(2, 'MAHJONG', 'normal', 3.00, '3', 3.00, 0.8000, 2, '1', NULL),
|
||||
(3, 'MAHJONG', 'normal', 5.00, '5', 5.00, 0.8000, 3, '1', NULL),
|
||||
(4, 'MAHJONG', 'normal', 10.00, '10', 10.00, 0.8000, 4, '1', NULL),
|
||||
(5, 'MAHJONG', 'normal', 20.00, '20', 20.00, 0.8000, 5, '1', NULL),
|
||||
(6, 'MAHJONG', 'normal', 30.00, '30', 30.00, 0.8000, 6, '1', NULL),
|
||||
(7, 'MAHJONG', 'normal', 50.00, '50', 50.00, 0.8000, 7, '1', NULL),
|
||||
(8, 'MAHJONG', 'normal', 100.00, '100', 100.00, 0.8000, 8, '1', NULL),
|
||||
(9, 'MAHJONG', 'normal', 200.00, '200', 200.00, 0.8000, 9, '1', NULL),
|
||||
(10, 'MAHJONG', 'normal', 300.00, '300', 300.00, 0.8000, 10, '1', NULL),
|
||||
(11, 'MAHJONG', 'normal', 500.00, '500', 500.00, 0.8000, 11, '1', NULL)
|
||||
ON DUPLICATE KEY UPDATE bet=VALUES(bet), price=VALUES(price), name=VALUES(name),
|
||||
rtp=VALUES(rtp), sort=VALUES(sort), status=VALUES(status);
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:返奖饰品 12 件(取自玫瑰女郎线上 dump,id 原样保留以维持区间映射)
|
||||
-- 说明:zone 是按**赢额金额(¥)**匹配的展示层,与饰品桶(倍率)无关;
|
||||
-- 保留原 id 是因为 prize_range.prize_id 直接引用它。
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_prize_ornament
|
||||
(id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback, create_time, update_time) VALUES
|
||||
(1, 'MAHJONG', '安慰奖', '/profile/upload/2026/09/15/VU1QLTQ1IHwgU2NvcmNoZWQgKEZhY3RvcnkgTmV3KQ==_20260915144142A001.png', 4, '战痕累累', 1, '1', 1, '2026-08-01 14:05:58', '2026-09-15 14:41:45'),
|
||||
(2, 'MAHJONG', 'SG 553 | 浪花喷漆', '/profile/upload/2026/07/02/U0cgNTUzIHwgV2F2ZSBTcHJheSAoRmFjdG9yeSBOZXcp_20260702034148A002.png', 2, '略有磨损', 2, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(3, 'MAHJONG', '双持贝瑞塔 | 黑榄仁木', '/profile/upload/2026/07/02/RHVhbCBCZXJldHRhcyB8IEJsYWNrIExpbWJhIChGYWN0b3J5IE5ldyk=_20260702034157A003.png', 3, '久经沙场', 3, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(4, 'MAHJONG', 'M4A4 | 皇家圣骑士', '/profile/upload/2026/07/02/TTRBNCB8IFJveWFsIFBhbGFkaW4gKEZhY3RvcnkgTmV3KQ==_20260702034203A004.png', 4, '破损不堪', 4, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(5, 'MAHJONG', 'AK-47 | 深海复仇', '/profile/upload/2026/07/02/QUstNDcgfCBBcXVhbWFyaW5lIFJldmVuZ2UgKEZhY3RvcnkgTmV3KQ==_20260702034212A005.png', 4, '破损不堪', 5, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(6, 'MAHJONG', 'AK-47 | 火蛇', '/profile/upload/2026/07/02/QUstNDcgfCBGaXJlIFNlcnBlbnQgKEZhY3RvcnkgTmV3KQ==_20260702034220A006.png', 3, '久经沙场', 6, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(7, 'MAHJONG', 'AK-47 | 燃料喷射器', '/profile/upload/2026/07/02/QUstNDcgfCBGdWVsIEluamVjdG9yIChGYWN0b3J5IE5ldyk=_20260702034228A007.png', 3, '久经沙场', 7, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(8, 'MAHJONG', '加利尔 AR | 水蓝阶地', '/profile/upload/2026/07/02/R2FsaWwgQVIgfCBBcXVhIFRlcnJhY2UgKEZhY3RvcnkgTmV3KQ==_20260702034236A008.png', 2, '略有磨损', 8, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(9, 'MAHJONG', '猎杀者匕首 | 渐变之色', '/profile/upload/2026/07/02/4piFIEh1bnRzbWFuIEtuaWZlIHwgRmFkZSAoRmFjdG9yeSBOZXcp_20260702034302A012.png', 2, '略有磨损', 9, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(10, 'MAHJONG', '爪子刀 | 都市伪装', '/profile/upload/2026/07/02/4piFIEthcmFtYml0IHwgVXJiYW4gTWFza2VkIChGYWN0b3J5IE5ldyk=_20260702034248A010.png', 1, '崭新出厂', 10, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(11, 'MAHJONG', '熊刀 | 多普勒', '/profile/upload/2026/07/02/4piFIFVyc3VzIEtuaWZlIHwgRG9wcGxlciAoTWluaW1hbCBXZWFyKQ==_20260702034256A011.png', 1, '崭新出厂', 11, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(12, 'MAHJONG', 'AWP | 巨龙传说', '/profile/upload/2026/07/02/QVdQIHwgRHJhZ29uIExvcmUgKEZhY3RvcnkgTmV3KQ==_20260702034308A013.png', 1, '崭新出厂', 12, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58')
|
||||
ON DUPLICATE KEY UPDATE game_type=VALUES(game_type), name=VALUES(name), image_url=VALUES(image_url),
|
||||
grade=VALUES(grade), exterior_name=VALUES(exterior_name), sort=VALUES(sort),
|
||||
status=VALUES(status), is_fallback=VALUES(is_fallback), update_time=NOW();
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:返奖区间 12 段(连续无缝覆盖 0.01 ~ 99,999,999)
|
||||
-- 已核:① 每段 win_high = 下一段 win_low(无空洞、无重叠)
|
||||
-- ② 最大档上界 99,999,999 ≥ 麻将单局理论最大赔付 ¥250,000(500x × ¥500)
|
||||
-- —— ¥250,000 落在第 9 段(100,005.82~499,988),发 9 号饰品
|
||||
-- ③ 空转局兜底 win=0.01 落在第 1 段(0.01~0.14),发「安慰奖」(is_fallback=1)
|
||||
-- ⚠️ 这 12 段只决定"发哪件饰品"(展示层);实际入账金额是 record.settled_win
|
||||
-- = 本局 win,与区间无关(见 service-user 分解入账:Σ TtBoxRecords.ornamentsPrice)
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_prize_range (id, prize_id, win_low, win_high) VALUES
|
||||
(1, 1, 0.01, 0.14),
|
||||
(2, 2, 0.14, 10.00),
|
||||
(3, 3, 10.00, 50.00),
|
||||
(4, 4, 50.00, 200.00),
|
||||
(5, 5, 200.00, 1000.00),
|
||||
(6, 6, 1000.00, 5000.00),
|
||||
(7, 7, 5000.00, 20000.00),
|
||||
(8, 8, 20000.00, 100005.82),
|
||||
(9, 9, 100005.82, 499988.00),
|
||||
(10, 10, 499988.00, 1000013.00),
|
||||
(11, 11, 1000013.00, 2000012.00),
|
||||
(12, 12, 2000012.00, 99999999.00)
|
||||
ON DUPLICATE KEY UPDATE prize_id=VALUES(prize_id), win_low=VALUES(win_low), win_high=VALUES(win_high);
|
||||
|
||||
-- ============================================================
|
||||
-- 还没做(不在本文件内,见设计文档 §6.5):
|
||||
-- 1. 528 条 game_mahjong_box_bucket 配比行(16 桶 × 11 箱 × 3 身份):由 `_recalc_boxes.py` / 后台「一键重算」生成
|
||||
-- 2. sys_config mahjongMaintenance、sys_menu 7 个菜单:平台共享改动,单独执行
|
||||
-- 3. 每箱×身份的初始水位:后台奖池水位页注水(¥300/¥500 两档必须单独注)
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,203 @@
|
||||
"""麻将胡了 · 引擎单测(不依赖 pytest:`python tests/test_engine.py` 直接跑)。
|
||||
|
||||
重点覆盖:§2.1 七个正反例、百搭补链/不能替代胡、金色就地转百搭、
|
||||
一个百搭被两条链同时借用、阶梯取档、FS 旋数上限与"发不满就不发"、500x 封顶。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mahjong import config as C # noqa: E402
|
||||
from mahjong.engine import ( # noqa: E402
|
||||
assert_wild_only_middle, count_scatters, new_grid, play_round,
|
||||
settle_rotation, ways_wins,
|
||||
)
|
||||
|
||||
|
||||
def make_grid(rows, golden=()):
|
||||
"""rows: 4 个长度 5 的短码字符串(上→下);golden: 0 基 (行,列) 集合。"""
|
||||
grid = [c for row in rows for c in row]
|
||||
assert len(grid) == C.GRID_SIZE, f"盘面必须是 20 格,收到 {len(grid)}"
|
||||
return grid, {r * C.GRID_COLS + c for r, c in golden}
|
||||
|
||||
|
||||
def cols_of(counts, fill="S", sym="B"):
|
||||
"""按"每轴 sym 的个数"造盘面(其余填 fill,保证不形成别的链)。"""
|
||||
rows = []
|
||||
for r in range(C.GRID_ROWS):
|
||||
row = []
|
||||
for c in range(C.GRID_COLS):
|
||||
# 从底部往上摆 sym
|
||||
row.append(sym if r >= C.GRID_ROWS - counts[c] else fill)
|
||||
rows.append("".join(row))
|
||||
return make_grid(rows)
|
||||
|
||||
|
||||
def mul(x):
|
||||
return round(x, 4)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §2.1 前缀链
|
||||
CASES = [
|
||||
([2, 3, 1, 0, 0], 3, 6), # ✅ 3 连
|
||||
([2, 3, 1, 2, 0], 4, 12), # ✅ 4 连(第 5 轴才断)
|
||||
([2, 0, 3, 4, 4], None, None), # ❌ 第 2 轴断
|
||||
([0, 4, 4, 4, 4], None, None), # ❌ 第 1 轴必需
|
||||
([1, 1, 0, 4, 4], None, None), # ❌ 不能跳轴
|
||||
([1, 2, 3, 4, 4], 5, 96), # ✅ 满链
|
||||
([1, 1, 1, 1, 1], 5, 1), # ✅ 最低 ways
|
||||
]
|
||||
|
||||
|
||||
def test_prefix_chain():
|
||||
for counts, run, ways in CASES:
|
||||
grid, _ = cols_of(counts)
|
||||
wins = [w for w in ways_wins(grid) if w.symbol == "B"]
|
||||
if run is None:
|
||||
assert not wins, f"{counts} 不该成奖,却得到 {wins}"
|
||||
else:
|
||||
assert len(wins) == 1, f"{counts} 应恰好一笔,得到 {wins}"
|
||||
assert wins[0].run == run and wins[0].ways == ways, f"{counts} → {wins[0]}"
|
||||
print("[ok] §2.1 前缀链 7 个正反例")
|
||||
|
||||
|
||||
def test_scatter_breaks_chain_and_no_substitute():
|
||||
grid, _ = make_grid(["ASASS", "SSSSS", "SSSSS", "SSSSS"])
|
||||
assert ways_wins(grid) == (), "發,胡,發 不能成链(胡不进链、也挡链)"
|
||||
grid, _ = make_grid(["AWASS", "SSSSS", "SSSSS", "SSSSS"])
|
||||
wins = [w for w in ways_wins(grid) if w.symbol == "A"]
|
||||
assert len(wins) == 1 and wins[0].run == 3 and wins[0].ways == 1, "百搭要能补链中间的断点"
|
||||
print("[ok] 胡不成链 / 百搭补链")
|
||||
|
||||
|
||||
def test_paytable_grid():
|
||||
for code, rule in C.SYMBOL_RULES.items():
|
||||
for run, pay in rule.payouts.items():
|
||||
assert abs(pay / 0.05 - round(pay / 0.05)) < 1e-9, f"{code} {run} 连 {pay} 不是 0.05 的整数倍"
|
||||
print("[ok] 赔付表全部落在 0.05 网格上")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 金色 → 百搭
|
||||
def test_golden_converts_in_place():
|
||||
"""§15 第 1 波:两张金色「中」各留一个百搭,且是就地改写的。"""
|
||||
grid, golden = make_grid(
|
||||
["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(1, 1), (1, 2)])
|
||||
rng = random.Random(7)
|
||||
rot = settle_rotation(grid, golden, 1, False, rng)
|
||||
assert rot is not None
|
||||
paid = {w.symbol: mul(w.pay_base) for w in rot.wins}
|
||||
assert paid == {"B": 2.0, "C": 0.8}, f"本波两笔奖应为 2.00 / 0.80,得到 {paid}"
|
||||
assert mul(rot.win_mul) == 2.8, "x1 阶梯下本波 2.80 倍"
|
||||
assert len(rot.converted) == 2, f"两个金色格各留一个百搭,得到 {rot.converted}"
|
||||
assert {s for _, s in rot.converted} == {"B"}, "转化前的自然符号应记进 wild_converted"
|
||||
assert all(grid[i] != C.WILD for i in golden), "百搭永不回填金色标记(每格只转化一次)"
|
||||
assert sum(1 for v in grid if v == C.WILD) == 2, "两个百搭仍在盘面上(跟着重力落)"
|
||||
assert_wild_only_middle(grid)
|
||||
print("[ok] 金色格就地转百搭(不空洞/不补牌,2 个金色格 → 2 个百搭)")
|
||||
|
||||
|
||||
def test_one_wild_borrowed_by_three_chains():
|
||||
"""§15 第 4 波:同一个百搭同时撑起「筒」5 连、「索红绿」5 连、「白板」3 连。
|
||||
|
||||
注:设计文档原来只算了前两笔(漏了白板链),本测试按规则把它补上;
|
||||
文档已同步勘误(本波 ¥1200、本旋总账 ¥2380)。
|
||||
"""
|
||||
grid, golden = make_grid(["GAWGC", "HCAAS", "CFBGF", "FGHFG"])
|
||||
rng = random.Random(11)
|
||||
rot = settle_rotation(grid, golden, 4, False, rng)
|
||||
assert rot is not None
|
||||
got = {w.symbol: (w.run, w.ways, mul(w.pay_base)) for w in rot.wins}
|
||||
assert got == {"G": (5, 2, 1.0), "F": (5, 1, 1.0), "C": (3, 1, 0.4)}, f"三笔并赔,得到 {got}"
|
||||
shared = set(rot.wins[0].loc)
|
||||
for w in rot.wins[1:]:
|
||||
shared &= set(w.loc)
|
||||
assert shared == {3}, f"三条链必须共用第 3 格那个百搭,实际 {shared}"
|
||||
assert mul(rot.base_mul) == 2.4 and mul(rot.win_mul) == 12.0, "本波 2.40 倍 × x5 = 12.00 倍"
|
||||
assert sum(1 for v in grid if v == C.WILD) == 0, "参与成链的百搭要随本波被消除"
|
||||
print("[ok] 一个百搭被三条链同时借用,并随波被消除")
|
||||
|
||||
|
||||
def test_involved_golden_only():
|
||||
"""只有"参与成奖"的金色格才转化;没被涉及的金色格原地不动。"""
|
||||
# B 链在轴1~轴3;轴4 的 F 带金色但没参与任何链 → 不转化
|
||||
grid, golden = make_grid(["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(3, 3)])
|
||||
rng = random.Random(9)
|
||||
rot = settle_rotation(grid, golden, 1, False, rng)
|
||||
assert rot is not None and rot.converted == (), f"未被涉及的金色格不该转化:{rot.converted}"
|
||||
assert 3 * C.GRID_COLS + 3 in golden, "未涉及的金色标记要保留"
|
||||
print("[ok] 只有参与成奖的金色格才就地转百搭")
|
||||
|
||||
|
||||
def test_golden_travels_with_symbol():
|
||||
"""金色是镀在符号上的:金色格没被消除但它下方的牌被消 → 它下落,金标记跟着走。"""
|
||||
grid, golden = make_grid(["BDBSS", "BBBSS", "BBBSS", "SSSSS"], golden=[(0, 1)])
|
||||
rot = settle_rotation(grid, golden, 1, False, random.Random(1))
|
||||
assert rot is not None, "B 应为 3 连(col0=3 / col1=2 / col2=3)"
|
||||
# D 从行1 落到行3(col1 有 2 格空洞),金标记必须跟着
|
||||
assert grid[2 * C.GRID_COLS + 1] == "D", "金色 D 应该落到行3"
|
||||
assert 2 * C.GRID_COLS + 1 in golden, "金标记要跟着符号落到新位置"
|
||||
assert 0 * C.GRID_COLS + 1 not in golden, "不能留在原格(否则会传染给新补的牌)"
|
||||
print("[ok] 金色标记跟着符号下落")
|
||||
|
||||
|
||||
def test_no_wild_without_conversion():
|
||||
"""原生盘面(卷轴+补位)永不落百搭;金色只在第 2/3/4 轴。"""
|
||||
for seed in range(300):
|
||||
g, gold = new_grid(random.Random(seed))
|
||||
assert C.WILD not in g, "原生盘面永远不落百搭"
|
||||
assert all(c % C.GRID_COLS in C.GOLDEN_COLS for c in gold), "金色只在第 2/3/4 轴"
|
||||
print("[ok] 原生不落百搭 / 金色只在第 2/3/4 轴")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 阶梯 / 封顶 / FS
|
||||
def test_ladder():
|
||||
base = [C.ladder_mul(n, False) for n in range(1, 7)]
|
||||
fs = [C.ladder_mul(n, True) for n in range(1, 7)]
|
||||
assert base == [1, 2, 3, 5, 5, 5], base
|
||||
assert fs == [2, 4, 6, 10, 10, 10], fs
|
||||
print("[ok] 阶梯:基础 1/2/3/5,FS 2/4/6/10,第 4 波起封顶")
|
||||
|
||||
|
||||
def test_example_2_9_1_wave1():
|
||||
"""§2.9.1 第 1 波:中 3 连 + 白板 3 连 = 0.90 倍(x1)。"""
|
||||
grid, golden = make_grid(["BCBEH", "DBAGF", "EDCAG", "CEHFD"])
|
||||
rot = settle_rotation(grid, golden, 1, False, random.Random(5))
|
||||
assert rot is not None and mul(rot.base_mul) == 0.9 and mul(rot.win_mul) == 0.9
|
||||
print("[ok] §2.9.1 第 1 波 = 0.90 倍")
|
||||
|
||||
|
||||
def test_fs_award_and_cap():
|
||||
assert [C.fs_award(n) for n in (3, 4, 5)] == [12, 14, 16]
|
||||
rng = random.Random(2026)
|
||||
reachable = set()
|
||||
for _ in range(40000):
|
||||
r = play_round(rng, box_id=5)
|
||||
assert r.actual_mul <= C.MAX_WIN_CAP_MUL, "单局封顶 500x"
|
||||
assert mul(r.actual_mul) == mul(min(r.raw_mul, C.MAX_WIN_CAP_MUL))
|
||||
if r.free_spins:
|
||||
assert r.free_spins <= C.MAX_FREE_SPINS, f"FS 上限 36,得到 {r.free_spins}"
|
||||
assert r.free_spins in (12, 14, 16, 24, 26, 28, 30, 32, 36), r.free_spins
|
||||
reachable.add(r.free_spins)
|
||||
if r.capped:
|
||||
assert r.actual_mul == C.MAX_WIN_CAP_MUL
|
||||
assert reachable, "4 万局都没触发过一次 FS,权重/参数明显不对"
|
||||
print(f"[ok] 封顶与 FS 上限(4 万局内出现的赠送旋数:{sorted(reachable)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_prefix_chain()
|
||||
test_scatter_breaks_chain_and_no_substitute()
|
||||
test_paytable_grid()
|
||||
test_golden_converts_in_place()
|
||||
test_one_wild_borrowed_by_three_chains()
|
||||
test_involved_golden_only()
|
||||
test_golden_travels_with_symbol()
|
||||
test_no_wild_without_conversion()
|
||||
test_ladder()
|
||||
test_example_2_9_1_wave1()
|
||||
test_fs_award_and_cap()
|
||||
print("\n全部通过 ✅")
|
||||
Reference in New Issue
Block a user