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