"""麻将胡了 · 引擎单测(不依赖 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全部通过 ✅")