extends SceneTree var failures: Array[String] = [] var started_actions: Array[StringName] = [] func _init() -> void: _run.call_deferred() func _run() -> void: await process_frame # 难度会缩放小怪血量(普通 ×3 / 困难 ×5);本测试关注相位系统, # 钉住 easy 以保持 650 基线断言(直接写字段、不落盘)。 var settings := root.get_node_or_null("GameSettings") if settings != null: settings.set("difficulty", &"easy") await _check_minion_scene_phase_swap_preserves_state() await _check_minion_strength_profiles_and_action_pools() await _check_stage_instances_phase_minions() await _check_boss_phase_action_restrictions() _finish() func _check_minion_scene_phase_swap_preserves_state() -> void: var bus := _ensure_event_bus() var manager := _ensure_time_phase_manager() var scene: PackedScene = load("res://scenes/enemies/minion.tscn") _expect(scene != null, "Minion scene should load") if scene == null: return var minion: Node2D = scene.instantiate() minion.name = "TestPhaseMinion" minion.set("past_form_id", &"jin_zhan_3") minion.set("future_form_id", &"jin_zhan_1") root.add_child(minion) await process_frame # 本检查只关心相位切换本身不移动小怪;关掉巡逻 AI,避免它在 # 等待帧里合法行走造成位置漂移误报。父节点先于子节点跑物理帧, # 因此还要清掉上一帧遗留的 approach_direction,否则仍会漂移一步。 var behavior := minion.get_node_or_null("MinionBehavior") if behavior != null: behavior.set("enabled", false) minion.set("approach_direction", 0.0) var health: Node = minion.get_node_or_null("HealthComponent") _expect(health != null, "Minion should have a HealthComponent") if health != null: _expect_int(int(health.get("maximum")), 650, "Minion max HP should follow the latest design baseline") health.call("set_values", 321, 650) minion.global_position = Vector2(222.0, 333.0) manager.call("reset_to_initial", &"past") await process_frame _expect_equal(minion.get("form_id"), &"jin_zhan_3", "Past phase should use jin_zhan_3 for this slot") manager.call("set_time_phase", &"future", &"debug") await process_frame _expect_equal(minion.get("form_id"), &"jin_zhan_1", "Future phase should swap this slot to jin_zhan_1") _expect_equal(minion.global_position, Vector2(222.0, 333.0), "Phase swap should preserve minion position") if health != null: _expect_int(int(health.get("current")), 321, "Phase swap should preserve minion current HP") manager.call("set_time_phase", &"past", &"debug") await process_frame _expect_equal(minion.get("form_id"), &"jin_zhan_3", "Switching back should restore the Past form") minion.queue_free() _cleanup_node(bus) _cleanup_node(manager) await process_frame func _check_minion_strength_profiles_and_action_pools() -> void: var bus := _ensure_event_bus() var manager := _ensure_time_phase_manager() var scene: PackedScene = load("res://scenes/enemies/minion.tscn") if scene == null: return var past_strong: Node = scene.instantiate() past_strong.name = "PastStrongMinion" past_strong.set("past_form_id", &"jin_zhan_3") past_strong.set("future_form_id", &"jin_zhan_1") past_strong.set("strength_profile", &"past_strong") root.add_child(past_strong) await process_frame manager.call("reset_to_initial", &"past") await process_frame _expect_int(int(past_strong.call("current_attack_interval_beats")), 2, "PastStrongEnemy should attack every two beats in Past") _expect(_all_action_ids_begin_with(past_strong.call("current_action_ids"), "minion_jin_zhan_3"), "Past form should use jin_zhan_3 actions") manager.call("set_time_phase", &"future", &"debug") await process_frame _expect_int(int(past_strong.call("current_attack_interval_beats")), 4, "PastStrongEnemy should attack every four beats in Future") _expect(_all_action_ids_begin_with(past_strong.call("current_action_ids"), "minion_jin_zhan_1"), "Future form should use jin_zhan_1 actions") past_strong.queue_free() var future_strong: Node = scene.instantiate() future_strong.name = "FutureStrongMinion" future_strong.set("past_form_id", &"yuan_cheng_1") future_strong.set("future_form_id", &"yuan_cheng_2") future_strong.set("strength_profile", &"future_strong") root.add_child(future_strong) await process_frame manager.call("set_time_phase", &"past", &"debug") await process_frame _expect_int(int(future_strong.call("current_attack_interval_beats")), 4, "FutureStrongEnemy should attack every four beats in Past") _expect(_all_action_ids_begin_with(future_strong.call("current_action_ids"), "minion_yuan_cheng_1"), "Past form should use yuan_cheng_1 actions") manager.call("set_time_phase", &"future", &"debug") await process_frame _expect_int(int(future_strong.call("current_attack_interval_beats")), 2, "FutureStrongEnemy should attack every two beats in Future") _expect(_all_action_ids_begin_with(future_strong.call("current_action_ids"), "minion_yuan_cheng_2"), "Future form should use yuan_cheng_2 actions") future_strong.queue_free() _cleanup_node(bus) _cleanup_node(manager) await process_frame func _check_stage_instances_phase_minions() -> void: var bus := _ensure_event_bus() var manager := _ensure_time_phase_manager() var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") _expect(stage_scene != null, "Stage scene should load") if stage_scene == null: return var stage: Node = stage_scene.instantiate() root.add_child(stage) await process_frame var jin := stage.get_node_or_null("ActorsContainer/JinZhanMinion") # 当前关卡设计只预置近战怪;远程怪由 LevelDirector 在阶段二动态生成, # 这里直接让导演生成一只来验证双形态相位切换。 var yuan: Node = null var director := stage.get_node_or_null("LevelDirector") if director != null: # 出生点左移(new2)后玩家在 x=1180:远程怪要放在新射程 450 之外, # 避免它在测试等待帧里开打、把形态切换推迟到攻击结束。 yuan = director.call("spawn_minion", &"ranged", Vector2(2100.0, 560.0)) _expect(jin != null, "Stage should instance JinZhanMinion") _expect(yuan != null, "LevelDirector should spawn a ranged YuanCheng minion") if yuan != null: await process_frame var yuan_behavior := yuan.get_node_or_null("MinionBehavior") if yuan_behavior != null: yuan_behavior.set("enabled", false) yuan.set("approach_direction", 0.0) if jin != null: var jin_behavior := jin.get_node_or_null("MinionBehavior") if jin_behavior != null: jin_behavior.set("enabled", false) jin.set("approach_direction", 0.0) if jin != null and yuan != null: manager.call("reset_to_initial", &"past") await process_frame _expect_equal(jin.get("form_id"), &"jin_zhan_3", "Map A/Past should contain jin_zhan_3") _expect_equal(yuan.get("form_id"), &"yuan_cheng_1", "Map A/Past should contain yuan_cheng_1") var jin_health: Node = jin.get_node("HealthComponent") var yuan_health: Node = yuan.get_node("HealthComponent") jin_health.call("set_values", 444, 650) yuan_health.call("set_values", 333, 650) var jin_pos: Vector2 = (jin as Node2D).global_position var yuan_pos: Vector2 = (yuan as Node2D).global_position manager.call("set_time_phase", &"future", &"debug") await process_frame _expect_equal(jin.get("form_id"), &"jin_zhan_1", "Map B/Future should swap jin_zhan_3 to jin_zhan_1") _expect_equal(yuan.get("form_id"), &"yuan_cheng_2", "Map B/Future should swap yuan_cheng_1 to yuan_cheng_2") _expect_equal((jin as Node2D).global_position, jin_pos, "JinZhan phase swap should preserve position") _expect_equal((yuan as Node2D).global_position, yuan_pos, "YuanCheng phase swap should preserve position") _expect_int(int(jin_health.get("current")), 444, "JinZhan phase swap should preserve HP") _expect_int(int(yuan_health.get("current")), 333, "YuanCheng phase swap should preserve HP") stage.queue_free() _cleanup_node(bus) _cleanup_node(manager) await process_frame func _check_boss_phase_action_restrictions() -> void: var bus := _ensure_event_bus() var manager := _ensure_time_phase_manager() var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") if stage_scene == null: return var stage: Node = stage_scene.instantiate() root.add_child(stage) await process_frame var player: Node2D = stage.get_node("ActorsContainer/Player") var boss: Node2D = stage.get_node("ActorsContainer/Boss") var controller: Node = boss.get_node("ActionController") var tree: Node = boss.get_node("BossBehaviorTree") player.global_position = Vector2(100.0, 360.0) boss.global_position = Vector2(170.0, 360.0) boss.set("stationary", false) # Boss 战入场后行为树才会出招(BossRoomGate 会置 combat_enabled)。 boss.set("combat_enabled", true) tree.set("enabled", true) tree.set("decision_interval_beats", 1.0) controller.connect("action_started", _on_action_started) started_actions.clear() manager.call("reset_to_initial", &"past") await process_frame bus.emit_signal("beat_ticked", 20) await process_frame _expect(not started_actions.is_empty(), "Boss should act in Past") if not started_actions.is_empty(): _expect(_action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Past Boss action should be melee-only") _expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"ranged"), "Past Boss action should not be ranged") controller.call("_reset_to_idle") boss.set("state", StringName("idle")) started_actions.clear() manager.call("set_time_phase", &"future", &"debug") await process_frame # new3 §6.4:相位切换后 Boss 停顿一拍,不立刻出招。 bus.emit_signal("beat_ticked", 21) await process_frame _expect(started_actions.is_empty(), "Boss should pause one beat right after the phase switch") bus.emit_signal("beat_ticked", 22) await process_frame _expect(not started_actions.is_empty(), "Boss should act after the phase-switch pause") if not started_actions.is_empty(): # new3 §6.7:远程相位被近身(≤240px)时优先后撤拉开距离,后撤不造成伤害。 _expect(_action_has_tag(started_actions[started_actions.size() - 1], &"retreat"), "Point-blank Future Boss should retreat first") _expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Future Boss action should not be melee") controller.call("_reset_to_idle") boss.set("state", StringName("idle")) started_actions.clear() bus.emit_signal("beat_ticked", 23) await process_frame _expect(not started_actions.is_empty(), "Boss should attack after the retreat") if not started_actions.is_empty(): _expect(_action_has_tag(started_actions[started_actions.size() - 1], &"ranged"), "Future Boss attack should be ranged-only") _expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Future Boss attack should not be melee") stage.queue_free() _cleanup_node(bus) _cleanup_node(manager) await process_frame func _on_action_started(action: Resource, _intent) -> void: if action != null: started_actions.append(StringName(str(action.get("id")))) func _all_action_ids_begin_with(ids: Variant, prefix: String) -> bool: if not ids is Array or (ids as Array).is_empty(): return false for id_value: Variant in ids: if not str(id_value).begins_with(prefix): return false return true func _action_has_tag(action_id: StringName, tag: StringName) -> bool: var resolver: Script = load("res://scenes/combat/action_resolver.gd") if resolver == null: return false var action: Resource = resolver.call("get_action", action_id) if action == null or not action.get("action_tags") is Array: return false return (action.get("action_tags") as Array).has(tag) func _ensure_event_bus() -> Node: var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus func _ensure_time_phase_manager() -> Node: var manager := root.get_node_or_null("TimePhaseManager") if manager == null: manager = load("res://autoload/time_phase_manager.gd").new() manager.name = "TimePhaseManager" root.add_child(manager) return manager func _cleanup_node(node: Node) -> void: if node == null or not is_instance_valid(node): return if node.name == "EventBus" or node.name == "TimePhaseManager": return if node != null and is_instance_valid(node) and node.get_parent() == root: root.remove_child(node) node.free() func _expect(condition: bool, label: String) -> void: if not condition: failures.append(label) func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _expect_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [label, expected, actual]) func _finish() -> void: if failures.is_empty(): print("PASS minion phase system") quit(0) else: for failure: String in failures: push_error(failure) quit(1)