177 lines
8.5 KiB
Python
177 lines
8.5 KiB
Python
"""复刻 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()
|