330 lines
16 KiB
Python
330 lines
16 KiB
Python
"""重新生成回合数据前的检查 / 快照 / 清空 / 还原(一次性脚本,用完可删)。
|
||
|
||
python _regen.py check # 查看箱配置、桶配比、回合池构成
|
||
python _regen.py snapshot # 备份 box / box_ornament / box_template 到 JSON
|
||
python _regen.py clear # 清空 game_olympus_step + game_olympus_round
|
||
python _regen.py restore # 从备份还原 box_ornament 的 count(used 归 0)
|
||
python _regen.py verify # 校验:buy_fs 触发盘 4S0M、FS 内仍有炸弹、池子每桶 77
|
||
python _regen.py fsstats [box_id] # 免费旋转观感统计:每局几次消除、几次炸弹并入、累计倍数
|
||
"""
|
||
import json
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import olympus_db as db
|
||
|
||
SNAP = Path(__file__).resolve().parent / '_backup_box_config.json'
|
||
|
||
|
||
def check():
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute('''SELECT box_id, box_type, bet, total_ornaments, rtp
|
||
FROM game_olympus_box ORDER BY box_id''')
|
||
boxes = cur.fetchall()
|
||
print('boxes=%d' % len(boxes))
|
||
for b in boxes[:3] + boxes[-2:]:
|
||
print(' ', b)
|
||
cur.execute('''SELECT audience, COUNT(DISTINCT box_id), SUM(count), SUM(used)
|
||
FROM game_olympus_box_ornament GROUP BY audience''')
|
||
print('box_ornament:', cur.fetchall())
|
||
cur.execute('''SELECT box_id, audience, COUNT(*) n, SUM(count) total, SUM(used) used
|
||
FROM game_olympus_box_ornament WHERE box_id IN (1,21,30)
|
||
GROUP BY box_id, audience ORDER BY box_id''')
|
||
for r in cur.fetchall():
|
||
print(' cfg', r)
|
||
for box_id in (1, 21, 30):
|
||
cur.execute('''SELECT ornament_id, COUNT(*) FROM game_olympus_round
|
||
WHERE box_id=%s GROUP BY ornament_id ORDER BY ornament_id''',
|
||
(box_id,))
|
||
print(' pool box%d:' % box_id, cur.fetchall())
|
||
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
|
||
WHERE box_id=21 AND audience='common' ORDER BY ornament_id''')
|
||
print(' cfg box21 common:', cur.fetchall())
|
||
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
|
||
WHERE box_id=30 AND audience='common' ORDER BY ornament_id''')
|
||
print(' cfg box30 common:', cur.fetchall())
|
||
|
||
|
||
def snapshot():
|
||
data = {'ts': datetime.now().isoformat(timespec='seconds')}
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor(db.pymysql.cursors.DictCursor)
|
||
for t in ('game_olympus_box', 'game_olympus_box_ornament', 'game_olympus_box_template'):
|
||
cur.execute('SELECT * FROM ' + t)
|
||
data[t] = [{k: (str(v) if v is not None else None) for k, v in row.items()}
|
||
for row in cur.fetchall()]
|
||
SNAP.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding='utf-8')
|
||
print('snapshot ->', SNAP,
|
||
{k: len(v) for k, v in data.items() if k != 'ts'})
|
||
|
||
|
||
def clear():
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor()
|
||
for t in ('game_olympus_step', 'game_olympus_round'):
|
||
cur.execute('TRUNCATE TABLE ' + t)
|
||
print('truncated', t)
|
||
print('max_round_id =', db.get_max_round_id(), 'max_step_id =', db.get_max_step_id())
|
||
|
||
|
||
def restore():
|
||
data = json.loads(SNAP.read_text(encoding='utf-8'))
|
||
rows = data['game_olympus_box_ornament']
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute('SELECT COUNT(*) FROM game_olympus_box_ornament')
|
||
before = cur.fetchone()[0]
|
||
cur.execute('DELETE FROM game_olympus_box_ornament')
|
||
cur.executemany('''INSERT INTO game_olympus_box_ornament
|
||
(box_id, ornament_id, audience, count, used) VALUES (%s,%s,%s,%s,0)''',
|
||
[(int(r['box_id']), int(r['ornament_id']), r['audience'],
|
||
int(r['count'])) for r in rows])
|
||
print('box_ornament: %d -> %d rows (used=0)' % (before, len(rows)))
|
||
# box 行对比:库里现值 vs 备份值
|
||
dc = conn.cursor(db.pymysql.cursors.DictCursor)
|
||
dc.execute('''SELECT box_id, box_type, bet, total_ornaments, rtp, price, status, sort
|
||
FROM game_olympus_box ORDER BY box_id''')
|
||
old = {int(r['box_id']): r for r in data['game_olympus_box']}
|
||
for r in dc.fetchall():
|
||
o = old.get(int(r['box_id']))
|
||
if o is None:
|
||
print(' box %s: 新增' % r['box_id'])
|
||
continue
|
||
diff = {k: (o[k], str(r[k])) for k in r
|
||
if str(o[k]) != str(r[k])}
|
||
if diff:
|
||
print(' box %s 变化:' % r['box_id'], diff)
|
||
|
||
|
||
def verify():
|
||
"""grid_state 里数 '"S"'/'"M"' 出现次数(短码是独立 JSON 字符串,不会误匹配符号名)。"""
|
||
cnt = ("(CHAR_LENGTH(s.grid_state)-CHAR_LENGTH(REPLACE(s.grid_state,'\"%s\"','')))/3")
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute('SELECT COUNT(*) FROM game_olympus_round')
|
||
rounds = cur.fetchone()[0]
|
||
cur.execute('SELECT COUNT(*) FROM game_olympus_step')
|
||
steps = cur.fetchone()[0]
|
||
cur.execute('SELECT box_id, COUNT(*) FROM game_olympus_round GROUP BY box_id ORDER BY box_id')
|
||
per_box = {r[0]: r[1] for r in cur.fetchall()}
|
||
print('rounds=%d steps=%d 每箱回合=%s' % (rounds, steps, sorted(set(per_box.values()))))
|
||
cur.execute('''SELECT COUNT(*) FROM game_olympus_round WHERE box_id NOT BETWEEN 1 AND 30''')
|
||
print('越界 box_id 回合:', cur.fetchone()[0])
|
||
|
||
# buy_fs 触发盘:每步 4 个 S、0 个 M
|
||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'S'}), SUM({cnt % 'M'}), MAX(s.cascade_seq)
|
||
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
|
||
WHERE r.box_id BETWEEN 21 AND 30 AND s.free_spin_id=0''')
|
||
n, sc, bm, maxseq = cur.fetchone()
|
||
print('buy_fs 触发盘: 步数=%s S总数=%s(应=%s) M总数=%s(应=0) 最大级联页=%s'
|
||
% (n, sc, 4 * (n or 0), bm, maxseq))
|
||
|
||
# 免费旋转内仍然有炸弹(累计器机制没被误伤)
|
||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
|
||
JOIN game_olympus_round r USING(round_id)
|
||
WHERE s.free_spin_id > 0''')
|
||
fn, fm = map(float, cur.fetchone())
|
||
fm = fm or 0.0
|
||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
|
||
JOIN game_olympus_round r USING(round_id)
|
||
WHERE r.box_id <= 10 AND s.free_spin_id = 0''')
|
||
nn, nm = map(float, cur.fetchone())
|
||
nm = nm or 0.0
|
||
print('FS 步数=%s 带炸弹格=%s (%.1f%%)' % (fn, fm, 100.0 * (fm or 0) / (30 * fn)))
|
||
print('normal 基础盘步数=%s 带炸弹格=%s (%.1f%%)' % (nn, nm, 100.0 * (nm or 0) / (30 * nn)))
|
||
|
||
# ante 基础旋同样不许有炸弹;normal/ante 的 100x+ 桶(基础旋无炸弹赔不出来)应全是 FS 回合
|
||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
|
||
JOIN game_olympus_round r USING(round_id)
|
||
WHERE r.box_id BETWEEN 11 AND 20 AND s.free_spin_id = 0''')
|
||
an, am = map(float, cur.fetchone())
|
||
am = am or 0.0
|
||
print('ante 基础盘步数=%s 带炸弹格=%s (%.1f%%)' % (an, am, 100.0 * am / (30 * an)))
|
||
cur.execute('''SELECT r.ornament_id, COUNT(*) 回合, SUM(r.free_spin_count>0) FS回合
|
||
FROM game_olympus_round r WHERE r.box_id <= 20 AND r.ornament_id >= 12
|
||
GROUP BY r.ornament_id ORDER BY r.ornament_id''')
|
||
print('normal/ante 100x+ 桶(应 回合=FS回合):', cur.fetchall())
|
||
cur.execute('''SELECT r.box_id, COUNT(*) 回合, SUM(r.free_spin_count>0) FS回合
|
||
FROM game_olympus_round r WHERE r.box_id <= 20
|
||
GROUP BY r.box_id ORDER BY r.box_id''')
|
||
print('normal/ante 每箱 FS 回合数:', cur.fetchall())
|
||
|
||
# 爆奖档位:只有小/中/大,且只落在免费旋转的旋末帧
|
||
cur.execute('''SELECT bigwin_level, COUNT(*) FROM game_olympus_step
|
||
GROUP BY bigwin_level ORDER BY bigwin_level''')
|
||
print('爆奖档位分布:', cur.fetchall())
|
||
cur.execute('''SELECT COUNT(*) FROM game_olympus_step
|
||
WHERE bigwin_level IS NOT NULL AND (is_final <> 1 OR free_spin_id = 0)''')
|
||
print('爆奖档位落在基础旋/非末帧的行数(应=0):', cur.fetchone()[0])
|
||
|
||
# 盘面绝不能有空格(前端会显示成空白格子)
|
||
cur.execute('''SELECT COUNT(*) FROM game_olympus_step WHERE grid_state LIKE '%""%' ''')
|
||
print('盘面空白格行数(应=0):', cur.fetchone()[0])
|
||
|
||
# 池子构成(每桶应≈77)
|
||
cur.execute('''SELECT box_id, MIN(c), MAX(c) FROM
|
||
(SELECT box_id, ornament_id, COUNT(*) c FROM game_olympus_round
|
||
GROUP BY box_id, ornament_id) t GROUP BY box_id ORDER BY box_id''')
|
||
print('每箱桶内回合数 min/max:', sorted({(r[1], r[2]) for r in cur.fetchall()}))
|
||
|
||
# 配比是否为后台调好的曲线(非均分)
|
||
cur.execute('''SELECT box_id, SUM(count) FROM game_olympus_box_ornament
|
||
GROUP BY box_id, audience ORDER BY box_id LIMIT 3''')
|
||
print('box_ornament 前3箱×(身份) sum(count):', cur.fetchall())
|
||
|
||
|
||
def fsstats():
|
||
"""免费旋转观感统计(按桶):每局"有消除的旋数"、"炸弹并入累计器的次数"、局末累计倍数中位数。
|
||
玩家真正感受到的是"按后台配比加权"后的那一行 —— 低倍桶占比高时观感就被低倍桶决定。"""
|
||
import statistics
|
||
from collections import defaultdict, Counter
|
||
from olympus.config import ORNAMENTS
|
||
_orn = {o['id']: o['name'] for o in ORNAMENTS}
|
||
box_id = int(sys.argv[2]) if len(sys.argv) > 2 else 21
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute('''SELECT r.round_id, r.ornament_id, s.free_spin_id, s.base_score,
|
||
s.accumulator_before, s.accumulator_after
|
||
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
|
||
WHERE r.box_id=%s AND s.free_spin_id>0 ORDER BY s.round_id, s.id''', (box_id,))
|
||
rows = cur.fetchall()
|
||
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
|
||
WHERE box_id=%s AND audience=%s''', (box_id, 'common'))
|
||
cfg = {int(a): int(b) for a, b in cur.fetchall()}
|
||
cur.execute('SELECT ornament_id, AVG(actual_mul) FROM game_olympus_round WHERE box_id=%s GROUP BY ornament_id', (box_id,))
|
||
avg = {int(a): float(b) for a, b in cur.fetchall()}
|
||
spins = {}
|
||
for rid, orn, fs, base, ab, aa in rows:
|
||
d = spins.setdefault((rid, orn, fs), {'elim': False, 'ab': 0.0, 'aa': 0.0})
|
||
if float(base or 0) > 0:
|
||
d['elim'] = True
|
||
d['ab'] = float(ab or 0)
|
||
d['aa'] = float(aa or 0)
|
||
per = defaultdict(Counter)
|
||
rounds_of = defaultdict(set)
|
||
acc_of = {}
|
||
for (rid, orn, fs), d in spins.items():
|
||
c = per[orn]
|
||
c['spins'] += 1
|
||
rounds_of[orn].add(rid)
|
||
acc_of[rid] = max(acc_of.get(rid, 0.0), d['aa'])
|
||
if d['elim']:
|
||
c['elim'] += 1
|
||
if d['aa'] > d['ab']:
|
||
c['merges'] += 1
|
||
total = sum(cfg.values()) or 1
|
||
print('box%d 免费旋转观感(桶 抽中率 | 每局消除旋数 | 每局炸弹并入 | 局末累计中位 | 该桶实际均值)' % box_id)
|
||
w_elim = w_merges = 0.0
|
||
for orn in sorted(per):
|
||
c = per[orn]
|
||
n = len(rounds_of[orn]) or 1
|
||
elim_per, merge_per = c['elim'] / n, c['merges'] / n
|
||
accs = [acc_of[r] for r in rounds_of[orn]]
|
||
w = cfg.get(orn, 0) / total
|
||
w_elim += w * elim_per
|
||
w_merges += w * merge_per
|
||
print(' %2d %6sx %5.1f%% | %6.1f 旋 | %6.2f 次 | %8.0f | %8.2f' % (
|
||
orn, _orn.get(orn, '?'), 100 * w, elim_per, merge_per,
|
||
statistics.median(accs) if accs else 0, avg.get(orn, 0)))
|
||
print(' ── 玩家加权(按配比):每局 %.1f 旋有消除、%.2f 次炸弹并入' % (w_elim, w_merges))
|
||
|
||
|
||
def fsdry():
|
||
"""免费旋转"连续空转"统计:一局里最长连续几旋一个符号都没消(玩家观感=一直白转)。
|
||
|
||
dry = 该旋所有步的 base_score 都是 0(scatter 赔付不算消除:S 不参与消除)。
|
||
只统计有 FS 的回合;1000x 封顶截断的回合里被丢掉的旋按缺失处理,不参与连击计数。
|
||
"""
|
||
from collections import Counter, defaultdict
|
||
from olympus.config import ORNAMENTS
|
||
_orn = {o['id']: o['name'] for o in ORNAMENTS}
|
||
box_id = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
||
sql = '''SELECT r.round_id, r.box_id, r.ornament_id, s.free_spin_id,
|
||
SUM(s.base_score > 0) elim
|
||
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
|
||
WHERE s.free_spin_id > 0'''
|
||
args = ()
|
||
if box_id:
|
||
sql += ' AND r.box_id=%s'
|
||
args = (box_id,)
|
||
sql += ''' GROUP BY r.round_id, r.box_id, r.ornament_id, s.free_spin_id
|
||
ORDER BY r.round_id, s.free_spin_id'''
|
||
with db.get_conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute(sql, args)
|
||
rows = cur.fetchall()
|
||
cur.execute('SELECT box_id, box_type FROM game_olympus_box')
|
||
btype = {int(a): str(b) for a, b in cur.fetchall()}
|
||
cur.execute("""SELECT box_id, ornament_id, count FROM game_olympus_box_ornament
|
||
WHERE audience='common'""")
|
||
cfg = defaultdict(dict)
|
||
for b, o, c in cur.fetchall():
|
||
cfg[int(b)][int(o)] = int(c)
|
||
|
||
per_round, meta = defaultdict(list), {}
|
||
for rid, b, o, fs, elim in rows:
|
||
per_round[int(rid)].append((int(fs), float(elim or 0) > 0))
|
||
meta[int(rid)] = (int(b), int(o))
|
||
|
||
# rid -> (box, orn, 旋数, 有消除旋数, 最长连空)
|
||
stat = {}
|
||
for rid, seq in per_round.items():
|
||
run = mx = 0
|
||
for _, e in sorted(seq):
|
||
run = 0 if e else run + 1
|
||
mx = max(mx, run)
|
||
stat[rid] = (meta[rid][0], meta[rid][1], len(seq), sum(e for _, e in seq), mx)
|
||
|
||
def show(tag, rids, weight_by_cfg=False):
|
||
"""weight_by_cfg:≥4 连空的概率按后台配比 count 加权(= 玩家实际遇到的概率)"""
|
||
if not rids:
|
||
return []
|
||
dist, wsum, p4 = Counter(), 0.0, 0.0
|
||
spins = elim = 0
|
||
worst = []
|
||
for rid in rids:
|
||
b, o, n, e, mx = stat[rid]
|
||
w = (cfg.get(b, {}).get(o, 0) or 1) if weight_by_cfg else 1
|
||
wsum += w
|
||
spins += n
|
||
elim += e
|
||
dist[min(mx, 6)] += 1 # 6 及以上并作一格,否则 >6 的局不进任何格、占比会莫名少一截
|
||
if mx >= 4:
|
||
p4 += w
|
||
worst.append((mx, rid))
|
||
n = len(rids)
|
||
worst.sort(reverse=True)
|
||
print('%-12s 局=%5d 旋/局=%5.1f 有消除=%4.1f/局(%4.1f%%) | 最长连空 0~6+ : %s | ≥4连空 %5.1f%%'
|
||
% (tag, n, spins / n, elim / n, 100.0 * elim / max(1, spins),
|
||
' '.join('%d%s:%.0f%%' % (k, '+' if k == 6 else '', 100.0 * dist[k] / n)
|
||
for k in range(7)),
|
||
100.0 * p4 / wsum))
|
||
return worst
|
||
|
||
by_mode = defaultdict(list)
|
||
for rid, (b, o) in meta.items():
|
||
by_mode[btype.get(b, '?')].append(rid)
|
||
print('=== 免费旋转"连续空转"(dry = 该旋 base_score 全 0;≥4连空 一列买 FS 按配比加权)===')
|
||
all_worst = []
|
||
for mode in ('normal', 'ante', 'buy_fs'):
|
||
rids = by_mode.get(mode) or []
|
||
if not rids:
|
||
continue
|
||
all_worst += [(mx, rid, mode, meta[rid]) for mx, rid in show(mode, rids)[:5]]
|
||
by_orn = defaultdict(list) # 同一模式的 10 个箱合并看桶
|
||
for rid in rids:
|
||
by_orn[meta[rid][1]].append(rid)
|
||
for o in sorted(by_orn):
|
||
show(' %s %s' % (mode, _orn.get(o, o)), by_orn[o], weight_by_cfg=True)
|
||
all_worst.sort(reverse=True)
|
||
print('--- 最差局(最长连空 / round_id / 模式 / 桶)---')
|
||
for mx, rid, mode, (b, o) in all_worst[:12]:
|
||
print(' 连空%d旋 round=%d %s box%d 桶%s' % (mx, rid, mode, b, _orn.get(o, o)))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
cmd = sys.argv[1] if len(sys.argv) > 1 else 'check'
|
||
globals()[cmd]()
|