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