From 339fa424749758ec06e9e3c2edff54a9cabf717d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 10 Apr 2026 19:26:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(world-map):=20=E2=9C=A8=20Introduce=20aren?= =?UTF-8?q?a=20mode=20with=20arena-specific=20world=20map=20logic=20and=20?= =?UTF-8?q?boundary=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/scenes/world_map/world_map.gd | 5 + .../scenes/world_map/world_map_arena.gd | 266 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 src/game/engine/scenes/world_map/world_map_arena.gd diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 6c121cc3..a3158b89 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -23,6 +23,9 @@ const WorldMapCityActionsScript: GDScript = preload( const WorldMapVisionScript: GDScript = preload( "res://engine/scenes/world_map/world_map_vision.gd" ) +const WorldMapArenaScript: GDScript = preload( + "res://engine/scenes/world_map/world_map_arena.gd" +) const VILLAGE_DISCOVERY_GOLD: int = 20 @@ -48,6 +51,8 @@ var _next_unit_id: int = 0 func _ready() -> void: _setup_renderers() _connect_signals() + if EnvConfig.get_bool("AI_ARENA"): + WorldMapArenaScript.new().setup(self) _start_game() diff --git a/src/game/engine/scenes/world_map/world_map_arena.gd b/src/game/engine/scenes/world_map/world_map_arena.gd new file mode 100644 index 00000000..4ccd196e --- /dev/null +++ b/src/game/engine/scenes/world_map/world_map_arena.gd @@ -0,0 +1,266 @@ +extends RefCounted +## Spectator-mode glue for the AI Arena. Activated when AI_ARENA env is set. +## +## Responsibilities: +## - Build a small CanvasLayer overlay showing match id, seed, player names, +## and current turn (so the orchestrator's grid is self-labeling). +## - Tear down UI nodes that would block AI-only play (VictoryScreen pauses +## the tree; AiTurnOverlay would flicker on every turn). +## - Watch for end-game (victory_achieved or turn_limit) and write a JSON +## result file to AI_ARENA_RESULTS_DIR/.json, then quit. +## +## All node manipulation happens through a setup(world_map) call so this stays +## a thin RefCounted helper that can be unit-tested independently of the scene. + +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const VictoryManagerScript: GDScript = preload( + "res://engine/src/modules/victory/victory_manager.gd" +) + +const QUIT_HOLD_SECONDS: float = 2.0 +const WALL_CLOCK_LIMIT_MSEC: int = 600_000 ## 10 minutes — hard safety net + +var _world_map: Node = null +var _turn_label: Label = null +var _start_ticks_msec: int = 0 +var _turn_limit: int = 0 +var _match_id: String = "" +var _seed: int = 0 +var _result_dir: String = "" +var _victory_manager: RefCounted = null +var _finished: bool = false + + +func setup(world_map: Node) -> void: + _world_map = world_map + _match_id = EnvConfig.get_var("AI_ARENA_MATCH_ID", "match_?") + _seed = EnvConfig.get_int("AI_ARENA_SEED", 0) + _turn_limit = EnvConfig.get_int("AI_ARENA_TURN_LIMIT", 150) + _result_dir = EnvConfig.get_var("AI_ARENA_RESULTS_DIR", "/tmp/ai-arena") + _start_ticks_msec = Time.get_ticks_msec() + _victory_manager = VictoryManagerScript.new() + + _disable_blocking_overlays() + _disable_human_input() + _build_label_overlay() + + EventBus.victory_achieved.connect(_on_victory) + EventBus.turn_ended.connect(_on_turn_ended) + print( + ( + "[AI ARENA] match=%s seed=%d turn_limit=%d players=%s vs %s" + % [ + _match_id, + _seed, + _turn_limit, + EnvConfig.get_var("AI_ARENA_P1_NAME", "P1"), + EnvConfig.get_var("AI_ARENA_P2_NAME", "P2"), + ] + ) + ) + + +# ── Overlay setup ──────────────────────────────────────────────────── + + +func _disable_blocking_overlays() -> void: + ## VictoryScreen calls get_tree().paused = true on victory, which would + ## freeze the timer that writes our result file. AiTurnOverlay dims the + ## screen during AI turns — useless when both players are AI. + for child_name: String in ["VictoryScreen", "AiTurnOverlay"]: + var node: Node = _world_map.get_node_or_null(child_name) + if node != null: + node.queue_free() + + +func _disable_human_input() -> void: + ## Detach the world_map node from the human input pipeline so spectator + ## clicks don't accidentally select units or end turns. We disconnect + ## the two UI signal sources and silence the unhandled-input listener. + _world_map.set_process_unhandled_input(false) + var vwm: Node = _world_map.get_node_or_null("ViewportWindowManager") + if vwm != null and vwm.has_signal("viewport_clicked"): + if vwm.viewport_clicked.is_connected(_world_map._on_viewport_clicked): + vwm.viewport_clicked.disconnect(_world_map._on_viewport_clicked) + var hud: Node = _world_map.get_node_or_null("WorldMapHud") + if hud != null and hud.has_signal("end_turn_pressed"): + if hud.end_turn_pressed.is_connected(_world_map._on_end_turn_pressed): + hud.end_turn_pressed.disconnect(_world_map._on_end_turn_pressed) + + +func _build_label_overlay() -> void: + var layer: CanvasLayer = CanvasLayer.new() + layer.name = "ArenaOverlay" + layer.layer = 20 + + var panel: PanelContainer = PanelContainer.new() + panel.position = Vector2(8, 8) + + var vbox: VBoxContainer = VBoxContainer.new() + vbox.add_theme_constant_override("separation", 2) + + var title: Label = Label.new() + title.text = "%s · seed %d" % [_match_id, _seed] + title.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4)) + title.add_theme_font_size_override("font_size", 13) + + var versus: Label = Label.new() + versus.text = ( + "%s vs %s" + % [ + EnvConfig.get_var("AI_ARENA_P1_NAME", "P1"), + EnvConfig.get_var("AI_ARENA_P2_NAME", "P2"), + ] + ) + versus.add_theme_font_size_override("font_size", 12) + + _turn_label = Label.new() + _turn_label.text = "turn 1 / %d" % _turn_limit + _turn_label.add_theme_color_override("font_color", Color(0.6, 0.85, 1.0)) + _turn_label.add_theme_font_size_override("font_size", 11) + + vbox.add_child(title) + vbox.add_child(versus) + vbox.add_child(_turn_label) + panel.add_child(vbox) + layer.add_child(panel) + _world_map.add_child(layer) + + +# ── End-game detection ─────────────────────────────────────────────── + + +func _on_turn_ended(_turn_number: int, _player_index: int) -> void: + if _finished: + return + if _turn_label != null: + _turn_label.text = "turn %d / %d" % [GameState.turn_number, _turn_limit] + + if Time.get_ticks_msec() - _start_ticks_msec > WALL_CLOCK_LIMIT_MSEC: + var winner_clock: int = _pick_winner_by_score() + _finish(winner_clock, "wall_clock_timeout") + return + + if GameState.turn_number > _turn_limit: + var winner_score: int = _pick_winner_by_score() + _finish(winner_score, "score") + + +func _on_victory(player_index: int, victory_type: String) -> void: + if _finished: + return + _finish(player_index, victory_type) + + +func _pick_winner_by_score() -> int: + ## Highest score wins; ties resolved deterministically by lower player index. + var game_map: RefCounted = GameState.get_game_map() + var best_idx: int = 0 + var best_score: int = -1 + for player: Variant in GameState.players: + if not player is PlayerScript: + continue + var s: int = (_victory_manager as VictoryManagerScript).calculate_score( + player, game_map + ) + if s > best_score: + best_score = s + best_idx = player.index + return best_idx + + +# ── Result writing ─────────────────────────────────────────────────── + + +func _finish(winner_index: int, victory_type: String) -> void: + _finished = true + var result: Dictionary = _build_result_dict(winner_index, victory_type) + _write_result(result) + print( + ( + "[AI ARENA] match=%s finished: winner=%s type=%s turn=%d" + % [ + _match_id, + str(result.get("winner_name", "?")), + victory_type, + int(result.get("final_turn", 0)), + ] + ) + ) + # Hold the final state on screen briefly so a human passing the monitor + # can see how the match ended, then quit cleanly. + _world_map.get_tree().create_timer(QUIT_HOLD_SECONDS).timeout.connect( + _quit, CONNECT_ONE_SHOT + ) + + +func _build_result_dict(winner_index: int, victory_type: String) -> Dictionary: + var game_map: RefCounted = GameState.get_game_map() + var vm: VictoryManagerScript = _victory_manager as VictoryManagerScript + var players_payload: Array = [] + var winner_name: String = "" + + for player: Variant in GameState.players: + if not player is PlayerScript: + continue + var pop_total: int = _total_population(player) + var entry: Dictionary = { + "index": player.index, + "name": player.player_name, + "race": player.race_id, + "score": vm.calculate_score(player, game_map), + "cities": player.cities.size(), + "units": player.units.size(), + "techs": player.researched_techs.size(), + "gold": int(player.gold), + "population": pop_total, + } + players_payload.append(entry) + if player.index == winner_index: + winner_name = player.player_name + + var duration_seconds: float = float( + Time.get_ticks_msec() - _start_ticks_msec + ) / 1000.0 + + return { + "match_id": _match_id, + "seed": _seed, + "winner_index": winner_index, + "winner_name": winner_name, + "victory_type": victory_type, + "final_turn": GameState.turn_number, + "turn_limit": _turn_limit, + "players": players_payload, + "duration_seconds": duration_seconds, + "timestamp": Time.get_datetime_string_from_system(true), + } + + +func _total_population(player: RefCounted) -> int: + var total: int = 0 + for city: Variant in player.cities: + if city is Object and city.has_method("get_population"): + total += int(city.get_population()) + elif city is Object: + total += int(city.get("population")) + return total + + +func _write_result(result: Dictionary) -> void: + if _result_dir.is_empty(): + push_warning("[AI ARENA] AI_ARENA_RESULTS_DIR not set — result discarded") + return + DirAccess.make_dir_recursive_absolute(_result_dir) + var path: String = "%s/%s.json" % [_result_dir, _match_id] + var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("[AI ARENA] Cannot open %s for writing" % path) + return + file.store_string(JSON.stringify(result, " ")) + file.close() + print("[AI ARENA] result written: %s" % path) + + +func _quit() -> void: + _world_map.get_tree().quit(0)