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

293 lines
13 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.
"""Olympus 引擎单元测试(unittest)。不依赖第三方库,可直接 `python tests/test_engine.py` 运行。"""
from __future__ import annotations
import sys
import unittest
from decimal import Decimal
from pathlib import Path
# 支持直接 `python tests/test_engine.py` 运行(无需先安装为包)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from olympus import OlympusEngine, RuleViolation
from olympus.config import MODES, SYMBOL_RULES
from olympus.models import Bomb
class TestSymbolRule(unittest.TestCase):
"""符号赔付校验:8/10/12 三档取值正确。"""
def test_symbol_payout_tiers(self) -> None:
crown = SYMBOL_RULES["CROWN"]
self.assertEqual(crown.payout_for(7), Decimal("0"))
self.assertEqual(crown.payout_for(8), Decimal("10"))
self.assertEqual(crown.payout_for(9), Decimal("10"))
self.assertEqual(crown.payout_for(10), Decimal("25"))
self.assertEqual(crown.payout_for(11), Decimal("25"))
self.assertEqual(crown.payout_for(12), Decimal("50"))
self.assertEqual(crown.payout_for(30), Decimal("50"))
class TestSingleCascade(unittest.TestCase):
"""单回合单次消除:无炸弹时有效倍数为 1。"""
def test_one_cascade_no_bomb(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(symbols={"BLUE_GEM": list(range(8))}, bombs=[])
spin = engine.end_spin()
self.assertEqual(spin.base_win, Decimal("0.25"))
self.assertEqual(spin.effective_multiplier, 1)
self.assertAlmostEqual(float(spin.total_win), 0.25, places=2)
def test_one_cascade_with_bomb(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(
symbols={"CROWN": [0, 1, 2, 7, 8, 9, 14, 15, 16]}, # 9 个 → 10x
bombs=[Bomb("b1", 2)],
)
spin = engine.end_spin()
self.assertEqual(spin.base_win, Decimal("10"))
self.assertEqual(spin.effective_multiplier, 2)
self.assertEqual(spin.total_win, Decimal("20"))
class TestMultiCascadeWithinSpin(unittest.TestCase):
"""一回合多次消除:炸弹只在整回合结束后统一引爆,本回合中间不逐次乘算。"""
def test_bomb_only_applies_after_all_cascades(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
c1 = engine.record_cascade(
symbols={"BLUE_GEM": list(range(8))}, bombs=[Bomb("m1", 3)]
)
c2 = engine.record_cascade(
symbols={"GREEN_GEM": list(range(8, 16))}, bombs=[]
)
# 级联阶段自身的 base_win 不应被乘数放大
self.assertEqual(c1.base_win, Decimal("0.25"))
self.assertEqual(c2.base_win, Decimal("0.4"))
spin = engine.end_spin()
# 回合基础总赢额 = 两次消除之和(未乘算)
self.assertEqual(spin.base_win, Decimal("0.65"))
# 炸弹在回合结束后才统一生效
self.assertEqual(spin.effective_multiplier, 3)
self.assertEqual(spin.total_win, Decimal("1.95"))
def test_symbols_are_accumulated_across_cascades(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(symbols={"BLUE_GEM": list(range(8))}, bombs=[Bomb("m1", 3)])
c2 = engine.record_cascade(symbols={"BLUE_GEM": list(range(8, 16))}, bombs=[])
# 同一符号在本回合内跨级联累计记录(8 + 8 = 16)
self.assertEqual(c2.accumulated_symbols.get("BLUE_GEM"), 16)
spin = engine.end_spin()
self.assertEqual(spin.accumulated_symbols.get("BLUE_GEM"), 16)
class TestCrossSpinAccumulator(unittest.TestCase):
"""炸弹累计器:仅免费旋转生效;基础旋转炸弹只乘本旋、不留存。"""
def test_base_spin_does_not_accumulate(self) -> None:
"""基础旋转:炸弹乘本旋赢额,但不进累计器。"""
engine = OlympusEngine(bet="1")
engine.begin_spin() # 默认基础旋转
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
s1 = engine.end_spin()
self.assertEqual(s1.effective_multiplier, 5) # 本旋生效
self.assertEqual(s1.total_win, Decimal("5")) # 1 * 5
self.assertEqual(s1.multiplier_after, 0) # 不留存
# 第二次基础旋转:只吃自己的炸弹
engine.begin_spin()
engine.record_cascade(symbols={"PURPLE_GEM": list(range(8, 16))}, bombs=[Bomb("m2", 3)])
s2 = engine.end_spin()
self.assertEqual(s2.multiplier_before, 0)
self.assertEqual(s2.effective_multiplier, 3)
self.assertEqual(s2.total_win, Decimal("2.4")) # 0.8 * 3
def test_accumulator_carries_across_free_spins(self) -> None:
"""免费旋转:炸弹并入累计器并跨旋传递。"""
engine = OlympusEngine(bet="1")
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
s1 = engine.end_spin()
self.assertEqual(s1.multiplier_before, 0)
self.assertEqual(s1.multiplier_after, 5)
self.assertEqual(s1.total_win, Decimal("5")) # 1 * 5
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"PURPLE_GEM": list(range(8, 16))}, bombs=[Bomb("m2", 3)])
s2 = engine.end_spin()
self.assertEqual(s2.multiplier_before, 5)
self.assertEqual(s2.multiplier_after, 8)
self.assertEqual(s2.total_win, Decimal("6.4")) # PURPLE_GEM 8个=0.8x, 0.8 * 8 = 6.4
def test_fs_spin_without_win_keeps_accumulator_unchanged(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
engine.end_spin()
# 第二次 FS 旋转没有任何消除(即使传入炸弹也无效,不计入累计)
engine.begin_spin(is_free_spin=True)
s2 = engine.end_spin()
self.assertEqual(s2.base_win, Decimal("0"))
self.assertEqual(s2.effective_multiplier, 0)
self.assertEqual(s2.multiplier_after, 5) # 累计值保持不变
def test_bomb_discarded_when_cascade_has_no_win(self) -> None:
"""无消除的旋转即使出现炸弹,也不应计入累计(消除是炸弹生效的前提)。"""
engine = OlympusEngine(bet="1")
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={}, bombs=[Bomb("m1", 10)])
spin = engine.end_spin()
self.assertEqual(spin.base_win, Decimal("0"))
self.assertEqual(spin.multiplier_after, 0)
class TestStateGuards(unittest.TestCase):
"""状态机误用与非法输入的防御性校验。"""
def test_non_positive_bet_raises(self) -> None:
with self.assertRaises(RuleViolation):
OlympusEngine(bet="0")
def test_record_cascade_without_begin_raises(self) -> None:
engine = OlympusEngine(bet="1")
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": list(range(8))})
def test_begin_spin_twice_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.begin_spin()
def test_invalid_symbol_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"UNKNOWN": list(range(8))})
def test_below_min_win_count_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": list(range(7))})
def test_duplicate_positions_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": [0, 0, 1, 2, 3, 4, 5, 6]})
def test_invalid_bomb_value_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 7)])
def test_inconsistent_bomb_redefinition_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 5)])
with self.assertRaises(RuleViolation):
engine.record_cascade(symbols={}, bombs=[Bomb("m1", 10)])
def test_reset_session_while_spin_active_raises(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
with self.assertRaises(RuleViolation):
engine.reset_session()
class TestGameModes(unittest.TestCase):
"""模式互斥与下注档位。"""
def test_mode_bet_tiers_and_prices(self) -> None:
# 普通:1 3 5 10 20 30 50 100 200 300(扣费=下注)
normal = MODES["normal"]
self.assertEqual(normal.base_bets, (1, 3, 5, 10, 20, 30, 50, 100, 200, 300))
self.assertEqual(normal.price_for(20), Decimal("20"))
# 底注:扣费 = 2 6 10 20 40 60 100 200 400 600
ante = MODES["ante"]
self.assertEqual(
[int(ante.price_for(b)) for b in ante.base_bets],
[2, 6, 10, 20, 40, 60, 100, 200, 400, 600],
)
# 购买免费旋转:扣费 = 50 150 250 500 1000 1500 2500 5000 10000 15000(10 档,50 倍)
buy = MODES["buy_fs"]
self.assertEqual(
[int(buy.price_for(b)) for b in buy.base_bets],
[50, 150, 250, 500, 1000, 1500, 2500, 5000, 10000, 15000],
)
def test_mode_mutual_exclusion(self) -> None:
# 底注模式禁止购买免费旋转
ante_engine = OlympusEngine(bet="1", mode="ante")
self.assertFalse(ante_engine.buy_free_spins_allowed())
self.assertTrue(ante_engine.ante_boost_active())
# 购买免费旋转模式禁用底注效果
buy_engine = OlympusEngine(bet="1", mode="buy_fs")
self.assertFalse(buy_engine.ante_boost_active())
self.assertFalse(buy_engine.buy_free_spins_allowed())
self.assertTrue(buy_engine.mode.guaranteed_free_spins)
# 普通模式允许购买
normal_engine = OlympusEngine(bet="1", mode="normal")
self.assertTrue(normal_engine.buy_free_spins_allowed())
self.assertFalse(normal_engine.ante_boost_active())
def test_engine_cost_uses_mode_multiplier(self) -> None:
self.assertEqual(OlympusEngine(bet="20", mode="normal").cost, Decimal("20"))
self.assertEqual(OlympusEngine(bet="20", mode="ante").cost, Decimal("40"))
self.assertEqual(OlympusEngine(bet="20", mode="buy_fs").cost, Decimal("1000"))
def test_invalid_mode_raises(self) -> None:
with self.assertRaises(RuleViolation):
OlympusEngine(bet="1", mode="buy_super_fs")
def test_bet_not_in_mode_tiers_raises(self) -> None:
# normal 没有 7 档
with self.assertRaises(RuleViolation):
OlympusEngine(bet="7", mode="normal")
# buy_fs 没有 40 档
with self.assertRaises(RuleViolation):
OlympusEngine(bet="40", mode="buy_fs")
class TestMaxWinCap(unittest.TestCase):
"""单局赢奖封顶 1000 倍基础下注。"""
def test_win_capped_at_1000x(self) -> None:
engine = OlympusEngine(bet="1")
engine.begin_spin()
# CROWN 12个=50x,配 1000 倍炸弹 → 未封顶前 50000x
engine.record_cascade(
symbols={"CROWN": list(range(12))}, bombs=[Bomb("m1", 1000)]
)
spin = engine.end_spin()
self.assertTrue(spin.win_capped)
self.assertEqual(spin.total_win, Decimal("1000"))
self.assertEqual(engine.state.total_win, Decimal("1000"))
def test_cap_applies_to_session_cumulative(self) -> None:
engine = OlympusEngine(bet="1")
# 第 1 次 FS 旋转拿 900x(基础 10x × 炸弹 90),累计器 → 90
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 50), Bomb("m2", 25), Bomb("m3", 15)])
s1 = engine.end_spin()
self.assertEqual(s1.total_win, Decimal("900"))
self.assertFalse(s1.win_capped)
# 第 2 次 FS 旋转再赢(无新炸弹,吃累计 90)→ 只能再拿 100x,整局顶格 1000x
engine.begin_spin(is_free_spin=True)
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[])
s2 = engine.end_spin()
self.assertTrue(s2.win_capped)
self.assertEqual(engine.state.total_win, Decimal("1000"))
if __name__ == "__main__":
unittest.main()