From f0ba6c2b5473632f8e6e0bdc0780e994f486455e Mon Sep 17 00:00:00 2001 From: adtpdn Date: Fri, 26 Jun 2026 18:31:17 +0800 Subject: [PATCH] feat: half update --- scripts/bot_controller.gd | 48 ++++ scripts/bot_strategic_planner.gd | 118 +++++++++ tests/helpers/gridmap_mock.gd | 10 + tests/test_bot_gauntlet.gd | 346 +++++++++++++++++++++++++ tests/test_gauntlet_floor_highlight.gd | 190 ++++++++++++++ 5 files changed, 712 insertions(+) create mode 100644 tests/test_bot_gauntlet.gd create mode 100644 tests/test_gauntlet_floor_highlight.gd diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index 8ce6726..e5880a4 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -210,6 +210,11 @@ func _run_ai_tick(): print("[BotController] Action Taken: Attack Pursuit") return + # Priority 0.5: Gauntlet (#075) — burn Cleanser if boxed in + if await _try_activate_cleanser(): + print("[BotController] Action Taken: Cleanser (trapped)") + return + # Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying) # Spawning while carrying is high priority; Hunting is medium priority. if await _try_tekton_action(): @@ -254,6 +259,49 @@ func _run_ai_tick(): elif not is_sng: return +# ============================================================================= +# Gauntlet (#075) — Cleanser + Sticky Avoidance wiring +# ============================================================================= + +func _try_activate_cleanser() -> bool: + """Activate Cleanser when the planner reports imminent danger. + + Server-authoritative RPC; we only request it. Returns true if the request + was sent successfully (not a guarantee it landed on a sticky cell).""" + if not strategic_planner or not strategic_planner.is_gauntlet_mode(): + return false + if not strategic_planner.should_activate_cleanser_now(): + return false + var gm = strategic_planner._get_gauntlet_manager() + if not gm: + return false + var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + if pid == null or pid < 0: + return false + if gm.has_method("rpc_activate_cleanser"): + gm.rpc_activate_cleanser(pid) + print("[BotController] %s requested Cleanser activation (trapped)" % actor.name) + return true + return false + +func _on_step_onto_unsafe() -> bool: + """Refuse to step onto a sticky/telegraphed cell and re-plan. Returns true + if the bot had to abort the planned move.""" + if not strategic_planner: + return false + var here = actor.current_position if "current_position" in actor else Vector2i(-1, -1) + if here == Vector2i(-1, -1): + return false + # Post-move guard: if we somehow landed on a sticky without cleanser active, + # burn Cleanser to clear ourselves out next tick. + if strategic_planner.is_gauntlet_mode() and strategic_planner._is_overlay_unsafe(here): + if not strategic_planner._is_bot_cleanser_active(): + var gm = strategic_planner._get_gauntlet_manager() + if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here): + print("[BotController] %s stepped onto sticky at %s — burning Cleanser" % [actor.name, here]) + return _try_activate_cleanser() + return false + # ============================================================================= # Power-Up / Sabotage # ============================================================================= diff --git a/scripts/bot_strategic_planner.gd b/scripts/bot_strategic_planner.gd index 8db7933..b68c209 100644 --- a/scripts/bot_strategic_planner.gd +++ b/scripts/bot_strategic_planner.gd @@ -8,11 +8,121 @@ class_name BotStrategicPlanner var actor: Node3D var enhanced_gridmap: Node +# Optional explicit gauntlet_manager binding (set by tests to avoid scene-tree +# traversal collisions; production code uses _get_gauntlet_manager() instead). +var gauntlet_manager_override: Node = null # Tile type constants const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles +# Gauntlet overlay layer (v2 ground-growth model — sticky/telegraph on layer 2). +# Bots must avoid these cells or burn a Cleanser charge to cross. +const GAUNTLET_OVERLAY_LAYER: int = 2 +const TILE_STICKY: int = 17 +const TILE_TELEGRAPH: int = 18 + +# ============================================================================= +# Gauntlet mode helpers (#075 — Bot AI: Sticky Avoidance & Pathfinding) +# ============================================================================= + +func is_gauntlet_mode() -> bool: + return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET) + +func _get_gauntlet_manager() -> Node: + """Resolve the active GauntletManager. + + Order of resolution: + 1. Explicit `gauntlet_manager_override` (used by tests). + 2. Walk actor's ancestors for any node containing a GauntletManager child + (production path — robust to non-standard scene trees). + 3. Fallback: scan /root children for a GauntletManager. + """ + if gauntlet_manager_override and is_instance_valid(gauntlet_manager_override): + return gauntlet_manager_override + var root: Node = null + if actor and actor.is_inside_tree(): + root = actor.get_tree().get_root() + # Walk actor's ancestors (handles production scenes where the bot is + # nested under Main → Arena → Player). + var n: Node = actor.get_parent() + while n: + var gm = n.get_node_or_null("GauntletManager") + if gm: + return gm + n = n.get_parent() + if not root: + return null + # Last-resort scan of root children (helps in unusual scene trees). + for child in root.get_children(): + if child.name.begins_with("Main") or child.name.begins_with("BotTestMain"): + var gm2 = child.get_node_or_null("GauntletManager") + if gm2: + return gm2 + return null + +func _bot_has_cleanser_charge() -> bool: + var gm = _get_gauntlet_manager() + if not gm or not "player_cleansers" in gm: + return false + var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + if pid == null or pid < 0: + return false + return gm.player_cleansers.get(pid, 0) > 0 + +func _is_bot_cleanser_active() -> bool: + var gm = _get_gauntlet_manager() + if not gm: + return false + var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + if pid == null or pid < 0: + return false + return gm.is_cleanser_active(pid) + +func _is_overlay_unsafe(pos: Vector2i) -> bool: + """True if the cell carries a sticky or telegraphed overlay on layer 2.""" + if not enhanced_gridmap: + return false + var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, GAUNTLET_OVERLAY_LAYER, pos.y)) + return item == TILE_STICKY or item == TILE_TELEGRAPH + +func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool: + """Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's + Cleanser is active (grants temporary immunity).""" + if not is_gauntlet_mode(): + return false + if _is_bot_cleanser_active(): + return false + var gm = _get_gauntlet_manager() + if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos): + return true + return _is_overlay_unsafe(pos) + +func _count_unsafe_neighbors(pos: Vector2i) -> int: + """Count 4-neighbors of `pos` that are sticky/telegraphed.""" + var count := 0 + for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]: + if _is_overlay_unsafe(pos + d): + count += 1 + return count + +func should_activate_cleanser_now() -> bool: + """True if the bot is boxed in / about to be sealed and should burn Cleanser.""" + if not is_gauntlet_mode(): + return false + if not _bot_has_cleanser_charge(): + return false + if _is_bot_cleanser_active(): + return false + var here = actor.current_position if actor and "current_position" in actor else Vector2i(-1, -1) + if here == Vector2i(-1, -1): + return false + if _is_overlay_unsafe(here) and _count_unsafe_neighbors(here) >= 3: + return true + if _count_unsafe_neighbors(here) == 4: + return true + return false + func _init(p_actor: Node3D, p_gridmap: Node): actor = p_actor enhanced_gridmap = p_gridmap @@ -509,6 +619,14 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool: if not ignore_players and actor.is_position_occupied(pos): return false + + # Gauntlet mode (#075): reject cells that are sticky or telegraphed — + # stepping onto them either traps the bot or strands it within 1s. + # Safety applies even when ignore_players is true (a sticky cell is unsafe + # regardless of whether another player is on it). Cleanser-active bots are + # exempt via the helper. + if _is_cell_unsafe_in_gauntlet(pos): + return false return true func _get_random_valid_position() -> Vector2i: diff --git a/tests/helpers/gridmap_mock.gd b/tests/helpers/gridmap_mock.gd index 63b0f08..2ea8e79 100644 --- a/tests/helpers/gridmap_mock.gd +++ b/tests/helpers/gridmap_mock.gd @@ -8,6 +8,16 @@ var cell_size := Vector3(1, 1, 1) var cells: Dictionary = {} # Vector3i -> item id var astar_inits := 0 +# Walkable-tile set. Items in this list are passable for the bot planner. +# By default everything is walkable except the items the bot calls out +# explicitly via `non_walkable_items`. +var non_walkable_items: Array[int] = [] +var columns: int = 20 +var rows: int = 20 + +func is_position_valid(pos: Vector2i) -> bool: + return pos.x >= 0 and pos.x < columns and pos.y >= 0 and pos.y < rows + func set_cell_item(pos: Vector3i, item: int, _orientation: int = 0) -> void: if item == -1: cells.erase(pos) diff --git a/tests/test_bot_gauntlet.gd b/tests/test_bot_gauntlet.gd new file mode 100644 index 0000000..84c482f --- /dev/null +++ b/tests/test_bot_gauntlet.gd @@ -0,0 +1,346 @@ +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() \ No newline at end of file diff --git a/tests/test_gauntlet_floor_highlight.gd b/tests/test_gauntlet_floor_highlight.gd new file mode 100644 index 0000000..54829d1 --- /dev/null +++ b/tests/test_gauntlet_floor_highlight.gd @@ -0,0 +1,190 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Telegraph Floor Highlight [Gauntlet #081] +# Verifies the amber floor overlay placed under cells during the 1-second +# telegraph window: amber color, two-stage alpha (build-up → flash), lifetime +# bound to telegraph_duration, distinct from sticky pink, RPC broadcast. +# ============================================================================= + +const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") +const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") + +var main_mock: Node +var gridmap_mock: Node +var manager: Node + +func before_all(): + gut.p("=== Feature Tests [Gauntlet #081 Telegraph Floor Highlight] ===") + +func before_each(): + main_mock = Node.new() + main_mock.name = "Main" + # Add under /root so visual helpers that look up /root/Main find it. + get_tree().get_root().add_child(main_mock) + gridmap_mock = GridMapMock.new() + gridmap_mock.name = "EnhancedGridMap" + main_mock.add_child(gridmap_mock) + + manager = GauntletManager.new() + main_mock.add_child(manager) + manager.initialize(main_mock, gridmap_mock) + +func after_each(): + if is_instance_valid(main_mock): + main_mock.queue_free() + manager = null + gridmap_mock = null + +func _without_peer(fn: Callable) -> void: + var saved = multiplayer.multiplayer_peer + multiplayer.multiplayer_peer = null + fn.call() + multiplayer.multiplayer_peer = saved + +# ============================================================================= +# Tile ID + lifecycle +# ============================================================================= + +func test_telegraph_tile_id_distinct_from_sticky(): + # #081 must not reuse the sticky overlay tile — players need to distinguish. + assert_ne(manager.TILE_TELEGRAPH, manager.TILE_STICKY, "Telegraph tile ≠ sticky tile") + assert_eq(manager.TILE_TELEGRAPH, 18, "Telegraph tile is layer-2 ID 18") + +func test_telegraph_uses_layer_2(): + # Floor highlight lives on GridMap layer 2 (overlay), y=2 in cell coords. + _without_peer(func(): + manager.sync_growth_telegraph([Vector2i(5, 5)]) + ) + assert_eq(gridmap_mock.get_cell_item(Vector3i(5, 2, 5)), manager.TILE_TELEGRAPH, + "Telegraph cell placed on layer 2") + +func test_telegraph_apply_converts_to_sticky(): + # Verify the tile ID conversion by inspecting state directly — invoking + # sync_growth_apply triggers _check_all_players_trapped which needs an + # active multiplayer peer. The conversion is exercised by the + # test_gauntlet_growth_tick.gd suite; here we only confirm the + # sticky tile ID is reserved and distinct. + _without_peer(func(): + manager.sync_growth_telegraph([Vector2i(7, 7)]) + ) + assert_eq(gridmap_mock.get_cell_item(Vector3i(7, 2, 7)), manager.TILE_TELEGRAPH, + "Telegraph set during warn window") + assert_ne(manager.TILE_TELEGRAPH, manager.TILE_STICKY, + "Conversion target is the distinct sticky tile ID") + +# ============================================================================= +# Multi-cell broadcast +# ============================================================================= + +func test_telegraph_multiple_cells_all_get_overlay(): + var cells := [Vector2i(2, 3), Vector2i(4, 5), Vector2i(6, 7)] + _without_peer(func(): + manager.sync_growth_telegraph(cells) + ) + for c in cells: + assert_eq(gridmap_mock.get_cell_item(Vector3i(c.x, 2, c.y)), manager.TILE_TELEGRAPH, + "Cell %s telegraphed" % str(c)) + +# ============================================================================= +# Visual highlight (mesh placed under /root/Main) +# ============================================================================= + +func test_telegraph_visual_helper_spawns_mesh(): + # _spawn_telegraph_highlight must add a MeshInstance3D under /root/Main. + var before := _count_main_children() + manager._spawn_telegraph_highlight(Vector2i(3, 3)) + var after := _count_main_children() + assert_gt(after, before, "Highlight mesh added to main scene") + +func test_telegraph_highlight_uses_amber_color(): + # Amber (warm orange) is required so it never reads as the sticky pink. + manager._spawn_telegraph_highlight(Vector2i(4, 4)) + var mesh = _find_main_mesh() + assert_not_null(mesh, "Highlight mesh exists") + var mat = mesh.material_override as StandardMaterial3D + assert_not_null(mat, "Has StandardMaterial3D override") + # Amber channel must dominate red+green over blue. + assert_gt(mat.albedo_color.r, mat.albedo_color.b + 0.2, + "Amber red > blue by ≥0.2") + assert_gt(mat.albedo_color.g, mat.albedo_color.b + 0.2, + "Amber green > blue by ≥0.2") + # Emission must be enabled so the highlight reads through shadows. + assert_true(mat.emission_enabled, "Emission enabled for floor highlight") + +func test_telegraph_highlight_is_unshaded(): + # Floor highlight must be UNSHADED so the amber is visible regardless of + # the scene's lighting setup (#076 polish prerequisite). + manager._spawn_telegraph_highlight(Vector2i(5, 5)) + var mesh = _find_main_mesh() + assert_not_null(mesh, "Highlight mesh exists") + var mat = mesh.material_override as StandardMaterial3D + assert_eq(mat.shading_mode, BaseMaterial3D.SHADING_MODE_UNSHADED, + "Unshaded so amber reads under any lighting") + +func test_telegraph_highlight_below_ground(): + # Highlight sits at a small positive y so it doesn't z-fight with the floor. + manager._spawn_telegraph_highlight(Vector2i(6, 6)) + var mesh = _find_main_mesh() + assert_not_null(mesh, "Highlight mesh exists") + assert_gt(mesh.position.y, 0.0, "Highlight raised above floor") + assert_lt(mesh.position.y, 0.5, "Highlight stays close to floor (no float-up)") + +func test_telegraph_highlight_uses_box_mesh(): + manager._spawn_telegraph_highlight(Vector2i(7, 7)) + var mesh = _find_main_mesh() + assert_not_null(mesh, "Highlight mesh exists") + assert_true(mesh.mesh is BoxMesh, "Uses BoxMesh for floor footprint") + +# ============================================================================= +# Bubble telegraph (uses same warning overlay, different source) +# ============================================================================= + +func test_bubble_spawn_applies_telegraph_overlay(): + # Bubbles reuse the same floor overlay during their grow window. + var footprint := [ + Vector2i(7, 7), Vector2i(8, 7), Vector2i(9, 7), + Vector2i(7, 8), Vector2i(8, 8), Vector2i(9, 8), + Vector2i(7, 9), Vector2i(8, 9), Vector2i(9, 9), + ] + _without_peer(func(): + manager.sync_bubble_spawn(Vector2i(8, 8), footprint) + ) + for c in footprint: + assert_eq(gridmap_mock.get_cell_item(Vector3i(c.x, 2, c.y)), manager.TILE_TELEGRAPH, + "Bubble cell %s telegraphed" % str(c)) + +func test_bubble_explode_replaces_telegraph_with_sticky(): + var footprint := [ + Vector2i(3, 4), Vector2i(4, 4), Vector2i(5, 4), + Vector2i(3, 5), Vector2i(4, 5), Vector2i(5, 5), + Vector2i(3, 6), Vector2i(4, 6), Vector2i(5, 6), + ] + _without_peer(func(): + manager.sync_bubble_spawn(Vector2i(4, 5), footprint) + ) + _without_peer(func(): + manager.sync_bubble_explode(Vector2i(4, 5), footprint) + ) + for c in footprint: + assert_eq(gridmap_mock.get_cell_item(Vector3i(c.x, 2, c.y)), manager.TILE_STICKY, + "Bubble cell %s → sticky after explode" % str(c)) + +# ============================================================================= +# Helpers +# ============================================================================= + +func _count_main_children() -> int: + var main := get_node_or_null("/root/Main") + if not main: + return 0 + return main.get_child_count() + +func _find_main_mesh() -> MeshInstance3D: + var main := get_node_or_null("/root/Main") + if not main: + return null + for c in main.get_children(): + if c is MeshInstance3D: + return c + return null \ No newline at end of file