""" 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)