mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4533da34e0
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner, net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet transport, manual polling, min-RTT clock sync), MatchNet (handshake, protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team columns, switch team, ready toggle), server_boot.tscn (headless dedicated server with structured logging and an overrun watchdog), and main_menu.gd's Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path). Followed by an adversarial review (Opus subagent) that found and fixed two real bugs - an unvalidated player_name broadcast that let one client's oversized name head-of-line-block the reliable channel for everyone, and a server-side roster leak across a host/re-host cycle - plus three gaps in the test suite itself where a claim of "verified" wasn't actually backed by what the test checked. All five two-process smoke tests plus the pure-function suite are green with the strengthened assertions in place.
271 lines
10 KiB
GDScript
271 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. Easy and Medium are now
|
|
# distinct models (medium.json beats easy.json 65-22-13 head-to-head); Hard
|
|
# still points at medium.json, the strongest promoted policy, and stays a
|
|
# label-only duplicate until a stronger one earns hard.json. 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/medium.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
|