feat: half update
This commit is contained in:
@@ -210,6 +210,11 @@ func _run_ai_tick():
|
|||||||
print("[BotController] Action Taken: Attack Pursuit")
|
print("[BotController] Action Taken: Attack Pursuit")
|
||||||
return
|
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)
|
# Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying)
|
||||||
# Spawning while carrying is high priority; Hunting is medium priority.
|
# Spawning while carrying is high priority; Hunting is medium priority.
|
||||||
if await _try_tekton_action():
|
if await _try_tekton_action():
|
||||||
@@ -254,6 +259,49 @@ func _run_ai_tick():
|
|||||||
elif not is_sng:
|
elif not is_sng:
|
||||||
return
|
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
|
# Power-Up / Sabotage
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -8,11 +8,121 @@ class_name BotStrategicPlanner
|
|||||||
|
|
||||||
var actor: Node3D
|
var actor: Node3D
|
||||||
var enhanced_gridmap: Node
|
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
|
# Tile type constants
|
||||||
const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
|
const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
|
||||||
const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles
|
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):
|
func _init(p_actor: Node3D, p_gridmap: Node):
|
||||||
actor = p_actor
|
actor = p_actor
|
||||||
enhanced_gridmap = p_gridmap
|
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):
|
if not ignore_players and actor.is_position_occupied(pos):
|
||||||
return false
|
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
|
return true
|
||||||
|
|
||||||
func _get_random_valid_position() -> Vector2i:
|
func _get_random_valid_position() -> Vector2i:
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ var cell_size := Vector3(1, 1, 1)
|
|||||||
var cells: Dictionary = {} # Vector3i -> item id
|
var cells: Dictionary = {} # Vector3i -> item id
|
||||||
var astar_inits := 0
|
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:
|
func set_cell_item(pos: Vector3i, item: int, _orientation: int = 0) -> void:
|
||||||
if item == -1:
|
if item == -1:
|
||||||
cells.erase(pos)
|
cells.erase(pos)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user