diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 64e4dad1..e2462bc4 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -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 diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index 67328b28..d37a4061 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -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 diff --git a/game/data/tables/level.tres b/game/data/tables/level.tres index 8589bab1..ee4a96aa 100644 --- a/game/data/tables/level.tres +++ b/game/data/tables/level.tres @@ -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": []}]}]}, diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index bfe556d1..cac24598 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -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 := {} diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 0dff5429..14963756 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -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): diff --git a/game/smoke/ui_input_probe.gd b/game/smoke/ui_input_probe.gd index a12d8246..ab7c26bc 100644 --- a/game/smoke/ui_input_probe.gd +++ b/game/smoke/ui_input_probe.gd @@ -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]) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 75305397..a2ee7b20 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -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}, diff --git a/game/tests/unit/core/level_map_type_vis_test.gd b/game/tests/unit/core/level_map_type_vis_test.gd new file mode 100644 index 00000000..b3ca3d97 --- /dev/null +++ b/game/tests/unit/core/level_map_type_vis_test.gd @@ -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()) diff --git a/game/tests/unit/core/level_map_type_vis_test.gd.uid b/game/tests/unit/core/level_map_type_vis_test.gd.uid new file mode 100644 index 00000000..124fad32 --- /dev/null +++ b/game/tests/unit/core/level_map_type_vis_test.gd.uid @@ -0,0 +1 @@ +uid://brh5an0s8q6ss diff --git a/game/tests/unit/presentation/level_map_info_fog_test.gd b/game/tests/unit/presentation/level_map_info_fog_test.gd new file mode 100644 index 00000000..732900f9 --- /dev/null +++ b/game/tests/unit/presentation/level_map_info_fog_test.gd @@ -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() diff --git a/game/tests/unit/presentation/level_map_info_fog_test.gd.uid b/game/tests/unit/presentation/level_map_info_fog_test.gd.uid new file mode 100644 index 00000000..801874ad --- /dev/null +++ b/game/tests/unit/presentation/level_map_info_fog_test.gd.uid @@ -0,0 +1 @@ +uid://bncwlglvp7tth