184 lines
8.3 KiB
Python
184 lines
8.3 KiB
Python
"""复刻后端 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()
|