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