麻将胡了

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
+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全部通过 ✅")