feat(tools): 技能池节奏体验验证批——出招节奏审计工具+三层定位探针+60种子批量采集器(2026-09-19 用户四点拍板后执行,未合并 doc 面)
- tools/skill_rhythm_audit.py:敌方出招节奏审计(特殊技份额/cast 间隔与 round 间隔 p50 p90/过旱过涝判读带/保底越界)——判读带用户拍板:过旱=战斗整体全敌人零特殊技(跳战场单列);过涝=单敌群连续特殊技无普攻基底(≥3 记信号,阈值 3 为代拍板默认) - smoke/skill_rhythm_probe.gd:地精一伙 0/484 零突刺异常的三层定位探针(表侧池快照/摇号侧大样本/战斗侧真实路径)——结论=种子偏置假象非 bug(behavior_roll 子流 scope 每种子确定性×4200 系 10 种子网格重复放大,4300 系复跑 D=1 执行 10/20 恢复正常;与 armor_roll 派生种子头部偏置同族) - smoke/rhythm_batch.gd:60 distinct seeds × 双策略 × 两关节奏批采集器(方法论规则:节奏/EV 读数须 ≥50 distinct seeds) 正式读数(240 局):份额对表——地精 10.4%(静态 10%)/地精一伙 8.4%/熊地精系 28.9-42.4%/巨蜘蛛 34.8%/酋长 28.9%/卓尔 35.4%;保底越界 0/247;过涝 8/1440 场 max 连击 3;过旱 456 场集中在短命杂鱼(地精系 82-87% 均出招 1.2-2.2 拍,耐久敌 0-2%)——生存时长问题非池配置,移交 06 清单 Alpha 池验证条目
This commit is contained in:
1 parent
d236d9f8e1
commit
99134ff181
4 files changed
+246
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# Date: 2026-09-19
|
||||
# 技能池节奏读数批量采集(06 待办「技能池节奏体验验证」机器半边正式读数):
|
||||
# 60 distinct seeds × 双策略 × 两关,atk/hp 零加成基线——单网格组合避免种子重复
|
||||
# (4200 系 0/484 假象教训:节奏/EV 读数须 ≥50 distinct seeds,见台账 89)。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://smoke/rhythm_batch.gd -- [seeds] [seed0]
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var n := 60
|
||||
if args.size() >= 1 and args[0].is_valid_int():
|
||||
n = int(args[0])
|
||||
var seed0 := 4400
|
||||
if args.size() >= 2 and args[1].is_valid_int():
|
||||
seed0 = int(args[1])
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var logger = load("res://presentation/playtest_logger.gd").new()
|
||||
logger.ap_per_round = int(db.action_economy.ap_per_round)
|
||||
var log_path: String = logger.start_session(seed0)
|
||||
print("节奏批日志: %s" % log_path)
|
||||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var total := 0
|
||||
for L in [1, 2]:
|
||||
for policy in ["normal", "perfect"]:
|
||||
for i in range(n):
|
||||
var r = autoplay.run_level(db, seed0 + i, L, 0, 0, false, logger, policy, "min")
|
||||
total += 1
|
||||
if i % 10 == 0:
|
||||
print("进度 L%d %s %d/%d" % [L, policy, i, n])
|
||||
logger.close_session()
|
||||
print("节奏批完成:%d 局,耗时 %.1f 秒\n日志: %s" % [total, (Time.get_ticks_msec() - t0) / 1000.0, log_path])
|
||||
quit(0)
|
||||
@@ -0,0 +1,72 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# Date: 2026-09-19
|
||||
# 技能池节奏探针:地精一伙 (1,1) 750 出招零突刺异常的定位——
|
||||
# ① 表侧:resolve_enemy_rowset(1,1) 的池快照内容;
|
||||
# ② 摇号侧:隔离 SkillLottery.draw 大样本份额;
|
||||
# ③ 战斗侧:真实 (1,1) 战斗逐执行行按 skill_id 计数。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://smoke/skill_rhythm_probe.gd
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
db.load_tables("res://data/tables", false)
|
||||
|
||||
# ① 表侧
|
||||
var row = db.resolve_enemy_rowset(1, 1)
|
||||
print("[1] row(1,1) display=", row.get("display_name"), " unit_count=", row.get("unit_count"),
|
||||
" pool=", row.get("pool"))
|
||||
|
||||
# ② 摇号侧隔离
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(20260919)
|
||||
var lot = load("res://core/skill_lottery.gd")
|
||||
var counter := {}
|
||||
for i in range(10000):
|
||||
var stream: RandomNumberGenerator = rng.open_scope("behavior_roll", i % 7)
|
||||
var s: String = lot.draw(row.get("pool"), stream)
|
||||
counter[s] = counter.get(s, 0) + 1
|
||||
print("[2] SkillLottery 10000 抽(scope 0-6 复用): ", counter)
|
||||
|
||||
# ③ 战斗侧(真实路径):(1,0) 对照 + (1,1) 异常组
|
||||
for depth in [0, 1]:
|
||||
var casts := {}
|
||||
var battles := 0
|
||||
for i in range(10):
|
||||
var r = _run_battle(db, 4200 + i, 1, depth, casts)
|
||||
battles += 1
|
||||
print("[3] 战斗侧 (1,%d) %d 场执行出招 skill_id 计数: " % [depth, battles], casts)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Dictionary:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
|
||||
var out := {"result": ""}
|
||||
settlement.battle_finished.connect(func(payload): out.result = payload.get("result", ""))
|
||||
settlement.step_row.connect(func(row):
|
||||
if row.type == "damage" and row.source_class == "enemy_action":
|
||||
var sid: String = str(row.get("skill_id", "?"))
|
||||
casts[sid] = casts.get(sid, 0) + 1)
|
||||
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}):
|
||||
return out
|
||||
var AutoPlayer = load("res://smoke/auto_player.gd")
|
||||
var guard_rounds := 40
|
||||
while out.result == "" and guard_rounds > 0:
|
||||
guard_rounds -= 1
|
||||
var start_round: int = settlement.round_index
|
||||
var minesweeper = settlement.minesweeper
|
||||
var guard := 400
|
||||
while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "":
|
||||
guard -= 1
|
||||
var plan = AutoPlayer.choose_dig(minesweeper)
|
||||
if plan.is_empty():
|
||||
break
|
||||
if plan.hero_cell != minesweeper.hero_pos():
|
||||
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||||
if settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
|
||||
break
|
||||
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||||
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
|
||||
minesweeper.settle_batch([{"action": "stop"}])
|
||||
return out
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5vagf2sq2suk
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""技能池出招节奏审计(消费 playtest_*.jsonl 的 enemy_action 行)。
|
||||
|
||||
对应 06 待办「单拍行为环的体验验证(改型:技能池节奏体验验证)」的机器半边。
|
||||
判读带(2026-09-19 用户拍板):
|
||||
· 过涝 = 敌群连续出特殊技、无普攻基底——读数 = 单敌群同场战斗内最长连续特殊技连击
|
||||
(连击 ≥3 记过涝信号,连击阈值 3 为代拍板默认:保底 M=4 与冷却 2-4 语义下 2 连属正常波动)
|
||||
· 过旱 = 按战斗整体:一场战斗里所有敌人都没用过特殊技记过旱样本;任一敌人用过即正常
|
||||
(战斗内敌人从未行动过的「首轮全灭/跳战」场单列,不与"有行动无特殊技"混计)
|
||||
机器读数:
|
||||
· 含闸速率 = 特殊技出招占全部出招的份额(对表值权重 = 静态份额比对)
|
||||
· 旱涝间隔 = 相邻两次特殊技之间的普攻连投数(cast 间隔)与回合间隔(round 间隔)
|
||||
· 保底 M=4 语义:连续 4 拍普攻 → 下拍必出特殊技 → cast 间隔样本 ≤4 合法
|
||||
|
||||
用法:
|
||||
python tools/skill_rhythm_audit.py <playtest_*.jsonl> [...]
|
||||
"""
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
SUFFIX_RE = re.compile(r"([^)]*)|+[^+]*$")
|
||||
|
||||
|
||||
def actor_of(display_name: str) -> str:
|
||||
# "蛛化卓尔精灵 毒爆(被压制)+附加2(必中)" → 敌群前缀取第一个空格前段(无空格 = 状态行,丢弃)
|
||||
if " " not in display_name:
|
||||
return ""
|
||||
return display_name.split(" ")[0]
|
||||
|
||||
|
||||
def pct(samples, q):
|
||||
if not samples:
|
||||
return "-"
|
||||
s = sorted(samples)
|
||||
idx = min(len(s) - 1, max(0, round(q * (len(s) - 1))))
|
||||
return s[idx]
|
||||
|
||||
|
||||
def main(paths):
|
||||
battles = []
|
||||
cur = None
|
||||
for pattern in paths:
|
||||
for path in sorted(glob.glob(pattern)):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
kind = d.get("kind")
|
||||
if kind == "node":
|
||||
cur = {"actors": collections.defaultdict(list)}
|
||||
battles.append(cur)
|
||||
elif (kind == "step" and d.get("source") == "enemy_action"
|
||||
and d.get("skill_id") and cur is not None):
|
||||
actor = actor_of(d.get("display_name", ""))
|
||||
if actor:
|
||||
cur["actors"][actor].append(
|
||||
(d.get("round_index", 0), d.get("skill_id", "attack")))
|
||||
|
||||
agg = collections.defaultdict(lambda: {
|
||||
"battles": 0, "casts": 0, "specials": 0,
|
||||
"cast_gaps": [], "round_gaps": [], "streaks": [],
|
||||
})
|
||||
# 过旱/过涝判读带(用户拍板 2026-09-19)——按战斗整体计
|
||||
drought_battles = 0 # 过旱:全战斗零特殊技(有敌行动)
|
||||
no_action_battles = 0 # 首轮全灭/跳战:敌从未行动(单列不混计)
|
||||
normal_battles = 0
|
||||
flood_signals = 0 # 过涝信号:单敌群连续特殊技 ≥3
|
||||
flood_max = 0
|
||||
for b in battles:
|
||||
actors = b["actors"]
|
||||
if not actors:
|
||||
no_action_battles += 1
|
||||
continue
|
||||
has_special = False
|
||||
for actor, casts in actors.items():
|
||||
a = agg[actor]
|
||||
a["battles"] += 1
|
||||
a["casts"] += len(casts)
|
||||
special_idx = [i for i, (_, sk) in enumerate(casts) if sk != "attack"]
|
||||
a["specials"] += len(special_idx)
|
||||
if special_idx:
|
||||
has_special = True
|
||||
# 过涝:最长连续特殊技连击
|
||||
run, best = 0, 0
|
||||
for _, sk in casts:
|
||||
if sk != "attack":
|
||||
run += 1
|
||||
best = max(best, run)
|
||||
else:
|
||||
run = 0
|
||||
a["streaks"].append(best)
|
||||
flood_max = max(flood_max, best)
|
||||
if best >= 3:
|
||||
flood_signals += 1
|
||||
for prev, nxt in zip(special_idx, special_idx[1:]):
|
||||
a["cast_gaps"].append(nxt - prev - 1)
|
||||
a["round_gaps"].append(casts[nxt][0] - casts[prev][0])
|
||||
if has_special:
|
||||
normal_battles += 1
|
||||
else:
|
||||
drought_battles += 1
|
||||
|
||||
print(f"{'敌群':<10}{'场数':>5}{'出招':>6}{'特殊':>6}{'份额':>7}"
|
||||
f"{'cast隔p50':>10}{'p90':>5}{'max':>5}"
|
||||
f"{'round隔p50':>11}{'p90':>5}{'max':>5}{'连击max':>8}")
|
||||
for actor, a in sorted(agg.items()):
|
||||
share = a["specials"] / a["casts"] if a["casts"] else 0
|
||||
print(f"{actor:<10}{a['battles']:>5}{a['casts']:>6}{a['specials']:>6}{share:>7.1%}"
|
||||
f"{str(pct(a['cast_gaps'],0.5)):>10}{str(pct(a['cast_gaps'],0.9)):>5}"
|
||||
f"{str(max(a['cast_gaps']) if a['cast_gaps'] else '-'):>5}"
|
||||
f"{str(pct(a['round_gaps'],0.5)):>11}{str(pct(a['round_gaps'],0.9)):>5}"
|
||||
f"{str(max(a['round_gaps']) if a['round_gaps'] else '-'):>5}"
|
||||
f"{str(max(a['streaks']) if a['streaks'] else 0):>8}")
|
||||
total_battles = len(battles)
|
||||
print(f"\n战斗总数 {total_battles}|跳战(敌未行动){no_action_battles}"
|
||||
f"|过旱(有行动·全战斗零特殊技){drought_battles}"
|
||||
f"|正常 {normal_battles}")
|
||||
print(f"过涝信号(单敌群连续特殊技≥3){flood_signals} 处|全场最长连击 {flood_max}")
|
||||
# 保底越界样本:cast 间隔 > M(M=4 → 间隔 ≥5 普攻才越界;间隔 4 = 连续 4 拍普攻后保底必出,合法)
|
||||
total_over = sum(1 for a in agg.values() for g in a["cast_gaps"] if g >= 5)
|
||||
total_gaps = sum(len(a["cast_gaps"]) for a in agg.values())
|
||||
print(f"保底越界(相邻特殊技间隔≥5 普攻):{total_over}/{total_gaps}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
main(sys.argv[1:])
|
||||
Reference in new issue
Block a user