first commit

This commit is contained in:
2026-09-21 19:21:35 +08:00
commit 6c944c4ffd
2733 changed files with 296956 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
"""复刻后端 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()