麻将胡了

This commit is contained in:
2026-09-22 15:39:53 +08:00
parent 8f8d2e042c
commit 470d1471e5
70 changed files with 6497 additions and 150 deletions
+38
View File
@@ -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` 已经支持外部传盘面,构造器接上去即可。
+7
View File
@@ -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
+166
View File
@@ -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` 那种冒烟规模
+279
View File
@@ -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_
+58
View File
@@ -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
+99
View File
@@ -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()
+27
View File
@@ -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(), '麻将胡了·返奖饰品与返奖区间');
+298
View File
@@ -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 两档必须单独注)
-- ============================================================
+203
View File
@@ -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全部通过 ✅")