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

177 lines
8.5 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.
"""复刻 Java 抽回合链路,模拟每个箱子抽完一个 cycle 的实时 RTP。
复刻的 Java 逻辑(OlympusRoundPicker / OlympusGameService / OlympusDistributionService):
1. 固定池 fixedSum = Σ 配比 count(按身份),N = round(fixedSum/(1-收敛比例)),
fixedRounds = round(N×(1-收敛比例));固定阶段 drawn < fixedRounds
2. 固定阶段:在"remaining>0 且 expect_mul×bet ≤ payoutCap"的桶里按 remaining 加权抽;
桶内取 40 条候选,优先取 actual_mul×bet ≤ payoutCap 的(取不到就退回候选第一条);该桶 used+1
3. 收敛阶段:desiredNextMul = (N×目标倍率 − 已派彩) / (N − drawn),
取期望最接近 desired 的 3 个桶(随机其一),桶内取 actual_mul 最接近 min(desired, capMul) 的一条
(优先 ≤ capMul),不消耗 used
4. 水位:payoutCap = max(0, 水位 + 本局成本) × rtp;水位 += 成本 − 派彩(白号不参与水位 → 无上限)
5. drawn ≥ N → cycle 结束:resetCycle + refillFromConfig(有生效模板则按模板重写 count,否则 used 归零)
用法:
python simulate_cycle.py # 全部 30 箱 × common 身份,跑 3 个 cycle
python simulate_cycle.py --cycles 5 --audience white
python simulate_cycle.py --box 21
"""
import argparse
import random
from collections import defaultdict
import olympus_db as db
from olympus.config import ORNAMENTS, MODES, payout_expect
# 抽回合的 payoutCap 过滤用**名义** expect_mul(对齐 Java OlympusRoundPicker,偏保守);
# 统计里的"名义 RTP"用 payout_expect(对齐后台「目标RTP」列的封顶修正口径)。
_ORN_MUL = {o['id']: o['expect_mul'] for o in ORNAMENTS}
_ORN_PAY = {o['id']: payout_expect(o['id']) for o in ORNAMENTS}
CANDIDATE_LIMIT = 40
def load_boxes(box_id=None):
with db.get_conn() as conn:
cur = conn.cursor()
sql = 'SELECT box_id, box_type, bet, rtp, rtp_converge_ratio, total_ornaments FROM game_olympus_box'
if box_id:
sql += ' WHERE box_id=%s' % int(box_id)
cur.execute(sql + ' ORDER BY box_id')
boxes = [dict(zip(('box_id', 'box_type', 'bet', 'rtp', 'cr', 'total'), r)) for r in cur.fetchall()]
cur.execute('SELECT box_id, ornament_id, audience, count FROM game_olympus_box_ornament')
cfg = defaultdict(dict)
for b, o, a, c in cur.fetchall():
cfg[(int(b), a)][int(o)] = int(c)
cur.execute('SELECT box_id, ornament_id, actual_mul FROM game_olympus_round')
rounds = defaultdict(lambda: defaultdict(list))
for b, o, m in cur.fetchall():
rounds[int(b)][int(o)].append(float(m))
# 生效模板(抽完重填的依据)
cur.execute('''SELECT box_id, audience, items_json FROM game_olympus_box_template
WHERE is_active=1 ORDER BY id''')
tpl = {}
import json
for b, a, items in cur.fetchall():
tpl[(int(b), a)] = {int(i['ornamentId']): int(i['count'])
for i in json.loads(items) if int(i.get('count', 0)) > 0}
for b in boxes:
b['cm'] = float(MODES[b['box_type']].cost_multiplier)
return boxes, cfg, rounds, tpl
def _weighted(cands, weights):
total = sum(weights)
r = random.uniform(0, total)
acc = 0.0
for c, w in zip(cands, weights):
acc += w
if r <= acc:
return c
return cands[-1]
def simulate(box, cfg, rounds, tpl, audience, cycles=3, cr_override=None):
cm, rtp = box['cm'], float(box['rtp'])
cr = float(cr_override if cr_override is not None else (box['cr'] or 0.15))
fixed_sum = sum(cfg.values())
n_total = round(fixed_sum / (1.0 - cr))
fixed_rounds = round(n_total * (1.0 - cr))
target_mul = rtp * cm
capped = (audience != 'white') # 白号 payoutCap=null
stats = []
counts = dict(cfg)
cumulative = {'drawn': 0, 'mul': 0.0}
balance = 0.0 # 箱子水位:跨 cycle 持久(= Σ投注 − Σ返奖)
for cyc in range(1, cycles + 1):
used = defaultdict(int)
drawn, mul_sum = 0, 0.0
rtp_fixed = None
while drawn < n_total:
cap_mul = max(0.0, balance + cm) * rtp if capped else float('inf')
remaining = {o: max(0, c - used[o]) for o, c in counts.items()}
if drawn < fixed_rounds and sum(remaining.values()) > 0:
elig = [o for o, r in remaining.items()
if r > 0 and _ORN_MUL[o] <= cap_mul] or \
[o for o, r in remaining.items() if r > 0]
orn = _weighted(elig, [remaining[o] for o in elig])
pool = rounds[box['box_id']].get(orn) or [0.0]
cands = random.sample(pool, min(CANDIDATE_LIMIT, len(pool)))
mul = next((m for m in cands if m <= cap_mul), cands[0])
used[orn] += 1
else:
desired = max(0.0, (n_total * target_mul - mul_sum) / max(1, n_total - drawn))
allowed = [o for o, c in counts.items() if _ORN_MUL[o] <= cap_mul] or list(counts)
allowed.sort(key=lambda o: abs(_ORN_MUL[o] - desired))
orn = random.choice(allowed[:3])
pool = rounds[box['box_id']].get(orn) or [0.0]
eff = min(desired, cap_mul)
mul = sorted(pool, key=lambda m: (0 if m <= cap_mul else 1, abs(m - eff)))[0]
drawn += 1
mul_sum += mul
balance += cm - mul
cumulative['drawn'] += 1
cumulative['mul'] += mul
if drawn == fixed_rounds:
rtp_fixed = mul_sum / drawn / cm
# cycle 结束 → 按模板重填(无模板则 used 归零、count 不变)
tpl_counts = tpl.get((box['box_id'], audience))
refilled = '模板' if tpl_counts else 'used归零'
if tpl_counts:
counts = {o: c for o, c in tpl_counts.items() if c > 0}
stats.append({
'cycle': cyc,
'rtp_fixed': rtp_fixed,
'rtp_end': mul_sum / drawn / cm,
'balance_end': balance,
'refill': refilled,
})
return {
'box_id': box['box_id'], 'box_type': box['box_type'], 'audience': audience,
'rtp': rtp, 'cr': cr, 'n_total': n_total, 'fixed_rounds': fixed_rounds,
'fixed_sum': fixed_sum, 'nominal': sum(c * _ORN_PAY.get(o, 0) for o, c in cfg.items()) / fixed_sum / cm,
'cycles': stats,
'cum_rtp': cumulative['mul'] / cumulative['drawn'] / cm,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--box', type=int)
ap.add_argument('--audience', default='common')
ap.add_argument('--cycles', type=int, default=3)
ap.add_argument('--cr', type=float, default=None, help='覆盖收敛比例(测敏感度用,不写库)')
ap.add_argument('--seed', type=int, default=2026)
args = ap.parse_args()
random.seed(args.seed)
boxes, cfg, rounds, tpl = load_boxes(args.box)
print('身份=%s cycle 数=%d seed=%d 收敛比例=%s' % (
args.audience, args.cycles, args.seed,
('箱配置' if args.cr is None else args.cr)))
print('箱 模式 配比sum N 固定池 名义RTP 各cycle(固定池末→整轮末) 累计RTP')
rows = []
for b in boxes:
c = cfg.get((b['box_id'], args.audience), {})
if not c:
continue
r = simulate(b, c, rounds, tpl, args.audience, args.cycles, args.cr)
rows.append(r)
per = ' '.join('%.1f→%.1f' % (s['rtp_fixed'] * 100, s['rtp_end'] * 100) for s in r['cycles'])
flag = '✓' if abs(r['cum_rtp'] - r['rtp']) < 0.03 else '✗'
print('%2d %-7s %5d %4d %5d %6.1f%% %-34s %6.1f%% %s' % (
r['box_id'], r['box_type'], r['fixed_sum'], r['n_total'], r['fixed_rounds'],
r['nominal'] * 100, per, r['cum_rtp'] * 100, flag))
bad = [r for r in rows if abs(r['cum_rtp'] - r['rtp']) >= 0.03]
print('\n达标(±3个百分点): %d/%d' % (len(rows) - len(bad), len(rows)))
if bad:
print('未达标:', [(r['box_id'], round(r['cum_rtp'] * 100, 1), round(r['rtp'] * 100, 1)) for r in bad])
if rows:
r = rows[0]
print('\n样例 box%d 逐 cycle 明细(%s):' % (r['box_id'], r['audience']))
for s in r['cycles']:
print(' cycle%d: 固定池结束 %.1f%% 整轮结束 %.1f%% 抽完水位=%.0f 倍底注 重填=%s' % (
s['cycle'], s['rtp_fixed'] * 100, s['rtp_end'] * 100, s['balance_end'], s['refill']))
if __name__ == '__main__':
main()