extends RefCounted class_name BotStrategicPlanner # BotStrategicPlanner - Strategic decision-making for bot AI # Evaluates tile needs, pathfinding targets, and sabotage opportunities 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 use Ghost mode 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_ghost_powerup() -> bool: """Check if the bot has a ghost powerup in its SpecialTilesManager inventory.""" var stm = actor.get_node_or_null("SpecialTilesManager") if not stm: return false return stm.inventory.get(stm.SpecialEffect.INVISIBLE_MODE, false) func _is_bot_ghost_active() -> bool: """Check if the bot is currently in ghost (invisible) mode.""" return actor.get("is_invisible") == true 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 Ghost mode is active (grants sticky bypass).""" if not is_gauntlet_mode(): return false if _is_bot_ghost_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_ghost_now() -> bool: """True if the bot is boxed in / about to be sealed and should use Ghost powerup.""" if not is_gauntlet_mode(): return false if not _bot_has_ghost_powerup(): return false if _is_bot_ghost_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 func _normalize_tile(tile: int) -> int: """Normal tiles 7-10 are goals. 11-14 are powerups and not goals.""" # If it's a holo tile, treat it as its normal counterpart for goal matching if tile >= 11 and tile <= 14: return tile - 4 # 11->7, 12->8, etc. return tile # ============================================================================= # Goal Analysis # ============================================================================= func calculate_goal_progress() -> float: """Returns 0.0-1.0 representing how close bot is to completing their goal.""" if not actor or actor.goals.size() == 0: return 0.0 var matches = 0 var total_required = 0 # Check center 3x3 of playerboard against goals for i in range(3): for j in range(3): var goal_idx = i * 3 + j if goal_idx >= actor.goals.size(): continue var goal_value = actor.goals[goal_idx] if goal_value == -1: continue # Empty goal slot total_required += 1 var board_idx = (i + 1) * 5 + (j + 1) # Center 3x3 in 5x5 board if board_idx < actor.playerboard.size() and _normalize_tile(actor.playerboard[board_idx]) == goal_value: matches += 1 if total_required == 0: return 1.0 # All goals are -1 (empty) return float(matches) / float(total_required) func get_tiles_needed() -> Array: """Returns array of tile types still needed to complete goal.""" var needed = [] if not actor or actor.goals.size() == 0: # print("[BotStrategicPlanner] %s - No goals assigned yet." % actor.name) return needed for i in range(3): for j in range(3): var goal_idx = i * 3 + j if goal_idx >= actor.goals.size(): continue var goal_value = actor.goals[goal_idx] if goal_value == -1: continue var board_idx = (i + 1) * 5 + (j + 1) if board_idx >= actor.playerboard.size() or _normalize_tile(actor.playerboard[board_idx]) != goal_value: if not goal_value in needed: needed.append(goal_value) # print("[BotStrategicPlanner] %s goals: %s. Needed: %s" % [actor.name, actor.goals, needed]) return needed func find_best_slot_for_tile(tile_type: int) -> int: """Find the best playerboard slot for a given tile type.""" # Check goals to find matching position for i in range(3): for j in range(3): var goal_idx = i * 3 + j if goal_idx < actor.goals.size() and actor.goals[goal_idx] == tile_type: var board_idx = (i + 1) * 5 + (j + 1) if board_idx < actor.playerboard.size() and actor.playerboard[board_idx] == -1: return board_idx # 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 # ============================================================================= func find_best_tile_to_grab() -> Dictionary: """Find the best tile to grab, prioritizing goal tiles then holo tiles.""" var needed_tiles = get_tiles_needed() var best_tile = {"position": null, "type": - 1, "priority": 0} if not enhanced_gridmap: return best_tile # Search nearby area for tiles var search_radius = 5 var current_pos = actor.current_position for dx in range(-search_radius, search_radius + 1): for dz in range(-search_radius, search_radius + 1): var pos = Vector2i(current_pos.x + dx, current_pos.y + dz) if not enhanced_gridmap.is_position_valid(pos): continue var cell = Vector3i(pos.x, 1, pos.y) var item = enhanced_gridmap.get_cell_item(cell) if item == -1: continue var priority = 0 # Priority 1: Tiles we need for goals if item in needed_tiles: priority = 10 - abs(dx) - abs(dz) # Closer = higher priority # Priority 2: Holo tiles for power-ups elif item in HOLO_TILES: priority = 5 - abs(dx) - abs(dz) elif item in GOAL_TILES: priority = 1 # Low priority - might be useful later if priority > best_tile.priority: best_tile = {"position": pos, "type": item, "priority": priority} return best_tile func find_nearest_tile_of_type(tile_types: Array) -> Vector2i: """Find nearest tile matching any type in array using optimized spiral search.""" var current_pos = actor.current_position if not enhanced_gridmap: return Vector2i(-1, -1) # Optimization: Start check at simple radius # If we find something in the spiral, it is guaranteed to be one of the nearest (by Chebyshev distance logic broadly, or just good enough) var max_radius = 25 # Limit search range to prevent full map scans on huge maps if OS.has_feature("mobile"): max_radius = 15 # Stricter limit on mobile # Check center first var center_cell = Vector3i(current_pos.x, 1, current_pos.y) var center_item = enhanced_gridmap.get_cell_item(center_cell) if _normalize_tile(center_item) in tile_types: return current_pos for r in range(1, max_radius + 1): var found_in_layer = [] # In Stop n Go, prefer tiles "ahead" (higher X) var is_stop_n_go = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) # Check the ring for x_off in range(-r, r + 1): _check_spiral_cell(current_pos.x + x_off, current_pos.y - r, tile_types, found_in_layer) # Top _check_spiral_cell(current_pos.x + x_off, current_pos.y + r, tile_types, found_in_layer) # Bottom for y_off in range(-r + 1, r): _check_spiral_cell(current_pos.x - r, current_pos.y + y_off, tile_types, found_in_layer) # Left _check_spiral_cell(current_pos.x + r, current_pos.y + y_off, tile_types, found_in_layer) # Right if found_in_layer.size() > 0: # If we found candidates in this layer, pick the physically closest one (Euclidean/Manhattan refinement) var nearest_in_layer = found_in_layer[0] var min_dist = 999999 for pos in found_in_layer: var dist = abs(pos.x - current_pos.x) + abs(pos.y - current_pos.y) # DIRECTIONAL BIAS: In Stop n Go, penalize tiles that are "behind" us if is_stop_n_go: if current_pos.x <= 10: # EARLY GAME: Extremely focused on moving right if pos.x < current_pos.x: dist += 10 # Heavier penalty for backtracking elif pos.x > current_pos.x: dist -= 4 # Heavier bonus for moving forward else: # LATE GAME: Normal bias if pos.x < current_pos.x: dist += 5 elif pos.x > current_pos.x: dist -= 2 if dist < min_dist: min_dist = dist nearest_in_layer = pos return nearest_in_layer return Vector2i(-1, -1) func _check_spiral_cell(x: int, z: int, tile_types: Array, result_array: Array): if x < 0 or z < 0 or x >= enhanced_gridmap.columns or z >= enhanced_gridmap.rows: return var cell = Vector3i(x, 1, z) var item = enhanced_gridmap.get_cell_item(cell) if _normalize_tile(item) in tile_types: result_array.append(Vector2i(x, z)) func find_nearest_roaming_tekton() -> Node3D: """Find the nearest Tekton that isn't carried and is on the grid.""" var tektons = actor.get_tree().get_nodes_in_group("Tektons") var nearest_tekton = null var min_dist = 999999.0 for tekton in tektons: if not is_instance_valid(tekton): continue if tekton.get("is_carried") or tekton.get("is_static_turret"): continue if tekton.get("is_recovering"): continue # Cannot target shrinking/recovering Tektons var dist = actor.global_position.distance_to(tekton.global_position) if dist < min_dist: min_dist = dist nearest_tekton = tekton return nearest_tekton # ============================================================================= # Movement Strategy # ============================================================================= func find_optimal_move_target() -> Vector2i: """Calculate the best position to move towards.""" var main = actor.get_tree().get_root().get_node_or_null("Main") var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null var time_left = gc_manager.get_global_time_remaining() if gc_manager else 999.0 var is_match_running = gc_manager.is_match_running() if gc_manager else false var is_late_game = is_sng and is_match_running and time_left > 0.0 and time_left <= 30.0 # 1. STOP N GO: Reach the finish line if goals are complete if is_sng and main: var sng_manager = main.get_node_or_null("StopNGoManager") if sng_manager and sng_manager.is_mission_complete(actor.name.to_int()): # MISSION COMPLETE: We CAN finish, but should we? if not is_late_game: # CHAOS MODE: Allow falling through to target holo tiles, but we'll limit the target X later # print("[BotStrategicPlanner] %s mission complete (Chaos Phase %.1fs). Roaming field." % [actor.name, time_left]) pass else: # Late game: go to finish var finish_target = Vector2i(21, actor.current_position.y) # Ensure finish_target is walkable if not _is_valid_move_target(finish_target): for dy in [1, -1, 2, -2]: var alt = Vector2i(21, actor.current_position.y + dy) if _is_valid_move_target(alt): finish_target = alt break print("[BotStrategicPlanner] %s mission complete (Late Game %.1fs)! Heading to finish: %s" % [actor.name, time_left, finish_target]) return finish_target var sng_manager = main.get_node_or_null("StopNGoManager") if main else null var is_mission_complete = sng_manager.is_mission_complete(actor.name.to_int()) if sng_manager else false var needed_tiles = get_tiles_needed() # Priority targets: needed tiles > holo tiles > any goal tile var targets_to_try = [] # If mission is complete, we don't need goal tiles or specific board tiles if not is_mission_complete: if needed_tiles.size() > 0: targets_to_try.append(needed_tiles) var pu_manager = actor.get_node_or_null("PowerUpManager") if pu_manager and pu_manager.current_points < pu_manager.MAX_POINTS: targets_to_try.append(HOLO_TILES) if not is_mission_complete: targets_to_try.append(GOAL_TILES) # CONSTRAINT: In Stop n Go, NEVER target X >= 21 unless it's late game (last 30s) var max_x = 22 # No limit by default if is_sng and not is_late_game: max_x = 20 for tile_set in targets_to_try: var target = find_nearest_tile_of_type(tile_set) if target != Vector2i(-1, -1) and target.x <= max_x: # Just return the target directly if it's a valid tile position. # The BotController will use A* to find the path. # We only need _get_adjacent_position if the target itself is an obstacle (e.g. Tekton Stand). if _is_valid_move_target(target, true): return target else: # If we can't stand ON it (e.g. it's on a stand), find a spot NEXT to it. var final = _get_adjacent_position(target) if final != actor.current_position and final.x <= max_x: return final # Fallback: move Right in Stop n Go mode even if idle if is_sng: # Only force forward if we haven't finished our mission OR time is almost up # DRIFT PREVENTION: Only step right if incomplete AND not already late in the track # If they reach column 16 without goals, they should stay there and wait for items. if (not is_mission_complete and actor.current_position.x < 16) or is_late_game: var right_step = actor.current_position + Vector2i(1, 0) if _is_valid_move_target(right_step): return right_step # Fallback: random valid position var rnd = _get_random_valid_position() # Apply X constraint to random move if is_sng and rnd.x > max_x: rnd.x = max_x # Clamp to safe zone if not _is_valid_move_target(rnd): # Try to find any other valid y at this x for dy in [1, -1, 2, -2]: var alt = Vector2i(rnd.x, rnd.y + dy) if _is_valid_move_target(alt): rnd = alt break if rnd.x > max_x: return actor.current_position # Last resort return rnd func _get_adjacent_position(target: Vector2i) -> Vector2i: """Get a valid position adjacent to or at the target.""" var current_pos = actor.current_position # If we are already at the target, stay there if current_pos == target: return target # If the target is walkable and within range, return it if _is_valid_move_target(target) and _is_within_movement_range(target): return target # 1. ORTHOGONAL NEIGHBORS (Normal priority) var neighbors = [ target + Vector2i(1, 0), target + Vector2i(-1, 0), target + Vector2i(0, 1), target + Vector2i(0, -1) ] # Priority: Pick neighbors that are NOT our current position first var candidates = [] for n_pos in neighbors: # Use ignore_players=true here because we want to see ALL potentially walkable paths # The movement manager will handle actual collisions/pushes if _is_valid_move_target(n_pos, true) and _is_within_movement_range(n_pos): candidates.append(n_pos) if candidates.size() > 0: # If we have candidates that aren't where we are, pick the closest one to target var non_current = candidates.filter(func(p): return p != current_pos) if non_current.size() > 0: non_current.sort_custom(func(a, b): return (a - current_pos).length_squared() < (b - current_pos).length_squared() ) return non_current[0] else: # If only option is current pos, we are "at" the target neighbor return current_pos # 2. STEP CLOSER FALLBACK var dx = sign(target.x - current_pos.x) var dz = sign(target.y - current_pos.y) var steps = [ Vector2i(current_pos.x + dx, current_pos.y + dz), Vector2i(current_pos.x + dx, current_pos.y), Vector2i(current_pos.x, current_pos.y + dz) ] for step in steps: if _is_valid_move_target(step, true): # Ignore players for step planning return step return Vector2i(-1, -1) func _is_within_movement_range(pos: Vector2i) -> bool: var current_pos = actor.current_position if actor.get("use_diagonal_movement"): return max(abs(pos.x - current_pos.x), abs(pos.y - current_pos.y)) <= actor.movement_range else: return (abs(pos.x - current_pos.x) + abs(pos.y - current_pos.y)) <= actor.movement_range func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool: if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos): return false # Check Floor 0 (Ground/Walls) var floor_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if floor_item == -1 or floor_item in enhanced_gridmap.non_walkable_items: return false # Check Floor 1 (Items/Obstacles) var item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 1, pos.y)) if item != -1 and item in enhanced_gridmap.non_walkable_items: return false # Check Physics (Stands/Static Objects) if actor.movement_manager and actor.movement_manager.has_method("_is_position_blocked_by_physics"): if actor.movement_manager._is_position_blocked_by_physics(pos): return false 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). Ghost-active bots are # exempt via the helper. if _is_cell_unsafe_in_gauntlet(pos): return false return true func _get_random_valid_position() -> Vector2i: var valid_positions = [] var current_pos = actor.current_position var range_val = actor.movement_range for dx in range(-range_val, range_val + 1): for dz in range(-range_val, range_val + 1): if dx == 0 and dz == 0: continue var pos = Vector2i(current_pos.x + dx, current_pos.y + dz) if _is_valid_move_target(pos): valid_positions.append(pos) if valid_positions.size() > 0: return valid_positions[randi() % valid_positions.size()] return Vector2i(-1, -1) # ============================================================================= # Sabotage Strategy # ============================================================================= func evaluate_sabotage_opportunity() -> Dictionary: """Evaluate whether to use power-up for sabotage.""" var result = {"should_sabotage": false, "reason": "", "target": null} var powerup_manager = actor.get_node_or_null("PowerUpManager") if not powerup_manager or not powerup_manager.can_use_special(): return result # 0. STOP N GO THRESHOLD: No sabotage until passing column 10 if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x <= 10: return result # Get opponents var opponents = _get_opponents() if opponents.size() == 0: return result # Check conditions for sabotage (balanced strategy) # Condition 1: Power-ups are maxed - use it or lose potential gains if powerup_manager.current_points >= powerup_manager.MAX_POINTS: result.should_sabotage = true result.reason = "max_powerup" result.target = opponents[randi() % opponents.size()] return result # Condition 2: Opponent is close to completing their goal var progress_threshold = 0.7 if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 10: progress_threshold = 0.4 # More aggressive in late game! for opponent in opponents: var opponent_progress = _estimate_opponent_progress(opponent) if opponent_progress >= progress_threshold: result.should_sabotage = true result.reason = "opponent_close_to_winning" result.target = opponent return result # Condition 3: Bot is behind in score - need to catch up var goals_cycle_manager = actor.get_tree().get_root().get_node_or_null("Main/GoalsCycleManager") if goals_cycle_manager: var leaderboard = goals_cycle_manager.get_leaderboard() var my_rank = _get_rank_in_leaderboard(leaderboard) if my_rank > 1 and powerup_manager.get_bars() >= 2: result.should_sabotage = true result.reason = "behind_in_score" result.target = opponents[0] if opponents.size() > 0 else null return result # Condition 4: Random Aggression (Stop n Go Late Game) if LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) and actor.current_position.x > 12: if randf() < 0.3: # 30% chance each tick to just be mean result.should_sabotage = true result.reason = "random_aggression" result.target = opponents[randi() % opponents.size()] return result return result func _get_opponents() -> Array: var all_players = actor.get_tree().get_nodes_in_group("Players") return all_players.filter(func(p): return p != actor) func _estimate_opponent_progress(opponent: Node) -> float: """Estimate opponent's goal progress based on their playerboard.""" if not opponent or opponent.goals.size() == 0: return 0.0 var matches = 0 var total = 0 for i in range(3): for j in range(3): var goal_idx = i * 3 + j if goal_idx >= opponent.goals.size(): continue var goal_value = opponent.goals[goal_idx] if goal_value == -1: continue total += 1 var board_idx = (i + 1) * 5 + (j + 1) if board_idx < opponent.playerboard.size() and opponent.playerboard[board_idx] == goal_value: matches += 1 return float(matches) / float(max(total, 1)) func _get_rank_in_leaderboard(leaderboard: Array) -> int: var my_id = actor.get_multiplayer_authority() for i in range(leaderboard.size()): if leaderboard[i].get("peer_id", -1) == my_id: return i + 1 return leaderboard.size() + 1