麻将胡了
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"""麻将胡了 · 规则引擎(服务端权威判定的唯一实现)。
|
||||
|
||||
与母本 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_
|
||||
Reference in New Issue
Block a user