feat(world-map): Introduce arena mode with arena-specific world map logic and boundary handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-10 19:26:54 -07:00
parent e3fd056a94
commit 339fa42474
2 changed files with 271 additions and 0 deletions

View file

@ -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()

View file

@ -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/<MATCH_ID>.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)