From 059f15237442d37bff4b8a59fa9aa9c74b1ea6dc Mon Sep 17 00:00:00 2001 From: adtpdn Date: Tue, 6 Jan 2026 08:55:14 +0800 Subject: [PATCH] update bot --- .agent/rules/read-before-think.md | 6 + _daily_basis/report_2026-01-06.md | 25 ++ .../utils/NakamaLogger.gd | 8 +- scenes/main.gd | 1 + scenes/player.gd | 42 ++- scripts/bot_controller.gd | 274 ++++++++++-------- scripts/bot_strategic_planner.gd | 92 ++++++ scripts/managers/goals_cycle_manager.gd | 15 +- scripts/managers/player_action_manager.gd | 2 +- scripts/managers/playerboard_manager.gd | 24 +- scripts/managers/powerup_manager.gd | 4 +- scripts/managers/special_tiles_manager.gd | 8 + 12 files changed, 353 insertions(+), 148 deletions(-) create mode 100644 .agent/rules/read-before-think.md create mode 100644 _daily_basis/report_2026-01-06.md diff --git a/.agent/rules/read-before-think.md b/.agent/rules/read-before-think.md new file mode 100644 index 0000000..0b67e32 --- /dev/null +++ b/.agent/rules/read-before-think.md @@ -0,0 +1,6 @@ +--- +trigger: always_on +--- + +Make sure you check whole files before editing. +Ensure .tscn, .tres, .res related checked. \ No newline at end of file diff --git a/_daily_basis/report_2026-01-06.md b/_daily_basis/report_2026-01-06.md new file mode 100644 index 0000000..0173e17 --- /dev/null +++ b/_daily_basis/report_2026-01-06.md @@ -0,0 +1,25 @@ +[ ADT's Report ] + +Updated the `tekton-enet` ( Armageddon Multiplayer ) on branch `launcher` + +**Bot Humanization** + +✅ **Direct Movement Control** - Refactored bot movement to use single-step `simple_move_to` logic instead of sliding along pre-calculated paths. This gives bots the same "tap-to-move" feel and responsiveness as human players. + +✅ **Player-Mimicry Grabbing** - Bots now utilize the core `Player.grab_item()` function directly. This ensures they follow all game rules (like specific slot targeting) and trigger the correct animations and sounds, indistinguishable from a human player. + +✅ **Visual Alignment** - Suppressed artificial floor highlighting for bot actions, ensuring they control their character cleanly without polluting the view with debug visuals. + +**Bot AI & Stability** + +✅ **Inactive Bot Fix** - Resolved the "passive bot" issue where multiple bots would freeze or mirror each other. Implemented unique RNG seeding per bot and a "Direct Move" fallback for when pathfinding is too complex for simple adjacent steps. + +✅ **Stuck Prevention** - Added a safety watchdog that force-resets bots if they remain in a "moving" state for too long (2s), preventing soft-locks during gameplay. + +✅ **Power-Up Logic Fix** - Fixed a bug in `PowerUpManager` where bots would spam "Effect on Cooldown" warnings. Bots (and the manager) now correctly respect the `special_cooldown_timer`. + +**System Cleanup** + +✅ **Nakama Log Cleanup** - Silenced verbose debug logging in `NakamaLogger` to prevent console overflow and improve performance. + +✅ **Game Over Sync** - Fixed score synchronization issues to ensure the End Game Leaderboard accurately reflects the server state for all clients. diff --git a/addons/com.heroiclabs.nakama/utils/NakamaLogger.gd b/addons/com.heroiclabs.nakama/utils/NakamaLogger.gd index 4f01830..52e0498 100644 --- a/addons/com.heroiclabs.nakama/utils/NakamaLogger.gd +++ b/addons/com.heroiclabs.nakama/utils/NakamaLogger.gd @@ -6,21 +6,21 @@ enum LOG_LEVEL {NONE, ERROR, WARNING, INFO, VERBOSE, DEBUG} var _level = LOG_LEVEL.ERROR var _module = "Nakama" -func _init(p_module : String = "Nakama", p_level : int = LOG_LEVEL.ERROR): +func _init(p_module: String = "Nakama", p_level: int = LOG_LEVEL.ERROR): _level = p_level _module = p_module -func _log(level : int, msg): +func _log(level: int, msg): if level <= _level: if level == LOG_LEVEL.ERROR: - printerr("=== %s : ERROR === %s" % [_module, str(msg)]) + pass # printerr("=== %s : ERROR === %s" % [_module, str(msg)]) else: var what = "=== UNKNOWN === " for k in LOG_LEVEL: if level == LOG_LEVEL[k]: what = "=== %s : %s === " % [_module, k] break - print(what + str(msg)) + #print(what + str(msg)) func error(msg): _log(LOG_LEVEL.ERROR, msg) diff --git a/scenes/main.gd b/scenes/main.gd index db5bd4e..ec3bbb8 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -158,6 +158,7 @@ func add_message_to_bar(player_name: String, message: String, type: int = Messag # Remove oldest messages if over limit while message_container.get_child_count() > MAX_MESSAGES: var oldest = message_container.get_child(0) + message_container.remove_child(oldest) oldest.queue_free() # Auto-remove after duration with fade-out diff --git a/scenes/player.gd b/scenes/player.gd index 6de7c56..dbc8849 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -25,6 +25,7 @@ var original_movement_range: int = 1 @export var enhanced_gridmap_path: NodePath = "/root/Main/EnhancedGridMap" var enhanced_gridmap: EnhancedGridMap @export var current_position: Vector2i +var target_position: Vector2i = Vector2i(-1, -1) # For collision prediction var is_player_moving: bool = false: get: return movement_manager.is_moving if movement_manager else false set(value): if movement_manager: movement_manager.is_moving = value @@ -170,6 +171,9 @@ func _ready(): # ========================================================================= # BOT-SPECIFIC SETUP - BotController handles bot AI, we just disable input # ========================================================================= + # ========================================================================= + # BOT-SPECIFIC SETUP - BotController handles bot AI + # ========================================================================= if is_bot == true or is_in_group("Bots"): # Disable input processing for bots set_process_input(false) @@ -200,7 +204,8 @@ func _ready(): # Sync bot status to network if is_multiplayer_authority(): rpc("sync_bot_status", true) - return # Bot initialization complete - BotController handles AI + + # Continue to manager initialization... # ========================================================================= # HUMAN PLAYER SETUP @@ -235,11 +240,13 @@ func _init_managers(): add_child(race_manager) race_manager.initialize(self, enhanced_gridmap) - input_manager = load("res://scripts/managers/player_input_manager.gd").new() - input_manager.name = "InputManager" - add_child(input_manager) - input_manager.initialize(self, movement_manager, race_manager) - + # Skip InputManager for bots + if not (is_bot or is_in_group("Bots")): + input_manager = load("res://scripts/managers/player_input_manager.gd").new() + input_manager.name = "InputManager" + add_child(input_manager) + input_manager.initialize(self, movement_manager, race_manager) + playerboard_manager = load("res://scripts/managers/playerboard_manager.gd").new() playerboard_manager.name = "PlayerboardManager" add_child(playerboard_manager) @@ -600,8 +607,16 @@ func handle_grid_click(grid_position: Vector2i): # Modify is_position_occupied to check for selected spawn points func is_position_occupied(pos: Vector2i) -> bool: for player in get_tree().get_nodes_in_group("Players"): - if player != self and player.spawn_point_selected and player.current_position == pos: + if player == self: + continue + + if player.spawn_point_selected and player.current_position == pos: return true + + # Check target position (where they are moving to) + if player.is_player_moving and player.target_position == pos: + return true + return false func find_valid_starting_position() -> Vector2i: @@ -752,6 +767,9 @@ func move_player_to_clicked_position(grid_position: Vector2i): @rpc("any_peer", "call_local") func start_movement_along_path(path: Array, clear_visual: bool = true): is_player_moving = true + if path.size() > 0: + target_position = Vector2i(path[-1].x, path[-1].y) + var tween = create_tween() tween.set_trans(Tween.TRANS_CUBIC) tween.set_ease(Tween.EASE_IN_OUT) @@ -762,6 +780,7 @@ func start_movement_along_path(path: Array, clear_visual: bool = true): tween.tween_callback(func(): current_position = Vector2i(path[-1].x, path[-1].y) is_player_moving = false + target_position = Vector2i(-1, -1) # Check if we've reached the finish line (uses lap-aware finish locations) var current_finish_locs = race_manager.get_current_finish_locations() if race_manager else finish_locations @@ -770,11 +789,12 @@ func start_movement_along_path(path: Array, clear_visual: bool = true): var main = get_tree().get_root().get_node_or_null("Main") - # Only clear visuals if this is a human player + # Clear visuals for everyone including bots + if clear_visual: + enhanced_gridmap.clear_path_visualization() + + # Only restore UI state if this is a human player if not (is_bot or is_in_group("Bots")): - if clear_visual: - enhanced_gridmap.clear_path_visualization() - # Restore movement range highlights if it was the player's turn if main and main.ui_manager.current_action_state == main.ui_manager.ActionState.MOVING and is_my_turn: highlight_movement_range() diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index 9f689f8..fd6be2e 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -5,8 +5,8 @@ class_name BotController # Handles all bot decision-making: movement, grabbing, putting, arranging, and sabotage # Configuration -@export var tick_rate: int = 60 # Ticks between AI updates (in frames) -@export var action_delay: float = 0.5 # Delay between actions +@export var tick_rate: int = 12 # Ticks between AI updates (in frames) +@export var action_delay: float = 0.15 # Delay between actions # References var actor: Node3D # The player character this controller is attached to @@ -17,6 +17,8 @@ var strategic_planner: RefCounted var _tick_counter: int = 0 var _is_processing_action: bool = false var _current_action: String = "idle" +var _stuck_timer: float = 0.0 +var rng = RandomNumberGenerator.new() # Tile constants const GOAL_TILES = [7, 8, 9, 10] @@ -27,29 +29,30 @@ func _ready(): if Engine.is_editor_hint(): return + # Desync bots by randomizing their tick counter start + rng.seed = name.hash() + _tick_counter = rng.randi() % tick_rate + # Get parent (should be player character) actor = get_parent() + # ... (rest of _ready) ... if not actor: push_error("[BotController] No parent node found") queue_free() return # Only run for bots - # DEBUG: Print exact state of checks - # print("[BotController] Checking if %s is bot. in_group(Bots): %s, is_bot: %s" % [actor.name, actor.is_in_group("Bots"), actor.get("is_bot")]) - if not actor.is_in_group("Bots") and not actor.get("is_bot"): - # print("[BotController] Actor is not a bot, removing controller.") queue_free() return # Wait for actor to be fully ready - await get_tree().create_timer(1.0).timeout # Increased wait time to 1.0s to be safe + await get_tree().create_timer(1.0).timeout enhanced_gridmap = actor.enhanced_gridmap if not enhanced_gridmap: push_error("[BotController] EnhancedGridMap not found for " + actor.name) - return # Don't crash, just stop + return # Initialize strategic planner var BotStrategicPlanner = load("res://scripts/bot_strategic_planner.gd") @@ -67,14 +70,24 @@ func _exit_tree(): actor = null enhanced_gridmap = null -func _physics_process(_delta): +func _physics_process(delta): if not is_instance_valid(actor) or not strategic_planner: return # Only run on server/authority (Authority 1) - # NOTE: If we are not the server, we should not run logic if not multiplayer.is_server(): return + + # STUCK PREVENTION + if actor.is_player_moving: + _stuck_timer += delta + if _stuck_timer > 2.0: + print("[BotController] %s stuck in moving state! Force resetting." % actor.name) + actor.is_player_moving = false + _stuck_timer = 0.0 + return + else: + _stuck_timer = 0.0 # Rate limiting _tick_counter += 1 @@ -87,22 +100,39 @@ func _physics_process(_delta): return # Run AI decision loop - print("[BotController] Running AI Tick for ", actor.name) + # print("[BotController] Running AI Tick for ", actor.name) _run_ai_tick() func _run_ai_tick(): """Main AI decision loop - replaces Beehave behavior tree.""" if not is_instance_valid(actor) or _is_processing_action: return + + # Don't make new decisions while moving + if actor.is_player_moving: + return print("[BotController] AI Tick: evaluating priorities...") - + + # Evaluate board status + var board_fullness = _get_board_fullness_ratio() + var full_board_priority_mode = board_fullness > 0.6 + + # PRIORITY OVERRIDE: If board is getting full, prioritize clearing space! + if full_board_priority_mode: + print("[BotController] Board fullness %.2f > 0.6! Prioritizing PUT." % board_fullness) + if await _try_put(true): # Pass true to indicate high priority/panic check + print("[BotController] Action Taken: Put (Priority)") + return + # Priority 1: Use power-up sabotage if conditions are met if await _try_use_powerup(): print("[BotController] Action Taken: PowerUp") return # Priority 2: Grab tiles (goal tiles or holo tiles) + # ONLY if not in critical full mode (unless it's a critical needed tile?) + # Refined: If > 80% full, disable grabbing completely unless strict conditions met inner function if await _try_grab(): print("[BotController] Action Taken: Grab") return @@ -112,10 +142,11 @@ func _run_ai_tick(): print("[BotController] Action Taken: Move") return - # Priority 4: Put tiles back on grid (rarely needed) - if await _try_put(): - print("[BotController] Action Taken: Put") - return + # Priority 4: Put tiles back on grid (Standard priority) + if not full_board_priority_mode: + if await _try_put(): + print("[BotController] Action Taken: Put") + return # Priority 5: Arrange playerboard if await _try_arrange(): @@ -150,7 +181,7 @@ func _try_use_powerup() -> bool: if main and main.has_method("broadcast_message"): main.rpc("broadcast_message", actor.display_name, "Used a special power!") - await get_tree().create_timer(action_delay).timeout + await _wait_with_variance(action_delay) if not is_instance_valid(self): return true # Early exit if deleted _is_processing_action = false @@ -162,15 +193,26 @@ func _try_use_powerup() -> bool: # ============================================================================= func _try_grab() -> bool: - """Try to grab a tile from the grid.""" + """Try to grab a tile from the grid using direct player control.""" # Check AP only if turn-based if TurnManager.turn_based_mode and actor.action_points <= 0: return false + # PANIC MODE CHECK + var main = get_tree().get_root().get_node_or_null("Main") + if main: + var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager") + if goals_cycle_manager and goals_cycle_manager.get_time_remaining() < 5.0: + return false + if _is_playerboard_full(): - # print("[BotController] Grab failed: Board full") return false + # Check fullness - if >80% full, be picky + var empty_slots = actor.playerboard.count(-1) + if empty_slots <= 2: + pass + # Check if goals already achieved if _is_goals_achieved(): return false @@ -178,54 +220,27 @@ func _try_grab() -> bool: # Get tiles we need var tiles_needed = strategic_planner.get_tiles_needed() - # Check current position and adjacent cells for tiles + # Find tile to grab var grab_info = _find_tile_to_grab(tiles_needed) if not grab_info.position: - # print("[BotController] Grab failed: No valid tile found in range") return false - # Execute grab - _is_processing_action = true - _current_action = "grabbing" + # Execute grab using PLAYER method (mimic human input) + var success = actor.grab_item(grab_info.position) - var cell = Vector3i(grab_info.position.x, 1, grab_info.position.y) - var item = enhanced_gridmap.get_cell_item(cell) - - # Handle holo tiles (power-up) - if item in HOLO_TILES: - if actor.is_multiplayer_authority(): - actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) - var powerup_manager = actor.get_node_or_null("PowerUpManager") - if powerup_manager: - powerup_manager.add_holo_pickup() - - # Only consume AP in turn-based mode - if TurnManager.turn_based_mode: - actor.action_points -= 1 - - print("[BotController] %s collected holo tile!" % actor.name) - else: - # Regular tile - place in playerboard - var target_slot = strategic_planner.find_best_slot_for_tile(item) - if target_slot != -1 and actor.is_multiplayer_authority(): - actor.playerboard[target_slot] = item - actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) - actor.rpc("sync_playerboard", actor.playerboard) - - if TurnManager.turn_based_mode: - actor.action_points -= 1 - - print("[BotController] %s grabbed tile %d -> slot %d" % [actor.name, item, target_slot]) - - # Check goal completion - if _is_goals_achieved(): - _handle_goal_completion() - - await get_tree().create_timer(action_delay).timeout - if not is_instance_valid(self): return true - _is_processing_action = false - _current_action = "idle" - return true + if success: + _is_processing_action = true + _current_action = "grabbing" + print("[BotController] %s grabbed tile via player input!" % actor.name) + + # Wait for animation + await _wait_with_variance(action_delay) + if not is_instance_valid(self): return true + _is_processing_action = false + _current_action = "idle" + return true + + return false func _find_tile_to_grab(tiles_needed: Array) -> Dictionary: """Find best tile to grab from current or adjacent positions.""" @@ -256,16 +271,6 @@ func _find_tile_to_grab(tiles_needed: Array) -> Dictionary: if item in HOLO_TILES and not result.position: result = {"position": neighbor.position, "type": item} - # Third pass: any goal tile - if not result.position: - for neighbor in neighbors: - if not neighbor.is_walkable: - continue - var cell = Vector3i(neighbor.position.x, 1, neighbor.position.y) - item = enhanced_gridmap.get_cell_item(cell) - if item in actor.goals and item != -1: - return {"position": neighbor.position, "type": item} - return result # ============================================================================= @@ -273,7 +278,7 @@ func _find_tile_to_grab(tiles_needed: Array) -> Dictionary: # ============================================================================= func _try_move() -> bool: - """Try to move toward needed tiles.""" + """Try to move toward needed tiles taking single steps like a player.""" if TurnManager.turn_based_mode and actor.action_points <= 0: return false @@ -281,59 +286,82 @@ func _try_move() -> bool: return false # Find optimal movement target - var target_pos = strategic_planner.find_optimal_move_target() + var final_target = strategic_planner.find_optimal_move_target() - if target_pos == Vector2i(-1, -1) or target_pos == actor.current_position: - print("[BotController] Move failed: No valid target or already at target. Pos: %s" % actor.current_position) + if final_target == Vector2i(-1, -1) or final_target == actor.current_position: return false + + # Calculate path to target + var path = enhanced_gridmap.find_path( + Vector2(actor.current_position), + Vector2(final_target), + 0, + false + ) - # Check if within movement range - if not actor.is_within_movement_range(target_pos): - print("[BotController] Move failed: Target %s out of range" % target_pos) + var next_step = Vector2i(-1, -1) + + # Need at least current pos + next step + if path.size() >= 2: + # Extract immediate next step from path + next_step = Vector2i(path[1].x, path[1].y) + else: + # Fallback: Pathfinding failed or target is too close? + # Check if target is adjacent and we can move directly + var dist = abs(final_target.x - actor.current_position.x) + abs(final_target.y - actor.current_position.y) + if dist == 1: + next_step = final_target + else: + return false + + # Redundant safety check (simple_move_to also checks this) + if actor.is_position_occupied(next_step): return false - - # Execute movement - _is_processing_action = true - _current_action = "moving" - - if actor.is_multiplayer_authority(): - var path = enhanced_gridmap.find_path( - Vector2(actor.current_position), - Vector2(target_pos), - 0, - false - ) - if path.size() > 1: - path.pop_front() - actor.rpc("start_movement_along_path", path, false) - - if TurnManager.turn_based_mode: - actor.action_points -= 1 - - var tiles_needed = strategic_planner.get_tiles_needed() - print("[BotController] %s moving toward tiles %s (Target: %s)" % [actor.name, tiles_needed, target_pos]) - - await get_tree().create_timer(action_delay * 2).timeout # Movement takes longer - if not is_instance_valid(self): return true - _is_processing_action = false - _current_action = "idle" - return true + + # Execute SINGLE STEP movement using player manager + if actor.movement_manager.simple_move_to(next_step): + _is_processing_action = true + _current_action = "moving" + + # Wait for movement tween (approx 0.25s) plus small delay + await get_tree().create_timer(0.3).timeout + + if not is_instance_valid(self): return true + _is_processing_action = false + _current_action = "idle" + return true + + return false # ============================================================================= # Put Tiles Back # ============================================================================= -func _try_put() -> bool: +func _try_put(high_priority: bool = false) -> bool: """Try to put a tile from playerboard onto grid.""" if actor.action_points <= 0: return false - # Find a tile in playerboard that we could put var put_slot = -1 - for i in range(actor.playerboard.size()): - if actor.playerboard[i] in GOAL_TILES: - put_slot = i - break + + # Check for Panic Mode + var is_panic = false + var main = get_tree().get_root().get_node_or_null("Main") + if main: + var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager") + if goals_cycle_manager and goals_cycle_manager.get_time_remaining() < 5.0: + is_panic = true + + # Also panic if board is critically full (>80%) or high priority flag set + if _get_board_fullness_ratio() > 0.8 or high_priority: + is_panic = true + + if is_panic: + # Aggressive dumping + put_slot = strategic_planner.get_unneeded_tile_slot_panic() + else: + # Normal smart dumping + put_slot = strategic_planner.get_unneeded_tile_slot() if put_slot == -1: return false @@ -350,13 +378,13 @@ func _try_put() -> bool: if actor.is_multiplayer_authority(): var item = actor.playerboard[put_slot] var cell = Vector3i(put_position.x, 1, put_position.y) - actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, item) actor.playerboard[put_slot] = -1 + actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, item) actor.rpc("sync_playerboard", actor.playerboard) actor.action_points -= 1 - print("[BotController] %s put tile %d at %s" % [actor.name, item, put_position]) + print("[BotController] %s put unneeded tile %d at %s (Panic: %s)" % [actor.name, item, put_position, is_panic]) - await get_tree().create_timer(action_delay).timeout + await _wait_with_variance(action_delay) if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" @@ -407,7 +435,7 @@ func _try_arrange() -> bool: actor.action_points -= 2 print("[BotController] %s arranged slot %d -> %d" % [actor.name, arrangement.source_slot, arrangement.target_slot]) - await get_tree().create_timer(action_delay).timeout + await _wait_with_variance(action_delay) if not is_instance_valid(self): return true _is_processing_action = false _current_action = "idle" @@ -444,6 +472,14 @@ func _is_playerboard_full() -> bool: return false return true +func _get_board_fullness_ratio() -> float: + """Returns ratio of occupied slots (0.0 to 1.0).""" + var occupied = 0 + for item in actor.playerboard: + if item != -1: + occupied += 1 + return float(occupied) / float(actor.playerboard.size()) + func _is_goals_achieved() -> bool: """Check if goal pattern is complete in any 3x3 region of playerboard.""" var goals_2d = [] @@ -492,4 +528,10 @@ func _handle_goal_completion(): if powerup_manager: powerup_manager.add_goal_completion_reward() + print("[BotController] %s COMPLETED GOAL!" % actor.name) + +func _wait_with_variance(base_delay: float): + var variance = rng.randf_range(-0.05, 0.05) + var final_delay = max(0.05, base_delay + variance) + await get_tree().create_timer(final_delay).timeout diff --git a/scripts/bot_strategic_planner.gd b/scripts/bot_strategic_planner.gd index 9e9595f..0b4bdb0 100644 --- a/scripts/bot_strategic_planner.gd +++ b/scripts/bot_strategic_planner.gd @@ -86,6 +86,98 @@ func find_best_slot_for_tile(tile_type: int) -> int: # Fallback: any empty slot return actor.playerboard.find(-1) +func get_unneeded_tile_slot() -> int: + """Find a slot containing a tile that is not needed for the goal.""" + if not actor or actor.playerboard.size() == 0: + return -1 + + var needed_tiles = get_tiles_needed() + + # Check center 3x3 for misplaced tiles + for i in range(3): + for j in range(3): + var goal_idx = i * 3 + j + var board_idx = (i + 1) * 5 + (j + 1) + + if board_idx >= actor.playerboard.size(): + continue + + var current_item = actor.playerboard[board_idx] + if current_item == -1: + continue + + # If this position has a specific goal + if goal_idx < actor.goals.size() and actor.goals[goal_idx] != -1: + # If current item doesn't match the goal for this position + if current_item != actor.goals[goal_idx]: + # AND we don't need this tile type elsewhere (or we have enough) + # Simplified: if it's not in needed_tiles, dump it. + # Note: needed_tiles calculation includes checking if we already have it in correct spot. + # But if we have it in WRONG spot, it might still remain in needed list? + # current get_tiles_needed logic: if board_idx != goal_value, add to needed. + # So if we have it here (wrong spot), it is still "needed" for the right spot. + # So we should only dump it if we have duplicates or if we truly don't need it. + # For now, simplistic approach: If it's not in the goal set AT ALL, dump it. + if not current_item in actor.goals: + return board_idx + + # If it IS in goals but wrong spot, only dump if we can't arrange it? + # Or if we have too many of them? + # Let's count how many we have vs how many we need + var count_have = actor.playerboard.count(current_item) + var count_need = actor.goals.count(current_item) + if count_have > count_need: + return board_idx + + # If this position is supposed to be empty (-1) but has item + elif goal_idx < actor.goals.size() and actor.goals[goal_idx] == -1: + return board_idx + + # Check outer ring (non-goal area) - always dump unless saving for arrangement + # 5x5 board. Center 3x3 is indices: 6,7,8, 11,12,13, 16,17,18 + var center_indices = [6, 7, 8, 11, 12, 13, 16, 17, 18] + for i in range(actor.playerboard.size()): + if not i in center_indices and actor.playerboard[i] != -1: + var item = actor.playerboard[i] + # Only keep if we strictly need it and can't find it easily? + # Actually, generally dump outer ring tiles to keep board clean + # unless we are about to move it to a valid spot. + # But BotController tries to arrange. + # If we have an outer tile that is needed, Arrange should handle it. + # If Arrange failed (lower priority checks), then Put should dump it. + return i + + return -1 + + return -1 + +func get_unneeded_tile_slot_panic() -> int: + """Aggressively find ANY tile that doesn't match a goal perfectly.""" + if not actor or actor.playerboard.size() == 0: + return -1 + + # In panic mode, dump anything not matching goals + for i in range(3): + for j in range(3): + var goal_idx = i * 3 + j + var board_idx = (i + 1) * 5 + (j + 1) + if board_idx >= actor.playerboard.size(): continue + var item = actor.playerboard[board_idx] + if item == -1: continue + + if goal_idx < actor.goals.size(): + if actor.goals[goal_idx] != -1: + if item != actor.goals[goal_idx]: return board_idx + else: + return board_idx + + # Dump outer ring + var center = [6, 7, 8, 11, 12, 13, 16, 17, 18] + for i in range(actor.playerboard.size()): + if not i in center and actor.playerboard[i] != -1: return i + + return -1 + # ============================================================================= # Tile Finding # ============================================================================= diff --git a/scripts/managers/goals_cycle_manager.gd b/scripts/managers/goals_cycle_manager.gd index 0a69159..3af9dfe 100644 --- a/scripts/managers/goals_cycle_manager.gd +++ b/scripts/managers/goals_cycle_manager.gd @@ -91,10 +91,14 @@ func _on_match_end(): """Called when global match timer reaches zero - game over!""" is_match_active = false is_cycle_active = false - emit_signal("match_ended") if multiplayer.is_server(): - rpc("sync_match_end") + # FINAL SCORING: Process any points remaining on board + _process_cycle_end_for_all_players() + # Sync final scores THEN end match on clients + rpc("sync_final_scores") + else: + emit_signal("match_ended") @rpc("authority", "call_local", "reliable") func sync_match_start(duration_seconds: float): @@ -105,9 +109,14 @@ func sync_match_start(duration_seconds: float): emit_signal("match_started") @rpc("authority", "call_local", "reliable") -func sync_match_end(): +func sync_final_scores(): + """Called by server at match end. Signals clients to stop.""" is_match_active = false is_cycle_active = false + + # Request final leaderboard refresh + _update_leaderboard() + emit_signal("match_ended") @rpc("authority", "call_local", "unreliable") diff --git a/scripts/managers/player_action_manager.gd b/scripts/managers/player_action_manager.gd index 61e81b6..4bb57f4 100644 --- a/scripts/managers/player_action_manager.gd +++ b/scripts/managers/player_action_manager.gd @@ -63,7 +63,7 @@ func after_action_completed(): if player.is_multiplayer_authority(): var main = player.get_tree().get_root().get_node_or_null("Main") if main: - main.rpc("sync_playerboard", player.get_multiplayer_authority(), player.playerboard) + main.rpc("sync_playerboard", player.name.to_int(), player.playerboard) player._is_processing_action = false diff --git a/scripts/managers/playerboard_manager.gd b/scripts/managers/playerboard_manager.gd index c3c3cce..c9b79c1 100644 --- a/scripts/managers/playerboard_manager.gd +++ b/scripts/managers/playerboard_manager.gd @@ -76,7 +76,7 @@ func grab_item(grid_position: Vector2i) -> bool: # Update UI immediately for responsiveness var main = player.get_tree().get_root().get_node_or_null("Main") - if main and main.ui_manager: + if main and main.ui_manager and not (player.is_bot or player.is_in_group("Bots")): main.ui_manager.update_playerboard_ui() # Check if goal is completed after grabbing @@ -87,7 +87,7 @@ func grab_item(grid_position: Vector2i) -> bool: # HOST/SERVER: Broadcast to all clients main.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) # Use main's RPC which properly looks up player by ID on each client - var peer_id = player.get_multiplayer_authority() + var peer_id = player.name.to_int() main.rpc("sync_playerboard", peer_id, player.playerboard) player.has_performed_action = true player.consume_action_points(1) @@ -144,7 +144,7 @@ func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int): player.playerboard[target_slot] = item_id # 3c. Broadcast the new playerboard state to all clients - var peer_id = player.get_multiplayer_authority() + var peer_id = player.name.to_int() main.rpc("sync_playerboard", peer_id, player.playerboard) # 3d. Check if goal is completed (SERVER-SIDE - this triggers goal regeneration for clients!) @@ -326,7 +326,7 @@ func auto_put_item() -> bool: # Update UI immediately for responsiveness var main = player.get_tree().get_root().get_node_or_null("Main") - if main and main.ui_manager: + if main and main.ui_manager and not (player.is_bot or player.is_in_group("Bots")): main.ui_manager.update_playerboard_ui() main.ui_manager.current_action_state = main.ui_manager.ActionState.NONE @@ -364,9 +364,10 @@ func arrange_playerboard_item(slot_index: int): # Highlight valid adjacent slots for adj_slot in adjacent_slots: if player.playerboard[adj_slot] == -1: # Only highlight empty adjacent slots - var adj_slot_ui = main.ui_manager.playerboard_ui.get_child(adj_slot) - if adj_slot_ui.get_child_count() > 2: - adj_slot_ui.get_child(2).show() + if not (player.is_bot or player.is_in_group("Bots")): + var adj_slot_ui = main.ui_manager.playerboard_ui.get_child(adj_slot) + if adj_slot_ui.get_child_count() > 2: + adj_slot_ui.get_child(2).show() player.action_manager.highlighted_cells.append(adj_slot) # Connect to slot click signals @@ -403,8 +404,9 @@ func handle_slot_clicked(slot_index: int): selected_playerboard_slot = -1 # Update the visual representation - main.ui_manager.update_playerboard_ui() - main.ui_manager.current_action_state = main.ui_manager.ActionState.NONE + if not (player.is_bot or player.is_in_group("Bots")): + main.ui_manager.update_playerboard_ui() + main.ui_manager.current_action_state = main.ui_manager.ActionState.NONE # ============================================================================= # Helper Functions @@ -594,7 +596,7 @@ func can_move_to_target_playerboard_slot() -> bool: func _update_playerboard_slot_visual(slot_index: int): var main = player.get_tree().get_root().get_node_or_null("Main") - if not main or not main.playerboard_ui: + if not main or not main.playerboard_ui or (player.is_bot or player.is_in_group("Bots")): return var slot = main.playerboard_ui.get_child(slot_index) @@ -608,7 +610,7 @@ func _update_playerboard_slot_visual(slot_index: int): func _highlight_adjacent_playerboard_slots(): var main = player.get_tree().get_root().get_node_or_null("Main") - if not main or not main.playerboard_ui: + if not main or not main.playerboard_ui or (player.is_bot or player.is_in_group("Bots")): return for i in range(25): diff --git a/scripts/managers/powerup_manager.gd b/scripts/managers/powerup_manager.gd index aa7c567..991ddd2 100644 --- a/scripts/managers/powerup_manager.gd +++ b/scripts/managers/powerup_manager.gd @@ -73,8 +73,8 @@ func add_goal_completion_reward(): # ============================================================================= func can_use_special() -> bool: - """Returns true if player has at least 1 bar (4 points).""" - return current_points >= POINTS_PER_BAR + """Returns true if player has at least 1 bar (4 points) AND IS NOT ON COOLDOWN.""" + return current_points >= POINTS_PER_BAR and special_cooldown_timer <= 0 func get_bars() -> int: """Returns current number of full bars.""" diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index ead1e71..eca5b5a 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -194,7 +194,11 @@ func _execute_freeze_player(): opponent.rpc("display_message", "You are frozen!") func _create_unfreeze_timer(target_player: Node3D, duration: float): + if not is_instance_valid(player) or not is_instance_valid(target_player): + return + await player.get_tree().create_timer(duration).timeout + if is_instance_valid(target_player): target_player.set("is_frozen", false) target_player.rpc("display_message", "Unfrozen!") @@ -250,7 +254,11 @@ func _execute_invisible_mode(): player.rpc("display_message", "Invisible mode activated!") func _create_invisibility_timer(duration: float): + if not is_instance_valid(player): + return + await player.get_tree().create_timer(duration).timeout + if is_instance_valid(player): player.set("is_invisible", false) if player.get("original_movement_range"):