iter34 卡④ T1-T3: 信息迷雾类型可见性——层距 N+Boss/候选豁免+单圈回雾(N 读表)+呈现显隐决策面+画布大图断言与探针常数同源

- level_table.gd: info_visible_layers 字段(int≥1)+validate 越域 domain 错;level.tres 挂 2
- level_runner.gd: type_visible_ids() 纯函数(入口 BFS 层差 ≤N ∪ Boss ∪ 候选,锚=当前节点层/入口停点 0;visited 不豁免)+_bfs_layers()
- level_map_screen.gd: node_display() 静态决策面+display_probe()+_draw_graph 图标/文字/Boss 描边三处改读决策面(结构通道零改动)
- 新单测 level_map_type_vis_test(26 断言)/level_map_info_fog_test(19 断言);run_all 注册两行
- smoke_all ⑬ 真图 L2 入口停点可见性组(+5 断言);ui_input_probe ⑬ 常数锚同源化+⑱ 雾不扩可点域

轻闸读数:冒烟 1211 项 0 失败(主区基线 1206,+5=新增组)/probe_gate --click 全绿
This commit is contained in:
DKChan committed 2026-10-22 05:52:11 +08:00
1 parent 43c0f3c118
commit ed8e0f9f3d
11 files changed
+571 -21

No files matched your search

+46
View File
@@ -414,6 +414,52 @@ func visited_ids() -> Array:
return out
# ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)----
# 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪
# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码);
# 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。
# 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。
func type_visible_ids() -> Array[int]:
var out: Array[int] = []
var layers := _bfs_layers()
var anchor := 0
var cur := current_node()
if not cur.is_empty():
anchor = int(layers.get(_node_id(cur), 0))
var n: int = int(db.level.info_visible_layers)
for node in nodes():
var nid := _node_id(node)
if String(node.get("type", "")) == "boss" \
or (layers.has(nid) and absi(int(layers[nid]) - anchor) <= n):
out.append(nid)
for cid in next_candidate_ids():
if not out.has(int(cid)):
out.append(int(cid))
return out
# 入口 BFS 层表(id → 层深;入口层 0、出边逐层 +1,与 LevelMapScreen._assign_layers 同源)。
# 不可达节点不落表(加载期孤儿校验应已拦截)。
func _bfs_layers() -> Dictionary:
var layers := {}
var entry := _entry_node()
if entry.is_empty():
return layers
var queue: Array[Dictionary] = [entry]
layers[_node_id(entry)] = 0
var i := 0
while i < queue.size():
var cur: Dictionary = queue[i]
i += 1
var d := int(layers[_node_id(cur)])
for nxt in _out_nodes(cur):
var nid := _node_id(nxt)
if not layers.has(nid):
layers[nid] = d + 1
queue.append(nxt)
return layers
func awaiting_choice() -> bool:
return _phase == Phase.AWAITING_CHOICE
+3
View File
@@ -5,6 +5,7 @@ class_name LevelTable
extends Resource
@export var rows: Array[Dictionary] = [] # Array[Dictionary]
@export var info_visible_layers: int = 2 # 信息迷雾可见层数 N(int ≥1;类型仅在邻近 N 层内可见——关卡节点地图 v2.1)
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v1.1)
@@ -19,6 +20,8 @@ func row_by_level_index(level_index: int) -> Dictionary:
func validate() -> Array:
var errs: Array[Dictionary] = []
if info_visible_layers < 1:
errs.append({"category": "domain", "message": "info_visible_layers 越域 int≥1: %d" % info_visible_layers})
if rows.is_empty():
errs.append({"category": "empty_table", "message": "LevelTable 为空"})
return errs
+1
View File
@@ -4,6 +4,7 @@
[resource]
script = ExtResource("1")
info_visible_layers = 2
rows = [
{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30, "templates": [{"display_name": "地精洞窟·主线洞道(现行)", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先赏后整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先整后赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·赏战整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·整战赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"node_depth": 4, "type": "boss"}]}]},
{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]},
+52 -18
View File
@@ -415,6 +415,26 @@ func _id_of(node: Dictionary) -> int:
return _runner.node_id_of(node)
# ---- 节点呈现决策面(信息迷雾 v2.1:类型仅在可见集内出示,Boss 标记不入雾)----
# 纯函数、零内容读取:只消费类型与状态(节点内详情=打谁/给什么,图上从不显示)。
static func node_display(node: Dictionary, nid: int, type_vis: Array,
is_current: bool, is_candidate: bool) -> Dictionary:
var t := String(node.get("type", ""))
var is_boss: bool = t == "boss"
var type_shown: bool = is_boss or type_vis.has(nid)
var label_text := String(NODE_TYPE_NAMES.get(t, t))
if is_current:
label_text = "当前位置"
elif is_candidate:
label_text += " · 可选"
return {
"draw_icon": type_shown,
"draw_label": type_shown,
"label_text": label_text,
"boss_border": is_boss,
}
# ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)----
func _draw_graph(canvas: Control) -> void:
@@ -426,6 +446,7 @@ func _draw_graph(canvas: Control) -> void:
# 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析
var visited: Array[Variant] = _runner.visited_ids()
var cand_ids: Array[Variant] = _runner.next_candidate_ids()
var type_vis: Array = _runner.type_visible_ids() # 本停点类型可见集(每帧一取——纯函数零状态)
# 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)
# 选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的
# 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象)
@@ -454,7 +475,8 @@ func _draw_graph(canvas: Control) -> void:
col = COL_ROOM_CURRENT
var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0)
canvas.draw_rect(rect, col)
if t == "boss":
var disp := node_display(node, nid, type_vis, nid == current_id, cand_ids.has(nid))
if bool(disp["boss_border"]):
# Boss 描边 = 紫赤 2px(§4.2 色相独占)+ 外缘 1px 深墨环(灰度冗余通道强制:
# 紫赤灰度 72 对石纹噪底 65 灰度不可辨;灰度/色弱下 Boss 环经深墨环可辨)
canvas.draw_rect(Rect2(rect.position - Vector2(3, 3), rect.size + Vector2(6, 6)), UiKit.INK_ROUTE, false, 1.0)
@@ -464,29 +486,26 @@ func _draw_graph(canvas: Control) -> void:
canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0)
# 节点图标(20px 烘焙 1:1 消费;双色墨制=乘法调制——当前金底深墨,暗底系 WARM 原色;
# 贴图缺位 → 字形回退=贴图在位字形退役惯例)
var icon: Texture2D = _node_icons.get(t)
if icon != null:
var tint := UiKit.ICON_TINT_ON_GOLD if col == COL_ROOM_CURRENT else Color(1.0, 1.0, 1.0)
canvas.draw_texture_rect(icon, Rect2(p - Vector2(NODE_ICON_PX, NODE_ICON_PX) / 2.0,
Vector2(NODE_ICON_PX, NODE_ICON_PX)), false, tint)
else:
var font := get_theme_default_font()
var label := String(NODE_LABELS.get(t, "?"))
canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE)
if bool(disp["draw_icon"]):
var icon: Texture2D = _node_icons.get(t)
if icon != null:
var tint := UiKit.ICON_TINT_ON_GOLD if col == COL_ROOM_CURRENT else Color(1.0, 1.0, 1.0)
canvas.draw_texture_rect(icon, Rect2(p - Vector2(NODE_ICON_PX, NODE_ICON_PX) / 2.0,
Vector2(NODE_ICON_PX, NODE_ICON_PX)), false, tint)
else:
var font := get_theme_default_font()
var label := String(NODE_LABELS.get(t, "?"))
canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE)
# 编号(右上角——与图标占位区零重叠;双色墨随态)
var num_ink := UiKit.INK_ROUTE if col == COL_ROOM_CURRENT else UiKit.WARM
canvas.draw_string(get_theme_default_font(), Vector2(rect.end.x - 16.0, rect.position.y + 8.0),
str(nid), HORIZONTAL_ALIGNMENT_RIGHT, 14, 10, num_ink)
# 类型文字标签(demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、
# 其余=类型名暖米弱化;文字+图标+形状三通道)
var type_text := String(NODE_TYPE_NAMES.get(t, t))
if nid == current_id:
type_text = "当前位置"
elif cand_ids.has(nid):
type_text += " · 可选"
var type_ink := COL_ROOM_CURRENT if nid == current_id else (UiKit.CANDLE if cand_ids.has(nid) else UiKit.WARM_FAINT)
canvas.draw_string(get_theme_default_font(), Vector2(p.x - 44.0, rect.end.y + 15.0),
type_text, HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink)
if bool(disp["draw_label"]):
var type_ink := COL_ROOM_CURRENT if nid == current_id else (UiKit.CANDLE if cand_ids.has(nid) else UiKit.WARM_FAINT)
canvas.draw_string(get_theme_default_font(), Vector2(p.x - 44.0, rect.end.y + 15.0),
String(disp["label_text"]), HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink)
func _layout(nodes: Array, canvas: Control) -> Dictionary:
@@ -513,6 +532,21 @@ func canvas_probe() -> Dictionary:
return {"origin": _canvas.get_global_position(), "min_size": _canvas.custom_minimum_size}
# 类型可见性呈现只读快照(id → node_display 决策输出;真机绘制面断言消费点)
func display_probe() -> Dictionary:
var out := {}
if _runner == null:
return out
var nodes: Array[Variant] = _runner.nodes()
var type_vis: Array = _runner.type_visible_ids()
var current_id: int = _runner.current_node_id()
var cand_ids: Array = _runner.next_candidate_ids()
for node in nodes:
var nid := _id_of(node)
out[nid] = node_display(node, nid, type_vis, nid == current_id, cand_ids.has(nid))
return out
# 分层(layout/fit 共享件):layer = 入口 BFS 深度;同层 slot 垂直排布;未达节点兜底排末层
func _assign_layers(nodes: Array, layer: Dictionary, slot: Dictionary) -> void:
var layer_counts := {}
+19
View File
@@ -4923,6 +4923,25 @@ func _smoke_level_graph() -> void:
_db.enemy.rows.remove_at(_db.enemy.rows.size() - 1)
check(int(_db.enemy.rows.size()) == 20, "混编断言段注入行全量还原(表回 20 行——首批混编投放后基线行数,迭代 18 卡⑤;后续断言面零污染)")
# ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 /
# Boss 恒可见 / 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。
var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
var vis_ledger := {"hero_hp": 10, "gp": 0}
var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true, 0)
check(vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1,
"可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)")
var vis_ids: Array = vis_runner.type_visible_ids()
var vis_cand_in := true
for cid in vis_runner.next_candidate_ids():
if not vis_ids.has(int(cid)):
vis_cand_in = false
check(vis_cand_in, "可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕")
check(vis_ids.has(_boss_depth(2)), "可见性冒烟:L2 Boss(编号 %d)恒在可见集(终点不入雾)〔fv3〕" % _boss_depth(2))
var vis_n: int = int(_db.level.info_visible_layers)
check(vis_n == 2, "可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n)
check(not vis_ids.has(7) and not vis_ids.has(8),
"可见性冒烟:入口锚层 0 时层距 3/4 的节点 7/8 入雾(非豁免——入口 BFS 层差 > N)〔fv1〕")
# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律)
func _fresh_rng(seed_val: int):
+42 -3
View File
@@ -41,6 +41,44 @@ func _ready() -> void:
if main.level_screen.visible:
var ro = main.level_screen
var cands: Array = main.run_scope.runner.next_candidate_ids()
# ---- ⑱ 信息迷雾呈现接线(回归闸):真机 display_probe 读绘制面(雾下节点类型不绘制、
# Boss 描边在、候选类型在)+点击雾下非候选节点零提交(雾不扩大可点域——候选集即合法集)----
var disp: Dictionary = ro.display_probe()
var fog_id := -1
var boss_id := -1
var nds_vis: Array = main.run_scope.runner.nodes()
for n in nds_vis:
var vnid: int = main.run_scope.runner.node_id_of(n)
if String(n.get("type", "")) == "boss":
boss_id = vnid
elif fog_id < 0 and disp.has(vnid) and not bool(disp[vnid]["draw_icon"]):
fog_id = vnid
if boss_id < 0 or fog_id < 0:
_pre_fail("case⑱ 雾面节点选取失败(boss=%d fog=%d)" % [boss_id, fog_id])
return
if not bool(disp[boss_id]["boss_border"]):
_pre_fail("case⑱ Boss 节点未带描边标记(id=%d)" % boss_id)
return
if not bool(disp[int(cands[0])]["draw_icon"]):
_pre_fail("case⑱ 候选节点类型未出示(id=%d——下一跳恒可见)" % int(cands[0]))
return
var fog_node := {}
for n in nds_vis:
if main.run_scope.runner.node_id_of(n) == fog_id:
fog_node = n
var fog_pos: Vector2 = ro.canvas_probe()["origin"] + ro.layout_probe(nds_vis)[fog_node]
var evf := InputEventMouseButton.new()
evf.button_index = MOUSE_BUTTON_LEFT
evf.pressed = true
evf.position = fog_pos
evf.global_position = fog_pos
main.get_viewport().push_input(evf, true)
await get_tree().process_frame
await get_tree().process_frame
if not main.level_screen.visible or not main.run_scope.runner.awaiting_choice():
_pre_fail("case⑱ 雾下节点点击产生提交(visible=%s 选路态=%s——雾不扩大可点域)"
% [str(main.level_screen.visible), str(main.run_scope.runner.awaiting_choice())])
return
var nds: Array = main.run_scope.runner.nodes()
var tgt := {}
for n in nds:
@@ -95,9 +133,10 @@ func _ready() -> void:
var maxl2 := 0
for k2 in layer2.keys():
maxl2 = maxi(maxl2, int(layer2[k2]))
# 常数锚(画布拟合探针):70/122/26/8 与 presentation/views/level_map_screen.gd 的 _content_size
# 公式(70 + maxl*122 + NODE_HALF.x + 8)同源复刻——改常数须两侧同步(本探针只读断言层,真源在屏)
var need_x2: float = 70.0 + maxl2 * 122.0 + 26.0 + 8.0
# 常数锚(画布拟合探针):直接引屏内导出常量(LAYER_BASE_X/LAYER_STEP_X/NODE_HALF)——
# 与 _content_size 公式(LAYER_BASE_X + maxl*LAYER_STEP_X + NODE_HALF.x + 8)逐项同源,禁再手抄数字
var need_x2: float = LevelMapScreen.LAYER_BASE_X + maxl2 * LevelMapScreen.LAYER_STEP_X \
+ LevelMapScreen.NODE_HALF.x + 8.0
var cvmin: Dictionary = ro2.canvas_probe()
if float(cvmin["min_size"].x) < need_x2 - 0.5:
_pre_fail("画布拟合不足(min.x=%.0f < 最深内容 %.0f——深层节点不可点回归)" % [cvmin["min_size"].x, need_x2])
+2
View File
@@ -51,8 +51,10 @@ func _suites() -> Array:
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面
{"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点
{"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口)
{"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值)
{"path": "res://tests/unit/presentation/pacing_queue_test.gd", "expect": 0}, # AC⑤/T9——排空零分叉/零丢行/延迟双态
{"path": "res://tests/unit/presentation/reward_overlay_test.gd", "expect": 0}, # 奖励节点 UI 直验(盲区补漏)
{"path": "res://tests/unit/presentation/level_map_info_fog_test.gd", "expect": 0}, # 信息迷雾——呈现显隐/Boss 豁免/详情零显示/大图拟合
{"path": "res://tests/unit/presentation/forge_workbench_test.gd", "expect": 0}, # 工作间两区六路线 UI 直验(V13 自动化前置面)
{"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0},
{"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0},
@@ -0,0 +1,208 @@
# tests/unit/core/level_map_type_vis_test.gd — 关卡图类型可见性(信息迷雾)直接单测
# 断言面 = LevelRunner.type_visible_ids() 语义:层距 N 边界+Boss/终点与候选双豁免+
# 单圈回雾(visited 不豁免)+N 读数据表(含越域 validate)+续玩重建逐位一致。
# 图夹具白盒注入 level_row 与停点态(smoke_all ⑫ 白盒先例);层距=入口 BFS 层差。
# 运行:godot --headless --path game --script res://tests/unit/core/level_map_type_vis_test.gd(expect 0)
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const LevelTableScript := preload("res://data/schemas/level_table.gd")
func _init() -> void:
var db = load("res://core/config_db.gd").new()
var loaded: bool = db.load_tables("res://data/tables", false)
Kit.check_quiet(loaded, "可见性用例:数值表加载成功")
if not loaded:
quit(Kit.finish("level_map_type_vis_test"))
return
_test_field_in_table(db)
_test_layer_distance(db)
_test_candidate_exempt(db)
_test_boss_exempt(db)
_test_single_circle(db)
_test_single_chain_entry(db)
_test_n_from_table(db)
_test_resume_rebuild(db)
quit(Kit.finish("level_map_type_vis_test"))
# ---- 夹具 ----
# 图形态夹具(id = node_depth):单干 0→1→2→3→4→5→6→7 加一条 6→3 跨层跳边。
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6——锚 6 时候选 3 层距 3 = N+1。
func _graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
{"node_depth": 1, "type": "battle", "next": [2]},
{"node_depth": 2, "type": "reward", "next": [3]},
{"node_depth": 3, "type": "rest", "next": [4]},
{"node_depth": 4, "type": "battle", "next": [5]},
{"node_depth": 5, "type": "battle", "next": [6]},
{"node_depth": 6, "type": "battle", "next": [7, 3]},
{"node_depth": 7, "type": "boss", "next": []},
]
# 单链夹具(无 next 键,仿 L1 现役模板形态):编号空间 = 数组下标,node_depth 只出现在战斗/Boss 上
func _chain_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle"},
{"node_depth": 1, "type": "battle"},
{"node_depth": 2, "type": "battle"},
{"node_depth": 3, "type": "battle"},
{"type": "reward"},
{"type": "rest"},
{"node_depth": 4, "type": "boss"},
]
# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5)
func _valid_graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
{"node_depth": 1, "type": "battle", "next": [3]},
{"node_depth": 2, "type": "reward", "next": [3]},
{"node_depth": 3, "type": "battle", "next": [4]},
{"node_depth": 4, "type": "boss", "next": []},
]
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点)
func _runner(db, nodes: Array, anchor_id: int, candidates: Array,
graph_mode: bool = true, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
db.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r._graph_mode = graph_mode
r.visited.clear()
r._candidates.assign(candidates)
r.node_index = -1
if anchor_id >= 0:
for i in nodes.size():
if int((nodes[i] as Dictionary).get("node_depth", -1)) == anchor_id:
r.node_index = i
break
return r
# ---- ⑥ N 进数据表(含越域校验)〔fv6〕----
func _test_field_in_table(db) -> void:
var n_in_table: int = int(db.level.info_visible_layers)
Kit.check("N 落表:level.tres 读数 2(实际 %d)〔fv6〕" % n_in_table, n_in_table == 2)
var t := LevelTableScript.new()
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": _valid_graph_nodes()}]
t.info_visible_layers = 1
Kit.check("N=1 合法:validate 零错〔fv6〕", t.validate().is_empty())
t.info_visible_layers = 0
Kit.check("N 越域 0:validate 判 domain 错〔fv6〕", _has_n_domain_err(t.validate()))
t.info_visible_layers = -1
Kit.check("N 越域 -1:validate 判 domain 错〔fv6〕", _has_n_domain_err(t.validate()))
func _has_n_domain_err(errs: Array) -> bool:
for e in errs:
if String(e.get("category", "")) == "domain" \
and String(e.get("message", "")).contains("info_visible_layers"):
return true
return false
# ---- ① 邻近 N 层见类型(层距边界)〔fv1〕----
func _test_layer_distance(db) -> void:
var nodes := _graph_nodes()
var r = _runner(db, nodes, 1, [nodes[2]]) # 锚 = 节点 1(层 1),候选 = 节点 2
var vis: Array = r.type_visible_ids()
Kit.check("层距 N 内可见:锚层 1 取节点 4(层距 2)〔fv1〕", vis.has(4))
Kit.check("层距 N+1 不可见:锚层 1 排节点 5(层距 3)〔fv1〕", not vis.has(5))
Kit.check("层距 N+2 不可见:锚层 1 排节点 6(层距 4)〔fv1〕", not vis.has(6))
# ---- ② 下一跳恒可见(跨层跳边候选)〔fv4〕----
func _test_candidate_exempt(db) -> void:
var nodes := _graph_nodes()
var r = _runner(db, nodes, 6, [nodes[3]]) # 锚 = 节点 6(层 5),候选 = 节点 3(层 2,层距 3)
var vis: Array = r.type_visible_ids()
Kit.check("候选恒显:跨层跳边候选(层距 N+1)仍在可见集〔fv4〕", vis.has(3))
Kit.check("候选豁免不改层规则:同锚点非候选节点 2(层距 4)不可见〔fv4〕", not vis.has(2))
# ---- ③ Boss/终点不入雾+⑤ 入口停点锚层 0〔fv3〕〔fv1〕----
func _test_boss_exempt(db) -> void:
var nodes := _graph_nodes()
var r = _runner(db, nodes, -1, [nodes[0]]) # 入口停点(无当前节点,锚层 0)
var vis: Array = r.type_visible_ids()
Kit.check("入口停点锚层 0:层距 N 内(节点 2)可见〔fv1〕", vis.has(2))
Kit.check("入口停点锚层 0:层距 N+2(节点 4)不可见〔fv1〕", not vis.has(4))
Kit.check("Boss 恒显:层距 6 的末位 Boss 仍在可见集〔fv3〕", vis.has(7))
Kit.check("Boss 豁免不扩散:同层距的非 Boss 节点 6 不在可见集〔fv3〕", not vis.has(6))
# ---- ④ 单圈回雾(visited 不豁免)〔fv7〕----
func _test_single_circle(db) -> void:
var nodes := _graph_nodes()
var r = _runner(db, nodes, 6, [nodes[7], nodes[3]])
r.visited.assign([nodes[0], nodes[1], nodes[2], nodes[3], nodes[4], nodes[5], nodes[6]])
var vis: Array = r.type_visible_ids()
Kit.check("单圈回雾:已过远端节点 0(层距 5)不因 visited 豁免〔fv7〕", not vis.has(0))
Kit.check("单圈回雾:已过中距节点 2(层距 4)不可见〔fv7〕", not vis.has(2))
Kit.check("单圈只按锚层取窗:已过近端节点 4(层距 2)仍可见〔fv7〕", vis.has(4))
# ---- ⑤ 单链退化形态(编号空间 = 数组下标)〔fv1〕----
func _test_single_chain_entry(db) -> void:
var nodes := _chain_nodes()
var r = _runner(db, nodes, -1, [nodes[0]], false)
var vis: Array = r.type_visible_ids()
Kit.check("单链退化形态:入口锚层 0,下标 2 可见〔fv1〕", vis.has(2))
Kit.check("单链退化形态:层距 N+1(下标 3)不可见〔fv1〕", not vis.has(3))
Kit.check("单链编号空间 = 数组下标:Boss 以末位下标 6 入集(非 node_depth 4)〔fv1〕",
vis.has(6) and not vis.has(4))
# ---- ⑥ N 读表:可见窗随表值随动〔fv6〕----
func _test_n_from_table(db) -> void:
var nodes := _graph_nodes()
var r = _runner(db, nodes, 1, [nodes[2]]) # 锚层 1
db.level.info_visible_layers = 1
Kit.check("N 读表:N=1 时节点 4(层距 2)出窗〔fv6〕", not r.type_visible_ids().has(4))
db.level.info_visible_layers = 3
Kit.check("N 读表:N=3 时节点 5(层距 3)入窗〔fv6〕", r.type_visible_ids().has(5))
db.level.info_visible_layers = 10
var all_in := true
for nid in 7: # 非豁免节点 0..6(7 = Boss 恒显,不作判据)
if not r.type_visible_ids().has(nid):
all_in = false
Kit.check("N 读表:N ≥ 全图层高 → 全部非豁免节点可见〔fv6〕", all_in)
# ---- ⑦ 续玩重建同值〔fv7〕----
func _test_resume_rebuild(db) -> void:
var nodes := _graph_nodes()
var t := LevelTableScript.new()
t.info_visible_layers = 2
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
db.level = t
var ledger := {"hero_hp": 10, "gp": 0}
var resumed = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false, 0,
{"node_id": 2, "visited_ids": [0, 1, 2]})
Kit.check_quiet(ok, "续玩用例:resume_state 重建停点成功")
if not ok:
return
Kit.check_quiet(resumed.awaiting_choice(), "续玩用例:重建后处于选路停点")
var direct = _runner(db, nodes, 2, [nodes[3]])
direct.visited.assign([nodes[0], nodes[1], nodes[2]])
Kit.check("续玩重建同值:resume 路径与同停点直入 type_visible_ids() 逐位一致〔fv7〕",
resumed.type_visible_ids() == direct.type_visible_ids())
@@ -0,0 +1 @@
uid://brh5an0s8q6ss
@@ -0,0 +1,196 @@
# tests/unit/presentation/level_map_info_fog_test.gd — 信息迷雾呈现显隐直接单测
# 断言面 = LevelMapScreen.node_display 决策面(显隐/Boss 豁免/详情零显示/label 三态)+
# display_probe 真机绘制面读数+大图(11 层×6 槽含双 Boss)画布拟合与布局包络。
# 节点图夹具白盒注入 LevelRunner(level_row+停点态),屏体真机实例化走 open_choices 真实入口。
# 运行:godot --headless --path game --script res://tests/unit/presentation/level_map_info_fog_test.gd(expect 0)
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const LevelTableScript := preload("res://data/schemas/level_table.gd")
const ScreenScript := preload("res://presentation/views/level_map_screen.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var db = load("res://core/config_db.gd").new()
var loaded: bool = db.load_tables("res://data/tables", false)
Kit.check_quiet(loaded, "迷雾呈现用例:数值表加载成功")
if not loaded:
quit(Kit.finish("level_map_info_fog_test"))
return
_test_fog_hides_type()
_test_boss_exempt_and_multi_boss()
_test_details_never_shown()
_test_label_three_states()
await _test_big_graph_fit(db)
quit(Kit.finish("level_map_info_fog_test"))
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=入边首节点)
func _runner(db, nodes: Array, candidates: Array, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
db.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r._graph_mode = true
r.node_index = -1
r._candidates.assign(candidates)
return r
# ---- ① 范围外只见结构:雾下节点类型图标与文字均不绘制〔fv2〕----
func _test_fog_hides_type() -> void:
var node := {"node_depth": 3, "type": "battle"}
var shown: Dictionary = ScreenScript.node_display(node, 2, [2], false, false)
Kit.check("可见集内节点:图标/类型文字均绘制〔fv2〕",
bool(shown["draw_icon"]) and bool(shown["draw_label"]))
var fogged: Dictionary = ScreenScript.node_display(node, 3, [2], false, false)
Kit.check("雾下节点:图标与类型文字均不绘制(显隐语义,非灰化)〔fv2〕",
not bool(fogged["draw_icon"]) and not bool(fogged["draw_label"]))
Kit.check("雾下非 Boss 节点无 Boss 描边〔fv2〕", not bool(fogged["boss_border"]))
# ---- ② Boss/终点不入雾+多终点标记〔fv3〕----
func _test_boss_exempt_and_multi_boss() -> void:
var type_vis: Array = [1, 7, 9] # 双 Boss 图:7/9 均在可见集
var boss_a: Dictionary = ScreenScript.node_display({"node_depth": 7, "type": "boss"}, 7, type_vis, false, false)
var boss_b: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, type_vis, false, false)
Kit.check("多终点:两个 Boss 节点均带 Boss 描边〔fv3〕",
bool(boss_a["boss_border"]) and bool(boss_b["boss_border"]))
Kit.check("Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕",
bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"]))
var far_boss: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, [1], false, false)
Kit.check("Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕",
bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"]))
var fogged_battle: Dictionary = ScreenScript.node_display({"node_depth": 5, "type": "battle"}, 5, type_vis, false, false)
Kit.check("Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕", not bool(fogged_battle["draw_icon"]))
# ---- ③ 节点内详情图上从不显示〔fv5〕----
func _test_details_never_shown() -> void:
var plain := {"node_depth": 3, "type": "battle"}
var loaded := {"node_depth": 3, "type": "battle", "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
var a: Dictionary = ScreenScript.node_display(plain, 3, [3], false, true)
var b: Dictionary = ScreenScript.node_display(loaded, 3, [3], false, true)
var same := true
for k in a.keys():
if a[k] != b[k]:
same = false
Kit.check("决策面不消费内容键:携带 enemy_id/reward_id 与不带者输出逐键相等〔fv5〕", same)
Kit.check("label_text 不含内容值(实际「%s」)〔fv5〕" % String(b["label_text"]),
not String(b["label_text"]).contains("goblin_secret") and not String(b["label_text"]).contains("reward_secret"))
# ---- ④ label_text 三态文案〔fv2〕----
func _test_label_three_states() -> void:
var node := {"node_depth": 3, "type": "battle"}
Kit.check("当前位文案=「当前位置」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置")
Kit.check("候选文案=「战斗 · 可选」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], false, true)["label_text"]) == "战斗 · 可选")
Kit.check("其余文案=类型名「战斗」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], false, false)["label_text"]) == "战斗")
# ---- 大图(11 层×6 槽含双 Boss)画布拟合与布局包络〔fv8〕----
# 大图夹具:编号按 12 层×6 槽构造,层 0 入口、末层双 Boss;含层 1 首节点→第 3 层节点的跨层跳边。
# 跳边目标按 BFS 最短路径提前一层,其后继链随之提前——布局落 11 层(最大槽 7:
# 跳边目标并入第 2 层槽组)。断言按布局实测取最大层/最大槽,画布公式与之同源。
func _big_graph_nodes() -> Array:
var layers: Array = []
var total := 0
var tiers := 12
for l in tiers:
var width: int = 1 if l == 0 else (2 if l == tiers - 1 else 6)
var ids: Array = []
for i in width:
ids.append(total)
total += 1
layers.append(ids)
var nodes: Array = []
var jump_from := -1
var jump_to := -1
for l in tiers:
for i in layers[l].size():
var nid: int = layers[l][i]
var n := {"node_depth": nid}
if l == 0:
n["type"] = "battle"
n["next"] = layers[1].duplicate() # 入口扇出到全层(层宽 6)
elif l == tiers - 1:
n["type"] = "boss"
n["next"] = []
else:
if i % 5 == 0:
n["type"] = "reward" if l % 2 == 0 else "rest"
else:
n["type"] = "battle"
var w2: int = layers[l + 1].size()
n["next"] = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
nodes.append(n)
jump_from = layers[1][0]
jump_to = layers[3][2]
for n in nodes:
if int(n["node_depth"]) == jump_from:
(n["next"] as Array).append(jump_to)
break
return nodes
func _test_big_graph_fit(db) -> void:
var nodes := _big_graph_nodes()
var entry: Dictionary = nodes[0]
var runner = _runner(db, nodes, [nodes[1]])
var screen = ScreenScript.new()
root.add_child(screen)
screen.open_choices([nodes[1]], runner)
await process_frame
await process_frame
var min_size: Vector2 = screen.canvas_probe()["min_size"]
var pos: Dictionary = screen.layout_probe(nodes)
var origin: Vector2 = (pos[entry] as Vector2) - Vector2(ScreenScript.LAYER_BASE_X, ScreenScript.SLOT_BASE_Y)
# 最大层 / 最大槽按布局实测反解(公式与 _fit_canvas 同源)
var maxl := 0
var maxslot := 0
for node in nodes:
var p: Vector2 = pos[node]
maxl = maxi(maxl, int(round((p.x - origin.x - ScreenScript.LAYER_BASE_X) / ScreenScript.LAYER_STEP_X)))
maxslot = maxi(maxslot, int(round((p.y - origin.y - ScreenScript.SLOT_BASE_Y) / ScreenScript.SLOT_STEP_Y)))
Kit.check("大图夹具规模:≥11 层 × ≥6 槽(实测最大层 %d/最大槽 %d)〔fv8〕" % [maxl, maxslot],
maxl >= 10 and maxslot >= 5)
var need_w: float = ScreenScript.LAYER_BASE_X + maxl * ScreenScript.LAYER_STEP_X \
+ ScreenScript.NODE_HALF.x + 8.0
var need_h: float = ScreenScript.SLOT_BASE_Y + maxslot * ScreenScript.SLOT_STEP_Y \
+ ScreenScript.NODE_HALF.y + 24.0
Kit.check("大图拟合:画布 min.x 覆盖最深节点(%.1f ≥ %.1f)〔fv8〕" % [min_size.x, need_w],
min_size.x >= need_w - 0.01)
Kit.check("大图拟合:画布 min.y 覆盖最宽层底节点(%.1f ≥ %.1f)〔fv8〕" % [min_size.y, need_h],
min_size.y >= need_h - 0.01)
var fit_rect := Rect2(origin, min_size)
var all_in := true
for node in nodes:
var p: Vector2 = pos[node]
if not fit_rect.encloses(Rect2(p - ScreenScript.NODE_HALF, ScreenScript.NODE_HALF * 2.0)):
all_in = false
Kit.check("大图布局:全节点方块落在拟合画布内(origin=(%.1f,%.1f) size=(%.1f,%.1f))〔fv8〕"
% [origin.x, origin.y, min_size.x, min_size.y], all_in)
var disp: Dictionary = screen.display_probe()
var boss_ids: Array = []
for node in nodes:
if String(node["type"]) == "boss":
boss_ids.append(int(node["node_depth"]))
Kit.check("大图呈现:两个终点 Boss 均有描边标记〔fv8〕",
disp.has(boss_ids[0]) and disp.has(boss_ids[1])
and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"]))
Kit.check("大图呈现:入口停点远处战斗节点(编号 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
screen.queue_free()
@@ -0,0 +1 @@
uid://bncwlglvp7tth