mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
e1f512c94e
Hard has been a label-only duplicate of medium.json since medium was promoted on 2026-08-17. Promote 20260823-1734-gen5-s5-intercepts-retry2 into hard.json so the tier is a genuinely distinct policy, and so the strongest bot the curriculum has produced survives the next round's checkpoint pruning — promoted files are never touched by training scripts. Stage 5 blocked after three attempts, so like medium.json this comes from a run recorded as decision: "fail". Both failing floors are covered in TRAINING.md: goal_rate 0.7369 vs 0.75 is marginal, and productive_air_touch_fraction 0.0001 vs 0.005 is a bar no policy in the lineage has approached, against a metric quantised at 0.01 per ~100-episode window. On every other axis it is the best yet: upright_fraction 0.757 against a 0.40 floor that the pre-Round-6 lineage never pushed past 0.331, and forward_motion_fraction 0.479 against 0.20. Chosen over attempt 2 (retry1) on a tiebreak, not a margin. retry1 posts a much wider indirect result against medium.json (63-23-14 vs 47-32-21), but a direct 100-episode head-to-head between the two finished 36-39 with 25 draws, so that gap does not reflect a real strength difference. Attempt 3 is the later checkpoint (it resumed from attempt 2) and edges every telemetry metric. Verified: hard.json is byte-identical to its source export, matches easy/medium on input_size 83, 3 layers and action space, and beats medium.json 19-7-4 in a fresh 30-episode paired run. Tiers stay monotonic: hard > medium > easy. That head-to-head also showed a 17% physical side imbalance (physical teams 0-1 = 29-46), reproduced at 13% in the 30-episode check. Inside the 20% bar used elsewhere and equal across both models, but noted in TRAINING.md as worth investigating rather than assuming variance.
270 lines
10 KiB
GDScript
270 lines
10 KiB
GDScript
extends Control
|
|
|
|
# Main menu: one handler per game mode. Match's opponent is a curated
|
|
# Easy/Medium/Hard difficulty picker (DIFFICULTIES) rather than a raw
|
|
# checkpoint list — see TRAINING.md for promoting new tiers into
|
|
# res://bots/promoted/. Raw-checkpoint testing and Spectate (bot vs bot) are
|
|
# dev-only tools, grouped under DevSection and hidden outside debug builds so
|
|
# they disappear automatically from release exports. Selections are pushed
|
|
# into the GameSettings autoload for the target mode to read, and restored
|
|
# when returning to the menu within a session.
|
|
|
|
const BOTS_DIR := "res://bots"
|
|
|
|
# Every tier runs its promoted checkpoint at full trained capability —
|
|
# difficulty is a genuinely different policy, never the same policy
|
|
# handicapped with reaction delay or action noise. All three tiers are now
|
|
# distinct models: medium.json beats easy.json 65-22-13, and hard.json (the
|
|
# generation-5 Stage-5 policy) beats medium.json 47-32-21. Keep the tiers
|
|
# monotonic: never leave a lower tier pointing at a stronger model than the
|
|
# one above it.
|
|
const DIFFICULTIES := [
|
|
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 8, "action_noise": 0.0},
|
|
{"name": "Medium", "model": "res://bots/promoted/medium.json", "reaction_ticks": 8, "action_noise": 0.0},
|
|
{"name": "Hard", "model": "res://bots/promoted/hard.json", "reaction_ticks": 8, "action_noise": 0.0},
|
|
]
|
|
|
|
@onready var difficulty_dropdown: OptionButton = %DifficultyDropdown
|
|
@onready var arena_dropdown: OptionButton = %ArenaDropdown
|
|
@onready var dev_section: Control = %DevSection
|
|
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
|
|
@onready var bot_a_dropdown: OptionButton = %BotADropdown
|
|
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
|
|
@onready var join_address_edit: LineEdit = %JoinAddressEdit
|
|
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
|
|
@onready var connecting_overlay: Control = %ConnectingOverlay
|
|
@onready var connecting_status_label: Label = %ConnectingStatusLabel
|
|
|
|
|
|
func _ready() -> void:
|
|
# An idle menu has no reason to render past the display's own refresh
|
|
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
|
|
var refresh_rate := DisplayServer.screen_get_refresh_rate()
|
|
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
|
|
_populate_difficulty_dropdown()
|
|
_populate_arena_dropdown()
|
|
dev_section.visible = OS.is_debug_build()
|
|
if dev_section.visible:
|
|
var bots := _list_bots()
|
|
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
|
|
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
|
|
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
|
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
|
NetworkManager.connection_failed.connect(_on_connection_failed)
|
|
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
|
|
|
|
|
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
|
# (NetworkManager.host() either succeeds immediately or fails immediately),
|
|
# but Join is not — it can take anywhere from a clean local-network round
|
|
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
|
|
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
|
|
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
|
|
# path that returns the player to a sane, retryable menu state rather than
|
|
# just hanging with no feedback.
|
|
func _process(_delta: float) -> void:
|
|
NetworkManager.poll()
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
NetworkManager.poll()
|
|
|
|
|
|
func _populate_difficulty_dropdown() -> void:
|
|
difficulty_dropdown.clear()
|
|
for tier in DIFFICULTIES:
|
|
difficulty_dropdown.add_item(tier["name"])
|
|
var selected := 0
|
|
for i in DIFFICULTIES.size():
|
|
if DIFFICULTIES[i]["name"] == GameSettings.selected_difficulty_name:
|
|
selected = i
|
|
break
|
|
difficulty_dropdown.select(selected)
|
|
|
|
|
|
# Free Play only — Match/Spectate pick a random arena in code instead.
|
|
func _populate_arena_dropdown() -> void:
|
|
arena_dropdown.clear()
|
|
for tier in ArenaRegistry.ARENAS:
|
|
arena_dropdown.add_item(tier["name"])
|
|
var selected := 0
|
|
for i in ArenaRegistry.ARENAS.size():
|
|
if ArenaRegistry.ARENAS[i]["path"] == GameSettings.selected_arena_path:
|
|
selected = i
|
|
break
|
|
arena_dropdown.select(selected)
|
|
|
|
|
|
# Lists bot checkpoints as paths relative to BOTS_DIR, including the
|
|
# top-level curriculum checkpoints and anything under promoted/.
|
|
func _list_bots() -> Array[String]:
|
|
var files: Array[String] = []
|
|
var dir := DirAccess.open(BOTS_DIR)
|
|
if dir:
|
|
for f in dir.get_files():
|
|
if f.get_extension() == "json":
|
|
files.append(f)
|
|
var promoted_dir := DirAccess.open(BOTS_DIR + "/promoted")
|
|
if promoted_dir:
|
|
for f in promoted_dir.get_files():
|
|
if f.get_extension() == "json":
|
|
files.append("promoted/" + f)
|
|
files.sort()
|
|
return files
|
|
|
|
|
|
# Fills a dropdown with bot names (metadata = full model path). When
|
|
# include_none is true, prepends a "(Use difficulty)" sentinel (metadata "")
|
|
# and defaults selection to it. Reselects `preferred_path` if it's still on
|
|
# disk, else the newest (last) bot.
|
|
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
|
|
dropdown.clear()
|
|
if include_none:
|
|
dropdown.add_item("(Use difficulty)")
|
|
dropdown.set_item_metadata(0, "")
|
|
for f in bots:
|
|
dropdown.add_item(f.get_basename())
|
|
dropdown.set_item_metadata(dropdown.item_count - 1, BOTS_DIR + "/" + f)
|
|
if dropdown.item_count == 0:
|
|
return
|
|
dropdown.select(0 if include_none else dropdown.item_count - 1)
|
|
for i in dropdown.item_count:
|
|
if dropdown.get_item_metadata(i) == preferred_path:
|
|
dropdown.select(i)
|
|
break
|
|
|
|
|
|
func _selected_path(dropdown: OptionButton) -> String:
|
|
if dropdown.item_count > 0 and dropdown.selected >= 0:
|
|
return dropdown.get_item_metadata(dropdown.selected)
|
|
return ""
|
|
|
|
|
|
# The menu's own refresh-rate fps cap (see _ready) is a menu-only concern;
|
|
# gameplay scenes respect the player's own VideoSettings fps cap instead
|
|
# (task 0.17), which only actually caps anything when vsync is Disabled and a
|
|
# divisor is chosen — otherwise this uncaps exactly like the old hardcoded 0.
|
|
func _leave_to_gameplay(scene_path: String) -> void:
|
|
VideoSettings.apply_fps_cap()
|
|
get_tree().change_scene_to_file(scene_path)
|
|
|
|
|
|
func _on_free_play_pressed() -> void:
|
|
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
|
|
GameSettings.selected_arena_path = chosen["path"]
|
|
_leave_to_gameplay("res://scenes/free_play.tscn")
|
|
|
|
|
|
func _on_match_pressed() -> void:
|
|
var tier: Dictionary = DIFFICULTIES[difficulty_dropdown.selected] if difficulty_dropdown.selected >= 0 else DIFFICULTIES[0]
|
|
var override_path := _selected_path(dev_bot_dropdown) if dev_section.visible else ""
|
|
GameSettings.dev_bot_override_path = override_path
|
|
GameSettings.selected_difficulty_name = tier["name"]
|
|
if override_path.is_empty():
|
|
GameSettings.selected_bot_path = tier["model"]
|
|
GameSettings.selected_bot_reaction_ticks = tier["reaction_ticks"]
|
|
GameSettings.selected_bot_action_noise = tier["action_noise"]
|
|
else:
|
|
GameSettings.selected_bot_path = override_path
|
|
GameSettings.selected_bot_reaction_ticks = -1
|
|
GameSettings.selected_bot_action_noise = -1.0
|
|
_leave_to_gameplay("res://scenes/match.tscn")
|
|
|
|
|
|
func _on_settings_pressed() -> void:
|
|
get_tree().change_scene_to_file("res://scenes/settings.tscn")
|
|
|
|
|
|
func _on_spectate_pressed() -> void:
|
|
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
|
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
|
_leave_to_gameplay("res://scenes/spectate.tscn")
|
|
|
|
|
|
func _on_host_pressed() -> void:
|
|
_clear_multiplayer_error()
|
|
var err := NetworkManager.host()
|
|
if err != OK:
|
|
_show_multiplayer_error("Could not host: %s" % error_string(err))
|
|
return
|
|
_leave_to_lobby()
|
|
|
|
|
|
func _on_join_pressed() -> void:
|
|
_start_join()
|
|
|
|
|
|
func _on_join_address_submitted(_new_text: String) -> void:
|
|
_start_join()
|
|
|
|
|
|
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
|
|
# anything a menu should make a player wait for — verified empirically
|
|
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
|
|
# refused loopback connection: connection_failed never fired within 14s.
|
|
# This timer is what actually guarantees "connection-refused reaches a sane
|
|
# UI state" rather than leaving the overlay up indefinitely.
|
|
const CONNECT_TIMEOUT_SECONDS := 6.0
|
|
|
|
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
|
|
|
|
|
|
func _start_join() -> void:
|
|
_clear_multiplayer_error()
|
|
var address := join_address_edit.text.strip_edges()
|
|
if address.is_empty():
|
|
_show_multiplayer_error("Enter an IP address to join")
|
|
return
|
|
var err := NetworkManager.join(address)
|
|
if err != OK:
|
|
_show_multiplayer_error("Could not join: %s" % error_string(err))
|
|
return
|
|
connecting_status_label.text = "Connecting to %s..." % address
|
|
connecting_overlay.visible = true
|
|
_connect_timeout_token += 1
|
|
var my_token := _connect_timeout_token
|
|
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
|
|
|
|
|
|
func _on_connect_timeout(token: int) -> void:
|
|
if token != _connect_timeout_token or not connecting_overlay.visible:
|
|
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
|
|
NetworkManager.shutdown()
|
|
connecting_overlay.visible = false
|
|
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
|
|
|
|
|
|
func _on_connecting_cancel_pressed() -> void:
|
|
_connect_timeout_token += 1
|
|
NetworkManager.shutdown()
|
|
connecting_overlay.visible = false
|
|
|
|
|
|
func _on_connected_to_server() -> void:
|
|
if not connecting_overlay.visible:
|
|
return # e.g. a stray/late signal after Cancel already shut the peer down
|
|
_connect_timeout_token += 1
|
|
connecting_overlay.visible = false
|
|
_leave_to_lobby()
|
|
|
|
|
|
func _on_connection_failed() -> void:
|
|
if not connecting_overlay.visible:
|
|
return
|
|
_connect_timeout_token += 1
|
|
connecting_overlay.visible = false
|
|
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
|
|
|
|
|
|
func _leave_to_lobby() -> void:
|
|
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
|
|
|
|
|
func _show_multiplayer_error(message: String) -> void:
|
|
multiplayer_error_label.text = message
|
|
multiplayer_error_label.visible = true
|
|
|
|
|
|
func _clear_multiplayer_error() -> void:
|
|
multiplayer_error_label.visible = false
|