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