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
+263
View File
@@ -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()