From 0529e1691c276d3c457ee47769f03ff127409f73 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sun, 25 Oct 2026 03:49:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(iter37=E2=91=A0):=20T2=20=E5=91=88=E7=8E=B0?= =?UTF-8?q?/=E7=BB=84=E5=90=88=E6=A0=B9=E7=BC=BA=E9=99=B7=E4=BF=AE?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=93=84=E5=8A=9B=E6=A1=A3=E5=88=86=E6=A1=A3?= =?UTF-8?q?=C2=B7=E5=BA=86=E7=A5=9D=E5=8F=A5=E6=9F=84=C2=B7=E4=BA=92?= =?UTF-8?q?=E6=96=A5=E5=9B=9B=E5=8F=A3=C2=B7=E6=A8=A1=E6=80=81=20fail-fast?= =?UTF-8?q?=C2=B7=E7=AB=99=E6=A1=A9=E6=8C=89=20id=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - d2 蓄力档 `_intent_charge` 布尔分档(颜色相等判据退役,深浅金同值不再误判);d4 庆祝浮动 tween 句柄 kill 纪律(close 必杀) - d5/d6/d7/d8 互斥四口:调试钮判据并入 `_sync_sim_button` 单点、组合根两守卫补模拟态/冒险态、`is_running()` 运行态查询面 - d9 ModalStack open/close fail-fast+遮挡账合并非覆盖(重复 open 不再吞旧账);d12 target_id 产发(damage_controller 四处)+站桩两口 id 先名兜底;d17 await 后实例守卫;d18 参数类型注解 - 断言:standee_fx_sweep_test(新增,18 项)/modal_flow_test V6-5(5 项 wd9,V6-1~4 原样同绿)/damage_controller_test target_id/pacing_queue_test 排播 target_id/system_test wd6+wd7 - 代拍板:d18 卡片字面 `lumas_out: Array[Variant] = null` 在 Godot 4.7 解析报错(typed array 不可默认 null),改用 `Variant = null`——唯一合法注法,零行为变化,随代拍板追认单留痕 --- game/core/damage_controller.gd | 7 +- game/presentation/main.gd | 10 +- game/presentation/modal_stack.gd | 16 ++- game/presentation/row_pacer.gd | 6 +- game/presentation/tune_sim_controller.gd | 8 ++ game/presentation/views/battle_end_overlay.gd | 9 +- game/presentation/views/battle_stage_view.gd | 23 ++- game/presentation/views/board_view.gd | 2 +- game/presentation/views/debug_overlay.gd | 4 +- game/presentation/views/settlement_fx_view.gd | 2 + game/presentation/views/standee_body.gd | 13 +- game/presentation/views/ui_kit.gd | 2 +- game/tests/run_all.gd | 1 + game/tests/system_test.gd | 42 ++++++ .../damage_controller_test.gd | 23 +++ .../unit/presentation/modal_flow_test.gd | 26 ++++ .../unit/presentation/pacing_queue_test.gd | 41 ++++++ .../presentation/standee_fx_sweep_test.gd | 133 ++++++++++++++++++ .../presentation/standee_fx_sweep_test.gd.uid | 1 + 19 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 game/tests/unit/presentation/standee_fx_sweep_test.gd create mode 100644 game/tests/unit/presentation/standee_fx_sweep_test.gd.uid diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 6d0571df..66a73caf 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -166,6 +166,7 @@ func open_ticket(kind: String, source_class: String, display: String, channel: S var matchup_o: String = (attack_type + "_vs_" + armor_b) if attack_type != "" else "" var ac_payload := {"atk": int(base_value), "target": String(tgt.get("display_name", "")) if tgt != null else "", + "target_id": String(tgt.get("enemy_id", "")) if tgt != null else "", "matchup": matchup_o, "dig_streak": dig_streak, "nest_count": _nest_count()} t.context["fx_attack_hits"] = _fx_hits("attack_commit", ac_payload) t.context["fx_attack_payload"] = ac_payload @@ -367,7 +368,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D var roll_read: Dictionary[Variant, Variant] = ctx["roll_read"] var atk_type: String = ctx["atk_type"] var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.settlement.crit_multiplier), int(ctx["cdelta"])) - var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name)} + var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name), "target_id": String(target.get("enemy_id", ""))} payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型) payload["roll"] = int(roll_read.roll) payload["ac"] = int(ctx["threshold"]) @@ -397,7 +398,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D # kill 发射+施加(敌人死亡确认;贯穿射击溢出弹射订阅面:对下一存活敌 # 以穿刺类型弹射本体 value%〔无次敌=弹射落空〕。B 模式入队段尾 flush 兑现;A 模式递归 resolve) if bool(payload.get("killed", false)): - for h in _fx_hits("kill", {"target": String(target.display_name), "overflow": landed}): + for h in _fx_hits("kill", {"target": String(target.display_name), "target_id": String(target.get("enemy_id", "")), "overflow": landed}): if String(h.get("kind", "")) == "reveal_tiles" and fx_reveal_request.is_valid(): fx_reveal_request.call() # 斩首学者——击杀揭示 1 格(编排器入队,授予段末兑付) continue @@ -501,7 +502,7 @@ func _resolve_board_typed(t: Dictionary) -> void: var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.settlement.damage_floor)) units.apply_damage(target, dealt) emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → %s" % [String(t.display), String(target.display_name)], dealt, - {"target": String(target.display_name), "attack_type": String(t.attack_type), "effectiveness": cdelta}, String(t.highlight)) + {"target": String(target.display_name), "target_id": String(target.get("enemy_id", "")), "attack_type": String(t.attack_type), "effectiveness": cdelta}, String(t.highlight)) _outcomes[int(t.ticket_id)] = "redeemed" diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 11eb50ed..e474c8a3 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -708,9 +708,9 @@ func _on_battle_end_continue() -> void: func _start_debug_levels() -> void: - # 守卫:调试对局与活跃冒险/结算并存不开放——并发会清写 - # settlement/编排态=状态机破坏;对局中开窗是只读浮窗,不是开局前置 - if run_scope.is_adventure or run_scope.settlement != null: + # 守卫:调试对局与活跃冒险/结算/批量模拟并存不开放——并发会清写 + # settlement/编排态=状态机破坏;模拟在跑时 reset_sliders 会作废测量读数(d5 置灰之外的信号绕过闸) + if run_scope.is_adventure or run_scope.settlement != null or _tune_sim.is_running(): return debug_overlay.reset_sliders() _close_modal(debug_overlay) # 浮窗内入口发起——随开随收(对局期不可再开:门禁在 _show_debug_overlay) @@ -1483,8 +1483,8 @@ func _on_tune_apply(atk_pct: int, hp_pct: int) -> void: func _on_tune_simulate(n: int) -> void: # 批量模拟编排 = 控制器承接(tune_sim_controller.gd);E4 后无表态需复位——模拟走 runner 覆盖参数。 - # 对局中禁用兜底(长程异步模拟与活跃结算并存态不开放;探针 emit 绕过 disabled 时由此守住) - if run_scope.settlement != null: + # 对局中禁用兜底(长程异步模拟与活跃冒险/结算并存态不开放;探针 emit 绕过 disabled 时由此守住) + if run_scope.settlement != null or run_scope.is_adventure: return _tune_sim.run(n) diff --git a/game/presentation/modal_stack.gd b/game/presentation/modal_stack.gd index dd8f0ec4..a02ea728 100644 --- a/game/presentation/modal_stack.gd +++ b/game/presentation/modal_stack.gd @@ -19,18 +19,30 @@ func register(m: Control, tags: Array = []) -> void: func open(m: Control) -> void: - var hidden: Array[Control] = [] var idx := _list.find(m) + if idx < 0: + push_error("ModalStack.open: 未登记层不可打开(register 先行)") + return + var hidden: Array[Control] = [] for i in range(idx + 1, _list.size()): var higher: Control = _list[i] if is_instance_valid(higher) and higher.visible: higher.visible = false hidden.append(higher) - _occluded[m] = hidden + # 遮挡账合并非覆盖:同层重复 open 时上方已隐、hidden 为空,旧账须保留 + # (空表覆盖会让先前被隐层在本层 close 后永不恢复) + var merged: Array = _occluded.get(m, []).duplicate() + for h in hidden: + if not merged.has(h): + merged.append(h) + _occluded[m] = merged m.visible = true func close(m: Control) -> void: + if _list.find(m) < 0: + push_error("ModalStack.close: 未登记层不可关闭(register 先行)") + return m.visible = false for h in _occluded.get(m, []): if is_instance_valid(h): diff --git a/game/presentation/row_pacer.gd b/game/presentation/row_pacer.gd index fb70ea59..9791fa8d 100644 --- a/game/presentation/row_pacer.gd +++ b/game/presentation/row_pacer.gd @@ -108,11 +108,12 @@ func _play_settlement_mark(row: Dictionary) -> void: if mark_type != "effect" and mark_type != "damage" and mark_type != "heal": return var target := String(row.get("target", "")) + var target_id := String(row.get("target_id", "")) var center: Vector2 if target == "hero": center = stage.hero_standee_center() else: - var v = stage.enemy_standee_center(target) if target != "" and target != "all_enemies" else null + var v = stage.enemy_standee_center(target_id, target) if target != "" and target != "all_enemies" else null center = v if v != null else board_view.get_global_rect().get_center() match mark_type: "effect": @@ -133,6 +134,7 @@ func _play_cross_zone_fx(row: Dictionary) -> void: match String(row.get("type", "")): "damage": var target := String(row.get("target", "")) + var target_id := String(row.get("target_id", "")) var crit := bool(row.get("crit", false)) if target == "" or target == "hero": # 主角受击弹道(禁「敌→主角零反馈」早退):⚔ 危险红飞向主角站桩; @@ -140,7 +142,7 @@ func _play_cross_zone_fx(row: Dictionary) -> void: settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "⚔", UiKit.RED_MINE, crit) return - var to_v = stage.enemy_standee_center(target) + var to_v = stage.enemy_standee_center(target_id, target) if to_v != null: settlement_fx.play_projectile(from_v, to_v, "⚔", UiKit.GOLD if crit else UiKit.RED_MINE, crit) diff --git a/game/presentation/tune_sim_controller.gd b/game/presentation/tune_sim_controller.gd index ccb3b0d8..a0a01e44 100644 --- a/game/presentation/tune_sim_controller.gd +++ b/game/presentation/tune_sim_controller.gd @@ -11,6 +11,7 @@ var db var panel # 调试浮窗 setter 面:atk_pct/hp_pct/set_sim_running/set_sim_progress/set_sim_report var _base_hp := 0 # 报告中的"基础 HP"口径注记值 var _driver = null # 关卡自动策略驱动(调用方注入;null = 旧行为运行时 load 兜底) +var _running := false # 运行态(run 全程 true——组合根互查守卫用,与面板置灰解耦) func _init(db_ref, panel_ref, base_hp: int, driver = null) -> void: @@ -27,7 +28,13 @@ func _autoplay(): return load("res://sim/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3 兜底路径) +# 运行态查询出口(组合根互查守卫:模拟在跑期禁开调试对局/二次模拟) +func is_running() -> bool: + return _running + + func run(n: int) -> void: + _running = true # 共享日志会话(模拟运行与真人试玩同日志合规——操作/轮次/战斗/通关全量 JSONL; # 一文件多局,run 行携带种子切分局次) var logger = PlaytestLogger.new() @@ -60,3 +67,4 @@ func run(n: int) -> void: panel.set_sim_report(HudPresenter.tune_sim_report(n, panel.atk_pct(), panel.hp_pct(), wins, dmg_all, hp_left_win, hp_boss, rounds_all, death_nodes, _base_hp)) panel.set_sim_running(false) + _running = false diff --git a/game/presentation/views/battle_end_overlay.gd b/game/presentation/views/battle_end_overlay.gd index 19a28ce0..cd77093e 100644 --- a/game/presentation/views/battle_end_overlay.gd +++ b/game/presentation/views/battle_end_overlay.gd @@ -24,6 +24,7 @@ var _bonus_row_l: HBoxContainer var _bonus_name: Label var _bonus_val: Label var _cele: TextureRect # 庆祝立绘(idle 占位浮动) +var _celebrate_tw: Tween # 庆祝浮动循环句柄(每调先 kill,close 必杀——无限循环禁裸创建) var _btn: Button var _board_rect := Rect2() @@ -158,6 +159,9 @@ func close() -> void: _seal.visible = false _seal.modulate = Color.WHITE _cele.visible = false + if _celebrate_tw != null and _celebrate_tw.is_valid(): + _celebrate_tw.kill() + _celebrate_tw = null _clear_crest_bricks() @@ -281,7 +285,10 @@ func _play_dust_sadle() -> void: func _play_celebrate() -> void: if UiKit.motion_scale < 1.0: return - var tw := _cele.create_tween() + if _celebrate_tw != null and _celebrate_tw.is_valid(): + _celebrate_tw.kill() + _celebrate_tw = _cele.create_tween() + var tw := _celebrate_tw tw.set_loops() tw.tween_property(_cele, "position:y", _cele.position.y - 4.0, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) tw.tween_property(_cele, "position:y", _cele.position.y, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) diff --git a/game/presentation/views/battle_stage_view.gd b/game/presentation/views/battle_stage_view.gd index fb23bc05..78ab8b5e 100644 --- a/game/presentation/views/battle_stage_view.gd +++ b/game/presentation/views/battle_stage_view.gd @@ -305,7 +305,9 @@ func on_step_row_fx(row: Dictionary) -> void: _schedule_arrival_fx(hero_standee, bool(row.get("killed", false))) return hero_standee.set_hero_state("attack") # 我方攻击行——attack 态(短驻自回) - var card: Node2D = _card_by_name(target) + var card: Node2D = _card_by_id(String(row.get("target_id", ""))) + if card == null: + card = _card_by_name(target) # 旧行无 target_id 时名兜底 if card == null: return _schedule_arrival_fx(card, bool(row.get("killed", false))) @@ -338,9 +340,24 @@ func _card_by_name(display_name: String) -> Node2D: return null +# 按敌单位 id 精确解析站桩(同名敌主键——空 id 恒不命中,由调用点名兜底) +func _card_by_id(enemy_id: String) -> Node2D: + if enemy_id == "": + return null + for c in _cards: + if not is_instance_valid(c): + continue + if c.enemy_id() == enemy_id: + return c + return null + + # 跨区弹道目标坐标(结算演出链批——组合根传给 SettlementFxView 的全局锚点;壳层口径 C4) -func enemy_standee_center(display_name: String): - var card := _card_by_name(display_name) +# 解析 id 先、display_name 兜底(旧行无 target_id) +func enemy_standee_center(target_id: String, display_name: String) -> Variant: + var card := _card_by_id(target_id) + if card == null: + card = _card_by_name(display_name) if card == null or not is_instance_valid(card): return null return world_to_shell(card.position + card.box * 0.5) diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index 1b919528..96870458 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -995,7 +995,7 @@ static func _flip_tex(tex: Texture2D) -> Texture2D: # 地板瓦片明度归一(方向稿 _v3_darken_floors 同式移植):8×8 双线性重采样均值测光 # (sRGB→线性域,Rec.709 系数),增益 k=(目标/实测)^(1/2.2) 钳 [0.6,1.8],逐通道 sRGB 域线性拉伸。 # 输入恒为 freshly-loaded 原图(load_tex 每次盘读)——幂等可重入;32×32 五变体开销可忽略 -static func _normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out = null) -> Texture2D: +static func _normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out: Variant = null) -> Texture2D: var img: Image = tex.get_image() if img.is_compressed(): img.decompress() diff --git a/game/presentation/views/debug_overlay.gd b/game/presentation/views/debug_overlay.gd index 7fb16c8d..19128e92 100644 --- a/game/presentation/views/debug_overlay.gd +++ b/game/presentation/views/debug_overlay.gd @@ -81,7 +81,6 @@ func set_sim_running(running: bool) -> void: # 在活跃对局并存态不开放(长程异步模拟/并发对局编排与活跃结算并存态破坏状态机) func set_context_locked(locked: bool) -> void: _ctx_locked = locked - _dbg_btn.disabled = _ctx_locked _sync_sim_button() @@ -97,9 +96,12 @@ func run_data_probe() -> Dictionary: return _run_data.duplicate() +# 两钮同判据(模拟运行 / 对局上下文锁定对「模拟」「两层调试」两入口等价)—— +# 判据单点:两 setter 都经此同步,禁在调用点各写一份 func _sync_sim_button() -> void: _sim_btn.disabled = _sim_running or _ctx_locked _sim_btn.text = "模拟中…" if _sim_running else "运行模拟" + _dbg_btn.disabled = _sim_running or _ctx_locked func set_sim_progress(text: String) -> void: diff --git a/game/presentation/views/settlement_fx_view.gd b/game/presentation/views/settlement_fx_view.gd index 6f02d2b3..0f429a83 100644 --- a/game/presentation/views/settlement_fx_view.gd +++ b/game/presentation/views/settlement_fx_view.gd @@ -92,6 +92,8 @@ func play_damage_number(pos: Vector2, text: String, col: Color, crit: bool = fal lab.z_index = 6 add_child(lab) await get_tree().process_frame # 等一帧取实际尺寸(文本度量) + if not is_instance_valid(lab): + return # 等待帧间节点已释放(父层收起/清场)——守卫后立即退出,禁触已释放实例 lab.position = pos - lab.size * 0.5 var tw := lab.create_tween() tw.tween_property(lab, "position:y", lab.position.y - 40.0, UiKit.motion(1.1)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) diff --git a/game/presentation/views/standee_body.gd b/game/presentation/views/standee_body.gd index 046f44ce..a5396a34 100644 --- a/game/presentation/views/standee_body.gd +++ b/game/presentation/views/standee_body.gd @@ -31,6 +31,7 @@ var _state_revert_tw: Tween var _order := 1 var _intent_glyph := "" var _intent_col := Color.WHITE +var _intent_charge := false # 蓄力档(pid ∈ CHARGE_SKILL_IDS——意图三档 ③ 分支判据) var _intent_visible := false var _intent_scale := 1.0: # 意图弹入动画参数 set(v): @@ -66,6 +67,11 @@ func display_name() -> String: return _display_name +# 敌单位身份出口(d12 站桩解析主键——同名敌按 id 精确匹配;主角站桩恒空串) +func enemy_id() -> String: + return _enemy_id + + # 血条存在性探针出口(世界直角血条常在) var has_world_hp_bar := true @@ -153,10 +159,12 @@ func set_order(i: int) -> void: # "" = 未摇定/死亡残位)。不显示技能名——类型级图标 + 一次性动作动画。 func set_pending(ptype: String, pid: String) -> void: if _dead: + _intent_charge = false _intent_visible = false queue_redraw() return if ptype == "" or ptype == "basic": + _intent_charge = false _intent_visible = false # 普通攻击零表示(完全不可预测——§2.4) queue_redraw() return @@ -168,7 +176,8 @@ func set_pending(ptype: String, pid: String) -> void: elif ptype == "debuff": glyph = "☠" # 妨害系 col = UiKit.DEBUFF_PURPLE - if pid in CHARGE_SKILL_IDS: + _intent_charge = pid in CHARGE_SKILL_IDS + if _intent_charge: col = UiKit.GOLD_INK # 重击类蓄力档(深金墨——浅垫牌上 ≥4.5:1) _intent_glyph = glyph _intent_col = col @@ -185,7 +194,7 @@ func _play_intent_motion() -> void: tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.16)) tw.parallel().tween_property(self, "_glyph_rot", 4.0, UiKit.motion(0.10)) tw.tween_property(self, "_glyph_rot", 0.0, UiKit.motion(0.14)) - if _intent_col == UiKit.GOLD_DEEP: + if _intent_charge: tw.tween_property(self, "_intent_scale", 1.22, UiKit.motion(0.28)).set_trans(Tween.TRANS_SINE) # ③蓄力缓推 tw.tween_interval(UiKit.motion(0.40)) # 定格微顿 tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.18)) diff --git a/game/presentation/views/ui_kit.gd b/game/presentation/views/ui_kit.gd index eb2441dd..65015456 100644 --- a/game/presentation/views/ui_kit.gd +++ b/game/presentation/views/ui_kit.gd @@ -25,7 +25,7 @@ const PANEL_BORDER := Color8(120, 110, 88) # 面板细线框;兼熄灭点 const GOLD := Color8(240, 168, 40) # 奖励金 const GOLD_DEEP := Color8(176, 122, 20) # 深金(金框描边/达意金) const GOLD_INK := Color8(116, 80, 11) # 深金墨(意图蓄力徽标墨——PANEL_DATA 垫牌上 4.9:1) -const GOLD_BRIGHT := Color8(224, 184, 96) # 金族亮档(敌顺位徽标①②③/重甲徽标对暗舞台底 6.5:1;值=收益金 e0b860 同门;GOLD_DEEP 语义岗〔standee:199 意图判据/board_view:35/level_map:237〕在役不动) +const GOLD_BRIGHT := Color8(224, 184, 96) # 金族亮档(敌顺位徽标①②③/重甲徽标对暗舞台底 6.5:1;值=收益金 e0b860 同门) const GOLD_LIT := Color8(230, 190, 90) # 亮金(候选高亮) const GOLD_DIM := Color8(140, 116, 60) # 暗金(已过节点) const GOLD_DONE := Color8(104, 86, 44) # 已过节点暗金·关卡画面呈现态 #68562C(GOLD_DIM #8C743C 对双色墨均 <4.5:1,对比硬指标逼守此值;GOLD_DIM 他用不动) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index a8501016..41a682fb 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -59,6 +59,7 @@ func _suites() -> Array: {"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/standee_fx_sweep_test.gd", "expect": 0}, # 呈现域缺陷扫掠(d2 蓄力档/d4 庆祝句柄/d5+d8 互斥/d12 站桩按 id 解析) {"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 自动化前置面) diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd index bfd2ea01..12c127bf 100644 --- a/game/tests/system_test.gd +++ b/game/tests/system_test.gd @@ -40,6 +40,7 @@ func _split_tail() -> void: await _smoke_scene_split() # 协程函数必须 await(无 await=发射后不管,首个 await 后即被 quit 截断) await _smoke_boot_chain() await _smoke_run_lifecycle() + await _smoke_sim_guards() await _smoke_lifecycle_matrix() else: check(false, "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)") @@ -269,6 +270,47 @@ func _smoke_run_lifecycle() -> void: DirAccess.remove_absolute(p) +# ---- d6/d7 批量模拟互斥守卫(wd6/wd7——白盒置态直调入口,以守卫早退的可见后果为断言面)---- +func _smoke_sim_guards() -> void: + print("--- 卡① T2:批量模拟互斥两守卫(d6/d7)---") + var main: Node = load("res://presentation/main.tscn").instantiate() + root.add_child(main) + await process_frame + var sm = main.get_node("/root/SaveManager") + for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + sm.attach_path("user://smoke_sim_guards.json") + sm.mark_first_adventure_done() + # wd6:闲时态 + 模拟运行态 → 调试两层入口守卫早退(开始层不收起、无结算对象) + check(main.run_scope.settlement == null and not main.run_scope.is_adventure, + "前置:闲时会话(settlement 空 + is_adventure=false)") + main._start_overlay.visible = true + main._tune_sim._running = true + main._start_debug_levels() + check(main.run_scope.settlement == null and main._start_overlay.visible, + "模拟运行态下调试两层入口守卫早退(开始层不收起)〔wd6〕") + main._tune_sim._running = false + # wd7:真实冒险关间态(is_adventure=true、settlement==null)→ 模拟入口守卫早退 + main.debug_overlay.seed_edit.text = "42" + main._start_adventure_from_menu() + await process_frame + check(main.run_scope.is_adventure and main.run_scope.settlement == null, + "前置:真实冒险关间态(选路停点,非战斗)") + main._on_tune_simulate(3) + check(not main._tune_sim.is_running(), + "冒险关间态下批量模拟入口守卫早退(d7 补 is_adventure)〔wd7〕") + main._tune_sim._running = false # 红态清理(守卫缺失时 run 已启动) + main.run_scope.adventure.abandon() + for i in range(8): + await process_frame + main.queue_free() + await process_frame + for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + # 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采) # (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) func _face_snapshot(main: Node) -> Dictionary: diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd index 400f58a0..e03ea8cf 100644 --- a/game/tests/unit/battle_settlement/damage_controller_test.gd +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd @@ -30,6 +30,7 @@ func _init() -> void: _test_policy_immediate_mode(db) _test_source_death_void(db) _test_identical_content_dual_enemy(db) + _test_target_id_payload(db) _test_requires_gate_and_zero(db) _test_hero_death_halt(db) _test_ac_merge(db) @@ -254,6 +255,28 @@ func _test_identical_content_dual_enemy(db) -> void: # ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ---- +# d12 产发端增键:目标身份随行/随载荷下发(同名敌按 id 精确解析的产发半) +func _test_target_id_payload(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + Kit.check("前置:units.spawn(1,0) 成功(target_id 断言)", false) + return + var target = h.units.default_target() + var expect_id := String(target.get("enemy_id", "")) + Kit.check("前置:目标敌携非空 enemy_id", expect_id != "") + var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", + "T6目标id", "战斗段", int(db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1) + var fx_payload: Dictionary = ticket.context.get("fx_attack_payload", {}) + Kit.check("attack_commit fx 载荷携 target_id〔wd12〕", + String(fx_payload.get("target_id", "")) == expect_id and String(fx_payload.get("target", "")) != "") + h.ctrl.submit(ticket) + h.ctrl.flush() + var row := _row_by_display_prefix("T6目标id") + Kit.check("伤害行携 target_id == 目标敌 enemy_id〔wd12〕", + not row.is_empty() and String(row.extra.get("target_id", "")) == expect_id) + + func _test_requires_gate_and_zero(db) -> void: _rows = [] var h = _harness(db) diff --git a/game/tests/unit/presentation/modal_flow_test.gd b/game/tests/unit/presentation/modal_flow_test.gd index 08cd4b33..97177d67 100644 --- a/game/tests/unit/presentation/modal_flow_test.gd +++ b/game/tests/unit/presentation/modal_flow_test.gd @@ -87,6 +87,7 @@ func _init() -> void: # V6-4 演播后排窗挂起+边沿派发(T17 语义) var end_ov := Control.new() end_ov.visible = false + flow.register(end_ov) # d9 fail-fast 后:未登记层不可进入编排(夹具按 register 先行契约登记) rows["empty"] = false var sync_before1: int = calls["open_sync"] flow.open_after_playback(end_ov) @@ -105,9 +106,34 @@ func _init() -> void: flow.open_after_playback(end_ov) rows["empty"] = true var other := Control.new() + flow.register(other) var sync_before: int = calls["open_sync"] flow.open(other) Kit.check_quiet(end_ov.visible and flow._deferred == null and calls["open_sync"] == sync_before + 2, "V6 挂起兜底:开新层先结挂起(T17 防永不兑现——挂起层先开+新层后开两拍)〔迭代27 T4〕") + # V6-5 遮挡失败快+遮挡账合而非覆盖(d9) + var ms = ModalStackScript.new() + var ma := Control.new() + var mb := Control.new() + var stranger := Control.new() + stranger.visible = false # Control 默认 visible=true——先隐,才能断言 fail-fast 未把它照上屏 + ms.register(ma) + ms.register(mb) + ms.open(ma) + ms.open(stranger) # 未登记层:push_error + 早退(零副作用) + Kit.check_quiet(not stranger.visible and ma.visible, + "V6 未登记层 open fail-fast(该层不可见、已开层不变)〔wd9〕") + ms.close(stranger) + Kit.check_quiet(not stranger.visible and ma.visible, + "V6 未登记层 close fail-fast(已开层不受影响)〔wd9〕") + ms.open(mb) + ms.open(ma) # 开低层隐其上方可见层 + Kit.check_quiet(not mb.visible, "V6 开低层隐其上方层(重复 open 前态)〔wd9〕") + ms.open(ma) # 同层重复 open:上方已隐,hidden 空表——旧账须保留 + Kit.check_quiet(not mb.visible, "V6 重复 open 保持遮挡(V6 在役语义不变)〔wd9〕") + ms.close(ma) + Kit.check_quiet(mb.visible, "V6 重复 open 不吞遮挡账(close 低层恢复 mb——合并非覆盖)〔wd9〕") + ms.close(mb) + quit(Kit.finish("modal_flow")) diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index e60e5778..d5ee24a0 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -7,9 +7,35 @@ # 运行:godot --headless --path game --script res://tests/unit/presentation/pacing_queue_test.gd(expect 0) extends SceneTree +const RowPacerScript := preload("res://presentation/row_pacer.gd") + var _problems: Array = [] +# 记参假站桩(d12 排播半):继承真类以过类型面,只记解析入参 +class FakeStage extends BattleStageView: + var calls: Array = [] + + func enemy_standee_center(target_id: String, display_name: String) -> Variant: + calls.append([target_id, display_name]) + return Vector2(10, 10) + + +# 记参假演出层(同上——play_* 全部只记不演) +class FakeFx extends SettlementFxView: + var calls: Array = [] + + func play_projectile(_from: Vector2, _to: Vector2, _glyph: String, _col: Color, _crit: bool = false) -> void: + calls.append(["projectile", _to]) + + func play_damage_number(_pos: Vector2, _text: String, _col: Color, _crit: bool = false) -> void: + calls.append(["mark", _pos]) + + func play_status_pulse(_pos: Vector2, _col: Color) -> void: + calls.append(["pulse", _pos]) + + + func check(cond: bool, msg: String) -> void: if not cond: _problems.append(msg) @@ -98,6 +124,21 @@ func _init() -> void: UiKit.motion_scale = 0.5 check(main.pace_delay_for("damage", "heal") == 0.0, "减动效段间延迟归零(行不跳过——同帧直落快路)") UiKit.motion_scale = 1.0 + # ---- 断言④:排播两处取用按行 target_id 解析站桩(d12 排播半——假 stage 记参)---- + var rp = RowPacerScript.new() + var fstage := FakeStage.new(null) + var ffx := FakeFx.new() + rp.stage = fstage + rp.board_view = main.board_view + rp.settlement_fx = ffx + rp._play_settlement_mark({"type": "damage", "target": "哥布林", "target_id": "goblin_b", "value": 3}) + check(fstage.calls.size() == 1 and fstage.calls[0] == ["goblin_b", "哥布林"], + "结算标出按行 target_id 解析站桩(id 与名同传——d12)") + rp._play_cross_zone_fx({"type": "damage", "target": "哥布林", "target_id": "goblin_b", "value": 3}) + check(fstage.calls.size() == 2 and fstage.calls[1] == ["goblin_b", "哥布林"], + "跨区弹道按行 target_id 解析站桩(id 与名同传——d12)") + check(ffx.calls.size() == 2, "两路解析成功后演出层各接一拍(记参面非空)") + rp.free() _finish() diff --git a/game/tests/unit/presentation/standee_fx_sweep_test.gd b/game/tests/unit/presentation/standee_fx_sweep_test.gd new file mode 100644 index 00000000..892f6d3c --- /dev/null +++ b/game/tests/unit/presentation/standee_fx_sweep_test.gd @@ -0,0 +1,133 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 呈现域缺陷扫掠断言(行为缺陷定点修批卡① T2 五族): +# ① 意图蓄力档可达(d2——_intent_charge 分档,旧 GOLD_DEEP 判据恒不可达); +# ② 庆祝 tween 句柄纪律(d4——重开不叠加、close 必杀); +# ③ 互斥四口(d5 调试钮同判据 / d8 控制器运行态可查); +# ⑤ 站桩按敌 id 解析(d12 两口:on_step_row_fx 闪白落点 + enemy_standee_center 弹道落点)。 +# d6/d7 两守卫断言宿主 = system_test;d3/d17/d18 属非行为面(实现报告等价申报)。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/standee_fx_sweep_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const StandeeBodyScript := preload("res://presentation/views/standee_body.gd") +const BattleEndScript := preload("res://presentation/views/battle_end_overlay.gd") +const DebugOverlayScript := preload("res://presentation/views/debug_overlay.gd") +const TuneSimScript := preload("res://presentation/tune_sim_controller.gd") +const StageScript := preload("res://presentation/views/battle_stage_view.gd") + + +# 假调试面板(TuneSimController 所需 setter 面 + 树内节点供给 get_tree) +class FakePanel extends Node: + var running := false + var progress := "" + var report := "" + var atk := 0 + var hp := 0 + + func atk_pct() -> int: + return atk + + func hp_pct() -> int: + return hp + + func set_sim_running(r: bool) -> void: + running = r + + func set_sim_progress(t: String) -> void: + progress = t + + func set_sim_report(t: String) -> void: + report = t + + +# 假自动策略(run_level 签名同 level_autoplay——回调内读控制器运行态) +class FakeDriver extends RefCounted: + var ctrl + var seen: Array = [] + var calls := 0 + + func run_level(_db, _seed: int, _level: int, _atk: int, _hp: int, _other: bool, _logger): + calls += 1 + seen.append(ctrl.is_running()) + if calls == 1: + return {"hero_dmg": 12.0, "rounds": 4, "result": "victory", "hp_left": 3.0, "hp_at_boss": 5.0} + return {"hero_dmg": 6.0, "rounds": 2, "result": "defeat", + "defeat_node_index": 3, "defeat_node_type": "battle", "hp_left": 0.0, "hp_at_boss": 0.0} + + +func _init() -> void: + UiKit.motion_scale = 1.0 + + # ---- ⑤ 站桩按 id 解析(两口共用同名字敌载体)---- + var stage = StageScript.new(null) + root.add_child(stage) + var s1 = StandeeBodyScript.new("enemy") + var s2 = StandeeBodyScript.new("enemy") + stage.world.add_child(s1) + stage.world.add_child(s2) + s1.set_data({"display_name": "哥布林", "enemy_id": "goblin_a", "hp": 3, "max_hp": 3, "order": 1}) + s2.set_data({"display_name": "哥布林", "enemy_id": "goblin_b", "hp": 3, "max_hp": 3, "order": 2}) + s1.position = Vector2(10, 20) + s2.position = Vector2(200, 20) + stage._cards.clear() + stage._cards.append(s1) + stage._cards.append(s2) + Kit.check("站桩 enemy_id 访问器读装载值〔wd12〕", s1.enemy_id() == "goblin_a" and s2.enemy_id() == "goblin_b") + Kit.check("同名敌按 id 精确解析(不做名子串撞车)〔wd12〕", + stage._card_by_id("goblin_b") == s2 and stage._card_by_id("goblin_a") == s1) + Kit.check("空 id / 未登记 id 恒不命中(名兜底归调用点)〔wd12〕", + stage._card_by_id("") == null and stage._card_by_id("no_such") == null) + # 口一:on_step_row_fx —— 伤害行携 target_id,闪白落点须在 id 指向的站桩 + stage.on_step_row_fx({"type": "damage", "target": "哥布林", "target_id": "goblin_b"}) + await create_timer(0.6).timeout # 弹道到达拍 0.33s + 余量 + Kit.check("伤害行按 target_id 命中正确站桩(闪白落点)〔wd12〕", + s2._flash_count == 1 and s1._flash_count == 0) + # 口二:enemy_standee_center —— id 先、display_name 兜底 + var c_b = stage.enemy_standee_center("goblin_b", "哥布林") + var c_b_expect: Vector2 = stage.world_to_shell(s2.position + s2.box * 0.5) + Kit.check("弹道落点按 target_id 取站桩中心〔wd12〕", c_b != null and c_b == c_b_expect) + var c_fallback = stage.enemy_standee_center("", "哥布林") + var c_fallback_expect: Vector2 = stage.world_to_shell(s1.position + s1.box * 0.5) + Kit.check("无 target_id 时按名兜底同旧行为(首命中)〔wd12〕", c_fallback != null and c_fallback == c_fallback_expect) + + # ---- ① 意图蓄力档可达(d2)---- + s1.set_pending("damage", "heavy") + Kit.check("蓄力技能(heavy)置 _intent_charge=true〔wd2〕", s1._intent_charge) + s1.set_pending("damage", "goblin_slash") + Kit.check("非蓄力攻击技(goblin_slash)零置位〔wd2〕", not s1._intent_charge) + s1.set_pending("buff", "goblin_rage") + Kit.check("增益系零置位〔wd2〕", not s1._intent_charge) + s1.set_pending("damage", "basic") + Kit.check("普攻零表示路径复位 _intent_charge〔wd2〕", not s1._intent_charge) + + # ---- ② 庆祝 tween 句柄纪律(d4)---- + var ov = BattleEndScript.new() + root.add_child(ov) + ov.open_victory("判定行", "继续", Rect2(0, 0, 100, 100)) + var first: Tween = ov._celebrate_tw + Kit.check("首开胜利画面创建庆祝句柄〔wd4〕", first != null and first.is_valid()) + ov.open_victory("判定行", "继续", Rect2(0, 0, 100, 100)) + Kit.check("重开胜利画面:旧句柄已 kill、新句柄在位(至多一个活跃)〔wd4〕", + first != null and not first.is_valid() and ov._celebrate_tw != null and ov._celebrate_tw.is_valid()) + ov.close() + Kit.check("close 必杀庆祝句柄(句柄清空)〔wd4〕", ov._celebrate_tw == null) + + # ---- ③ 互斥四口(d5/d8)---- + var dbg = DebugOverlayScript.new(null, 0, 0) + dbg.set_sim_running(true) + Kit.check("模拟运行期调试入口钮同置灰〔wd5〕", dbg._dbg_btn.disabled) + dbg.set_sim_running(false) + Kit.check("模拟收尾调试入口钮恢复(未锁定)〔wd5〕", not dbg._dbg_btn.disabled) + dbg.free() + + var panel := FakePanel.new() + root.add_child(panel) + var drv := FakeDriver.new() + var ctrl = TuneSimScript.new({"action_economy": {"ap_per_round": 2}}, panel, 100, drv) + drv.ctrl = ctrl + await ctrl.run(2) # 假 driver 回调内读 is_running()(两局各一读) + Kit.check("控制器运行中 is_running()=true(驱动回调内读数)〔wd8〕", drv.seen == [true, true]) + Kit.check("控制器收尾 is_running()=false〔wd8〕", not ctrl.is_running() and not panel.running) + Kit.check("两局报告全文注入面板(run 收尾链路完整)〔wd8〕", String(panel.report) != "") + + quit(Kit.finish("standee_fx_sweep")) diff --git a/game/tests/unit/presentation/standee_fx_sweep_test.gd.uid b/game/tests/unit/presentation/standee_fx_sweep_test.gd.uid new file mode 100644 index 00000000..35ce3080 --- /dev/null +++ b/game/tests/unit/presentation/standee_fx_sweep_test.gd.uid @@ -0,0 +1 @@ +uid://b3vqjio51r2nf