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:])