280 lines
11 KiB
Python
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_
|