293 lines
13 KiB
Python
293 lines
13 KiB
Python
"""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()
|