312 lines
17 KiB
Python
312 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal
|
||
from typing import Mapping, Tuple
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SymbolRule:
|
||
code: str
|
||
name: str
|
||
payouts: Mapping[int, Decimal]
|
||
|
||
def payout_for(self, count: int) -> Decimal:
|
||
for threshold in sorted(self.payouts, reverse=True):
|
||
if count >= threshold:
|
||
return self.payouts[threshold]
|
||
return Decimal("0")
|
||
|
||
|
||
# 赔付倍数以 1 倍下注为基准,数值对齐官方赔付表(8-9 / 10-11 / 12-30 三档)
|
||
SYMBOL_RULES = {
|
||
"CROWN": SymbolRule("CROWN", "皇冠", {12: Decimal("50"), 10: Decimal("25"), 8: Decimal("10")}),
|
||
"HOURGLASS": SymbolRule("HOURGLASS", "沙漏", {12: Decimal("25"), 10: Decimal("10"), 8: Decimal("2.5")}),
|
||
"RING": SymbolRule("RING", "环", {12: Decimal("15"), 10: Decimal("5"), 8: Decimal("2")}),
|
||
"CHALICE": SymbolRule("CHALICE", "杯赛", {12: Decimal("12"), 10: Decimal("2"), 8: Decimal("1.5")}),
|
||
"RED_GEM": SymbolRule("RED_GEM", "红宝石", {12: Decimal("10"), 10: Decimal("1.5"), 8: Decimal("1")}),
|
||
"PURPLE_GEM": SymbolRule("PURPLE_GEM", "紫宝石", {12: Decimal("8"), 10: Decimal("1.2"), 8: Decimal("0.8")}),
|
||
"YELLOW_GEM": SymbolRule("YELLOW_GEM", "黄宝石", {12: Decimal("5"), 10: Decimal("1"), 8: Decimal("0.5")}),
|
||
"GREEN_GEM": SymbolRule("GREEN_GEM", "绿宝石", {12: Decimal("4"), 10: Decimal("0.9"), 8: Decimal("0.4")}),
|
||
"BLUE_GEM": SymbolRule("BLUE_GEM", "蓝宝石", {12: Decimal("2"), 10: Decimal("0.75"), 8: Decimal("0.25")}),
|
||
}
|
||
|
||
SCATTER_CODE = "SCATTER"
|
||
# 6/5/4 个 SCATTER 的赔付倍数(1 倍下注基准,与皇冠系列量表同源)
|
||
SCATTER_PAYOUTS = {
|
||
6: Decimal("100"),
|
||
5: Decimal("5"),
|
||
4: Decimal("3"),
|
||
}
|
||
SCATTER_FREE_SPIN_TRIGGER = 4 # 基础盘 4+ SCATTER 触发免费旋转
|
||
SCATTER_RETRIGGER_TRIGGER = 3 # 免费旋转中 3+ SCATTER 触发重触发
|
||
FREE_SPINS_AWARDED = 15 # 首次触发赠送次数
|
||
FREE_SPIN_RETRIGGER_COUNT = 5 # 每次重触发追加次数
|
||
|
||
MULTIPLIER_VALUES = frozenset({
|
||
2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 50, 100, 250, 500, 1000,
|
||
})
|
||
|
||
MIN_WIN_COUNT = 8
|
||
MAX_SYMBOL_COUNT = 30
|
||
|
||
# 单局最高赢奖封顶:1000 倍基础下注(最高 1000x 饰品)
|
||
MAX_WIN_MULTIPLIER = Decimal("1000")
|
||
|
||
|
||
# ============================================================
|
||
# 游戏模式:互斥规则
|
||
# - 底注(ante)开启时禁用购买免费旋转;购买免费旋转(buy_fs)时禁用底注效果
|
||
# - 只有一个购买免费旋转档(无 super 模式)
|
||
# ============================================================
|
||
|
||
@dataclass(frozen=True)
|
||
class GameMode:
|
||
code: str
|
||
name: str
|
||
cost_multiplier: int # 实际扣费 = 基础下注 × cost_multiplier
|
||
base_bets: Tuple[int, ...] # 可选基础下注档位
|
||
ante_boost: bool # 底注效果(SCATTER 概率提升)
|
||
guaranteed_free_spins: bool # 保证直接进入免费旋转
|
||
can_buy_free_spins: bool # 是否允许购买免费旋转
|
||
|
||
def price_for(self, base_bet: int) -> Decimal:
|
||
if base_bet not in self.base_bets:
|
||
raise ValueError(f"模式 {self.code} 不支持下注档位 {base_bet}")
|
||
return Decimal(base_bet) * self.cost_multiplier
|
||
|
||
|
||
_NORMAL_BETS = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300)
|
||
_BUY_FS_BETS = _NORMAL_BETS # 10 档,实际价格 50 ~ 15000
|
||
|
||
MODES = {
|
||
# 普通旋转:实际扣费 = 1 3 5 10 20 30 50 100 200 300
|
||
"normal": GameMode(
|
||
code="normal", name="普通旋转", cost_multiplier=1,
|
||
base_bets=_NORMAL_BETS,
|
||
ante_boost=False, guaranteed_free_spins=False, can_buy_free_spins=True,
|
||
),
|
||
# 底注:实际扣费 = 2 6 10 20 40 60 100 200 400 600;禁止购买免费旋转
|
||
"ante": GameMode(
|
||
code="ante", name="底注", cost_multiplier=2,
|
||
base_bets=_NORMAL_BETS,
|
||
ante_boost=True, guaranteed_free_spins=False, can_buy_free_spins=False,
|
||
),
|
||
# 购买免费旋转:实际扣费 = 50 150 250 500 1000 1500 2500 5000 10000 15000;无底注效果
|
||
# 售价 50 倍(原 20 倍):局均赔付 = 售价 × RTP = 40 倍底注 → 后台一键重算会把配比压到
|
||
# 25x/40x/60x 桶(FS 预算 22~57),每局才演得出 6~10 次消除 + 4~6 次炸弹累计;
|
||
# 20 倍时局均只有 16 倍,配比被迫压在 10x/16x 桶(预算 7~13),动画"钱不够"。
|
||
"buy_fs": GameMode(
|
||
code="buy_fs", name="购买免费旋转", cost_multiplier=50,
|
||
base_bets=_BUY_FS_BETS,
|
||
ante_boost=False, guaranteed_free_spins=True, can_buy_free_spins=False,
|
||
),
|
||
}
|
||
|
||
DEFAULT_MODE = "normal"
|
||
|
||
|
||
# ============================================================
|
||
# 饰品桶定义(DB 写入用):最高 1000x,对齐封顶规则
|
||
# 设计原则:区间连续无缝覆盖 0~1370(封顶前理论值域);
|
||
# expect_mul 严格等于 (range_low + range_high) / 2;
|
||
# 低倍段密集(高频小奖)、高倍段递宽。
|
||
# 注:顶桶 id=17 区间 630~1370(中点=期望=1000),但实际发放受封顶约束:
|
||
# 超过 1000 倍的部分不再给奖,一律按 1000 倍发放(引擎 MAX_WIN_MULTIPLIER 截断)。
|
||
# ============================================================
|
||
ORNAMENTS = [
|
||
{'id': 1, 'name': '0x', 'expect_mul': 0.0, 'range_low': 0.00, 'range_high': 0.00},
|
||
{'id': 2, 'name': '0.5x', 'expect_mul': 0.5, 'range_low': 0.01, 'range_high': 1.00},
|
||
{'id': 3, 'name': '1.5x', 'expect_mul': 1.5, 'range_low': 1.00, 'range_high': 2.00},
|
||
{'id': 4, 'name': '2.5x', 'expect_mul': 2.5, 'range_low': 2.00, 'range_high': 3.00},
|
||
{'id': 5, 'name': '4x', 'expect_mul': 4.0, 'range_low': 3.00, 'range_high': 5.00},
|
||
{'id': 6, 'name': '6.5x', 'expect_mul': 6.5, 'range_low': 5.00, 'range_high': 8.00},
|
||
{'id': 7, 'name': '10x', 'expect_mul': 10.0, 'range_low': 8.00, 'range_high': 12.00},
|
||
{'id': 8, 'name': '16x', 'expect_mul': 16.0, 'range_low': 12.00, 'range_high': 20.00},
|
||
{'id': 9, 'name': '25x', 'expect_mul': 25.0, 'range_low': 20.00, 'range_high': 30.00},
|
||
{'id': 10, 'name': '40x', 'expect_mul': 40.0, 'range_low': 30.00, 'range_high': 50.00},
|
||
{'id': 11, 'name': '60x', 'expect_mul': 60.0, 'range_low': 50.00, 'range_high': 70.00},
|
||
{'id': 12, 'name': '100x', 'expect_mul': 100.0, 'range_low': 70.00, 'range_high': 130.00},
|
||
{'id': 13, 'name': '150x', 'expect_mul': 150.0, 'range_low': 130.00, 'range_high': 170.00},
|
||
{'id': 14, 'name': '200x', 'expect_mul': 200.0, 'range_low': 170.00, 'range_high': 230.00},
|
||
{'id': 15, 'name': '300x', 'expect_mul': 300.0, 'range_low': 230.00, 'range_high': 370.00},
|
||
{'id': 16, 'name': '500x', 'expect_mul': 500.0, 'range_low': 370.00, 'range_high': 630.00},
|
||
{'id': 17, 'name': '1000x', 'expect_mul': 1000.0, 'range_low': 630.00, 'range_high': 1370.00},
|
||
]
|
||
|
||
|
||
# ============================================================
|
||
# 回合生成器参数
|
||
# ============================================================
|
||
GRID_ROWS = 5
|
||
GRID_COLS = 6
|
||
GRID_SIZE = GRID_ROWS * GRID_COLS # 30
|
||
|
||
# 短码映射(网格/步骤 JSON 存储用,对齐 sweet 风格)
|
||
SYMBOL_SHORT = {
|
||
'CROWN': 'A', 'HOURGLASS': 'B', 'RING': 'C', 'CHALICE': 'D', 'RED_GEM': 'E',
|
||
'PURPLE_GEM': 'F', 'YELLOW_GEM': 'G', 'GREEN_GEM': 'H', 'BLUE_GEM': 'I',
|
||
}
|
||
SHORT_TO_SYMBOL = {v: k for k, v in SYMBOL_SHORT.items()}
|
||
SCATTER_SHORT = 'S'
|
||
BOMB_SHORT = 'M'
|
||
|
||
# 普通符号随机权重(赔率越高权重越低)
|
||
SYMBOL_WEIGHTS = {'A': 5, 'B': 6, 'C': 7, 'D': 8, 'E': 9, 'F': 10, 'G': 10, 'H': 10, 'I': 12}
|
||
|
||
# 炸弹(乘数符号)参数
|
||
BOMB_CELL_PROBABILITY = 0.04 # 每格出现炸弹概率 ≈1个/屏
|
||
BOMB_VALUES = sorted(MULTIPLIER_VALUES)
|
||
BOMB_WEIGHT_POWER = 0.5 # 值越大权重越低: w = 1/v^power
|
||
|
||
# 炸弹品质(按乘数值分 5 档,对应绿/蓝/紫/红/彩虹 5 种球体)
|
||
BOMB_QUALITIES = [
|
||
# (quality_code, 名称, 等级, 包含乘数值)
|
||
('GREEN', '绿色炸弹', 1, (2, 3, 4, 5, 6)),
|
||
('BLUE', '蓝色炸弹', 2, (8, 10, 12, 15)),
|
||
('PURPLE', '紫色炸弹', 3, (20, 25, 50)),
|
||
('RED', '红色炸弹', 4, (100, 250)),
|
||
('RAINBOW', '彩虹炸弹', 5, (500, 1000)),
|
||
]
|
||
# value -> quality_code 快速查表
|
||
BOMB_VALUE_QUALITY = {v: code for code, _, _, vals in BOMB_QUALITIES for v in vals}
|
||
|
||
|
||
def bomb_quality(value: int) -> str:
|
||
"""炸弹乘数值 → 品质编码(GREEN/BLUE/PURPLE/RED/RAINBOW)"""
|
||
return BOMB_VALUE_QUALITY[value]
|
||
|
||
# 免费旋转触发/散布(底注概率翻倍,互斥规则:buy_fs 保证触发)
|
||
BASE_FS_TRIGGER_PROB = {'normal': 0.005, 'ante': 0.010}
|
||
BASE_NEARMISS_SCATTER = {
|
||
'normal': {0: 0.88, 1: 0.08, 2: 0.03, 3: 0.01},
|
||
'ante': {0: 0.74, 1: 0.16, 2: 0.07, 3: 0.03},
|
||
}
|
||
FS_TRIGGER_SCATTER = {4: 0.80, 5: 0.15, 6: 0.05} # 触发盘落 S 数
|
||
FREESPIN_SCATTER = { # FS 每旋落 S 数(≥3 → +5)
|
||
'default': {0: 0.70, 1: 0.15, 2: 0.07, 3: 0.05, 4: 0.025, 5: 0.005},
|
||
'buy_fs': {0: 0.78, 1: 0.13, 2: 0.06, 3: 0.02, 4: 0.008, 5: 0.002},
|
||
}
|
||
MAX_FREE_SPINS = 30 # 重触发总次数上限
|
||
|
||
# ============================================================
|
||
# 免费旋转"每旋保底消除"(仅 buy_fs)
|
||
# 动机:15 旋原来 86.8% 的旋完全空转(45% 强制空转 + 自然填充 82.9% 凑不出 8 连)
|
||
# 做法:本旋没消成时按概率补一组最小档消除(8 连低价值符号 = 0.25 倍底注),消完再按概率续链
|
||
# 预算:补桶时按桶目标反推本局 FS 预算 —— 按剩余旋数自平衡,花完即停
|
||
# 注:normal/ante 的自然触发盘不走这套(仍按原 45% 空转 + 自然),避免打穿 0x~2.5x 低倍桶
|
||
# ============================================================
|
||
FS_SPIN_WIN_PROB = 0.60 # 自然阶段:每旋"至少消一波"的概率
|
||
# 有预算时(buy_fs / 高倍桶补桶)不再按"剩余预算÷剩余旋数"算每旋概率 —— 扣预算扣的是**已被累计
|
||
# 倍数放大**的赢额,累计器一涨几旋就把预算烧穿 → 后半局 p=0 一直空转到结束(实测买 FS 有 88% 的局
|
||
# 出现 ≥4 旋连空、51.7% 的局尾巴一直空转)。改成:一局能买几次消除由预算反推
|
||
# (k ≈ √(预算 / FS_GUARANTEE_COST)),每次消除后**重抽下一次的位置**(间隔随机,见
|
||
# `olympus_simulator._fs_next_target`)—— 整局消除次数不变,但不再"每 4 旋必消"。
|
||
FS_GUARANTEE_COST = 0.45 # 每多一次"保底消除+炸弹累计"的边际赔付(实测 ≈0.45×k)
|
||
# 随机间隔的兜底:连续空转越久,越可能被强制补一次保底消除(**软**上限,不是硬闸)——
|
||
# 硬上限(到第 N 旋必消)会留下新的固定天花板(实测硬顶 6 时有 42% 的局"最长连空正好 6"),
|
||
# 所以按概率递增:dry=5/6 时 0.5/0.85,dry≥8 必补。配额提前用完时靠它兜住长空尾。
|
||
# 实测(box21 整池):最长连空 2~8 旋散开、单值最高 42%(等距版是 87% 都卡在 4),≥7 旋占 2.5%。
|
||
FS_DRY_FORCE_PROB = {5: 0.50, 6: 0.85}
|
||
FS_DRY_FORCE_HARD = 8 # 到这个旋数无条件补(概率表兜不到时)
|
||
# 连消概率:越低,同一份预算能覆盖的"有消除的旋"越多(0.45 时预算集中在少数旋上,
|
||
# 一局只剩 1~2 旋有消除;0.10 时同样的钱能铺到 4~10 旋)
|
||
FS_CASCADE_CONTINUE_PROB = 0.10
|
||
FS_CASCADE_MAX = 5 # 单旋最大消除波数
|
||
FS_GUARANTEE_COMBOS = ( # 保底消除组合 (符号, 个数, 赔付倍数@1倍底注),最小档为主
|
||
('I', 8, 0.25), ('I', 8, 0.25), ('H', 8, 0.40), ('G', 8, 0.50),
|
||
)
|
||
|
||
# 免费旋转里的炸弹(buy_fs):**每一旋都可能出现、每屏最多 1 颗**。
|
||
# ① 每一旋都可能出现:前面几旋的消除盘面上也看得见炸弹,倍数从第 1~2 旋就开始涨;
|
||
# ② 每屏最多 1 颗:一屏堆 2~4 颗会"一次消除好几颗一起爆",看不出"消一次爆一颗"的节奏;
|
||
# ③ 炸弹**有大有小**,但按该局的档位配:低倍档(< FS_BIG_BOMB_TARGET)用 2~8 的小球
|
||
# (累计倍数作用到每一次消除:0.4×2 + 0.5×4 = 2.8 这种滚雪球,大球一合并后面每次消除
|
||
# 都被放大几十倍,低倍档的预算立刻见底),高倍档(≥100x)用全量 2~1000 —— 所以
|
||
# "大炸弹"照样存在,只是它出现的那些局自然落在高倍桶。
|
||
FS_SCREEN_BOMB_PROB = 0.08
|
||
FS_SMALL_BOMB_MAX = 8
|
||
FS_BIG_BOMB_TARGET = 100
|
||
FS_MAX_BOMBS_PER_SPIN = 1
|
||
|
||
# "只能靠免费旋转赔出来"的目标倍数下限:normal/ante 的基础旋不出炸弹,最多只能凑到
|
||
# A12(50) + B10(10) ≈ 60x(自然多组 75x 已属极罕见),所以目标 ≥80x 的桶(100x/150x/200x)
|
||
# 只能生成"自然触发 FS"的回合来装 —— 这也是这两个模式里玩家能进 FS 的现实来源。
|
||
FS_ONLY_TARGET_MUL = 80.0
|
||
|
||
# 目标 RTP 与各模式可用饰品桶
|
||
RTP = 0.8
|
||
MODE_ORNAMENT_IDS = {
|
||
'normal': list(range(1, 14)), # 0x ~ 150x
|
||
'ante': list(range(1, 15)), # 0x ~ 200x
|
||
'buy_fs': list(range(7, 18)), # 10x ~ 1000x(保证进 FS;原 5 起=4x 起,低倍桶预算装不下 FS 动画)
|
||
}
|
||
# 桶"实际发出期望"的**回退值**:桶 17 展示 1000x(区间 630~1370),但单局封顶 1000 →
|
||
# 真正发出去的是 min(原始分, 1000):
|
||
# · 理论值(假设原始分在 630~1370 均匀分布):E[min(x,1000)] = 907.5
|
||
# · 实测值(buy_fs 10 箱 × 桶17 全部回合 = 200 个):均值 834.6、中位 821.3、27.5% 触顶 1000
|
||
# —— 生成端种的大奖目标集中在 1000 附近、不是均匀分布,所以实测低于理论值。
|
||
# 注意:配比(calcRounds)与后台「目标RTP」现在优先用**该箱池内 AVG(actual_mul)**(见
|
||
# _recalc_boxes.load_pool_expect / Java loadPoolExpect),这个覆盖值只在"池子里一条回合都没有"
|
||
# 时兜底 —— 它比名义 1000 诚实(高估约 0.5~1.6 个百分点)。
|
||
PAYOUT_EXPECT_OVERRIDE = {17: 835.0}
|
||
|
||
_ORN_EXPECT = {o['id']: float(o['expect_mul']) for o in ORNAMENTS}
|
||
|
||
|
||
def payout_expect(orn_id: int) -> float:
|
||
"""桶的"实际发出期望"回退值:受封顶影响的桶用实测覆盖值,其余用名义 expect_mul。
|
||
|
||
用于:池子里还没有该桶回合时的兜底、以及模拟/报表里的名义口径对比。
|
||
**抽回合时的 payoutCap 过滤仍用名义值**(Java OlympusRoundPicker 读 DB 的 expect_mul,
|
||
偏保守,保留安全余量)。"""
|
||
return PAYOUT_EXPECT_OVERRIDE.get(orn_id, _ORN_EXPECT.get(orn_id, 0.0))
|
||
|
||
|
||
# ============================================================
|
||
# 多身份爆率(audience):每种身份一套独立的饰品桶数量分布,实现差异化 RTP。
|
||
# 回合库存池(round/step)完全共享,仅“抽哪个桶”的概率按身份区分。
|
||
# 与现有宝箱 tt_user 对齐:user_type 01主播 / 02普通 / 03机器人;is_blank_account=1 白号。
|
||
# 身份解析优先级(Java 开局):白号最高优先,压过 user_type:
|
||
# is_blank_account==1 → white;user_type=01 → anchor;03 → robot;其余 → common。
|
||
# 白号池(white):独立 count/used,抽取只递减自己的 used、独立补桶,
|
||
# 永不消耗 common/anchor/robot 库存;白号 bet/win 不计水位、赢奖不进真实经济。
|
||
# 机器人暂不细分幸运/非幸运档(后续视 2v2 需求再扩展 robot_unlucky)。
|
||
# ============================================================
|
||
AUDIENCE_COMMON = 'common' # 普通用户(对齐 user_type=02 及招商/卡商/超管等其他)
|
||
AUDIENCE_ANCHOR = 'anchor' # 主播(user_type=01)
|
||
AUDIENCE_ROBOT = 'robot' # 机器人(user_type=03)
|
||
AUDIENCE_WHITE = 'white' # 白号(is_blank_account=1,压过 user_type)
|
||
AUDIENCES = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_ROBOT, AUDIENCE_WHITE)
|
||
DEFAULT_AUDIENCE = AUDIENCE_COMMON
|
||
|
||
|
||
# ============================================================
|
||
# 爆奖动画(小/中/大 3 档):**只在免费旋转里判档**,按"单旋赢额"独立判档,
|
||
# 达标即在该旋末帧盖章,同档一局可多次;normal/ante 的基础旋一律 null(不播大奖动画)。
|
||
# 两种情况两套阈值(单位:单旋赢额 ÷ 基础下注):
|
||
# ① 自然免费旋转(normal/ante 摇出 4+ S 进入):5 / 25 / 100
|
||
# 两个模式的 FS 预算与旋数完全一致 → 共用一套;实测 FS 内约 15% 的旋 ≥5、
|
||
# 约 4% 的旋 ≥25、约 1.7% 的旋 ≥100(约 1/4 的 FS 局能见到"大")
|
||
# ② 购买免费旋转(buy_fs):20 / 60 / 200
|
||
# 买 FS 的赢额分散在 15+ 旋里,单旋阈值整体下调;实测约 3.5% 的旋 ≥20、
|
||
# 约 0.9% 的旋 ≥60、约 0.1% 的旋 ≥200(约 2% 的买 FS 局能见到"大")
|
||
# 编码 small < middle < big:前端按这三个值播小/中/大动画,null 表示不播
|
||
# ============================================================
|
||
BIGWIN_LEVELS = ('small', 'middle', 'big')
|
||
BIGWIN_TIERS = {
|
||
'normal': (5, 25, 100),
|
||
'ante': (5, 25, 100), # 与 normal 同一口径:都是"自然触发的 FS",整局规模相同
|
||
'buy_fs': (20, 60, 200),
|
||
}
|
||
|
||
|
||
def bigwin_level(mode_code: str, spin_mul: float):
|
||
"""单旋赢额(基础注倍数)→ 爆奖档位编码 small/middle/big;未达最低档返回 None"""
|
||
tiers = BIGWIN_TIERS[mode_code]
|
||
level = None
|
||
for name, threshold in zip(BIGWIN_LEVELS, tiers):
|
||
if spin_mul >= threshold:
|
||
level = name
|
||
return level
|