extends GutTest # ============================================================================= # Test: Bot AI — Sticky Avoidance & Pathfinding [Gauntlet #075] # Verifies the bot's strategic planner correctly: # • Detects Gauntlet mode and exposes helpers. # • Rejects sticky / telegraphed cells in _is_valid_move_target. # • Activates Cleanser when boxed in or standing on telegraphed ground. # • Honours an active Cleanser (treats sticky cells as passable). # • Calls rpc_activate_cleanser on the GauntletManager when triggered. # ============================================================================= const BotStrategicPlanner = preload("res://scripts/bot_strategic_planner.gd") const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") # ---- Mock actors and managers ------------------------------------------------ class StubActor extends Node3D: var peer_id: int = 7 var current_position: Vector2i = Vector2i(5, 5) var enhanced_gridmap: Node = null var movement_range: int = 4 var movement_manager: Node = null var goals: Array = [] var use_diagonal_movement: bool = false func is_position_occupied(_p: Vector2i) -> bool: return false class StubGauntletManager extends Node: # Mimics the slice of GauntletManager API the bot planner depends on. var sticky_map: Dictionary = {} # Vector2i -> true var player_cleansers: Dictionary = {} # peer_id -> int var cleanser_active: Dictionary = {} # peer_id -> true var activate_rpc_calls: Array = [] # recorded [peer_id] func is_sticky_cell(pos: Vector2i) -> bool: return sticky_map.get(pos, false) func is_cleanser_active(pid: int) -> bool: return cleanser_active.get(pid, false) func rpc_activate_cleanser(pid: int) -> void: activate_rpc_calls.append(pid) # ---- Test fixture ----------------------------------------------------------- var main_node: Node var gridmap: Node var gauntlet_manager: StubGauntletManager var actor: StubActor var planner: RefCounted func before_each(): main_node = Node.new() # Unique name per test to avoid collisions with previous runs that haven't # been fully freed yet. main_node.name = "BotTestMain_%d" % Time.get_ticks_usec() get_tree().get_root().add_child(main_node) gridmap = GridMapMock.new() gridmap.name = "EnhancedGridMap" var nwi: Array[int] = [4] gridmap.non_walkable_items = nwi # Pre-seed Floor 0 with a walkable tile (id 1) for every cell, so the bot's # `_is_valid_move_target` Floor 0 check passes by default. for x in range(20): for z in range(20): gridmap.set_cell_item(Vector3i(x, 0, z), 1) main_node.add_child(gridmap) gauntlet_manager = StubGauntletManager.new() gauntlet_manager.name = "GauntletManager" main_node.add_child(gauntlet_manager) actor = StubActor.new() actor.enhanced_gridmap = gridmap actor.name = "Bot7" main_node.add_child(actor) planner = BotStrategicPlanner.new(actor, gridmap) planner.gauntlet_manager_override = gauntlet_manager # Default to Gauntlet mode for these tests. LobbyManager.game_mode = "Candy Pump Survival" func after_each(): if is_instance_valid(main_node): main_node.queue_free() actor = null planner = null gauntlet_manager = null gridmap = null # Reset lobby mode so other tests aren't affected. LobbyManager.game_mode = "Freemode" # ============================================================================= # Mode detection # ============================================================================= func test_is_gauntlet_mode_true_when_set(): assert_true(planner.is_gauntlet_mode(), "Detects Gauntlet via LobbyManager") func test_is_gauntlet_mode_false_in_other_modes(): LobbyManager.game_mode = "Stop n Go" assert_false(planner.is_gauntlet_mode(), "Stop n Go is not Gauntlet") LobbyManager.game_mode = "Freemode" assert_false(planner.is_gauntlet_mode(), "Freemode is not Gauntlet") func test_get_gauntlet_manager_resolves_from_main(): var gm = planner._get_gauntlet_manager() assert_not_null(gm, "Resolves GauntletManager under /root/Main") assert_eq(gm, gauntlet_manager, "Same instance as the one we added") # ============================================================================= # Overlay detection # ============================================================================= func test_overlay_unsafe_false_on_empty_layer2(): assert_false(planner._is_overlay_unsafe(Vector2i(3, 3)), "Empty layer 2 → safe") func test_overlay_unsafe_true_for_sticky_tile(): gridmap.set_cell_item(Vector3i(3, 2, 3), 17) # TILE_STICKY assert_true(planner._is_overlay_unsafe(Vector2i(3, 3)), "Sticky overlay is unsafe") func test_overlay_unsafe_true_for_telegraph_tile(): gridmap.set_cell_item(Vector3i(4, 2, 4), 18) # TILE_TELEGRAPH assert_true(planner._is_overlay_unsafe(Vector2i(4, 4)), "Telegraph overlay is unsafe") func test_overlay_unsafe_ignores_layer0_and_layer1(): # Sticky value on the wrong layer should NOT be flagged as unsafe. gridmap.set_cell_item(Vector3i(3, 0, 3), 17) gridmap.set_cell_item(Vector3i(3, 1, 3), 17) assert_false(planner._is_overlay_unsafe(Vector2i(3, 3)), "Only layer 2 overlay matters for Gauntlet safety") # ============================================================================= # _is_valid_move_target integration # ============================================================================= func test_valid_move_target_rejects_sticky_in_gauntlet(): # Make a sticky cell pass all other checks (valid position, walkable floor). gridmap.set_cell_item(Vector3i(3, 2, 3), 17) assert_false(planner._is_valid_move_target(Vector2i(3, 3)), "Sticky cell rejected in Gauntlet mode") func test_valid_move_target_rejects_telegraphed_in_gauntlet(): gridmap.set_cell_item(Vector3i(5, 2, 5), 18) assert_false(planner._is_valid_move_target(Vector2i(5, 5)), "Telegraphed cell rejected in Gauntlet mode") func test_valid_move_target_accepts_clean_cells(): assert_true(planner._is_valid_move_target(Vector2i(8, 8)), "Clean cell accepted") func test_valid_move_target_ignores_players_when_requested(): # Even a sticky cell is bypassed when ignore_players path skips safety. gridmap.set_cell_item(Vector3i(3, 2, 3), 17) # ignore_players=true is used by find_nearest_tile_of_type for tile pickup; # safety must still apply, so this should still be rejected. assert_false(planner._is_valid_move_target(Vector2i(3, 3), true), "Safety check still active with ignore_players=true") func test_valid_move_target_outside_gauntlet_allows_sticky(): # Outside Gauntlet, layer-2 sticky overlays are not safety-relevant. LobbyManager.game_mode = "Freemode" gridmap.set_cell_item(Vector3i(3, 2, 3), 17) assert_true(planner._is_valid_move_target(Vector2i(3, 3)), "Sticky overlay ignored in non-Gauntlet modes") func test_valid_move_target_allows_sticky_when_cleanser_active(): gauntlet_manager.cleanser_active[actor.peer_id] = true gridmap.set_cell_item(Vector3i(3, 2, 3), 17) assert_true(planner._is_valid_move_target(Vector2i(3, 3)), "Active Cleanser grants temporary immunity") # ============================================================================= # Sticky-cell awareness via GauntletManager authority # ============================================================================= func test_valid_move_target_uses_gauntlet_manager_sticky_map(): # Even if the gridmap overlay hasn't landed yet (RPC in flight), the # GauntletManager's authoritative sticky_cells map must block the move. gauntlet_manager.sticky_map[Vector2i(7, 7)] = true assert_false(planner._is_valid_move_target(Vector2i(7, 7)), "Manager's sticky map blocks moves before overlay tiles land") # ============================================================================= # _count_unsafe_neighbors # ============================================================================= func test_count_unsafe_neighbors_zero_in_open_field(): assert_eq(planner._count_unsafe_neighbors(Vector2i(5, 5)), 0, "Open field has zero unsafe neighbors") func test_count_unsafe_neighbors_four_when_surrounded(): for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) assert_eq(planner._count_unsafe_neighbors(Vector2i(5, 5)), 4, "All four neighbors sticky") func test_count_unsafe_neighbors_partial_box(): gridmap.set_cell_item(Vector3i(6, 2, 5), 17) # east gridmap.set_cell_item(Vector3i(5, 2, 6), 17) # south assert_eq(planner._count_unsafe_neighbors(Vector2i(5, 5)), 2, "Two unsafe neighbors") # ============================================================================= # should_activate_cleanser_now # ============================================================================= func test_should_activate_cleanser_false_without_charge(): actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) assert_false(planner.should_activate_cleanser_now(), "Trapped but no charge → cannot activate") func test_should_activate_cleanser_true_when_surrounded(): gauntlet_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) assert_true(planner.should_activate_cleanser_now(), "Trapped with charge → activate Cleanser") func test_should_activate_cleanser_true_when_on_telegraphed(): gauntlet_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) gridmap.set_cell_item(Vector3i(5, 2, 5), 18) # bot standing on telegraph # 3+ unsafe neighbors required by spec; add three. for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) assert_true(planner.should_activate_cleanser_now(), "Standing on telegraph with 3 sticky neighbors → activate Cleanser") func test_should_activate_cleanser_false_when_already_active(): gauntlet_manager.player_cleansers[actor.peer_id] = 1 gauntlet_manager.cleanser_active[actor.peer_id] = true actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) assert_false(planner.should_activate_cleanser_now(), "Already active → don't re-fire") func test_should_activate_cleanser_false_outside_gauntlet(): LobbyManager.game_mode = "Stop n Go" gauntlet_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) assert_false(planner.should_activate_cleanser_now(), "Outside Gauntlet → never auto-activate Cleanser") func test_should_activate_cleanser_false_when_not_trapped(): gauntlet_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) # No sticky neighbors, plenty of room. assert_false(planner.should_activate_cleanser_now(), "Open field → no reason to burn Cleanser") # ============================================================================= # Cleanser charge helpers # ============================================================================= func test_bot_has_cleanser_charge_false_when_empty(): assert_false(planner._bot_has_cleanser_charge(), "Empty by default") func test_bot_has_cleanser_charge_true_when_granted(): gauntlet_manager.player_cleansers[actor.peer_id] = 1 assert_true(planner._bot_has_cleanser_charge(), "Charge granted") func test_is_bot_cleanser_active_reflects_manager(): assert_false(planner._is_bot_cleanser_active(), "Inactive by default") gauntlet_manager.cleanser_active[actor.peer_id] = true assert_true(planner._is_bot_cleanser_active(), "Manager reports active") # ============================================================================= # BotController → GauntletManager RPC integration # ============================================================================= func test_bot_controller_requests_cleanser_when_trapped(): var BotController = load("res://scripts/bot_controller.gd") var ctrl = BotController.new() # Attach as a child of the actor so _ready() resolves get_parent() correctly. actor.add_child(ctrl) ctrl.strategic_planner = planner ctrl.actor = actor gauntlet_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) var requested = ctrl._try_activate_cleanser() assert_true(requested, "Controller requests Cleanser when trapped") assert_eq(gauntlet_manager.activate_rpc_calls.size(), 1, "RPC called exactly once") assert_eq(gauntlet_manager.activate_rpc_calls[0], actor.peer_id, "Correct peer id sent") ctrl.queue_free() func test_bot_controller_does_not_request_when_safe(): var BotController = load("res://scripts/bot_controller.gd") var ctrl = BotController.new() actor.add_child(ctrl) ctrl.strategic_planner = planner ctrl.actor = actor gauntlet_manager.player_cleansers[actor.peer_id] = 1 # No sticky anywhere. var requested = ctrl._try_activate_cleanser() assert_false(requested, "No request when not trapped") assert_eq(gauntlet_manager.activate_rpc_calls.size(), 0, "No RPC fired") ctrl.queue_free() func test_bot_controller_skips_cleanser_outside_gauntlet(): var BotController = load("res://scripts/bot_controller.gd") var ctrl = BotController.new() actor.add_child(ctrl) ctrl.strategic_planner = planner ctrl.actor = actor LobbyManager.game_mode = "Stop n Go" gauntlet_manager.player_cleansers[actor.peer_id] = 1 actor.current_position = Vector2i(5, 5) for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: var n = Vector2i(5, 5) + d gridmap.set_cell_item(Vector3i(n.x, 2, n.y), 17) var requested = ctrl._try_activate_cleanser() assert_false(requested, "No request outside Gauntlet") ctrl.queue_free()