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
+292
View File
@@ -0,0 +1,292 @@
"""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()