first commit
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user