"""复刻后端 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 DEL ruoyi:olympus:cycle::,' '或在后台对这几个箱点一次「一键重算分布」(它会同时清账本)。') print('记得再跑 python _make_templates.py 刷新模板。') if __name__ == '__main__': main()