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
+805
View File
@@ -0,0 +1,805 @@
"""
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)