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

296 lines
14 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 回合生成运行入口 — 只负责回合库存的多样性
职责说明:
- 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()