rename gauntlet→candy_survival + rewrite Candy Survival per boss design

- Rename enum GAUNTLET→CANDY_SURVIVAL, all gauntlet_*→candy_survival_*
- Rename files: gauntlet_manager→candy_survival_manager, candy_cannon→candy_survival_npc, gauntlet.tscn→candy_survival.tscn
- Rewrite candy_survival_manager.gd: blueprints, candy stack, Mekton delivery, Sugar Rush, knock/ghost charges, sticky-as-wall
- Update player_movement_manager.gd: smack→knock system, ghost integration
- All Candy Survival issues (#54-57, #65-70) retitled per boss design
- Shared managers (goals_cycle, goal, player_race, playerboard, turn) untouched
This commit is contained in:
god
2026-07-06 01:28:39 +08:00
parent 114748a54f
commit 528e22875d
33 changed files with 731 additions and 2181 deletions
+50 -50
View File
@@ -1,13 +1,13 @@
extends GutTest
# =============================================================================
# Test: Bot AI — Sticky Avoidance & Pathfinding [Gauntlet #075]
# Test: Bot AI — Sticky Avoidance & Pathfinding Candy Survival #075]
# Verifies the bot's strategic planner correctly:
# • Detects Gauntlet mode and exposes helpers.
# • Detects Candy Survival 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.
# • Calls rpc_activate_cleanser on the CandySurvivalManager when triggered.
# =============================================================================
const BotStrategicPlanner = preload("res://scripts/bot_strategic_planner.gd")
@@ -26,8 +26,8 @@ class StubActor extends Node3D:
func is_position_occupied(_p: Vector2i) -> bool:
return false
class StubGauntletManager extends Node:
# Mimics the slice of GauntletManager API the bot planner depends on.
class StubCandySurvivalManager extends Node:
# Mimics the slice of CandySurvivalManager 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
@@ -46,7 +46,7 @@ class StubGauntletManager extends Node:
var main_node: Node
var gridmap: Node
var gauntlet_manager: StubGauntletManager
var candy_survival_manager: StubCandySurvivalManager
var actor: StubActor
var planner: RefCounted
@@ -68,9 +68,9 @@ func before_each():
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)
candy_survival_manager = StubCandySurvivalManager.new()
candy_survival_manager.name = "CandySurvivalManager"
main_node.add_child(candy_survival_manager)
actor = StubActor.new()
actor.enhanced_gridmap = gridmap
@@ -78,17 +78,17 @@ func before_each():
main_node.add_child(actor)
planner = BotStrategicPlanner.new(actor, gridmap)
planner.gauntlet_manager_override = gauntlet_manager
planner.candy_survival_manager_override = candy_survival_manager
# Default to Gauntlet mode for these tests.
LobbyManager.game_mode = "Candy Pump Survival"
# Default to Candy Survival mode for these tests.
LobbyManager.game_mode = "Candy Survival"
func after_each():
if is_instance_valid(main_node):
main_node.queue_free()
actor = null
planner = null
gauntlet_manager = null
candy_survival_manager = null
gridmap = null
# Reset lobby mode so other tests aren't affected.
LobbyManager.game_mode = "Freemode"
@@ -97,19 +97,19 @@ func after_each():
# Mode detection
# =============================================================================
func test_is_gauntlet_mode_true_when_set():
assert_true(planner.is_gauntlet_mode(), "Detects Gauntlet via LobbyManager")
func test_is_candy_survival_mode_true_when_set():
assert_true(planner.is_candy_survival_mode(), "Detects Candy Survival via LobbyManager")
func test_is_gauntlet_mode_false_in_other_modes():
func test_is_candy_survival_mode_false_in_other_modes():
LobbyManager.game_mode = "Stop n Go"
assert_false(planner.is_gauntlet_mode(), "Stop n Go is not Gauntlet")
assert_false(planner.is_candy_survival_mode(), "Stop n Go is not Candy Survival")
LobbyManager.game_mode = "Freemode"
assert_false(planner.is_gauntlet_mode(), "Freemode is not Gauntlet")
assert_false(planner.is_candy_survival_mode(), "Freemode is not Candy Survival")
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")
func test_get_candy_survival_manager_resolves_from_main():
var gm = planner._get_candy_survival_manager()
assert_not_null(gm, "Resolves CandySurvivalManager under /root/Main")
assert_eq(gm, candy_survival_manager, "Same instance as the one we added")
# =============================================================================
# Overlay detection
@@ -134,7 +134,7 @@ func test_overlay_unsafe_ignores_layer0_and_layer1():
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")
"Only layer 2 overlay matters for Candy Survival safety")
# =============================================================================
# _is_valid_move_target integration
@@ -144,12 +144,12 @@ 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")
"Sticky cell rejected in Candy Survival 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")
"Telegraphed cell rejected in Candy Survival mode")
func test_valid_move_target_accepts_clean_cells():
assert_true(planner._is_valid_move_target(Vector2i(8, 8)),
@@ -163,27 +163,27 @@ func test_valid_move_target_ignores_players_when_requested():
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.
func test_valid_move_target_outside_candy_survival_allows_sticky():
# Outside Candy Survival, 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")
"Sticky overlay ignored in non-Candy Survival modes")
func test_valid_move_target_allows_sticky_when_cleanser_active():
gauntlet_manager.cleanser_active[actor.peer_id] = true
candy_survival_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
# Sticky-cell awareness via CandySurvivalManager authority
# =============================================================================
func test_valid_move_target_uses_gauntlet_manager_sticky_map():
func test_valid_move_target_uses_candy_survival_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
# CandySurvivalManager's authoritative sticky_cells map must block the move.
candy_survival_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")
@@ -221,7 +221,7 @@ func test_should_activate_cleanser_false_without_charge():
"Trapped but no charge → cannot activate")
func test_should_activate_cleanser_true_when_surrounded():
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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
@@ -230,7 +230,7 @@ func test_should_activate_cleanser_true_when_surrounded():
"Trapped with charge → activate Cleanser")
func test_should_activate_cleanser_true_when_on_telegraphed():
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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.
@@ -241,8 +241,8 @@ func test_should_activate_cleanser_true_when_on_telegraphed():
"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
candy_survival_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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
@@ -252,16 +252,16 @@ func test_should_activate_cleanser_false_when_already_active():
func test_should_activate_cleanser_false_outside_gauntlet():
LobbyManager.game_mode = "Stop n Go"
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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")
"Outside Candy Survival → never auto-activate Cleanser")
func test_should_activate_cleanser_false_when_not_trapped():
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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(),
@@ -275,16 +275,16 @@ 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
candy_survival_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
candy_survival_manager.cleanser_active[actor.peer_id] = true
assert_true(planner._is_bot_cleanser_active(), "Manager reports active")
# =============================================================================
# BotController → GauntletManager RPC integration
# BotController → CandySurvivalManager RPC integration
# =============================================================================
func test_bot_controller_requests_cleanser_when_trapped():
@@ -295,7 +295,7 @@ func test_bot_controller_requests_cleanser_when_trapped():
ctrl.strategic_planner = planner
ctrl.actor = actor
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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
@@ -303,9 +303,9 @@ func test_bot_controller_requests_cleanser_when_trapped():
var requested = ctrl._try_activate_cleanser()
assert_true(requested, "Controller requests Cleanser when trapped")
assert_eq(gauntlet_manager.activate_rpc_calls.size(), 1,
assert_eq(candy_survival_manager.activate_rpc_calls.size(), 1,
"RPC called exactly once")
assert_eq(gauntlet_manager.activate_rpc_calls[0], actor.peer_id,
assert_eq(candy_survival_manager.activate_rpc_calls[0], actor.peer_id,
"Correct peer id sent")
ctrl.queue_free()
@@ -317,11 +317,11 @@ func test_bot_controller_does_not_request_when_safe():
ctrl.strategic_planner = planner
ctrl.actor = actor
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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,
assert_eq(candy_survival_manager.activate_rpc_calls.size(), 0,
"No RPC fired")
ctrl.queue_free()
@@ -334,13 +334,13 @@ func test_bot_controller_skips_cleanser_outside_gauntlet():
ctrl.actor = actor
LobbyManager.game_mode = "Stop n Go"
gauntlet_manager.player_cleansers[actor.peer_id] = 1
candy_survival_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")
assert_false(requested, "No request outside Candy Survival")
ctrl.queue_free()