first commit

This commit is contained in:
2026-09-21 19:21:35 +08:00
commit 6c944c4ffd
2733 changed files with 296956 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
"""Olympus 电子游戏规则核心 (0.1.0)。"""
from .config import (
SYMBOL_RULES,
SCATTER_CODE,
SCATTER_PAYOUTS,
MODES,
DEFAULT_MODE,
MAX_WIN_MULTIPLIER,
GameMode,
)
__version__ = "0.1.0"
__all__ = [
# config
"SYMBOL_RULES",
"SCATTER_CODE",
"SCATTER_PAYOUTS",
"MODES",
"DEFAULT_MODE",
"MAX_WIN_MULTIPLIER",
"GameMode",
# engine
"OlympusEngine",
"RuleViolation",
# models
"Bomb",
"CascadeResult",
"Elimination",
"SessionState",
"SpinResult",
]
from .engine import OlympusEngine, RuleViolation
from .models import Bomb, CascadeResult, Elimination, SessionState, SpinResult
+311
View File
@@ -0,0 +1,311 @@
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
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from collections import Counter
from decimal import Decimal
from typing import Iterable, Mapping, Sequence
from .config import (
DEFAULT_MODE,
MAX_SYMBOL_COUNT,
MAX_WIN_MULTIPLIER,
MIN_WIN_COUNT,
MODES,
MULTIPLIER_VALUES,
SYMBOL_RULES,
GameMode,
)
from .models import Bomb, CascadeResult, Elimination, SessionState, SpinResult
class RuleViolation(ValueError):
pass
class OlympusEngine:
def __init__(
self,
bet: Decimal | int | str = Decimal("1"),
mode: str = DEFAULT_MODE,
) -> None:
if mode not in MODES:
raise RuleViolation(f"未知模式: {mode},可选: {sorted(MODES)}")
self.mode: GameMode = MODES[mode]
self.bet = Decimal(str(bet))
if self.bet <= 0:
raise RuleViolation("bet 必须大于 0")
# 档位校验:整数档位必须在模式允许列表内(非整数 bet 仅用于单测/模拟,不校验)
if self.bet == self.bet.to_integral_value() and int(self.bet) not in self.mode.base_bets:
raise RuleViolation(
f"模式 {self.mode.code} 不支持下注档位 {int(self.bet)},可选: {self.mode.base_bets}"
)
self.cost = self.bet * self.mode.cost_multiplier # 实际扣费
self.max_win = self.bet * MAX_WIN_MULTIPLIER # 单局封顶 1000x 基础下注
self.state = SessionState()
self._cascades: list[CascadeResult] | None = None
self._symbol_counts: Counter[str] = Counter()
self._spin_base_win = Decimal("0")
self._bombs: dict[str, Bomb] = {}
self._is_free_spin = False
def buy_free_spins_allowed(self) -> bool:
"""当前模式是否允许购买免费旋转(底注与购买互斥)。"""
return self.mode.can_buy_free_spins
def ante_boost_active(self) -> bool:
"""当前模式是否启用底注效果(购买免费旋转模式下强制关闭)。"""
return self.mode.ante_boost
def begin_spin(self, is_free_spin: bool = False) -> int:
"""is_free_spin=True 表示免费旋转:炸弹进入累计器;基础旋转炸弹仅对本旋生效。"""
if self._cascades is not None:
raise RuleViolation("当前回合尚未结束")
self._cascades = []
self._symbol_counts.clear()
self._spin_base_win = Decimal("0")
self._bombs.clear()
self._is_free_spin = is_free_spin
return self.state.next_spin_index
def record_cascade(
self,
symbols: Mapping[str, Sequence[int]] | None = None,
bombs: Iterable[Bomb] = (),
) -> CascadeResult:
if self._cascades is None:
raise RuleViolation("请先调用 begin_spin")
eliminations = []
cascade_base_win = Decimal("0")
for symbol, raw_positions in (symbols or {}).items():
if symbol not in SYMBOL_RULES:
raise RuleViolation(f"未知符号: {symbol}")
positions = tuple(raw_positions)
count = len(positions)
if count < MIN_WIN_COUNT or count > MAX_SYMBOL_COUNT:
raise RuleViolation(f"{symbol} 消除数量必须在 {MIN_WIN_COUNT}-{MAX_SYMBOL_COUNT} 之间")
if len(set(positions)) != count:
raise RuleViolation(f"{symbol} 的消除位置不能重复")
payout_multiplier = SYMBOL_RULES[symbol].payout_for(count)
elimination = Elimination(
symbol=symbol,
count=count,
positions=positions,
payout_multiplier=payout_multiplier,
)
eliminations.append(elimination)
cascade_base_win += payout_multiplier * self.bet
self._symbol_counts[symbol] += count
current_bombs = []
for bomb in bombs:
if bomb.value not in MULTIPLIER_VALUES:
raise RuleViolation(f"不支持的炸弹倍数: {bomb.value}")
existing = self._bombs.get(bomb.bomb_id)
if existing is not None and existing != bomb:
raise RuleViolation(f"炸弹 {bomb.bomb_id} 的数据前后不一致")
if existing is None:
self._bombs[bomb.bomb_id] = bomb
current_bombs.append(bomb)
self._spin_base_win += cascade_base_win
result = CascadeResult(
cascade_index=len(self._cascades) + 1,
eliminations=tuple(eliminations),
bombs=tuple(current_bombs),
base_win=cascade_base_win,
accumulated_symbols=dict(self._symbol_counts),
)
self._cascades.append(result)
return result
def end_spin(self) -> SpinResult:
if self._cascades is None:
raise RuleViolation("请先调用 begin_spin")
multiplier_before = self.state.accumulated_multiplier
has_win = self._spin_base_win > 0
bombs_added = sum(bomb.value for bomb in self._bombs.values()) if has_win else 0
if self._is_free_spin:
# 免费旋转:炸弹并入累计器,赢额乘“累计+本旋新炸弹”
multiplier_after = multiplier_before + bombs_added
effective_multiplier = max(1, multiplier_after) if has_win else 0
else:
# 基础旋转:无累计器,炸弹仅乘本旋赢额,不留存
multiplier_after = multiplier_before
effective_multiplier = max(1, bombs_added) if has_win else 0
total_win = self._spin_base_win * effective_multiplier
# 封顶:整局累计赢奖不超过 1000 倍基础下注(最高 1000x 饰品)
remaining = self.max_win - self.state.total_win
if remaining < 0:
remaining = Decimal("0")
capped = total_win > remaining
if capped:
total_win = remaining
result = SpinResult(
spin_index=self.state.next_spin_index,
cascades=tuple(self._cascades),
base_win=self._spin_base_win,
multiplier_before=multiplier_before,
bombs_added=bombs_added,
multiplier_after=multiplier_after,
effective_multiplier=effective_multiplier,
total_win=total_win,
accumulated_symbols=dict(self._symbol_counts),
win_capped=capped,
)
self.state.spins.append(result)
self.state.next_spin_index += 1
self.state.accumulated_multiplier = multiplier_after
self.state.total_win += total_win
self._cascades = None
return result
def reset_session(self) -> None:
if self._cascades is not None:
raise RuleViolation("当前回合尚未结束")
self.state.reset()
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Dict, List, Tuple
@dataclass(frozen=True)
class Bomb:
bomb_id: str
value: int
position: int | None = None
@dataclass(frozen=True)
class Elimination:
symbol: str
count: int
positions: Tuple[int, ...]
payout_multiplier: Decimal
@dataclass(frozen=True)
class CascadeResult:
cascade_index: int
eliminations: Tuple[Elimination, ...]
bombs: Tuple[Bomb, ...]
base_win: Decimal
accumulated_symbols: Dict[str, int]
@dataclass(frozen=True)
class SpinResult:
spin_index: int
cascades: Tuple[CascadeResult, ...]
base_win: Decimal
multiplier_before: int
bombs_added: int
multiplier_after: int
effective_multiplier: int
total_win: Decimal
accumulated_symbols: Dict[str, int]
win_capped: bool = False # 本回合赢奖因 1000x 封顶被截断
@dataclass
class SessionState:
next_spin_index: int = 1
accumulated_multiplier: int = 0
total_win: Decimal = Decimal("0")
spins: List[SpinResult] = field(default_factory=list)
def reset(self) -> None:
self.next_spin_index = 1
self.accumulated_multiplier = 0
self.total_win = Decimal("0")
self.spins.clear()