167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""麻将胡了 · 唯一事实来源(规则参数)。
|
||
|
||
数值全部取自 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` 那种冒烟规模
|