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