swagger 推广后台 优化对战百佳历史接口

This commit is contained in:
2026-09-24 14:55:58 +08:00
parent 4be9ce4e85
commit 43286f10ef
160 changed files with 1710 additions and 1719 deletions
+405
View File
@@ -0,0 +1,405 @@
"""麻将胡了 · 回合池生成器:把引擎跑出的回合写进 `game_mahjong_round` / `game_mahjong_step`。
两种模式:
--mode natural (默认)自然形态:不干预,直接采样落库。**RTP≈5.25,只能测链路,不能当验收**。
--mode quota 配额构造:先标定每桶池内实测均值 → 二分解出 16 桶配额(空转率锁 65%、目标 RTP 0.80)
→ 按配额定量填充。**这才是能上线/能做 RTP 验收的池子**。
用法(在 mahjong-py 目录下):
python generate_rounds.py --mode quota --per-box 1000 --dry-run
python generate_rounds.py --mode quota --per-box 1000 --write
python generate_rounds.py --mode natural --per-box 100 --dry-run
python generate_rounds.py --boxes 1,2 --per-box 100 --write --mode quota
写库内容(两种模式一样):
1. `game_mahjong_round`:每局一行(bucket_id / actual_mul / 旋数 / 波数 / has_fs / near_miss)
2. `game_mahjong_step`:每帧一行(逐波;含 grid_state / ways_detail / 下落补位 / 阶梯 / 触发信息)
3. `game_mahjong_box_bucket`:**配额就是 count**(3 个身份各一套,used=0)
4. `game_mahjong_box_template`:每箱每身份一条生效模板(= 配额快照,供抽干补桶)
5. `game_mahjong_box.total_rounds`:回写成本次生成的回合数
"""
from __future__ import annotations
import argparse
import json
import math
import random
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mahjong import config as C # noqa: E402
from mahjong.engine import GRID_COLS, GRID_ROWS, play_round # noqa: E402
DB = dict(host="127.0.0.1", port=23306, user="henan", password="iTSJSPPZM3LSGAPC", database="henan")
EMPTY_SHARE = 0.65 # 空转率(桶 1 配额占比,见设计文档 §3.9);未按箱分档时的统一值
# 按档调波动(用户 2026-09-22 定):**所有箱子 RTP 都锁 0.80**,但波动不同 ——
# 低档(1~20):空转低 → 剩余配额必须更"平" → 小奖更多、手感热;
# 高档(200~500):空转高 → 剩余配额更"陡" → 中大奖更多、博性更强。
# 机制:`solve_quotas` 会把"剩余部分"的加权均值凑到 `0.80/(1-空转率)`,所以
# **只改空转率就能同时改形状**,不用再单独调 power。
VOLATILITY_LADDER = (
(20, 0.62, "低档:空转 62%,小奖更多(热)"),
(100, 0.65, "中档:空转 65%(设计基准)"),
(10 ** 9, 0.68, "高档:空转 68%,中大奖更多(陡)"),
)
def empty_share_for(box_id: int) -> float:
bet = C.bet_of(box_id)
for upper, share, _ in VOLATILITY_LADDER:
if bet <= upper:
return share
return EMPTY_SHARE
TARGET_RTP = 0.80
CALIB_ROUNDS = 6000 # 每箱标定样本(用来量每个桶的**池内实测**均值)
MAX_TRIES = 400_000 # 单个桶的采样上限(防低概率桶把时间吃光)
# ---------------------------------------------------------------- 行构造
def grid_rows(flat) -> list:
"""扁平 20 格 → 4 行 × 5 列(接口用的 gridState 形状)。"""
return [list(flat[r * GRID_COLS:(r + 1) * GRID_COLS]) for r in range(GRID_ROWS)]
def build_steps(round_, round_id: int):
"""把一局摊成逐帧行。"""
rows, step_seq = [], 0
spin_win = 0.0
for spin in round_.spins:
spin_win_before = spin_win
acc_sym = Counter()
winners = []
rotations = spin.rotations
frames = rotations or (None,) # 空旋也要发一帧(落定盘面),否则客户端无从渲染
for i, rot in enumerate(frames, start=1):
step_seq += 1
if rot is None:
grid_flat = spin.landing
ways, removed, drops, fills, conv, gold = [], [], [], [], [], []
base_mul, rot_mul = 0.0, C.ladder_mul(1, spin.is_fs)
else:
grid_flat = rot.grid_before
gold = [{"pos": p, "symbol": rot.grid_before[p - 1]} for p in rot.golden_before]
conv = [{"pos": p, "fromSymbol": s} for p, s in rot.converted]
ways = [{"symbol": w.symbol, "reelCount": w.run, "counts": list(w.counts),
"loc": list(w.loc), "ways": w.ways, "payBase": w.pay_base,
"rotationMul": rot.rotation_mul,
"win": round(w.pay_base * rot.rotation_mul, 4)}
for w in rot.wins]
seen = set()
removed = [p for w in rot.wins for p in w.loc if not (p in seen or seen.add(p))]
drops = [{"from": a, "to": b, "symbol": s} for a, b, s in rot.drops]
fills = [{"pos": p, "symbol": s} for p, s in rot.fills]
base_mul, rot_mul = rot.base_mul, rot.rotation_mul
for w in rot.wins:
acc_sym[w.symbol] += len(w.loc)
winners.append({"symbol": w.symbol, "count": w.run,
"win": round(w.pay_base * rot.rotation_mul, 4)})
spin_win = spin_win_before + sum(r.win_mul for r in rotations[:i])
is_last = (i == len(frames))
scat = ({"scatterCount": spin.scatters, "freeSpins": spin.fs_awarded, "extraSpins": 0}
if spin.index == 0 and i == 1 and spin.fs_awarded else None)
retri = ({"scatterCount": spin.scatters, "extraSpins": C.fs_award(spin.scatters)}
if spin.index > 0 and i == 1 and spin.retrigger else None)
rows.append(dict(
id=round_id * 100 + step_seq,
round_id=round_id,
reel_spin_id=spin.index,
rotation_seq=i,
grid_state=json.dumps(grid_rows(grid_flat), ensure_ascii=False) if grid_flat else None,
golden_detail=json.dumps(gold, ensure_ascii=False) if gold else None,
wild_converted=json.dumps(conv, ensure_ascii=False) if conv else None,
ways_detail=json.dumps(ways, ensure_ascii=False) if ways else None,
accumulated_symbols=json.dumps(dict(acc_sym), ensure_ascii=False),
spin_winners=json.dumps(winners, ensure_ascii=False),
removed_positions=json.dumps(removed),
drop_data=json.dumps(drops, ensure_ascii=False) if drops else None,
fill_data=json.dumps(fills, ensure_ascii=False) if fills else None,
scatter_trigger=json.dumps(scat, ensure_ascii=False) if scat else None,
retrigger_info=json.dumps(retri, ensure_ascii=False) if retri else None,
base_score=round(base_mul, 4),
win_amount=round(sum(r.win_mul for r in rotations[:i]), 4),
rotation_mul=rot_mul,
spin_win=round(spin_win, 4),
win_capped=1 if (round_.capped and is_last and spin is round_.spins[-1]) else 0,
bigwin_level=None,
is_final=1 if is_last else 0,
))
spin_win = spin_win_before + sum(r.win_mul for r in rotations)
return rows
def make_round(box_id: int, seq: int, rng: random.Random):
r = play_round(rng, box_id=box_id)
round_id = box_id * 1_000_000 + seq
steps = build_steps(r, round_id=round_id)
row = dict(round_id=round_id, box_id=box_id, bucket_id=C.bucket_of(r.actual_mul),
actual_mul=round(r.actual_mul, 4), free_spin_count=r.free_spins,
total_rotations=sum(len(s.rotations) for s in r.spins),
has_fs=1 if r.has_fs else 0, near_miss=1 if r.near_miss else 0)
return row, steps
# ---------------------------------------------------------------- 配额构造
def calibrate(box_id: int, n: int, seed: int):
"""小样本标定:每个桶的**池内实测均值**(自然均值与名义 expect_mul 差很多,必须实测)。"""
rng = random.Random(seed)
tot, cnt = Counter(), Counter()
for _ in range(n):
r = play_round(rng, box_id=box_id)
b = C.bucket_of(r.actual_mul)
tot[b] += r.actual_mul
cnt[b] += 1
means = {}
for b in C.BUCKETS:
means[b.id] = (tot[b.id] / cnt[b.id]) if cnt[b.id] else b.expect_mul
return means, cnt
def _quota_from_power(means, power: float, empty_share: float):
"""w_b = 30×0.6^((b-1)×power)(b≥2),桶 1 锁 empty_share;返回 16 个配额(下标 1..16)。"""
ws = {b.id: 30.0 * (0.6 ** ((b.id - 1) * power)) for b in C.BUCKETS if b.id >= 2}
s = sum(ws.values())
q = {b.id: 0.0 for b in C.BUCKETS}
q[1] = empty_share
for b_id, w in ws.items():
q[b_id] = (1.0 - empty_share) * w / s
return q
def solve_quotas(means, target_rtp=TARGET_RTP, empty_share=EMPTY_SHARE):
"""二分 power,使 Σ q_b × mean_b = target_rtp。power 越大越偏向低倍桶(期望越低)。"""
def expected(power):
q = _quota_from_power(means, power, empty_share)
return sum(q[b.id] * means[b.id] for b in C.BUCKETS)
lo, hi = 0.05, 8.0
# expected(power) 随 power **递减**:power 越大越偏向低倍桶
for _ in range(80):
mid = (lo + hi) / 2
if expected(mid) > target_rtp:
lo = mid # 期望偏高 → 还要更大的 power
else:
hi = mid
q = _quota_from_power(means, (lo + hi) / 2, empty_share)
return q, expected((lo + hi) / 2)
def _fill(box_id: int, targets: dict, seed: int, max_tries=MAX_TRIES):
"""按桶目标条数填充:落进目标桶才收。返回 (pool, bucket_cnt, short)。"""
rng = random.Random(seed)
pool, tries, short = [], Counter(), []
for b in C.BUCKETS:
got = 0
while got < targets[b.id] and tries[b.id] < max_tries:
tries[b.id] += 1
row, steps = make_round(box_id, len(pool) + 1, rng)
if row["bucket_id"] == b.id:
pool.append((row, steps))
got += 1
if got < targets[b.id]:
short.append(f"桶{b.id} {got}/{targets[b.id]}")
for i, (row, steps) in enumerate(pool, start=1):
rid = box_id * 1_000_000 + i
row["round_id"] = rid
for j, st in enumerate(steps, start=1):
st["round_id"] = rid
st["id"] = rid * 100 + j
cnt = Counter(row["bucket_id"] for row, _ in pool)
return pool, cnt, short
def quota_targets(per_box: int, quotas: dict):
targets = {b.id: int(round(per_box * quotas[b.id])) for b in C.BUCKETS}
diff = per_box - sum(targets.values())
if diff:
targets[1] += diff
return targets
def _sample_bucket(box_id: int, bucket_id: int, rng: random.Random, max_tries=MAX_TRIES):
"""采一条落进指定桶的回合(低概率桶会多试几次)。"""
for _ in range(max_tries):
row, steps = make_round(box_id, 0, rng)
if row["bucket_id"] == bucket_id:
return row, steps
return None
def _tune(box_id: int, pool: list, target_rtp: float, per_box: int, seed: int, verbose=True):
"""整数可执行的定向微调:配额算出来是小数,四舍五入会把「0.4 条」的尾部桶抹成 0 条,
于是它本该贡献的期望就丢了(实测池内均值会系统性偏低 5~8pt)。
做法:拿空转局(桶 1,倍率 0)去换中高桶的回合,每换一条按**真实倍率**结账,
直到池内均值落进目标 ±0.005 以内。空转率只会动几条(<0.5pt)。
"""
rng = random.Random(seed + 31337)
cur = sum(r["actual_mul"] for r, _ in pool)
need = target_rtp * per_box - cur # 还差多少(单位:倍率总和)
empties = [i for i, (r, _) in enumerate(pool) if r["bucket_id"] == 1]
ordered = sorted((x for x in C.BUCKETS if x.id >= 2), key=lambda x: -x.expect_mul)
swaps = 0
while need > 0.6 and empties and ordered:
cand = next((b for b in ordered if b.expect_mul <= need), None)
if cand is None:
break
got = None
for _ in range(300): # 只收"不超过缺口"的回合,保证不会冲过头
s = _sample_bucket(box_id, cand.id, rng)
if s and s[0]["actual_mul"] <= need:
got = s
break
if got is None: # 这一档试不出来,退一档
ordered = [b for b in ordered if b.id != cand.id]
continue
row, steps = got
pool[empties.pop()] = (row, steps)
need -= row["actual_mul"]
swaps += 1
rng = random.Random(rng.random()) # 让后续采样换流
if verbose and swaps:
print(f" 微调:换入 {swaps} 条中高桶回合(空转率随之动 {swaps / per_box:.2%})")
cnt = Counter(r["bucket_id"] for r, _ in pool)
rtp = sum(r["actual_mul"] for r, _ in pool) / max(1, len(pool))
if verbose:
print(f" 微调后池内实测 RTP {rtp:.4f}(与目标差 {abs(rtp - target_rtp):.4f})")
return pool, cnt
def generate_quota(box_id: int, per_box: int, seed: int, empty_share: float, target_rtp: float,
verbose=True):
"""解配额 → 定条数 → 填充 → 整数微调 → 重排 id。
`empty_share`:桶 1 的配额占比。CLI 不传时按 `empty_share_for(box_id)` 取**按档波动**的值
(低档 62% / 中档 65% / 高档 68%),这样各箱形状不同、但 RTP 都锁在 `target_rtp`。
为什么填完还要微调:运行时按库存消耗(一个 cycle 每条回合正好播一次)⇒ `cycle RTP = 池内均值`,
而配额是连续解出来的小数,四舍五入会抹掉尾部桶(见 `_tune` 的注释)。
"""
means, _ = calibrate(box_id, CALIB_ROUNDS, seed)
quotas, exp_rtp = solve_quotas(means, target_rtp, empty_share)
targets = quota_targets(per_box, quotas)
if verbose:
print(f" 标定:配额理论期望 {exp_rtp:.4f}(目标 {target_rtp});桶1 配额 {quotas[1]:.2%};"
f"桶条数(1/2/3/12/16) = {targets[1]}/{targets[2]}/{targets[3]}/{targets[12]}/{targets[16]}")
pool, cnt, short = _fill(box_id, targets, seed + 999 + box_id * 7919)
pool, cnt = _tune(box_id, pool, target_rtp, per_box, seed + box_id, verbose)
for i, (row, steps) in enumerate(pool, start=1): # 重排 id(采样与微调都打乱了顺序)
rid = box_id * 1_000_000 + i
row["round_id"] = rid
for j, st in enumerate(steps, start=1):
st["round_id"] = rid
st["id"] = rid * 100 + j
return pool, quotas, means, exp_rtp, short
# ---------------------------------------------------------------- 写库
def write(conn, pool_by_box, per_box, audiences=C.AUDIENCES):
cur = conn.cursor()
total_r = total_s = 0
for box, (pool, quotas, bucket_cnt) in pool_by_box.items():
rounds = [row for row, _ in pool]
steps = [st for _, sts in pool for st in sts]
lo, hi = box * 1_000_000 + 1, box * 1_000_000 + per_box
cur.execute(f"DELETE FROM game_mahjong_step WHERE round_id BETWEEN {lo} AND {hi}")
cur.execute(f"DELETE FROM game_mahjong_round WHERE box_id={box}")
cur.executemany(
"INSERT INTO game_mahjong_round (round_id,box_id,bucket_id,actual_mul,free_spin_count,"
"total_rotations,has_fs,near_miss) VALUES (%(round_id)s,%(box_id)s,%(bucket_id)s,"
"%(actual_mul)s,%(free_spin_count)s,%(total_rotations)s,%(has_fs)s,%(near_miss)s)", rounds)
cols = ("id", "round_id", "reel_spin_id", "rotation_seq", "grid_state", "golden_detail",
"wild_converted", "ways_detail", "accumulated_symbols", "spin_winners",
"removed_positions", "drop_data", "fill_data", "scatter_trigger", "retrigger_info",
"base_score", "win_amount", "rotation_mul", "spin_win", "win_capped",
"bigwin_level", "is_final")
cur.executemany(
f"INSERT INTO game_mahjong_step ({','.join(cols)}) "
f"VALUES ({','.join('%(' + c + ')s' for c in cols)})", steps)
for aud in audiences:
cur.execute("DELETE FROM game_mahjong_box_bucket WHERE box_id=%s AND audience=%s", (box, aud))
cur.executemany(
"INSERT INTO game_mahjong_box_bucket (box_id,bucket_id,audience,count,used) "
"VALUES (%s,%s,%s,%s,0)",
[(box, b.id, aud, bucket_cnt.get(b.id, 0)) for b in C.BUCKETS])
items = json.dumps([{"bucketId": b.id, "count": bucket_cnt.get(b.id, 0)} for b in C.BUCKETS],
ensure_ascii=False)
cur.execute("DELETE FROM game_mahjong_box_template WHERE box_id=%s AND audience=%s", (box, aud))
cur.execute(
"INSERT INTO game_mahjong_box_template (box_id,audience,name,total_rounds,items_json,is_active) "
"VALUES (%s,%s,%s,%s,%s,1)",
(box, aud, f"配额池-{per_box}局", per_box, items))
cur.execute("UPDATE game_mahjong_box SET total_rounds=%s WHERE box_id=%s", (per_box, box))
conn.commit()
total_r += len(rounds)
total_s += len(steps)
rtp = sum(r["actual_mul"] for r in rounds) / max(1, len(rounds))
print(f" 箱 {box:2d}(赌注 {C.bet_of(box):>3}):回合 {len(rounds):>5} 帧 {len(steps):>6} "
f"**池内实测 RTP {rtp:.4f}** 空转 {bucket_cnt.get(1,0)/max(1,len(rounds)):.1%} "
f"FS 局 {sum(r['has_fs'] for r in rounds)}")
print(f"合计:回合 {total_r},帧 {total_s}")
return total_r, total_s
def build_natural(box_id, per_box, seed):
rng = random.Random(seed + box_id)
pool, cnt = [], Counter()
for i in range(1, per_box + 1):
row, steps = make_round(box_id, i, rng)
pool.append((row, steps))
cnt[row["bucket_id"]] += 1
return pool, cnt
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=("natural", "quota"), default="natural")
ap.add_argument("--per-box", type=int, default=1000)
ap.add_argument("--boxes", default="")
ap.add_argument("--seed", type=int, default=20260922)
ap.add_argument("--empty-share", type=float, default=None,
help="空转率(桶 1 配额占比);不传=按档取(低档 0.62 / 中档 0.65 / 高档 0.68)")
ap.add_argument("--target-rtp", type=float, default=TARGET_RTP)
ap.add_argument("--write", action="store_true")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--host", default=DB["host"]); ap.add_argument("--port", type=int, default=DB["port"])
ap.add_argument("--user", default=DB["user"]); ap.add_argument("--password", default=DB["password"])
ap.add_argument("--database", default=DB["database"])
args = ap.parse_args()
boxes = [int(x) for x in args.boxes.split(",") if x.strip()] or list(C.BOX_IDS)
print(f"==== {args.mode} 模式:{len(boxes)} 箱 × {args.per_box} 局"
f"{'' if args.write and not args.dry_run else '(干跑,不写库)'} ====")
pool_by_box = {}
for box in boxes:
if args.mode == "natural":
pool, cnt = build_natural(box, args.per_box, args.seed)
quotas = None
pool_by_box[box] = (pool, quotas, cnt)
else:
share = args.empty_share if args.empty_share is not None else empty_share_for(box)
pool, quotas, means, exp_rtp, short = generate_quota(
box, args.per_box, args.seed, share, args.target_rtp)
cnt = Counter(row["bucket_id"] for row, _ in pool)
if short:
print(f" 未填满:{';'.join(short)}")
pool_by_box[box] = (pool, quotas, cnt)
if not (args.write and not args.dry_run):
rows = [r for r, _ in pool_by_box[box][0]]
rtp = sum(r["actual_mul"] for r in rows) / max(1, len(rows))
print(f" 箱 {box:2d}(赌注 {C.bet_of(box):>3}):回合 {len(rows):>5} "
f"**池内实测 RTP {rtp:.4f}** 空转 {cnt.get(1,0)/max(1,len(rows)):.1%} "
f"FS 局 {sum(r['has_fs'] for r in rows)}")
if not (args.write and not args.dry_run):
return
import pymysql
conn = pymysql.connect(host=args.host, port=args.port, user=args.user,
password=args.password, database=args.database, charset="utf8mb4")
write(conn, pool_by_box, args.per_box)
conn.close()
if __name__ == "__main__":
main()