"""回合生成器规则校验(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()