Files
2026-09-22 15:39:53 +08:00

204 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""麻将胡了 · 引擎单测(不依赖 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全部通过 ✅")