""" Olympus 回合生成运行入口 — 只负责回合库存的多样性 职责说明: - Python 端: 为模式允许的每个饰品桶预生成足够数量的回合(库存),保证多样性 - 后台端: 通过配置各桶的数量分布(game_olympus_box_ornament.count)控制箱子 抽完的实时 RTP 是否达到目标,本脚本不做 RTP 收敛 用法: python generate_rounds.py # 干跑: 每模式每桶生成20个验证多样性 python generate_rounds.py --all --per-box 200 --write # 全部模式×档位建箱写库(box_id从1,每箱总回合200) python generate_rounds.py --all --alloc config --write # 池子按后台桶配比分配(推荐):每桶 = 配比 ×1.5 python generate_rounds.py --box-type normal --bet 1 --per-box 200 --write # 单箱写库 """ import argparse import time from collections import Counter from olympus.config import MODES, MODE_ORNAMENT_IDS, ORNAMENTS, RTP, AUDIENCES from olympus_simulator import fill_distribution _ORN = {o['id']: o for o in ORNAMENTS} ALLOC_K = 1.5 # --alloc config 时,每桶池子 = 配比 × 该系数(留余量给"不重复发") # 每桶池子下限。只对"配比 × ALLOC_K < 下限"的桶起作用,也就是**配比 ≤13 的桶(6.5x 以上)**: # 这些桶条数少、盘面又显眼(有赢奖/大奖动画),是玩家真正会"看着眼熟"的那几个,所以 20 → 60 # 把它们的复现间隔拉长 3 倍(实测 10x 桶 5,455 抽 → 16,364 抽、100x 桶 6 万 → 18 万抽), # 而 0x~4x 这些"量大但没人记住"的桶走 ALLOC_K、完全不受影响 —— 池子总量只 +8%。 ALLOC_FLOOR = 60 # 每桶池子下限 # 并行写库用:round_id 按"该箱池子的实际条数"连续切段(池子条数由配比唯一决定,能事前算准,见 planned_rounds); # 步数取决于随机级联、事前算不准,只能给每箱预留一段固定 id 空间。 # 注意要按**最重的箱**留:buy_fs 每箱 ~84,000 步(每局都是 15~30 旋的 FS),normal/ante 只有 ~1.1w 步(大多 1~2 步的基础旋)。 BOX_STEP_BAND = 200_000 def read_config_counts(box_id): """读该箱现有桶配比(各身份取最大值)。没有配置时返回 {}。""" import olympus_db as db with db.get_conn() as conn: cur = conn.cursor() cur.execute('''SELECT ornament_id, MAX(count) FROM game_olympus_box_ornament WHERE box_id=%s GROUP BY ornament_id''', (box_id,)) return {int(a): int(b) for a, b in cur.fetchall()} def build_counts_from_config(mode_code, box_id, k=ALLOC_K): """按后台桶配比分配池子:每桶回合数 = max(下限, 配比 × k)。 池子 ≥ 配比 → 一个 cycle 内每个回合最多发一次(配合 Java 侧"不重复发")。""" cfg = read_config_counts(box_id) counts = {} for i in MODE_ORNAMENT_IDS[mode_code]: c = cfg.get(i, 0) if c > 0: counts[i] = max(ALLOC_FLOOR, int(round(c * k))) return counts def build_counts(mode_code, per_bucket): """每个可用桶生成相同数量的回合库存,后台再按需配比""" return {i: per_bucket for i in MODE_ORNAMENT_IDS[mode_code]} def build_counts_total(mode_code, total): """每箱固定总回合数,跨该模式可用桶均分(余数分给靠前的桶),保证每桶≥1""" ids = list(MODE_ORNAMENT_IDS[mode_code]) n = len(ids) base, rem = divmod(total, n) return {i: base + (1 if idx < rem else 0) for idx, i in enumerate(ids)} def seq_box_id(mode_code, bet): """全局顺序 box_id(从 1 开始):按模式顺序×档位顺序编号""" idx = 0 for mc, mode in MODES.items(): for b in mode.base_bets: idx += 1 if mc == mode_code and b == bet: return idx return idx def report(mode_code, counts, rounds): total_target = sum(counts.values()) dist = Counter(rd['ornament_id'] for rd, _ in rounds) print(f'[{mode_code}] rounds={len(rounds)}/{total_target}') for i in sorted(counts): hits = [rd for rd, _ in rounds if rd['ornament_id'] == i] if hits: muls = [rd['actual_mul'] for rd in hits] casc = [rd['total_cascades'] for rd in hits] fs = sum(1 for rd in hits if rd['free_spin_count'] > 0) extra = (f'倍率{min(muls):.2f}~{max(muls):.2f} ' f'步数{min(casc)}~{max(casc)} FS回合{fs}') else: extra = '' print(f' 桶{i:>2} {_ORN[i]["name"]:>6}: {dist.get(i, 0):>5}/{counts[i]:>5} {extra}') capped = sum(1 for _, ss in rounds if any(s['win_capped'] for s in ss)) overmax = sum(1 for rd, _ in rounds if rd['actual_mul'] > 1000) print(f' 封顶截断={capped} 超1000={overmax}') def dry_run(per_bucket=20): for mode_code in MODES: counts = build_counts(mode_code, per_bucket) t0 = time.time() rounds, _ = fill_distribution({'box_id': 0, 'box_type': mode_code}, counts) report(mode_code, counts, rounds) print(f' 耗时 {time.time() - t0:.1f}s\n') def derive_cycle_rounds(box_id, fixed_sum, default_cr=0.15): """一轮抽多少抽 N = round(固定池 /(1-收敛比例)),对齐 Java deriveTotalRounds。 box.total_ornaments 的语义是"一轮总抽数",不是池子大小(抽完补池时 Java 也会回写这个值)。""" import olympus_db as db cr = default_cr with db.get_conn() as conn: cur = conn.cursor() cur.execute('SELECT rtp_converge_ratio FROM game_olympus_box WHERE box_id=%s', (box_id,)) row = cur.fetchone() if row and row[0] is not None: cr = float(row[0]) cr = min(max(cr, 0.0), 0.9) return int(round(fixed_sum / (1.0 - cr))) def write_box(mode_code, bet, box_id, per_box, alloc='flat', round_base=None, step_base=None, planned=None): import olympus_db as db mode = MODES[mode_code] if bet not in mode.base_bets: raise SystemExit(f'模式 {mode_code} 不支持档位 {bet},可选: {mode.base_bets}') price = float(mode.price_for(bet)) counts = build_counts_from_config(mode_code, box_id) if alloc == 'config' else {} if not counts: counts = build_counts_total(mode_code, per_box) total_rounds = sum(counts.values()) config_counts = read_config_counts(box_id) or dict(counts) box = {'box_id': box_id, 'box_type': mode_code, 'bet': bet, 'name': f'{mode.name}-bet{bet}', 'price': price, 'total_ornaments': derive_cycle_rounds(box_id, sum(config_counts.values())), 'rtp': RTP} print(f'生成 box={box_id} {box["name"]} price={price} ' f'分配方式={alloc} 池子{total_rounds}条 / 一轮{box["total_ornaments"]}抽 ...') t0 = time.time() rounds, gen = fill_distribution(box, counts) report(mode_code, counts, rounds) if planned is not None and len(rounds) != planned: raise SystemExit(f'box={box_id}: 实际生成 {len(rounds)} 条 ≠ 预留 id 段 {planned} 条,' f'id 段会错位 —— 重跑一次(配比刚改过就先 --clear)') round_base = db.get_max_round_id() if round_base is None else round_base step_base = db.get_max_step_id() if step_base is None else step_base rounds_data, steps_data = [], [] rid, sid = round_base, step_base for rd, steps in rounds: rid += 1 rd['round_id'] = rid rounds_data.append(rd) for s in steps: sid += 1 s['id'] = sid s['round_id'] = rid steps_data.append(s) if len(steps_data) > BOX_STEP_BAND: raise SystemExit(f'box={box_id}: {len(steps_data)} 步超出每箱预留段 {BOX_STEP_BAND},' f'把 BOX_STEP_BAND 调大再跑') db.insert_box(box) # box_ornament:已有后台配比则原样写回(池子形状 ≠ 配比,别把配比覆盖成池子形状); # 首次建箱才用实际生成量做种子 keep = read_config_counts(box_id) or dict(gen) db.write_box_distributions(box_id, {aud: dict(keep) for aud in AUDIENCES}) db.write_rounds_and_steps(box_id, rounds_data, steps_data) print(f'写库完成: {len(rounds_data)} rounds / {len(steps_data)} steps, ' f'耗时 {time.time() - t0:.1f}s\n') def all_boxes(): """(box_id, mode_code, bet) 顺序编号,与旧 --all 完全一致(1..30)""" boxes = [] box_id = 0 for mode_code, mode in MODES.items(): for bet in mode.base_bets: box_id += 1 boxes.append((box_id, mode_code, bet)) return boxes def _box_task(task): """worker 入口:跑一箱(id 段由任务自带,互不重叠)""" box_id, mode_code, bet, per_box, alloc, r_base, s_base, planned = task write_box(mode_code, bet, box_id, per_box, alloc, r_base, s_base, planned) return box_id def planned_rounds(mode_code, box_id, per_box, alloc): """这箱这一轮会生成多少条回合:池子条数 = Σ max(ALLOC_FLOOR, 配比×ALLOC_K),由配比唯一决定。 用它给每箱切一段**连续**的 round_id(并行写库不会撞主键,id 也不会有空洞)。""" counts = build_counts_from_config(mode_code, box_id) if alloc == 'config' else {} if not counts: counts = build_counts_total(mode_code, per_box) return sum(counts.values()) def clear_pool_tables(): """TRUNCATE 回合池两张表:清空全部 30 箱的旧回合,id 从 1 重排。 注意 game_olympus_record(战绩)里的 round_id 会因此指向新池子里的同号回合 —— 战绩金额本身不受影响。""" import olympus_db as db with db.get_conn() as conn: cur = conn.cursor() cur.execute('TRUNCATE TABLE game_olympus_step') cur.execute('TRUNCATE TABLE game_olympus_round') print('已 TRUNCATE game_olympus_step / game_olympus_round(旧池子清空,round_id 从 1 重排)') def _existing_rounds(box_id): import olympus_db as db with db.get_conn() as conn: cur = conn.cursor() cur.execute('SELECT COUNT(*) FROM game_olympus_round WHERE box_id=%s', (box_id,)) return int(cur.fetchone()[0]) def run_all(alloc, per_box, workers, clear=False, resume=False): """--all:顺序(workers=1)或按箱并行写库。 生成是纯 CPU 活(多线程没用,要进程);各箱 round_id 段连续不重叠,所以并行也不会撞主键。 --resume:跳过已经写好的箱(条数 == 计划条数),只补剩下的 —— id 段仍按整轮 30 箱排,所以布局和整轮跑一致。 进程数:本机只有 4 个物理核(8 逻辑),而且 VS Code/IDEA/Docker 常驻,建议 1~2。""" import olympus_db as db if clear: clear_pool_tables() cursor, step_base0 = 0, 0 else: cursor, step_base0 = db.get_max_round_id(), db.get_max_step_id() tasks = [] for bid, mc, bet in all_boxes(): n = planned_rounds(mc, bid, per_box, alloc) tasks.append((bid, mc, bet, per_box, alloc, cursor, step_base0 + bid * BOX_STEP_BAND, n)) cursor += n if resume: left = [t for t in tasks if _existing_rounds(t[0]) != t[7]] print(f'--resume:已写好 {len(tasks) - len(left)} 箱,待生成 {len(left)} 箱') tasks = left if not tasks: print('没有需要生成的箱') return print(f'共 {len(tasks)} 箱,round_id 段 {tasks[0][5] + 1} ~ {cursor}(连续、不重叠)') if workers <= 1: t0 = time.time() for done, t in enumerate(tasks, 1): _box_task(t) print(f'--- [{done}/{len(tasks)}] box={t[0]} 完成 累计 {time.time() - t0:.1f}s') return import multiprocessing as mp t0 = time.time() print(f'并行 {workers} 进程 ...') with mp.Pool(processes=workers) as pool: for done, bid in enumerate(pool.imap_unordered(_box_task, tasks), 1): print(f'--- [{done}/{len(tasks)}] box={bid} 完成 累计 {time.time() - t0:.1f}s') def main(): ap = argparse.ArgumentParser() ap.add_argument('--box-type', choices=list(MODES), help='模式') ap.add_argument('--bet', type=int, help='基础下注档位') ap.add_argument('--per-bucket', type=int, default=20, help='干跑每桶回合数(仅 dry-run 用)') ap.add_argument('--per-box', type=int, default=200, help='每箱总回合数(--alloc flat 用,跨桶均分)') ap.add_argument('--alloc', choices=['flat', 'config'], default='flat', help='flat=跨桶均分(旧行为);config=按后台桶配比×1.5 分配(池子≥配比)') ap.add_argument('--write', action='store_true', help='写入数据库') ap.add_argument('--all', action='store_true', help='全部模式×档位,box_id 从 1 顺序编号') ap.add_argument('--workers', type=int, default=1, help='并行进程数(生成是纯 CPU 活,多线程没用;建议 ≤3,DB 连接数限制)') ap.add_argument('--clear', action='store_true', help='先 TRUNCATE 回合池两张表(全部箱),round_id 从 1 重排;只配合 --all 用') ap.add_argument('--resume', action='store_true', help='只补还没写好的箱(跳过已写完的),id 段仍按整轮 30 箱排') args = ap.parse_args() if args.clear and not args.all: raise SystemExit('--clear 会清空全部箱的回合池,只配合 --all 用') if not args.write: dry_run(min(args.per_bucket, 20)) return if args.all: run_all(args.alloc, args.per_box, args.workers, args.clear, args.resume) elif args.box_type and args.bet: write_box(args.box_type, args.bet, seq_box_id(args.box_type, args.bet), args.per_box, args.alloc) else: raise SystemExit('--write 需要 --box-type + --bet,或 --all') if __name__ == '__main__': main()