From 99134ff181df8945329e0b897ee0e32468addbb6 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 19 Sep 2026 21:46:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=8A=80=E8=83=BD=E6=B1=A0?= =?UTF-8?q?=E8=8A=82=E5=A5=8F=E4=BD=93=E9=AA=8C=E9=AA=8C=E8=AF=81=E6=89=B9?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=87=BA=E6=8B=9B=E8=8A=82=E5=A5=8F=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E5=B7=A5=E5=85=B7+=E4=B8=89=E5=B1=82=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E6=8E=A2=E9=92=88+60=E7=A7=8D=E5=AD=90=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E9=87=87=E9=9B=86=E5=99=A8=EF=BC=882026-09-19=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=9B=9B=E7=82=B9=E6=8B=8D=E6=9D=BF=E5=90=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=EF=BC=8C=E6=9C=AA=E5=90=88=E5=B9=B6=20doc=20?= =?UTF-8?q?=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 池验证条目 --- .../smoke/rhythm_batch.gd | 38 +++++ .../smoke/skill_rhythm_probe.gd | 72 ++++++++++ .../smoke/skill_rhythm_probe.gd.uid | 1 + tools/skill_rhythm_audit.py | 135 ++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 prototypes/dungeon-sweeper-vertical-slice/smoke/rhythm_batch.gd create mode 100644 prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd create mode 100644 prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd.uid create mode 100644 tools/skill_rhythm_audit.py diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/rhythm_batch.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/rhythm_batch.gd new file mode 100644 index 00000000..55dbde78 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/rhythm_batch.gd @@ -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) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd new file mode 100644 index 00000000..5410ec96 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd @@ -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 diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd.uid new file mode 100644 index 00000000..25920bb7 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/skill_rhythm_probe.gd.uid @@ -0,0 +1 @@ +uid://b5vagf2sq2suk diff --git a/tools/skill_rhythm_audit.py b/tools/skill_rhythm_audit.py new file mode 100644 index 00000000..2662c597 --- /dev/null +++ b/tools/skill_rhythm_audit.py @@ -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 [...] +""" +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:])