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