Files
henan-backend/olympus-py/olympus/config.py
T
2026-09-21 19:21:35 +08:00

312 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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