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

184 lines
8.3 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.
"""复刻后端 OlympusDistributionService.calcRounds + reset(),重算某模式各箱的桶配比。
为什么需要:`OlympusModeType.getOrnamentRange()` 改了(buy_fs 从 5~17 改成 7~17)之后,
后台要「一键重算」才会按新范围重配 count。这个脚本把同一套算法跑在 py 侧,
用来在没有后台操作的场合先把配比算出来(结果与后端一键重算一致)。
算法移植自 Java(calcRounds):基础权重 30×0.6^k → 二分 power 命中目标均值 →
按权重分配固定池 → 单调约束 count[i] ≥ count[i+1] → 只在最大的 3 个桶间微调逼近目标。
用法:
python _recalc_boxes.py --mode buy_fs --dry # 只看结果
python _recalc_boxes.py --mode buy_fs # 写库(count 覆盖、used 归零)
"""
import argparse
import olympus_db as db
from olympus.config import MODES, MODE_ORNAMENT_IDS, ORNAMENTS, payout_expect
# 回退用的"桶期望":桶 17 展示 1000x 但封顶发放,按实测值算(见 config.PAYOUT_EXPECT_OVERRIDE)
_ORN_MUL = {o['id']: payout_expect(o['id']) for o in ORNAMENTS}
# 该桶池子里一条回合都没有时才回退到 _ORN_MUL(名义/封顶修正值)。
# 只要池子里有回合,就用它的实际均值:箱子发的是**自己这批固定回合**,它的均值就是这个箱子的真实期望。
MIN_POOL_SAMPLE = 1
def load_pool_expect(box_id, mode_code):
"""该箱池子里每个桶的**实际**均值(AVG(actual_mul))——配比按"这个箱子真正会赔出去多少"算,
而不是按桶区间中点:桶内均值偏差(实测 -18%~+11%)和每箱池子的抽样差异会直接变成 RTP 偏差。"""
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT ornament_id, COUNT(*), AVG(actual_mul) FROM game_olympus_round
WHERE box_id=%s GROUP BY ornament_id''', (box_id,))
rows = cur.fetchall()
exp = {i: _ORN_MUL[i] for i in MODE_ORNAMENT_IDS[mode_code]}
for oid, n, avg in rows:
if int(oid) in exp and int(n) >= MIN_POOL_SAMPLE and avg and float(avg) > 0:
exp[int(oid)] = float(avg)
return exp
def derive_total_rounds(fixed_rounds, cr):
cr = min(max(cr, 0.0), 0.9)
return int(fixed_rounds / (1.0 - cr) + 0.5)
def derive_fixed_rounds(total_rounds, cr):
cr = min(max(cr, 0.0), 0.9)
return int(total_rounds * (1.0 - cr) + 0.5)
def calc_rounds(mode_code, total_rounds, target_rtp, expect=None):
"""返回 {ornament_id: count},合计 = total_rounds(对齐 Java calcRounds)
expect:桶 → 该桶"实际赔出去多少"的期望;不传则用名义值(含桶 17 封顶修正)。"""
mul = expect or _ORN_MUL
cm = float(MODES[mode_code].cost_multiplier)
target_avg = target_rtp * cm
ids = sorted(MODE_ORNAMENT_IDS[mode_code])
n = len(ids)
if total_rounds <= n:
return {i: 1 for i in ids}
base_w = [30.0 * (0.6 ** i) for i in range(n)]
remaining = total_rounds - n
guaranteed = sum(mul[i] for i in ids)
rem_target_avg = (target_avg * total_rounds - guaranteed) / remaining
power, plo, phi = 1.0, 0.2, 10.0
for _ in range(30):
ws = [w ** power for w in base_w]
sw = sum(ws)
avg = sum(w * mul[i] for w, i in zip(ws, ids)) / sw if sw > 0 else 0.0
if abs(avg - rem_target_avg) < max(rem_target_avg * 0.01, 0.001):
break
if avg < rem_target_avg:
phi = power
else:
plo = power
power = (plo + phi) / 2
ws = [w ** power for w in base_w]
tw = sum(ws)
result = {i: 1 for i in ids}
rem = remaining
for i, w in zip(ids, ws):
extra = int(w / tw * remaining + 0.5) if tw > 0 else 0
put = min(extra, rem)
if put > 0:
result[i] += put
rem -= put
if rem > 0:
result[ids[0]] += rem
for k in range(n - 2, -1, -1): # 单调 count[i] >= count[i+1]
if result[ids[k]] < result[ids[k + 1]]:
result[ids[k]] = result[ids[k + 1]]
diff = total_rounds - sum(result.values())
if diff:
result[ids[0]] += diff
for _ in range(400): # 微调:任取两个桶搬 1 个(保持单调),只要更接近目标
cur_mul = sum(result[i] * mul[i] for i in ids)
delta = target_avg * total_rounds - cur_mul
if abs(delta) < 0.01:
break
best, best_diff = None, abs(delta)
for lo in ids:
if result[lo] <= 1:
continue
for hi in ids:
if lo == hi:
continue
change = mul[hi] - mul[lo]
if abs(change) < 0.001:
continue
nd = abs(delta - change)
if nd < best_diff:
ok = True
ca, idx_lo = result[lo] - 1, ids.index(lo)
if idx_lo < n - 1 and ca < result[ids[idx_lo + 1]]:
ok = False
ca2, idx_hi = result[hi] + 1, ids.index(hi)
if idx_hi > 0 and ca2 > result[ids[idx_hi - 1]]:
ok = False
if ids.index(lo) < ids.index(hi) and ca < ca2:
ok = False
if ok:
best, best_diff = (lo, hi), nd
if best is None:
break
result[best[0]] -= 1
result[best[1]] += 1
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--mode', default='buy_fs')
ap.add_argument('--dry', action='store_true')
args = ap.parse_args()
from olympus.config import AUDIENCES
with db.get_conn() as conn:
cur = conn.cursor()
cur.execute('''SELECT box_id, rtp, rtp_converge_ratio, total_ornaments
FROM game_olympus_box WHERE box_type=%s ORDER BY box_id''', (args.mode,))
boxes = cur.fetchall()
print('%s 共 %d 箱,桶范围 %s;池内实际均值按**每个箱子自己的池子**算(样本<%d的桶回退名义值)'
% (args.mode, len(boxes), sorted(MODE_ORNAMENT_IDS[args.mode]), MIN_POOL_SAMPLE))
for box_id, rtp, cr, total in boxes:
rtp = float(rtp or 0.8)
cr = float(cr if cr is not None else 0.15)
fixed = derive_fixed_rounds(int(total or 1000), cr)
pool_expect = load_pool_expect(box_id, args.mode)
counts = calc_rounds(args.mode, fixed, rtp, pool_expect)
s = sum(counts.values())
cm = float(MODES[args.mode].cost_multiplier)
pay_pool = sum(counts[i] * pool_expect[i] for i in counts)
pay_nominal = sum(counts[i] * _ORN_MUL[i] for i in counts)
print('box%-3d 固定池%d 配比 %s' % (
box_id, s, {k: v for k, v in sorted(counts.items()) if v > 0}))
print(' 目标RTP(本箱池内实际口径) %.2f%% 名义口径 %.2f%%'
% (100.0 * pay_pool / s / cm, 100.0 * pay_nominal / s / cm))
if args.dry:
continue
n_cycle = derive_total_rounds(s, cr)
with db.get_conn() as conn:
cur = conn.cursor()
for aud in AUDIENCES:
cur.execute('DELETE FROM game_olympus_box_ornament WHERE box_id=%s AND audience=%s',
(box_id, aud))
cur.executemany('''INSERT INTO game_olympus_box_ornament
(box_id, ornament_id, audience, count, used) VALUES (%s,%s,%s,%s,0)''',
[(box_id, i, aud, c) for i, c in sorted(counts.items()) if c > 0])
cur.execute('UPDATE game_olympus_box SET total_ornaments=%s WHERE box_id=%s',
(n_cycle, box_id))
if not args.dry:
print('已写库(count 覆盖、used 归零、total_ornaments=一轮抽数)。')
print('提醒:本脚本只动库里的 used,**不会清 Redis 的本轮账本**'
'(ruoyi:olympus:cycle:{boxId}:{audience});不清的话后台「实时RTP」会把'
'重置前的旧抽数和新抽数混在一起显示。'
'清账本:docker exec redis_cache redis-cli -a <pwd> DEL ruoyi:olympus:cycle:<boxId>:<audience>,'
'或在后台对这几个箱点一次「一键重算分布」(它会同时清账本)。')
print('记得再跑 python _make_templates.py 刷新模板。')
if __name__ == '__main__':
main()