806 lines
36 KiB
Python
806 lines
36 KiB
Python
"""
|
||
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)
|