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

806 lines
36 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.
"""
Olympus 回合生成器核心 — 正向生成
流程: 生成 cascade → 自然累计 → 总分落桶
核心规则(与 olympus/engine.py 对齐):
1. 一旋转内多次消除,中间步只记基础分,末帧统一引爆炸弹
2. 炸弹倍数跨旋转累计(accumulator 贯穿整个回合:基础旋转 + 免费旋转)
3. 每步 accumulated_symbols 记录本旋转累计消除符号
4. 落桶用理论总分(最高 1370),发放封顶 1000(末步截断 + win_capped)
"""
import json
import random
from collections import Counter
from olympus.config import (
SYMBOL_RULES, SHORT_TO_SYMBOL, SCATTER_SHORT, BOMB_SHORT,
SYMBOL_WEIGHTS, BOMB_CELL_PROBABILITY, BOMB_VALUES, BOMB_WEIGHT_POWER,
GRID_ROWS, GRID_COLS, GRID_SIZE, MIN_WIN_COUNT,
SCATTER_PAYOUTS, SCATTER_FREE_SPIN_TRIGGER, SCATTER_RETRIGGER_TRIGGER,
FREE_SPINS_AWARDED, FREE_SPIN_RETRIGGER_COUNT, MAX_FREE_SPINS,
BASE_FS_TRIGGER_PROB, BASE_NEARMISS_SCATTER, FS_TRIGGER_SCATTER, FREESPIN_SCATTER,
FS_SPIN_WIN_PROB, FS_CASCADE_CONTINUE_PROB, FS_CASCADE_MAX, FS_GUARANTEE_COMBOS,
FS_GUARANTEE_COST, FS_DRY_FORCE_PROB, FS_DRY_FORCE_HARD,
FS_SMALL_BOMB_MAX, FS_BIG_BOMB_TARGET, FS_SCREEN_BOMB_PROB, FS_MAX_BOMBS_PER_SPIN,
FS_ONLY_TARGET_MUL,
ORNAMENTS, MAX_WIN_MULTIPLIER, bomb_quality, bigwin_level,
)
REGULAR_SHORTS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
MAX_WIN = float(MAX_WIN_MULTIPLIER) # 发放封顶 1000
BUCKET_CEIL = max(o['range_high'] for o in ORNAMENTS) # 落桶理论上限 1370
# 保底消除预算(1 倍底注口径):最小档 0.25,连消后期望 1/(1-p) 波
FS_FLOOR_UNIT = min(c[2] for c in FS_GUARANTEE_COMBOS)
FS_FLOOR_SPEND = FS_FLOOR_UNIT / (1.0 - FS_CASCADE_CONTINUE_PROB) # ≈0.45/旋
FS_FLOOR_BUDGET_CAP = FREE_SPINS_AWARDED * FS_FLOOR_SPEND # ≈6.8/局
# ====================================================================
# 工具
# ====================================================================
def _payout(short, cnt):
return float(SYMBOL_RULES[SHORT_TO_SYMBOL[short]].payout_for(cnt))
def _weighted_sym(exclude=()):
syms = [s for s in REGULAR_SHORTS if s not in exclude]
wts = [SYMBOL_WEIGHTS[s] for s in syms]
r = random.uniform(0, sum(wts))
a = 0.0
for s, w in zip(syms, wts):
a += w
if r <= a:
return s
return syms[-1]
def _weighted_choice(dist):
r = random.random()
cum, last = 0.0, None
for k, p in dist.items():
last = k
cum += p
if r <= cum:
return k
return last
def _bucket(score):
"""理论总分落桶(score 可到 1370+,超出归顶桶)"""
if score <= 0:
return 1
if score > BUCKET_CEIL:
return ORNAMENTS[-1]['id']
for o in ORNAMENTS:
if o['range_low'] <= score <= o['range_high']:
return o['id']
for o in ORNAMENTS[1:]:
if score <= o['range_high']:
return o['id']
return ORNAMENTS[-1]['id']
def _gen_bomb_value(max_val=None):
opts = [v for v in BOMB_VALUES if max_val is None or v <= max_val]
wts = [1.0 / (v ** BOMB_WEIGHT_POWER) for v in opts]
r = random.uniform(0, sum(wts))
a = 0.0
for v, w in zip(opts, wts):
a += w
if r <= a:
return v
return opts[-1]
def _bomb_combos(target):
"""列出 ≤3 颗炸弹、和最接近 target 的所有组合:(误差, 组合),按误差升序"""
out = []
vals = BOMB_VALUES
for a in vals:
out.append((abs(a - target), [a]))
if a > target * 1.5:
break
for a in vals:
if a > target:
break
for b in vals:
s = a + b
if s > target * 1.5:
break
out.append((abs(s - target), [a, b]))
for a in vals:
if a > target:
break
for b in vals:
if a + b > target:
break
for c in vals:
s = a + b + c
if s > target * 1.5:
break
out.append((abs(s - target), [a, b, c]))
out.sort(key=lambda x: (x[0], len(x[1])))
return out
def _best_bombs(target):
"""确定性最优(只用于评估方案误差)"""
combos = _bomb_combos(target)
return combos[0][1] if combos else [2]
def _decompose_best(target):
"""在"接近最优"的组合里随机挑一个,并强烈偏向单颗 —— 一屏一颗,
玩家才看得出"消一次爆一颗、倍数一颗一颗涨";改前每次都返回同一套
(比如某些目标永远是 [3] 或 [3,6]),看起来"消除必定带 3 和 6 倍炸弹"。"""
combos = _bomb_combos(target)
if not combos:
return [2]
best_err = combos[0][0]
pool = [c for e, c in combos if e <= best_err + max(0.5, best_err * 0.15)]
weights = [1.0 / (len(c) ** 3) for c in pool] # 1 颗 : 2 颗 : 3 颗 = 1 : 1/8 : 1/27
return random.choices(pool, weights=weights)[0]
# ====================================================================
# 网格
# ====================================================================
def _empty_grid():
return [[''] * GRID_COLS for _ in range(GRID_ROWS)]
def _count(grid):
c = Counter()
for row in grid:
for cell in row:
if cell:
c[cell] += 1
return c
def _winners(grid):
w = {}
for sym, cnt in _count(grid).items():
if sym in (BOMB_SHORT, SCATTER_SHORT, ''):
continue
if cnt >= MIN_WIN_COUNT:
pay = _payout(sym, cnt)
if pay > 0:
w[sym] = (cnt, pay)
return w
def _no_extra(grid, allowed):
"""打散计划外的 ≥8 同符号组"""
backup = [s for s in REGULAR_SHORTS if s not in allowed] or ['I']
for _ in range(50):
dirty = False
for sym, cnt in _count(grid).items():
if sym in (BOMB_SHORT, SCATTER_SHORT, '') or sym in allowed:
continue
if cnt >= MIN_WIN_COUNT:
excess = cnt - (MIN_WIN_COUNT - 1)
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
if grid[r][c] == sym and excess > 0:
grid[r][c] = random.choice(backup)
excess -= 1
dirty = True
if not dirty:
break
def _fill(grid, mul_map, allow_bomb, track=False, exclude_syms=(),
bomb_prob=BOMB_CELL_PROBABILITY, bomb_max=None, max_bombs=None):
"""填充空格。allow_bomb 时按 bomb_prob 概率放炸弹(bomb_max 限制炸弹值、max_bombs 限制每屏颗数)。
track 返回填充数据(仅普通符号)"""
filled = [] if track else None
placed = len(mul_map)
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
if grid[r][c] == '':
pos = r * GRID_COLS + c
if allow_bomb and random.random() < bomb_prob \
and (max_bombs is None or placed < max_bombs):
grid[r][c] = BOMB_SHORT
mul_map[pos] = _gen_bomb_value(bomb_max)
placed += 1
else:
sym = _weighted_sym(exclude_syms)
grid[r][c] = sym
if track:
filled.append({"pos": pos + 1, "symbol": sym})
return filled
def _collapse(grid, remove, mul_map):
"""消除指定位置(0-based),重力下落。炸弹/散布随位置移动,mul_map 同步重映射"""
rs = set(remove)
drops = []
ng = _empty_grid()
pos_remap = {}
for c in range(GRID_COLS):
surviving = []
for r in range(GRID_ROWS - 1, -1, -1):
p = r * GRID_COLS + c
if p not in rs:
surviving.append((p, grid[r][c]))
for i, (old_pos, sym) in enumerate(surviving):
new_r = GRID_ROWS - 1 - i
new_pos = new_r * GRID_COLS + c
ng[new_r][c] = sym
pos_remap[old_pos] = new_pos
if new_pos != old_pos:
drops.append({"from": old_pos + 1, "to": new_pos + 1, "symbol": sym})
new_mul = {pos_remap[p]: v for p, v in mul_map.items() if p in pos_remap}
return ng, drops, new_mul
def _place_scatter(grid, count, protect=()):
cand = [(r, c) for r in range(GRID_ROWS) for c in range(GRID_COLS)
if grid[r][c] not in ({SCATTER_SHORT, BOMB_SHORT} | set(protect))]
for r, c in random.sample(cand, min(count, len(cand))):
grid[r][c] = SCATTER_SHORT
def _count_scatter(grid):
return sum(1 for row in grid for cell in row if cell == SCATTER_SHORT)
def _force_small_win(grid, prefer=()):
"""就地补一组最小档消除(默认 8 连低价值符号),不覆盖已有 S/M 格。
prefer = 这一波"刚落下 / 刚补位"的格子(0 基)。保底消除优先种在这些格子上:
它们本来就有 drop/fill 记录,前端按动画数据落地就能与 grid_state 完全一致;
种在**没移动过**的格子上则没有任何动画记录,前端只能靠 grid_state 兜底。"""
sym, cnt, _ = random.choice(FS_GUARANTEE_COMBOS)
free = [p for p in range(GRID_SIZE)
if grid[p // GRID_COLS][p % GRID_COLS] not in (SCATTER_SHORT, BOMB_SHORT)]
if len(free) < cnt:
return
fresh = [p for p in prefer if p in set(free)]
if len(fresh) >= cnt:
picks = random.sample(fresh, cnt)
else:
rest = [p for p in free if p not in set(fresh)]
picks = fresh + random.sample(rest, cnt - len(fresh))
for p in picks:
grid[p // GRID_COLS][p % GRID_COLS] = sym
_no_extra(grid, {sym})
def _scatter_payout(count):
if count >= SCATTER_FREE_SPIN_TRIGGER:
return float(SCATTER_PAYOUTS.get(min(count, 6), 0))
return 0.0
# ====================================================================
# 强制中奖(bias 补桶用)
# ====================================================================
# (基础分, 组合) 按基础分降序
_BASE_OPTIONS = [
(60.0, [('A', 12), ('B', 10)]),
(50.0, [('A', 12)]),
(35.0, [('A', 10), ('B', 10)]),
(25.0, [('A', 10)]),
(12.5, [('A', 8), ('B', 8)]),
(10.0, [('A', 8)]),
(5.0, [('C', 10)]),
(3.5, [('C', 8), ('D', 8)]),
(2.5, [('B', 8)]),
(2.0, [('C', 8)]),
(1.5, [('D', 8)]),
(1.0, [('E', 8)]),
(0.8, [('F', 8)]),
(0.5, [('G', 8)]),
(0.4, [('H', 8)]),
(0.25, [('I', 8)]),
]
def _plan_for(target):
"""全局搜索 (基础组合, 炸弹列表),使 base × max(1, Σ炸弹) 最接近 target。
在"误差在最优 +5 个百分点以内"的若干方案里随机挑一个 —— 改前永远返回同一套最优解,
所以同一档位的回合盘面/炸弹老是那几个("某个符号消除必定带着 3 和 6 倍炸弹")。"""
if target <= 0:
return [], []
plans = []
for base, combo in _BASE_OPTIONS:
plans.append((abs(base - target) / target, combo, None)) # 方案A:无炸弹
m = target / base
if m >= 2: # 方案B:配炸弹
achieved = base * sum(_best_bombs(m))
plans.append((abs(achieved - target) / target, combo, m))
plans.sort(key=lambda p: p[0])
best_err = plans[0][0]
pool = [p for p in plans if p[0] <= best_err + 0.05]
_, combo, m = random.choice(pool)
return combo, ([] if m is None else _decompose_best(m))
def _plan_base_only(target):
"""只挑基础组合(不植炸弹):在接近最优的组合里随机。acc 已经提供倍数时用这个。"""
if target <= 0:
return [('I', 8)]
cands = sorted((abs(base - target) / target, combo) for base, combo in _BASE_OPTIONS)
best = cands[0][0]
pool = [c for e, c in cands if e <= best + 0.05]
return random.choice(pool)
def _force_win(grid, target, mul_map, acc=0.0, allow_bomb=True):
"""把 target 当"本旋最终赢额"来造奖:先按当前累计倍数 acc 折算需要的基础分。
买单旋赢额 = 基础分 × max(1, acc + 本旋炸弹),所以:
· acc 已经 >1 时只挑基础分、**不再植炸弹**(否则 acc 和炸弹的倍数会被算两次,
实测把 10 倍目标打成 40~50 倍);
· 该模式不允许炸弹(普通旋转/底注)时同理只挑基础分;
· 其余情况(buy_fs 且 acc=0)按原逻辑植炸弹,大奖局(100 倍以上)靠这一步才能到。"""
need_base = target / max(1.0, acc) if acc > 0 else target
if need_base >= 0.25:
if acc > 1 or not allow_bomb:
combo, bombs = _plan_base_only(need_base), []
else:
combo, bombs = _plan_for(need_base)
else:
combo, bombs = [('I', 8)], [] # acc 本身已超过目标:只给最小档,不再加炸弹
used = set()
win_syms = set()
for sym, cnt in combo:
win_syms.add(sym)
free = [p for p in range(GRID_SIZE) if p not in used]
for p in random.sample(free, min(cnt, len(free))):
r, c = divmod(p, GRID_COLS)
grid[r][c] = sym
used.add(p)
free = [p for p in range(GRID_SIZE) if p not in used]
for v in bombs:
if not free:
break
p = free.pop(random.randrange(len(free)))
r, c = divmod(p, GRID_COLS)
grid[r][c] = BOMB_SHORT
mul_map[p] = v
used.add(p)
for p in range(GRID_SIZE):
r, c = divmod(p, GRID_COLS)
if grid[r][c] == '':
grid[r][c] = _weighted_sym(exclude=win_syms)
_no_extra(grid, win_syms)
return win_syms
# ====================================================================
# 核心:一次旋转的 cascade 链
# ====================================================================
def _spin(mode_code, bias=None, acc=0, is_free_spin=False, target_scatter=0,
allow_bomb=True, win_prob=0.0, continue_prob=0.0, cascade_max=0,
bomb_prob=None, bomb_max=None, max_bombs=None):
"""
一次旋转(含多次 cascade)。
炸弹跨 cascade 保留不消除;末帧统一结算: total = base_sum * max(1, acc+bomb_sum)
acc 只在本旋转有消除时累加炸弹(与 engine.end_spin 一致)。
allow_bomb=False 时初始盘也不出炸弹;级联补位一律不出。
**炸弹只存在于免费旋转内**:普通旋转/底注的基础旋(含进 FS 的那张触发盘)一律不出炸弹;
三个模式进 FS 后规则完全一致(炸弹 + 累计器 + 每旋保底消除)。
win_prob/continue_prob:本盘凑不出消除时按概率补最小档消除(首盘/级联各自取一个),
cascade_max 限制单旋最大波数 —— 让 FS 里"每旋都在消"。
返回 (steps, spin_total, acc_out, scatter_count)
"""
if not is_free_spin:
allow_bomb = False
steps = []
seq = 0
mul_map = {} # {pos0: bomb_val} 跨 cascade 累积
if bomb_prob is None:
bomb_prob = BOMB_CELL_PROBABILITY
# 本旋是不是"大奖旋"(补桶植入目标额的那一旋):只有它按累计倍数结算
plant_spin = isinstance(bias, (int, float)) and bias > 0
grid = None
prev_win_pos = []
sym_acc = Counter() # 本旋转累计消除符号
spin_winners = [] # 落库留档用(接口已不下发,面板由 symbol_links 汇总)
spin_links = [] # 本旋累积连线上报:[{symbol,count,loc,base_score,tumble}],新旋清空
total_base = 0.0
recorded = 0.0 # 已记入前置步骤的赢额
removed, drops, fills = [], [], []
acc_before_spin = acc
while True:
seq += 1
if seq == 1:
grid = _empty_grid()
if isinstance(bias, (int, float)) and bias > 0:
win_syms = _force_win(grid, float(bias), mul_map, acc, allow_bomb)
else:
_fill(grid, mul_map, allow_bomb, bomb_prob=bomb_prob, bomb_max=bomb_max,
max_bombs=max_bombs)
if bias == 'zero':
_no_extra(grid, set())
if target_scatter > 0:
_place_scatter(grid, target_scatter, protect=set(_winners(grid)))
removed, drops, fills = [], [], []
else:
removed = [p + 1 for p in prev_win_pos]
grid, drops, mul_map = _collapse(grid, prev_win_pos, mul_map)
fills = _fill(grid, mul_map, allow_bomb=False, track=True)
# 保底消除:本盘自然凑不出 8 连时,按概率补一组最小档(首盘用 win_prob,级联用 continue_prob)
prob = win_prob if seq == 1 else continue_prob
if prob > 0 and (not cascade_max or seq < cascade_max) \
and random.random() < prob and not _winners(grid):
_force_small_win(grid, prefer=[f['pos'] - 1 for f in fills])
# 保底消除 / 打散会覆盖"刚落下"或"刚补位"的格子 —— 这两份动画数据里的 symbol 按**最终盘面**
# 回填,前端按"消→落→补"重建出来的盘面才与 grid_state 完全一致(否则会画出不对的符号)。
if drops:
drops = [dict(d, symbol=grid[(d['to'] - 1) // GRID_COLS][(d['to'] - 1) % GRID_COLS])
for d in drops]
if fills:
fills = [dict(f, symbol=grid[(f['pos'] - 1) // GRID_COLS][(f['pos'] - 1) % GRID_COLS])
for f in fills]
w = _winners(grid)
is_final = (len(w) == 0)
base = 0.0
win_pos = []
mul_det = [{"pos": p + 1, "val": v, "quality": bomb_quality(v)}
for p, v in sorted(mul_map.items())]
bomb_sum = sum(mul_map.values())
# symbol_links = **本旋累积**的连线上报(每波消除往上追加,tumble 标明是旋内第几波)——
# 它同时充当"获胜者面板"(前端按 symbol 汇总即可),所以 spin_winners 字段已删。
for sym, (cnt, pay) in sorted(w.items()):
pos_list = [r * GRID_COLS + c for r in range(GRID_ROWS)
for c in range(GRID_COLS) if grid[r][c] == sym][:cnt]
base += pay
win_pos.extend(pos_list)
sym_acc[sym] += cnt
spin_links.append({
'symbol': sym, 'count': cnt,
'loc': [p + 1 for p in pos_list],
'base_score': round(pay, 4),
'tumble': seq, # 本旋第几波消除(= 本步 tumbleNum)
})
total_base += base
# 末帧:统一引爆(一旋转内不管几次消除,消完才爆)
# 累计器仅免费旋转有效;基础旋转炸弹只乘本旋赢额、不留存
scatter_win = 0.0
scatter_count = 0
acc_after = acc
if is_final:
scatter_count = _count_scatter(grid)
scatter_win = _scatter_payout(scatter_count)
if total_base > 0:
if is_free_spin:
acc_after = acc + bomb_sum # FS:炸弹并入累计器,跨旋传递
# 本旋赢额 = 基础分 × max(1, 累计 + 本旋炸弹) —— 累计倍数作用到每一次消除
# (玩家要的就是这个滚雪球:0.4×2 + 0.5×4 = 2.8)
effective = max(1, acc_after)
else:
acc_after = acc # 基础旋:无累计器
effective = max(1, bomb_sum)
win_amt = total_base * effective - recorded + scatter_win
else:
win_amt = scatter_win # 无消除→炸弹作废、累计不变
else:
win_amt = base # 中间步只记基础分
recorded += win_amt if not is_final else 0.0
# scatter 触发/重触发(仅末帧)
scatter_trigger, retrigger_info = None, None
if is_final:
if is_free_spin and scatter_count >= SCATTER_RETRIGGER_TRIGGER:
retrigger_info = {'scatterCount': scatter_count,
'extraSpins': FREE_SPIN_RETRIGGER_COUNT,
'scatterWin': round(scatter_win, 2)}
elif not is_free_spin and scatter_count >= SCATTER_FREE_SPIN_TRIGGER:
scatter_trigger = {'scatterCount': scatter_count,
'freeSpins': FREE_SPINS_AWARDED,
'scatterWin': round(scatter_win, 2)}
steps.append({
'cascade_seq': seq, 'free_spin_id': 0,
'grid_state': [row[:] for row in grid],
'multiplier_detail': json.dumps(mul_det),
'symbol_links': json.dumps(spin_links, ensure_ascii=False), # 本旋累积(含 tumble)
'accumulated_symbols': json.dumps(dict(sym_acc)), # 仅落库留档,不下发
'spin_winners': json.dumps(spin_winners, ensure_ascii=False), # 仅落库留档,不下发
'removed_positions': json.dumps(removed), # 仅落库留档,不下发
'drop_data': json.dumps(drops),
'fill_data': json.dumps(fills if fills else []),
'scatter_trigger': json.dumps(scatter_trigger) if scatter_trigger else None,
'retrigger_info': json.dumps(retrigger_info) if retrigger_info else None,
'base_score': round(base, 2), # 仅落库留档,不下发
'win_amount': round(win_amt, 2),
'accumulator_before': round(acc_before_spin, 2),
'accumulator_after': round(acc_after if is_final else acc, 2),
'spin_win': 0, 'win_capped': 0, 'bigwin_level': None,
'is_final': 1 if is_final else 0,
})
if is_final:
total = round(sum(s['win_amount'] for s in steps), 2)
return steps, total, acc_after, scatter_count
prev_win_pos = win_pos
# ====================================================================
# 免费旋转会话(15 次起,≥3S 重触发 +5,上限 30)
# ====================================================================
def _fs_next_target(spin_no, wins, total_spins, quota):
"""下一旋"该保底消除"的位置:间隔随机,但均值 = 剩余旋数 / 剩余配额。
为什么要随机:等距铺(每 quota/总局数 旋必消一次)会让玩家几局就看穿"到第 4 旋肯定消"——
实测等距版本有 87% 的局"最长连空正好 4 旋"。这里改成每次消除后重抽下一次的位置:
间隔 = round(均值 + δ),δ ∈ {-2..2} 权重 {1,2,3,2,1}(均值 0,所以整局的消除次数不变),
玩家看到的空转长度在 1~5 旋之间跳,而不是恒定 4。剩余配额为 0 时返回 None(后面只靠自然消)。
"""
left_spins = total_spins - spin_no
left_quota = quota - wins
if left_spins <= 0 or left_quota <= 0:
return None
delta = random.choices((-2, -1, 0, 1, 2), weights=(1, 2, 3, 2, 1))[0]
return spin_no + max(1, int(round(left_spins / left_quota + delta)))
def _free_spin_session(mode_code, slot_target=None, acc=0, fs_budget=None):
"""免费旋转会话(15 次起,≥3S 重触发 +5,上限 30)。
**三个模式同一套规则**(炸弹 + 累计器 + 每旋保底消除),区别只在"这一局值多少钱":
· buy_fs:价格换来桶目标,拆成「保底消除预算 fs_budget + 一旋大奖 slot_target」;
· normal/ante 自然触发:没有价格可拆 → 只给一份同档保底预算、不植大奖旋,
整局赔付落在哪由自然结果决定。
fs_budget 按"配额 + 随机间隔"花:预算能买 k 次消除(k ≈ √(预算/FS_GUARANTEE_COST)),
每次消除后重抽下一次的位置(间隔随机、均值 = 剩余旋数/剩余配额),自然消除也算一次进度;
空转太久再按 FS_DRY_FORCE_PROB 软兜底 —— 旧写法按"剩余预算÷剩余旋数"算每旋概率,累计倍数
一涨就把预算烧穿,后半局十几旋连空;等距铺法又会让玩家看穿"到第 4 旋肯定消"(见 _fs_next_target)。
slot_target 只作用于其中随机一旋(它按累计倍数结算,不占保底配额)。
"""
total_spins = FREE_SPINS_AWARDED
fs_index = 0
all_steps = []
fs_total = 0.0
sc_dist = FREESPIN_SCATTER.get(mode_code, FREESPIN_SCATTER['default'])
budget = fs_budget
# 炸弹大小按档位配:低倍局用小球(预算买得起多次"消除+爆"),高倍局用全量(2~1000 的大球)
target_mul = float(slot_target) if isinstance(slot_target, (int, float)) else 0.0
spin_bomb_max = None if target_mul >= FS_BIG_BOMB_TARGET else FS_SMALL_BOMB_MAX
bias_slot = random.randrange(FREE_SPINS_AWARDED) if slot_target else -1
# 保底配额:预算 ÷ 单次消除边际赔付再开方 = 这一局"买得起"几次消除
quota = None if budget is None else max(1, int((max(0.0, budget) / FS_GUARANTEE_COST) ** 0.5))
wins = 0 # 已消除的旋数(自然 + 保底)
dry = 0 # 已经连续空转了几旋(兜底用)
target = None if quota is None else _fs_next_target(0, 0, total_spins, quota)
while fs_index < total_spins:
fs_index += 1
is_target_slot = (fs_index - 1) == bias_slot
spin_bias = slot_target if is_target_slot else None
forced = False
if spin_bias is None and quota is not None:
# 到点了 / 空转太久被软兜底抽中 → 本旋必补一次保底消除
forced = target is not None and fs_index >= target
if not forced and dry >= FS_DRY_FORCE_HARD:
forced = True
elif not forced and dry in FS_DRY_FORCE_PROB:
forced = random.random() < FS_DRY_FORCE_PROB[dry]
p = 1.0 if forced else 0.0
else:
p = FS_SPIN_WIN_PROB
sc = _weighted_choice(sc_dist)
# 已到 30 旋上限:这一旋不再落 3+ 个 S —— 否则会写出"extraSpins=5 但没有旋可加"的
# retrigger_info(玩家白看一次 +5,前端还得自己夹 30)。上限之前的那一旋照常触发第 3 次 +5。
if total_spins >= MAX_FREE_SPINS:
sc = min(sc, SCATTER_RETRIGGER_TRIGGER - 1)
spin_steps, spin_score, acc, sc_count = _spin(
mode_code, spin_bias, acc, is_free_spin=True, target_scatter=sc,
win_prob=p, continue_prob=FS_CASCADE_CONTINUE_PROB,
cascade_max=FS_CASCADE_MAX,
bomb_prob=FS_SCREEN_BOMB_PROB, bomb_max=spin_bomb_max,
max_bombs=FS_MAX_BOMBS_PER_SPIN)
if sc_count >= SCATTER_RETRIGGER_TRIGGER and total_spins < MAX_FREE_SPINS:
total_spins += FREE_SPIN_RETRIGGER_COUNT
# 目标大奖旋的花费由目标额单独控制,不占用保底预算
if budget is not None and not is_target_slot:
budget -= spin_score
for s in spin_steps:
s['free_spin_id'] = fs_index
fs_total += spin_score
all_steps.extend(spin_steps)
# 进度:消了(不管自然还是保底)就重新抽下一次的位置 —— 整局消除次数仍由配额决定
if spin_bias is None and quota is not None:
if any(float(s['base_score']) > 0 for s in spin_steps):
wins += 1
dry = 0
target = _fs_next_target(fs_index, wins, total_spins, quota)
else:
dry += 1
return all_steps, fs_total
# ====================================================================
# 主入口
# ====================================================================
def generate_round(box, bias=None):
"""
生成一个完整回合。box: {'box_id','box_type',...} bias: None/'zero'/目标倍率(float)
返回 (round_data, steps, orn_id)
"""
mode_code = box['box_type']
buy_fs = mode_code == 'buy_fs'
# 目标高于"基础旋(无炸弹)能赔到的上限"时,只能生成自然触发 FS 的回合来装这个桶:
# normal/ante 的 100x/150x/200x 桶没有别的来源(基础旋不出炸弹,最多 ~60x)。
must_free_spins = isinstance(bias, (int, float)) and not buy_fs \
and float(bias) >= FS_ONLY_TARGET_MUL
is_fs = False
if buy_fs:
is_fs = True # 购买模式保证进 FS(互斥:无底注效果)
elif must_free_spins:
is_fs = True
elif mode_code in BASE_FS_TRIGGER_PROB and bias is None:
is_fs = random.random() < BASE_FS_TRIGGER_PROB[mode_code]
if is_fs:
# 触发盘:三模式都固定/摇出 SCATTER,且**不出炸弹**(炸弹只存在于免费旋转内)
trigger_sc = 4 if buy_fs else _weighted_choice(FS_TRIGGER_SCATTER)
base_steps, base_total, _, _ = _spin(
mode_code, None, 0, is_free_spin=False, target_scatter=trigger_sc,
allow_bomb=False)
# FS 预算:买 FS / 高倍桶(bias=桶目标)拆成「保底消除预算 + 一旋大奖」;
# normal/ante 自然摇出来的 FS(bias=None)没有价格可拆 → 只给同档保底预算、不植大奖旋,
# 整局赔付由自然消除 + 炸弹累计决定(实测约 80x、5 旋有消除,落在模式自己的桶里)。
fs_budget, slot_target = None, None
if isinstance(bias, (int, float)) and bias > 0:
budget = max(0.0, float(bias) - float(base_total))
if budget <= FS_FLOOR_BUDGET_CAP:
fs_budget = budget # 低倍局:全靠保底小消
else:
fs_budget = FS_FLOOR_BUDGET_CAP # 保底小消是平价结算(×1),预算可以给足
slot_target = budget - fs_budget # 其余给"大奖旋"(它按累计倍数结算)
elif not buy_fs:
fs_budget = FS_FLOOR_BUDGET_CAP
# 炸弹累计器仅免费旋转内生效:FS 会话从 0 起累,触发盘炸弹不带入
fs_steps, fs_total = _free_spin_session(
mode_code, slot_target=slot_target, acc=0, fs_budget=fs_budget)
steps = base_steps + fs_steps
else:
nearmiss = _weighted_choice(BASE_NEARMISS_SCATTER.get(mode_code, {0: 1.0}))
steps, _, _, _ = _spin(mode_code, bias, 0,
is_free_spin=False, target_scatter=nearmiss)
# 理论总分(落桶用,可到 1370+)
raw_total = round(sum(s['win_amount'] for s in steps), 2)
orn_id = _bucket(raw_total)
# 发放截断 + 口径重写:累计到 1000 即终止,动画=到账零偏差。
# win_amount = **本旋累计到本步的赢额**(该旋末步就是这一旋的总额);
# spin_win = **本局累计到本步的赢额**(最后一步 = 整局实付 = totalWin)
# 前端因此不需要自己相加:本步增量 = 与上一步相减,本旋总额 = 本旋末步的 win_amount。
raw_increments = [s['win_amount'] for s in steps]
cumul, spin_cumul, capped_steps, hit = 0.0, {}, [], False
for s, inc in zip(steps, raw_increments):
if cumul + inc >= MAX_WIN: # 封顶:本步补足到 1000 并终止
inc = round(MAX_WIN - cumul, 2)
s['win_capped'] = 1
s['is_final'] = 1
hit = True
key = s['free_spin_id']
spin_cumul[key] = round(spin_cumul.get(key, 0.0) + inc, 2)
cumul = round(cumul + inc, 2)
s['win_amount'] = spin_cumul[key] # 本旋累计(末步即该旋总额)
s['spin_win'] = cumul # 本局累计
capped_steps.append(s)
if hit:
break
if hit:
steps = capped_steps
paid_total = round(cumul, 2)
# 爆奖动画(小/中/大):**只有免费旋转里才判档** —— normal/ante 靠自然触发 FS、
# buy_fs 本身即 FS;基础旋(free_spin_id=0)一律 null,不播大奖动画。
# 单旋赢额 = 该旋末帧的 win_amount;旋与旋互不影响,同档一局可多次播放。
for s in steps:
if s['is_final'] and s['free_spin_id'] > 0:
s['bigwin_level'] = bigwin_level(mode_code, float(s['win_amount']))
fs_cnt = len({s['free_spin_id'] for s in steps if s['free_spin_id'] > 0})
rd = {
'box_id': box['box_id'], 'ornament_id': orn_id,
'actual_mul': round(paid_total, 4), # 实付倍率 ≤1000
'free_spin_count': fs_cnt,
'total_cascades': len(steps),
}
return rd, steps, orn_id
# ====================================================================
# 分布补偿:两阶段填桶
# ====================================================================
def _bias_for(orn_id):
if orn_id == 1:
return 'zero'
o = next(x for x in ORNAMENTS if x['id'] == orn_id)
return float(o['expect_mul'])
def _calibrate_bias(box, orn_id, samples=8):
"""给某个桶在线找一个"更容易撞上"的 bias(只改传给生成器的目标值,不动任何规则)。
为什么需要:`_bias_for` 用的是桶的名义 expect_mul,但 buy_fs 的整局赔付被 FS 会话抬高了 ——
实测 bias=10(桶 7 名义 10x)时实际赔付 min 14 / 中位 58,**永远撞不进 8~12**,命中率仅 3%,
stage2 只能白跑几万次(单箱 5~10 分钟)。这里拿几个候选值各试几局,挑命中率最高的那个。"""
target = _bias_for(orn_id)
best, best_hit = target, -1
for mult in (1.0, 0.5, 0.3, 0.15, 0.08):
b = target * mult
hit = 0
for _ in range(samples):
_, _, oid = generate_round(box, b)
hit += (oid == orn_id)
if hit > best_hit:
best, best_hit = b, hit
if hit == samples: # 已经全中,不用再试更小/更大的
break
return best
def fill_distribution(box, target_counts, max_rounds=None):
"""阶段1 自由生成积累常见桶;阶段2 按缺额 bias 补桶。返回 (rounds, 实际分布)
阶段2 的尝试上限按 total×60:保底消除改成"随机间隔"之后,低倍桶(10x/16x)的命中率大约减半
(同样的钱摊到整局、摊到累计倍数高的旋上更贵),尝试数不够会填不满池子(实测 ×30 时
10x 桶只填到 178/297,×60 满池)。
某个桶饿死时(试了 4 倍于它条数的次数还没填满)会重新校准它的 bias —— 只对慢桶生效,
命中率本来就高的桶走原来的名义值,不额外花钱。"""
gen = Counter()
rounds = []
total_target = sum(target_counts.values())
bias_use = {} # orn_id -> 校准后的 bias
tries = Counter() # orn_id -> 已尝试次数
def _accept(oid):
if oid in target_counts and gen[oid] < target_counts[oid]:
gen[oid] += 1
return True
return False
free_budget = min(total_target * 3, 12000)
for _ in range(free_budget):
if sum(gen.values()) >= total_target:
break
rd, steps, oid = generate_round(box, None)
if _accept(oid):
rounds.append((rd, steps))
max_att = (max_rounds or total_target * 60)
for _ in range(max_att):
deficit = [(oid, target_counts[oid] - gen[oid])
for oid in target_counts if gen[oid] < target_counts[oid]]
if not deficit:
break
# 按缺口加权随机选桶,避免“最难桶”饿死其他桶
need = random.choices([d[0] for d in deficit],
weights=[d[1] for d in deficit])[0]
tries[need] += 1
if need not in bias_use and tries[need] > target_counts[need] * 4:
bias_use[need] = _calibrate_bias(box, need)
rd, steps, oid = generate_round(box, bias_use.get(need, _bias_for(need)))
if _accept(oid):
rounds.append((rd, steps))
return rounds, dict(gen)