"""麻将胡了 · 唯一事实来源(规则参数)。 数值全部取自 docs/麻将胡了设计方案.md(§2 规则 / §3 桶与 RTP / §2.8 投注档)。 改规则改这里;生成器、引擎、单测、仿真都从这里读。 """ from __future__ import annotations from dataclasses import dataclass from typing import Dict, Mapping, Tuple # ============================================================ # 盘面:5 轴 × 4 行 = 20 格,ways = 4^5 = 1024 # ============================================================ GRID_COLS = 5 GRID_ROWS = 4 GRID_SIZE = GRID_ROWS * GRID_COLS WAYS = GRID_ROWS ** GRID_COLS WILD = "W" # 百搭(元宝):只能由金色符号转化产生,卷轴永不落 SCATTER = "S" # 胡(夺宝):只触发免费旋转,不赔现金 # 格位约定:接口/落库用 1 基、行优先(1~20);引擎内部用 0 基。 # 引擎内 index = row * GRID_COLS + col;pos() 负责换算(见 engine.py)。 @dataclass(frozen=True) class SymbolRule: code: str name: str payouts: Mapping[int, float] # 连轴数(3/4/5) → 倍投注总额 # 赔付表:原版"每条基础投注点数"已 ÷20 内化,表中数值**直接是倍率** SYMBOL_RULES: Dict[str, SymbolRule] = { "A": SymbolRule("A", "發", {3: 0.75, 4: 3.0, 5: 5.0}), "B": SymbolRule("B", "中", {3: 0.5, 4: 2.0, 5: 4.0}), "C": SymbolRule("C", "白板", {3: 0.4, 4: 1.0, 5: 3.0}), "D": SymbolRule("D", "八萬", {3: 0.3, 4: 0.75, 5: 2.0}), "E": SymbolRule("E", "花筒", {3: 0.2, 4: 0.5, 5: 1.0}), "F": SymbolRule("F", "索红绿", {3: 0.2, 4: 0.5, 5: 1.0}), "G": SymbolRule("G", "筒", {3: 0.1, 4: 0.25, 5: 0.5}), "H": SymbolRule("H", "索绿", {3: 0.1, 4: 0.25, 5: 0.5}), } PAYING: Tuple[str, ...] = ("A", "B", "C", "D", "E", "F", "G", "H") PAYTABLE: Dict[str, Mapping[int, float]] = {code: r.payouts for code, r in SYMBOL_RULES.items()} MIN_CHAIN = 3 # 前缀链至少 3 连才成奖(只有 3/4/5 三档) # 卷轴出现权重:只有 8 个付费符号 + 胡(**没有 W**,原生盘面永不落百搭) SYMBOL_WEIGHTS: Dict[str, int] = { "A": 5, "B": 6, "C": 8, "D": 9, "E": 11, "F": 11, "G": 13, "H": 13, "S": 2, } # 金色麻将符号:只可能出现在第 2/3/4 轴(0 基列 1..3) GOLDEN_COLS: Tuple[int, ...] = (1, 2, 3) GOLDEN_CELL_PROB = 0.10 # 逐格独立;新补入的符号也可以带金 # ============================================================ # 乘数阶梯(本波阶梯倍率):每旋从第一档重新开始,不跨旋累计 # ============================================================ LADDER_BASE: Tuple[int, ...] = (1, 2, 3, 5) LADDER_FS: Tuple[int, ...] = (2, 4, 6, 10) def ladder_mul(rotation_seq: int, is_fs: bool) -> int: """第 n 波(1 基)的阶梯倍率;第 4 波及以后取最后一档。""" if rotation_seq < 1: raise ValueError("波序从 1 开始") ladder = LADDER_FS if is_fs else LADDER_BASE return ladder[min(rotation_seq, len(ladder)) - 1] # ============================================================ # 免费旋转:3+ 胡 → 12 旋 + 每多 1 个 +2;一屏最多 5 个胡;上限 36 旋 # "发不满就不发":已发放 + 本次应赠 > 36 时,本旋绝不落 3+ 胡 # ============================================================ SCATTER_TRIGGER = 3 FS_BASE_SPINS = 12 FS_EXTRA_PER_SCATTER = 2 MAX_SCATTER_PER_SCREEN = 5 MAX_FREE_SPINS = 36 def fs_award(scatter_count: int) -> int: return FS_BASE_SPINS + FS_EXTRA_PER_SCATTER * max(0, scatter_count - SCATTER_TRIGGER) # 单局封顶:总倍率到达即截断,剩余旋作废 MAX_WIN_CAP_MUL = 500 # 悬念盘:基础旋恰好 2 个胡且第 5 轴无胡(生成端配额,客户端播第 5 列放大) NEARMISS_SCATTER_COUNT = 2 NEARMISS_2SCATTER_RATE = 0.015 # ============================================================ # 投注档:11 档纯投注档,无投注倍数(betMul 仅预留、服务端只接受 1) # price = cost = box.bet;boxId = 序号(1~11) # ============================================================ BET_LADDER: Tuple[int, ...] = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500) BOX_IDS: Tuple[int, ...] = tuple(range(1, len(BET_LADDER) + 1)) def bet_of(box_id: int) -> int: return BET_LADDER[box_id - 1] # ============================================================ # 饰品桶(16 档,0x~500x):沿用玫瑰女郎 17 档结构、删掉 1000x 那档 # 期望倍率 = (区间下限 + 上限) / 2;区间连续无缝 # 桶 16 区间上界故意开到 700 作缓冲,但生成端"先按 500 截断再落桶" # ============================================================ @dataclass(frozen=True) class Bucket: id: int name: str expect_mul: float range_low: float range_high: float BUCKETS: Tuple[Bucket, ...] = ( Bucket(1, "0倍饰品", 0.0, 0.0, 0.0), Bucket(2, "0.5倍饰品", 0.5, 0.01, 1.0), Bucket(3, "1.5倍饰品", 1.5, 1.0, 2.0), Bucket(4, "2.5倍饰品", 2.5, 2.0, 3.0), Bucket(5, "4倍饰品", 4.0, 3.0, 5.0), Bucket(6, "6.5倍饰品", 6.5, 5.0, 8.0), Bucket(7, "10倍饰品", 10.0, 8.0, 12.0), Bucket(8, "16倍饰品", 16.0, 12.0, 20.0), Bucket(9, "25倍饰品", 25.0, 20.0, 30.0), Bucket(10, "40倍饰品", 40.0, 30.0, 50.0), Bucket(11, "60倍饰品", 60.0, 50.0, 70.0), Bucket(12, "100倍饰品", 100.0, 70.0, 130.0), Bucket(13, "150倍饰品", 150.0, 130.0, 170.0), Bucket(14, "200倍饰品", 200.0, 170.0, 230.0), Bucket(15, "300倍饰品", 300.0, 230.0, 370.0), Bucket(16, "500倍饰品(封顶)", 500.0, 370.0, 700.0), ) FIXED_CAP_BUCKET_ID = 16 # 封顶桶 def bucket_of(mul: float) -> int: """按**已截断**的倍率落桶(顺序:先 min(mul, 500) 再落桶)。<0.01 视为空转。""" mul = min(float(mul), MAX_WIN_CAP_MUL) if mul < 0.01: return 1 for b in BUCKETS: if b.range_low <= mul <= b.range_high: return b.id return FIXED_CAP_BUCKET_ID # ============================================================ # RTP 与身份 # ============================================================ RTP = 0.80 # 目标返奖率(后台可按箱改) CONVERGE_RATIO = 0.15 # 收敛阶段占比(一轮 N = fixedSum / (1 - cr)) AUDIENCE_COMMON = "common" # 其余全部(**含 user_type='03' 机器人**) AUDIENCE_ANCHOR = "anchor" # user_type = '01' AUDIENCE_WHITE = "white" # is_blank_account = 1(最高优先) # 只有 3 个身份维度:robot 维度已按用户要求去掉(2026-09-22),机器人归 common AUDIENCES: Tuple[str, ...] = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_WHITE) # 生成器/池子规模(先小规模验证,再放大到线上) ALLOC_K = 1.5 # 池子 = 配比 × 1.5 ALLOC_FLOOR = 60 # 每桶保底条数 SMOKE_ROUNDS_PER_BUCKET = 20 # `--per-bucket 20` 那种冒烟规模