Files
henan-backend/mahjong-py/mahjong/engine.py
T
2026-09-22 15:39:53 +08:00

280 lines
11 KiB
Python

"""麻将胡了 · 规则引擎(服务端权威判定的唯一实现)。
与母本 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_