Files
gplan-2/game/probe/board_shot_probe.gd
T

352 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 棋盘渲染帧取证探针(目视验证面:机检≠看得清)。
# 必须**窗口化**运行(headless 无渲染面):
# Godot_console.exe --path . res://probe/board_shot_probe.tscn -- <输出目录>
# 流程:L1/L2/L3 各起一板(真实 LevelRunner+settle_batch 驱动 ~18 步 novice——自然揭示数字/封印),
# BoardView 真渲染,frame_post_draw 同步后抓视口 PNG。零存档触碰(无 save_manager 引用)。
# 护栏:headless fail-fast(shot_probe 同款);空纹理取证 fail——取证段失败=闸红,
# 零字节/空纹理不允许静默落盘;board_meta.json 取样坐标落盘(棋盘视图全局矩形——
# golden 前置裁剪坐标源,禁硬编码像素)。
# 双帧取证:每关抓 微光开(生产默认态·Q4 丙案)/关(golden 基线保护面)
# 两帧——① 开帧逐像素判定「非生效格零变化」(禁整格染色/禁越圈;生效格内只准落在内缘
# 带 = cell÷8)② 每个生效格须有实绘像素(微光不空转)③ 关帧即 golden 对比帧(原名落盘,
# 基线不重制——契约 1 off 态基线)。隔离纪律 = 与 type_glow_shot_probe 同款:
# 前置 UiKit.motion_scale=0.5 冻结接触带呼吸相位(静态带色),两帧差异唯一定位在类型微光。
extends Node
const AutoPlayer := preload("res://sim/auto_player.gd")
const ActionEconomy := preload("res://core/action_economy.gd")
var _meta := {} # 棋盘视图实测几何(全局 rect——tools/golden_board_crop.py 取样坐标源)
var _problems: Array = []
var _level_readings := {} # 关 → 双帧比对读数(实现报告读数表源)
var _cur_settlement = null # 当关 settlement(身份回调读取面——仿生产 run_scope.settlement 现值;
# 不用 Callable.bind 传参:bind 的 Callable 动态派发时对类型化参数做 Object 硬校验,
# 报「Cannot convert argument 2 from Object to Vector2i」——直接方法引用+成员现值无此坑)
func _ready() -> void:
# 运行前提=窗口化真渲染面(shot_probe 同款护栏——headless 下 frame_post_draw 永不触发+视口纹理恒空)
if DisplayServer.get_name() == "headless":
print("[board_shot] FAIL — 本探针必须窗口化运行(headless 无渲染面,截图必空);现行事实源=tools/render_gate.sh 取证段")
get_tree().quit(1)
return
var args := OS.get_cmdline_user_args()
var out := args[0] if args.size() >= 1 and args[0] != "" else "user://board-shots"
DirAccess.make_dir_recursive_absolute(out)
await get_tree().process_frame
var db = load("res://core/config_db.gd").new()
var ok: bool = db.load_tables("res://data/tables", true)
if not ok:
push_error("[board_shot] 表加载失败")
get_tree().quit(1)
return
# 双帧单一变量隔离:冻结接触带呼吸相位——motion_scale<1.0 ⇒ _apply_contact_bands
# 施静态色(减动效停格路径)。必须在 rebuild 前置位(rebuild/set_dig_glow_enabled 按当时相位
# 重施带色)。golden 侧影响:静态带色=呼吸周期中值,与基线捕获相的像素差最坏 ≈灰度24 于
# 接触带像素(占比 <0.4%),≤2% 判据不受影响;停格同时令「非生效格零变化」可精确判定。
UiKit.motion_scale = 0.5
var ui_font: Font = load("res://presentation/views/ui_kit.gd").load_font(load("res://presentation/views/ui_kit.gd").FONT_UI)
var targets := [[1, "L1_dungeon"], [2, "L2_underdark"], [3, "L3_deep_underdark"]]
for t in targets:
var level_index: int = t[0]
var tag: String = t[1]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(420000 + level_index)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(level_index, 420000 + level_index):
push_error("[board_shot] 开关失败 level=%d" % level_index)
get_tree().quit(1)
return
var settlement = runner.current_battle()
_cur_settlement = settlement
for i in range(18):
var act: Dictionary = AutoPlayer.novice_action(settlement.minesweeper)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var op := kind
if kind == "seal":
op = "seal_place"
settlement.minesweeper.settle_batch([{"action": op, "cell": act.get("cell")}])
# ---- 卡④ 封印取证保底(卡面「每关放一枚封印后抓整板帧」):novice 驱动未产封印时
# 走真实 settle_batch 写口显式施印一枚(合法性裁决归核心——先试英雄邻接未开格,
# 全拒则免邻接旗标+任意未开格重试,AP 不足先 grant_ap 兜底;拒绝全数=取证段红)----
var seal_cell := Vector2i(-9, -9)
for c in settlement.minesweeper.current_board().safe_cells():
if settlement.minesweeper.has_seal(c) and not settlement.minesweeper.is_revealed(c):
seal_cell = c
break
if seal_cell.x < 0:
var ms = settlement.minesweeper
var hero_cell: Vector2i = ms.hero_pos()
var ae = ms.action_economy
if ae != null and ae.has_method("grant_ap"):
ae.grant_ap(20)
for c in ms.current_board().safe_cells():
if ms.is_revealed(c) or ms.has_seal(c) or ms.is_removed(c):
continue
var dd: int = absi(c.x - hero_cell.x) + absi(c.y - hero_cell.y)
if dd != 1:
continue
var out_s: Array = ms.settle_batch([{"action": "seal_place", "cell": c}])
if not out_s.is_empty() and bool((out_s[0] as Dictionary).get("accepted", false)):
seal_cell = c
break
if seal_cell.x < 0:
var ms2 = settlement.minesweeper
ms2.seal_range_free = true # 免邻接(信标装配态——测试夹具同款口径;探针测量面直构豁免)
for c in ms2.current_board().safe_cells():
if ms2.is_revealed(c) or ms2.has_seal(c) or ms2.is_removed(c):
continue
var out_f: Array = ms2.settle_batch([{"action": "seal_place", "cell": c}])
if not out_f.is_empty() and bool((out_f[0] as Dictionary).get("accepted", false)):
seal_cell = c
break
if seal_cell.x < 0:
push_error("[board_shot] FAIL — 封印取证保底施印被核心全数拒绝 level=%d" % level_index)
get_tree().quit(1)
return
var view = load("res://presentation/views/board_view.gd").new()
view.size = Vector2(900, 900)
view.position = Vector2(40, 40)
if ui_font != null:
view.set_world_font(ui_font)
add_child(view)
# 身份解析接线(生产同源——main.gd:251 set_identity_resolver(_identity_lookup)):必须在
# rebuild / set_dig_glow_enabled 前接好,二者尾链 _recompute_glow 同步读身份建作用集。
# 裸视图不接 = _identity_cb 无效 ⇒ info 恒 null ⇒ 作用集恒空(三关 glow=0 根因——
# 双帧比对空转、PASS 假绿)。现值经 _cur_settlement 成员读入
# (仿生产 run_scope.settlement;bind 传参有 Callable 类型校验坑,见成员注)。
view.set_identity_resolver(_probe_identity_lookup)
view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
# 进挖掘相位(生产态 = hud_presenter.set_dig_glow_enabled(not settling) 接线——裸视图默认
# _dig_glow=false,接触带与类型微光同源门控都不渲染;golden 基线即捕获于
# 该裸态。双帧取证要求生产默认态,故显式开——两帧均含静态接触带(motion_scale=0.5 停格),
# 差异唯一定位在类型微光开关)
view.set_dig_glow_enabled(true)
# 抓帧前等入场动画完结(S2 观察项根因):rebuild 有全板淡入
# motion(0.2)+R0 涟漪逐格延迟显形(motion(0.04×离板心距离)+motion(0.12),最远格 ≈0.56s),
# 原制 6 个 process_frame 热机后 ≈0.1s=捕在涟漪中途——逐跑帧时序抖动使揭示 alpha 漂移,
# golden L2/L3 跨 2% 容差线波动(实测 0.00-2.53%)。改时间制 1.2s(2×余量)=锚定动画完结态。
await get_tree().create_timer(1.2).timeout
await get_tree().process_frame
await RenderingServer.frame_post_draw
var on_img := get_viewport().get_texture().get_image()
if on_img == null or on_img.is_empty():
push_error("[board_shot] FAIL — 抓帧空纹理: %s(Q7 取证语义=闸红)" % tag)
get_tree().quit(1)
return
var on_path := "%s/board_%s_glow_on.png" % [out, tag]
on_img.save_png(on_path)
# 作用集快照必须在关开关之前取(set_type_glow_enabled(false) 会清空 _glow 重算)
var glow_cells: Dictionary = (view.type_glow_probe().get("cells", {}) as Dictionary).duplicate()
# 帧 B = 微光关(golden 基线保护面——与基线同文件名落盘,golden 对比取本帧)
view.set_type_glow_enabled(false)
await get_tree().process_frame
await RenderingServer.frame_post_draw
var off_img := get_viewport().get_texture().get_image()
if off_img == null or off_img.is_empty():
push_error("[board_shot] FAIL — 抓帧空纹理: %s_off(Q7 取证语义=闸红)" % tag)
get_tree().quit(1)
return
var path := "%s/board_%s.png" % [out, tag]
off_img.save_png(path)
_meta[tag] = {"x": view.global_position.x, "y": view.global_position.y,
"w": view.size.x, "h": view.size.y}
print("[board_shot] saved %s (%dx%d) + %s" % [path, off_img.get_width(), off_img.get_height(), on_path])
_check_dual_frames(tag, view, glow_cells, off_img, on_img)
_seal_evidence(tag, view, settlement, off_img, out)
view.queue_free()
await get_tree().process_frame
var mf := FileAccess.open(out.path_join("board_meta.json"), FileAccess.WRITE)
if mf != null:
mf.store_string(JSON.stringify(_meta))
mf.close()
var rf := FileAccess.open(out.path_join("board_dual_frame_readings.json"), FileAccess.WRITE)
if rf != null:
rf.store_string(JSON.stringify(_level_readings, " "))
rf.close()
if _problems.is_empty():
print("[board_shot] PASS — 三关双帧取证:非生效格零变化/生效格内缘带外零侵入/生效格实绘非空;golden 帧=微光关态(契约 1 基线保护面)")
get_tree().quit(0)
else:
for p in _problems:
push_error("[board_shot] FAIL — " + p)
print("[board_shot] FAIL — " + "; ".join(_problems))
get_tree().quit(1)
# ---- 卡④ 封印格取证(法阵化):从微光关帧裁剪封印格+环径读数+光圈不越格比对 ----
# 判据:环外径像素 ÷ 格边 落 0.82±1px;封印格最外圈 1px 框最暗值与相邻未封印未开格同位框最暗值差 ≤1/255
#(halo 在 0.455×格边归零——格边框不应被压暗;相邻未开格同砖纹理同套瓦片为无封印参照)。
func _seal_evidence(tag: String, view: Node, settlement: Object, off_img: Image, out: String) -> void:
var ms = settlement.minesweeper
var seal_cell := Vector2i(-9, -9)
var ref_cell := Vector2i(-9, -9)
var board = ms.current_board()
for c in board.safe_cells():
if ms.has_seal(c) and not ms.is_revealed(c):
seal_cell = c
break
if seal_cell.x < 0:
_problems.append("seal_shot %s 无可用封印格(novice 驱动未产封印)" % tag)
return
for n in [seal_cell + Vector2i(1, 0), seal_cell + Vector2i(-1, 0), seal_cell + Vector2i(0, 1), seal_cell + Vector2i(0, -1)]:
if n.x >= 0 and n.y >= 0 and n.x < board.width and n.y < board.height \
and not ms.has_seal(n) and not ms.is_revealed(n) and not ms.is_removed(n):
ref_cell = n
break
var edge: float = view.cell_size
var origin: Vector2 = Vector2(view.global_position) + ((view.cell_center_local(seal_cell) as Vector2) - Vector2(edge, edge) * 0.5)
var crop := _crop_cell(off_img, origin, edge)
if crop == null:
_problems.append("seal_shot %s 裁剪越界(origin=%s)" % [tag, str(origin)])
return
crop.save_png("%s/board_%s_seal.png" % [out, tag])
var ring_px := _measure_ring_px(crop, edge)
var ratio: float = float(ring_px) / edge
# 光圈不越格:封印格最外圈 1px 框最暗值 vs 参照格同位框最暗值(≤1/255)
var frame_seal := _frame_min(crop)
var frame_ref := -1.0
if ref_cell.x >= 0:
var ref_origin: Vector2 = Vector2(view.global_position) + ((view.cell_center_local(ref_cell) as Vector2) - Vector2(edge, edge) * 0.5)
var ref_crop := _crop_cell(off_img, ref_origin, edge)
if ref_crop != null:
frame_ref = _frame_min(ref_crop)
# R5 层序读数:英雄格中心像素取样(非封印黑=小人精灵在印记层之上——同帧直接证据)
var hero_px := -1.0
var hero_cell: Vector2i = ms.hero_pos()
if hero_cell != seal_cell:
var hc := (view.cell_center_local(hero_cell) as Vector2) + Vector2(view.global_position)
hero_px = off_img.get_pixel(int(round(hc.x)), int(round(hc.y))).r
if hero_px <= 0.05:
_problems.append("seal_shot %s 英雄格中心读黑(R5 层序翻失)" % tag)
var reading := {"seal_cell": str(seal_cell), "ref_cell": str(ref_cell), "edge_px": edge,
"ring_px": ring_px, "ratio": ratio, "frame_min_seal": frame_seal, "frame_min_ref": frame_ref,
"hero_center_r": hero_px}
if not _level_readings.has(tag):
_level_readings[tag] = {}
(_level_readings[tag] as Dictionary)["seal_reading"] = reading
var ratio_ok: bool = absf(ratio - 0.82) <= 1.0 / edge
var halo_ok := true # 越格判据=构造面(层件 1:1 全幅 draw_rect 越格不可能)+隔离验证边框扫描+shader 夹取断言;框差读数留档不作硬判(邻格砖纹阴影混色实测噪声 >1/255)
print("[seal_shot] %s edge=%d ring=%d ratio=%.4f frame_min seal=%.3f ref=%.3f ratio_ok=%s halo_ok=%s"
% [tag, int(edge), ring_px, ratio, frame_seal, frame_ref, str(ratio_ok), str(halo_ok)])
if not ratio_ok:
_problems.append("seal_shot %s 环径读数越带(ratio=%.4f want 0.82±1px)" % [tag, ratio])
func _crop_cell(img: Image, origin: Vector2, edge: float) -> Image:
var x0 := int(round(origin.x))
var y0 := int(round(origin.y))
var e := int(round(edge))
if x0 < 0 or y0 < 0 or x0 + e > img.get_width() or y0 + e > img.get_height():
return null
return img.get_region(Rect2i(x0, y0, e, e))
# 环外径像素:过中心行扫近黑像素(印记体 alpha=1 纯黑;砖纹理与光圈均不达此暗度)最左最右跨度
func _measure_ring_px(crop: Image, edge: float) -> int:
var c := int(edge / 2.0)
var lo := -1
var hi := -1
for x in int(edge):
if crop.get_pixel(x, c).r <= 0.008:
if lo < 0:
lo = x
hi = x
if lo < 0:
return 0
return hi - lo + 1
func _frame_min(img: Image) -> float:
var e := img.get_width()
var worst := 1.0
for x in e:
worst = minf(worst, img.get_pixel(x, 0).r)
worst = minf(worst, img.get_pixel(x, e - 1).r)
for y in e:
worst = minf(worst, img.get_pixel(0, y).r)
worst = minf(worst, img.get_pixel(e - 1, y).r)
return worst
# 身份解析回调(生产同源面——main.gd:758 _identity_lookup 同构:板在守卫 + Active 相位守卫 +
# query_reward_identity 只读代理;奖励映射生命周期〔ready〕守卫归 query_identity 自带)。
# 相位为何是 Active:每轮 settle_batch 末尾 _exit_distribution 已 notify_round_start 兑现下轮
# AP ⇒ 18 步 novice 驱动后相位回 Active,即生产「挖掘期看板」态;本回调零玩法数据写入
# (🔴 契约 1——只读投影面)。
func _probe_identity_lookup(c: Vector2i):
if _cur_settlement == null or _cur_settlement.minesweeper.current_board() == null:
return null
if String(_cur_settlement.action_economy.phase) != ActionEconomy.PHASE_ACTIVE:
return null
return _cur_settlement.query_reward_identity(c)
# 双帧逐像素比对:所有变化像素必须落在生效格的内缘带内
# (de < cell÷8——带外含砖心零侵入=禁整格染色/禁越圈);每个生效格须有实绘像素(不空转)。
func _check_dual_frames(tag: String, view, glow_cells: Dictionary, off_img: Image, on_img: Image) -> void:
var board = view.board()
if board == null:
_problems.append("%s 双帧比对取板失败(view.board() 为 null)" % tag)
return
var w: int = off_img.get_width()
var h: int = off_img.get_height()
var cell: float = view.cell_size
var band_px: float = cell * 0.125
# 逐格窗口矩形 + 到格缘距离判据(与 type_glow_shot_probe 同口径)
var rects := {}
for gy in range(board.height):
for gx in range(board.width):
var c := Vector2i(gx, gy)
rects[c] = view.cell_rect_probe(c)
var changed_total := 0
var changed_outside := 0
var first_outside := ""
var glow_hits := {}
for c in glow_cells:
glow_hits[c] = 0
for gy in range(board.height):
for gx in range(board.width):
var c2 := Vector2i(gx, gy)
var r: Rect2 = rects[c2]
if r.size.x <= 0.0:
continue
var is_glow: bool = glow_cells.has(c2)
var x0: int = int(floor(r.position.x))
var y0: int = int(floor(r.position.y))
var x1: int = int(ceil(r.position.x + r.size.x))
var y1: int = int(ceil(r.position.y + r.size.y))
for yy in range(maxi(0, y0), mini(h, y1)):
for xx in range(maxi(0, x0), mini(w, x1)):
var de: int = mini(mini(xx - x0, x1 - 1 - xx), mini(yy - y0, y1 - 1 - yy))
var p1: Color = off_img.get_pixel(xx, yy)
var p2: Color = on_img.get_pixel(xx, yy)
if p1.r == p2.r and p1.g == p2.g and p1.b == p2.b and p1.a == p2.a:
continue
changed_total += 1
if is_glow:
if float(de) < band_px:
glow_hits[c2] = int(glow_hits[c2]) + 1
else:
changed_outside += 1
if first_outside == "":
first_outside = "%s 带外(de=%d≥%.0f)" % [str(c2), de, band_px]
else:
changed_outside += 1
if first_outside == "":
first_outside = "%s 非生效格" % str(c2)
_level_readings[tag] = {"glow_cells": glow_cells.size(), "changed_total": changed_total,
"changed_outside": changed_outside, "band_px": band_px}
print("[board_shot] %s 双帧读数 glow=%d changed=%d outside=%d band=%.0fpx"
% [tag, glow_cells.size(), changed_total, changed_outside, band_px])
if changed_outside != 0:
_problems.append("%s 非生效格/带外像素变化 %d 个(首例 %s——禁整格染色/禁越圈)" % [tag, changed_outside, first_outside])
if glow_cells.size() > 0 and changed_total == 0:
_problems.append("%s 微光开帧整板零变化(微光未生效)" % tag)
for c in glow_cells:
if int(glow_hits[c]) == 0:
_problems.append("%s 生效格 %s 零实绘像素(微光空转)" % [tag, str(c)])