Files
henan-backend/olympus-py/tests/test_simulator.py
T
2026-09-21 19:21:35 +08:00

264 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回合生成器规则校验(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()