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()