first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Olympus tests."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1,263 @@
|
||||
"""回合生成器规则校验(unittest)。不依赖第三方库,可直接 `python tests/test_simulator.py` 运行。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
# 支持直接 `python tests/test_simulator.py` 运行(无需先安装为包)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from olympus.config import BIGWIN_LEVELS, BIGWIN_TIERS, BOMB_SHORT, SCATTER_SHORT
|
||||
import olympus_simulator as sim
|
||||
from olympus_simulator import generate_round
|
||||
|
||||
|
||||
def _cells(step):
|
||||
return [c for row in step['grid_state'] for c in row]
|
||||
|
||||
|
||||
class TestBuyFsTriggerSpin(unittest.TestCase):
|
||||
"""buy_fs 触发盘:首盘固定 4 个 SCATTER,且整盘不出炸弹(炸弹只在免费旋转内生效)。"""
|
||||
|
||||
def test_trigger_pages(self) -> None:
|
||||
for _ in range(100):
|
||||
_, steps, _ = generate_round({'box_id': 0, 'box_type': 'buy_fs'})
|
||||
trigger = [s for s in steps if s['free_spin_id'] == 0]
|
||||
self.assertTrue(trigger)
|
||||
for s in trigger:
|
||||
cells = _cells(s)
|
||||
self.assertEqual(cells.count(SCATTER_SHORT), 4)
|
||||
self.assertEqual(cells.count(BOMB_SHORT), 0)
|
||||
|
||||
|
||||
class TestBuyFsFreeSpinHits(unittest.TestCase):
|
||||
"""buy_fs 免费旋转"每旋保底消除":自然阶段命中率应远高于旧实现的 ~13%(旧版 45% 强制空转)。"""
|
||||
|
||||
def test_natural_phase_hit_rate(self) -> None:
|
||||
random.seed(12345)
|
||||
box = {'box_id': 0, 'box_type': 'buy_fs'}
|
||||
total = hit = 0
|
||||
for _ in range(60):
|
||||
_, steps, _ = generate_round(box, None)
|
||||
spins = Counter(s['free_spin_id'] for s in steps if s['free_spin_id'] > 0)
|
||||
total += len(spins)
|
||||
hit += sum(1 for v in spins.values() if v > 1)
|
||||
self.assertGreater(hit / total, 0.45)
|
||||
|
||||
|
||||
class TestBombOnlyInFreeSpins(unittest.TestCase):
|
||||
"""炸弹只存在于免费旋转内:
|
||||
· normal/ante 的基础旋(含进 FS 的触发盘、含补桶植入)一律不出炸弹;
|
||||
· 三个模式一旦进 FS 规则一致(炸弹 + 累计器),normal/ante 靠它演 100x/150x/200x 桶。"""
|
||||
|
||||
def test_normal_ante_base_spin_never_emit_bomb(self) -> None:
|
||||
random.seed(20260822)
|
||||
for mode in ('normal', 'ante'):
|
||||
box = {'box_id': 0, 'box_type': mode}
|
||||
bombs = 0
|
||||
for bias in (None, 'zero', 0.5, 10.0, 150.0):
|
||||
for _ in range(400):
|
||||
_, steps, _ = generate_round(box, bias)
|
||||
bombs += sum(_cells(s).count(BOMB_SHORT)
|
||||
for s in steps if s['free_spin_id'] == 0)
|
||||
self.assertEqual(bombs, 0, '%s 基础旋不该出炸弹' % mode)
|
||||
|
||||
def test_normal_ante_free_spins_have_bomb(self) -> None:
|
||||
random.seed(11)
|
||||
for mode in ('normal', 'ante'):
|
||||
box = {'box_id': 0, 'box_type': mode}
|
||||
grids = bombs = 0
|
||||
for _ in range(400):
|
||||
# 目标 150x ≥ FS_ONLY_TARGET_MUL → 该模式的 150x 桶只能靠自然触发 FS 的回合来装
|
||||
_, steps, _ = generate_round(box, 150.0)
|
||||
for s in steps:
|
||||
if s['free_spin_id'] > 0:
|
||||
grids += 1
|
||||
bombs += _cells(s).count(BOMB_SHORT)
|
||||
self.assertGreater(grids, 0, '%s 高倍桶应该是 FS 回合' % mode)
|
||||
self.assertGreater(bombs / (30.0 * grids), 0.02,
|
||||
'%s 的 FS 盘面应该有炸弹' % mode)
|
||||
|
||||
def test_high_target_round_enters_free_spins(self) -> None:
|
||||
"""目标高到基础旋赔不出来(≥80x)时,生成器必须改用自然触发 FS 的回合。"""
|
||||
for mode in ('normal', 'ante'):
|
||||
_, steps, _ = generate_round({'box_id': 0, 'box_type': mode}, 150.0)
|
||||
self.assertTrue(any(s['free_spin_id'] > 0 for s in steps))
|
||||
trigger = [s for s in steps if s['free_spin_id'] == 0][-1]
|
||||
self.assertGreaterEqual(_cells(trigger).count(SCATTER_SHORT), 4)
|
||||
self.assertEqual(_cells(trigger).count(BOMB_SHORT), 0)
|
||||
|
||||
def test_buy_fs_still_has_bomb_in_free_spins(self) -> None:
|
||||
random.seed(7)
|
||||
box = {'box_id': 0, 'box_type': 'buy_fs'}
|
||||
bombs = 0
|
||||
for _ in range(200):
|
||||
_, steps, _ = generate_round(box, None)
|
||||
bombs += sum(_cells(s).count(BOMB_SHORT) for s in steps if s['free_spin_id'] > 0)
|
||||
self.assertGreater(bombs, 0)
|
||||
|
||||
|
||||
class TestBigWinLevels(unittest.TestCase):
|
||||
"""爆奖动画只有小/中/大 3 档,而且只在免费旋转里判档:
|
||||
· 基础旋(含触发盘,`free_spin_id=0`)恒为 null —— 这两个模式的普通旋转不播大奖动画;
|
||||
· FS 旋的档位 = 该旋末帧 winAmount 与新阈值(两种情况两套)比对的结果。"""
|
||||
|
||||
def test_tiers_are_three_and_two_cases(self) -> None:
|
||||
self.assertEqual(BIGWIN_LEVELS, ('small', 'middle', 'big'))
|
||||
self.assertEqual(BIGWIN_TIERS['normal'], BIGWIN_TIERS['ante']) # 自然 FS 共用一套
|
||||
self.assertNotEqual(BIGWIN_TIERS['normal'], BIGWIN_TIERS['buy_fs'])
|
||||
for tiers in BIGWIN_TIERS.values():
|
||||
self.assertEqual(len(tiers), len(BIGWIN_LEVELS))
|
||||
|
||||
def test_only_free_spin_final_frames_get_level(self) -> None:
|
||||
random.seed(4242)
|
||||
cases = (('normal', None), ('ante', None), ('buy_fs', None),
|
||||
('normal', 150.0), ('ante', 200.0))
|
||||
for mode, bias in cases:
|
||||
box = {'box_id': 0, 'box_type': mode}
|
||||
for _ in range(120):
|
||||
_, steps, _ = generate_round(box, bias)
|
||||
for s in steps:
|
||||
if s['free_spin_id'] == 0 or not s['is_final']:
|
||||
self.assertIsNone(s['bigwin_level'],
|
||||
'%s 基础旋/非末帧不该有爆奖档' % mode)
|
||||
continue
|
||||
want = None
|
||||
for name, threshold in zip(BIGWIN_LEVELS, BIGWIN_TIERS[mode]):
|
||||
if float(s['win_amount']) >= threshold:
|
||||
want = name
|
||||
self.assertEqual(s['bigwin_level'], want)
|
||||
|
||||
|
||||
class TestFreeSpinFloorSpread(unittest.TestCase):
|
||||
"""保底消除"随机间隔":既不能出现长空尾,也不能变成固定节奏。
|
||||
|
||||
口径:dry = 该旋所有 cascade 步 `base_score` 全 0(scatter 赔付不算消除,S 不参与消除)。
|
||||
两版历史(都别退回去):
|
||||
· 最初按"剩余预算÷剩余旋数"算概率、扣预算扣的是被累计倍数放大后的赢额 → 买 FS 87.9% 的局
|
||||
≥4 旋连空、51.7% 的局尾巴一直空转、单局最长 29 旋;
|
||||
· 修成"等距铺"后虽然没长空尾,但 87% 的局"最长连空正好 4 旋",玩家几局就看穿"到第 4 旋肯定消"。"""
|
||||
|
||||
@staticmethod
|
||||
def _max_dry(steps) -> int:
|
||||
per = {}
|
||||
for s in steps:
|
||||
if s['free_spin_id'] > 0:
|
||||
per[s['free_spin_id']] = per.get(s['free_spin_id'], 0.0) + float(s['base_score'])
|
||||
run = mx = 0
|
||||
for k in sorted(per):
|
||||
run = 0 if per[k] > 0 else run + 1
|
||||
mx = max(mx, run)
|
||||
return mx
|
||||
|
||||
def test_dry_run_stays_short(self) -> None:
|
||||
random.seed(20260916)
|
||||
for bias in (25.0, None):
|
||||
runs = [self._max_dry(generate_round({'box_id': 0, 'box_type': 'buy_fs'}, bias)[1])
|
||||
for _ in range(80)]
|
||||
avg, worst = sum(runs) / len(runs), max(runs)
|
||||
self.assertLess(avg, 7, 'bias=%s 平均最长连空 %.1f 太长(旧实现 9.6)' % (bias, avg))
|
||||
self.assertLessEqual(worst, 9, 'bias=%s 出现 %d 旋死尾巴(旧实现最长 29)' % (bias, worst))
|
||||
|
||||
def test_gap_is_not_a_fixed_cadence(self) -> None:
|
||||
"""最长连空的取值必须散开:等距版 87% 的局都卡在同一个值。"""
|
||||
random.seed(4242)
|
||||
runs = [self._max_dry(generate_round({'box_id': 0, 'box_type': 'buy_fs'}, 25.0)[1])
|
||||
for _ in range(120)]
|
||||
top, cnt = Counter(runs).most_common(1)[0]
|
||||
self.assertGreaterEqual(len(set(runs)), 3, '最长连空只有 %s,节奏太固定' % sorted(set(runs)))
|
||||
self.assertLess(cnt / len(runs), 0.7, '最长连空过于集中:%.0f%% 都是 %d 旋' % (100 * cnt / len(runs), top))
|
||||
|
||||
|
||||
class TestTumbleGeometry(unittest.TestCase):
|
||||
"""每一步都要能按"消掉上一步的连线 → 重力下落 → 补位"复刻出 `grid_state`:
|
||||
前端据此播动画,任何一格对不上/漏掉都会表现成画错符号或空白格子。"""
|
||||
|
||||
def _check_round(self, steps) -> None:
|
||||
rows, cols = 5, 6
|
||||
for i in range(1, len(steps)):
|
||||
prev, cur = steps[i - 1], steps[i]
|
||||
drops = json.loads(cur['drop_data'] or '[]')
|
||||
fills = json.loads(cur['fill_data'] or '[]')
|
||||
if prev['free_spin_id'] != cur['free_spin_id']:
|
||||
# 新的一旋:整盘重发,不该带下落/补位
|
||||
self.assertEqual(drops, [])
|
||||
self.assertEqual(fills, [])
|
||||
continue
|
||||
links = json.loads(prev['symbol_links'] or '[]')
|
||||
removed = {p - 1 for l in links if l.get('tumble') == prev['cascade_seq']
|
||||
for p in l['loc']}
|
||||
old = prev['grid_state']
|
||||
board = [['' if (r * cols + c) in removed else old[r][c] for c in range(cols)]
|
||||
for r in range(rows)]
|
||||
nb = [[''] * cols for _ in range(rows)]
|
||||
exp_drops = []
|
||||
for c in range(cols):
|
||||
surv = [(r, board[r][c]) for r in range(rows - 1, -1, -1) if board[r][c] != '']
|
||||
for k, (r, sym) in enumerate(surv):
|
||||
nr = rows - 1 - k
|
||||
nb[nr][c] = sym
|
||||
if nr != r:
|
||||
exp_drops.append({'from': r * cols + c + 1, 'to': nr * cols + c + 1,
|
||||
'symbol': sym})
|
||||
key = lambda d: (d['from'], d['to'])
|
||||
self.assertEqual(sorted(exp_drops, key=key), sorted(drops, key=key))
|
||||
# 下落目标的 symbol 必须是"这一波结束后"的符号(= grid_state 同位置)
|
||||
for d in drops:
|
||||
p = d['to'] - 1
|
||||
self.assertEqual(d['symbol'], cur['grid_state'][p // cols][p % cols])
|
||||
# 补位必须正好盖住所有空位,且符号与最终盘面一致
|
||||
empty = sorted(r * cols + c for c in range(cols) for r in range(rows)
|
||||
if nb[r][c] == '')
|
||||
self.assertEqual(sorted(f['pos'] - 1 for f in fills), empty)
|
||||
for f in fills:
|
||||
p = f['pos'] - 1
|
||||
self.assertEqual(f['symbol'], cur['grid_state'][p // cols][p % cols])
|
||||
nb[p // cols][p % cols] = f['symbol']
|
||||
self.assertEqual(nb, cur['grid_state'], '复刻盘面 ≠ grid_state')
|
||||
|
||||
def test_all_modes(self) -> None:
|
||||
random.seed(31337)
|
||||
for mode, biases in (('normal', (None, 'zero', 20.0, 150.0)),
|
||||
('ante', (None, 100.0, 200.0)),
|
||||
('buy_fs', (None, 25.0, 500.0))):
|
||||
for bias in biases:
|
||||
for _ in range(60):
|
||||
_, steps, _ = generate_round({'box_id': 0, 'box_type': mode}, bias)
|
||||
self._check_round(steps)
|
||||
|
||||
|
||||
class TestFreeSpinCap(unittest.TestCase):
|
||||
"""30 旋上限(15 + 5×3):到上限之后不得再落 3+ 个 S。
|
||||
|
||||
旧实现只在"加旋"那行有闸门,写 retrigger_info 的那行没有 —— 到 30 旋后照样摇出 3+ 个 S、
|
||||
照样写 extraSpins=5,玩家白看 "+5" 提示(全库 650 个这种帧)。这里用"每旋必落 5 个 S"的
|
||||
极端分布把上限逼出来:应恰好 30 旋、恰好 3 次重触发,上限之后的旋 S 数 ≤2。
|
||||
"""
|
||||
|
||||
def test_no_retrigger_beyond_cap(self) -> None:
|
||||
random.seed(20260919)
|
||||
original = sim.FREESPIN_SCATTER
|
||||
sim.FREESPIN_SCATTER = {'default': {5: 1.0}, 'buy_fs': {5: 1.0}}
|
||||
try:
|
||||
for mode in ('normal', 'ante', 'buy_fs'):
|
||||
steps, _ = sim._free_spin_session(mode)
|
||||
spins = {s['free_spin_id'] for s in steps}
|
||||
retrigger = [s for s in steps if s.get('retrigger_info')]
|
||||
self.assertEqual(len(spins), 30, f'{mode}: 旋数应为 30,实际 {len(spins)}')
|
||||
self.assertEqual(len(retrigger), 3, f'{mode}: 重触发应为 3 次,实际 {len(retrigger)}')
|
||||
for s in steps:
|
||||
if s['is_final'] and s['free_spin_id'] > 3:
|
||||
self.assertLessEqual(_cells(s).count(SCATTER_SHORT), 2,
|
||||
f'{mode}: 上限之后(第 {s["free_spin_id"]} 旋)又落了 3+ 个 S')
|
||||
finally:
|
||||
sim.FREESPIN_SCATTER = original
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user