diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 373c800..33745bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,6 +33,14 @@ node names, connect persistent and networked state to that data. network services validate and replicate bounded domains such as fishing, sales, shops, item use, profiles, jobs, mail, chat, drawings, time, and weather. +`DiscoveryClient` is an optional directory layer beside `NetworkSession`. An +open host may publish a short-lived room lease, and the shared Join Game page +may browse compatible leases before handing the selected address back to the +existing direct ENet connection flow. The directory does not carry gameplay +traffic or become a gameplay authority. Its base URL comes from +`network/discovery/base_url`, with `NETFISHING_DISCOVERY_URL` available as a +development/deployment override. + The host is authoritative. Clients submit requests or evidence; the host derives trusted context from registered peers, authoritative regions, and server-owned state before mutating inventory, wallet, progression, or shared diff --git a/main/main.gd b/main/main.gd index 29396b7..0c15905 100644 --- a/main/main.gd +++ b/main/main.gd @@ -38,6 +38,7 @@ const WorldPixelationPostprocessType = preload( "res://main/world_pixelation_postprocess.gd" ) const NetworkSessionType = preload("res://network/network_session.gd") +const DiscoveryClientType = preload("res://network/discovery_client.gd") const NetworkProfilePreferencesType = preload( "res://network/network_profile_preferences.gd" ) @@ -131,6 +132,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 %WorldPixelationPostprocess ) @onready var _network_session: NetworkSessionType = %NetworkSession +@onready var _discovery: DiscoveryClientType = %DiscoveryClient @onready var _world_time: WorldTimeServiceType = %WorldTimeService @onready var _world_time_visuals: WorldTimeVisualControllerType = ( %WorldTimeVisualController @@ -313,6 +315,7 @@ func _initialize_after_data_root() -> void: _server_trust, _host_bans, ) + _discovery.setup(_network_session) _world_time_visuals.setup( _world_time, _test_world.get_world_environment(), @@ -546,6 +549,7 @@ func _initialize_after_data_root() -> void: _asset_reservations, _network_profile_service, _network_player_list, + _discovery, _settings_manager, _network_surface_drawing, _player.art_unlocks, @@ -623,6 +627,7 @@ func _initialize_after_data_root() -> void: _network_session, _saved_servers, _server_trust, + _discovery, ) var pause_menu: PauseMenuType = _game_ui.get_pause_menu() pause_menu.setup( @@ -633,6 +638,7 @@ func _initialize_after_data_root() -> void: _network_session, _saved_servers, _server_trust, + _discovery, ) title_screen.join_game_requested.connect(_on_title_join_game_requested) pause_menu.join_game_requested.connect(_on_pause_join_game_requested) diff --git a/main/main.tscn b/main/main.tscn index 608b40a..70eaa5d 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -56,6 +56,7 @@ [ext_resource type="Script" uid="uid://b0c3o2vy76fg3" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"] [ext_resource type="AudioStream" uid="uid://dck3tf8qkadea" path="res://audio/ambience/waves.wav" id="55_waves"] [ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"] +[ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -214,6 +215,10 @@ script = ExtResource("38_identity_backup") unique_name_in_owner = true script = ExtResource("17_network_session") +[node name="DiscoveryClient" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("57_discovery") + [node name="NetworkProfilePreferences" type="Node" parent="." unique_id=2119445545] unique_name_in_owner = true script = ExtResource("18_network_profile") diff --git a/network/discovery_client.gd b/network/discovery_client.gd new file mode 100644 index 0000000..9b7c644 --- /dev/null +++ b/network/discovery_client.gd @@ -0,0 +1,478 @@ +class_name DiscoveryClient +extends Node + +signal rooms_updated(rooms: Array[Dictionary]) +signal browse_status_changed(message: String, is_error: bool) +signal host_settings_changed(room_name: String, discoverable: bool) +signal host_status_changed(message: String, is_error: bool) + +const BASE_URL_SETTING: String = "network/discovery/base_url" +const BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL" +const SETTINGS_PATH: String = "user://network_discovery.cfg" +const DEFAULT_ROOM_NAME: String = "NETfishing Room" +const MAX_ROOM_NAME_LENGTH: int = 48 +const HEARTBEAT_INTERVAL_SECONDS: float = 15.0 +const REQUEST_TIMEOUT_SECONDS: float = 8.0 + +enum HostRequestKind { + NONE, + CREATE, + UPDATE, + DELETE, +} + +var _session: NetworkSession +var _base_url: String = "" +var _room_name: String = DEFAULT_ROOM_NAME +var _discoverable: bool = false +var _host_status_message: String = "" +var _host_status_is_error: bool = false +var _lease_room_id: String = "" +var _lease_token: String = "" +var _host_request_kind: HostRequestKind = HostRequestKind.NONE +var _host_request_in_flight: bool = false +var _host_sync_queued: bool = false +var _browse_request_in_flight: bool = false +var _host_request: HTTPRequest +var _browse_request: HTTPRequest +var _heartbeat: Timer + + +func _ready() -> void: + _host_request = HTTPRequest.new() + _host_request.name = "HostLeaseRequest" + _host_request.timeout = REQUEST_TIMEOUT_SECONDS + add_child(_host_request) + _host_request.request_completed.connect(_on_host_request_completed) + + _browse_request = HTTPRequest.new() + _browse_request.name = "RoomBrowseRequest" + _browse_request.timeout = REQUEST_TIMEOUT_SECONDS + add_child(_browse_request) + _browse_request.request_completed.connect(_on_browse_request_completed) + + _heartbeat = Timer.new() + _heartbeat.name = "HostLeaseHeartbeat" + _heartbeat.wait_time = HEARTBEAT_INTERVAL_SECONDS + _heartbeat.one_shot = false + add_child(_heartbeat) + _heartbeat.timeout.connect(_synchronize_host_lease) + _load_settings() + _base_url = _configured_base_url() + + +func setup(session: NetworkSession) -> void: + _session = session + _session.set_session_display_name(_room_name) + if not _session.state_changed.is_connected(_on_session_state_changed): + _session.state_changed.connect(_on_session_state_changed) + if not _session.peer_count_changed.is_connected(_on_peer_count_changed): + _session.peer_count_changed.connect(_on_peer_count_changed) + if not _session.host_openness_changed.is_connected(_on_host_openness_changed): + _session.host_openness_changed.connect(_on_host_openness_changed) + host_settings_changed.emit(_room_name, _discoverable) + if not is_configured(): + _set_host_status("Room discovery is not configured in this build.", true) + elif _session.is_open_host(): + _set_host_status("Room is open but unlisted.", false) + else: + _set_host_status("Open the game before listing it publicly.", false) + + +func is_configured() -> bool: + return not _base_url.is_empty() + + +func get_base_url() -> String: + return _base_url + + +func get_room_name() -> String: + return _room_name + + +func is_discoverable() -> bool: + return _discoverable + + +func get_host_status_message() -> String: + return _host_status_message + + +func host_status_is_error() -> bool: + return _host_status_is_error + + +func set_room_name(value: String) -> bool: + var cleaned: String = _sanitize_room_name(value) + if cleaned.is_empty(): + _set_host_status("Room name cannot be empty.", true) + return false + if cleaned == _room_name: + return true + _room_name = cleaned + _save_settings() + if _session != null: + _session.set_session_display_name(_room_name) + host_settings_changed.emit(_room_name, _discoverable) + if _discoverable: + _synchronize_host_lease() + return true + + +func set_discoverable(enabled: bool) -> bool: + if enabled and ( + _session == null + or not _session.is_open_host() + or not is_configured() + ): + _set_host_status( + "Open the game before enabling discovery." + if is_configured() + else "Room discovery is not configured in this build.", + true, + ) + return false + if _discoverable == enabled: + return true + _discoverable = enabled + host_settings_changed.emit(_room_name, _discoverable) + if enabled: + _heartbeat.start() + _set_host_status("Publishing room…", false) + _synchronize_host_lease() + else: + _heartbeat.stop() + _remove_host_lease() + _set_host_status("Room is open but unlisted.", false) + return true + + +func request_rooms() -> bool: + if not is_configured(): + rooms_updated.emit([]) + browse_status_changed.emit( + "Public room discovery is not configured in this build.", true + ) + return false + if _browse_request_in_flight: + return true + var game_version: String = str( + ProjectSettings.get_setting("application/config/version", "unknown") + ) + var url: String = "%s/v1/rooms?game_version=%s&protocol_version=%d" % [ + _base_url, + game_version.uri_encode(), + NetworkProtocol.PROTOCOL_VERSION, + ] + var error: Error = _browse_request.request(url) + if error != OK: + rooms_updated.emit([]) + browse_status_changed.emit("Could not request public rooms.", true) + return false + _browse_request_in_flight = true + browse_status_changed.emit("Looking for public rooms…", false) + return true + + +func room_endpoint(room: Dictionary) -> String: + var address: String = str(room.get("address", "")).strip_edges() + var port: int = int(room.get("port", 0)) + if address.is_empty() or port < 1 or port > 65535: + return "" + if ":" in address and not address.begins_with("["): + address = "[%s]" % address + return "%s:%d" % [address, port] + + +func _configured_base_url() -> String: + var value: String = OS.get_environment(BASE_URL_ENVIRONMENT).strip_edges() + if value.is_empty(): + value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges() + while value.ends_with("/"): + value = value.left(value.length() - 1) + if not value.is_empty() and not ( + value.begins_with("https://") or value.begins_with("http://") + ): + push_warning("Ignored invalid NETfishing discovery URL.") + return "" + return value + + +func _synchronize_host_lease() -> void: + if not _should_advertise(): + if not _lease_room_id.is_empty(): + _remove_host_lease() + return + if _host_request_in_flight: + _host_sync_queued = true + return + var method: HTTPClient.Method = HTTPClient.METHOD_POST + var url: String = "%s/v1/rooms" % _base_url + var headers := PackedStringArray(["Content-Type: application/json"]) + _host_request_kind = HostRequestKind.CREATE + if not _lease_room_id.is_empty(): + method = HTTPClient.METHOD_PUT + url = "%s/v1/rooms/%s" % [_base_url, _lease_room_id.uri_encode()] + headers.append("Authorization: Bearer %s" % _lease_token) + _host_request_kind = HostRequestKind.UPDATE + _start_host_request(url, headers, method, JSON.stringify(_host_payload())) + + +func _remove_host_lease() -> void: + if _lease_room_id.is_empty(): + return + if _host_request_in_flight: + _host_sync_queued = true + return + var url: String = "%s/v1/rooms/%s" % [ + _base_url, _lease_room_id.uri_encode() + ] + var headers := PackedStringArray([ + "Authorization: Bearer %s" % _lease_token, + ]) + _host_request_kind = HostRequestKind.DELETE + _start_host_request(url, headers, HTTPClient.METHOD_DELETE, "") + + +func _start_host_request( + url: String, + headers: PackedStringArray, + method: HTTPClient.Method, + body: String, +) -> void: + var error: Error = _host_request.request(url, headers, method, body) + if error != OK: + _host_request_kind = HostRequestKind.NONE + _set_host_status("Could not contact room discovery.", true) + return + _host_request_in_flight = true + + +func _host_payload() -> Dictionary: + return { + "room_name": _room_name, + "port": _session.get_host_port(), + "current_players": _session.get_player_count(), + "max_players": _session.get_session_max_players(), + "game_version": str( + ProjectSettings.get_setting("application/config/version", "unknown") + ), + "protocol_version": NetworkProtocol.PROTOCOL_VERSION, + } + + +func _should_advertise() -> bool: + return ( + _discoverable + and is_configured() + and _session != null + and _session.is_open_host() + ) + + +func _on_host_request_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, +) -> void: + var completed_kind: HostRequestKind = _host_request_kind + _host_request_kind = HostRequestKind.NONE + _host_request_in_flight = false + var response: Dictionary = _parse_response_dictionary(body) + var transport_ok: bool = result == HTTPRequest.RESULT_SUCCESS + match completed_kind: + HostRequestKind.CREATE: + if transport_ok and response_code == HTTPClient.RESPONSE_CREATED: + var room: Dictionary = response.get("room", {}) + _lease_room_id = str(room.get("room_id", "")) + _lease_token = str(response.get("lease_token", "")) + if _lease_room_id.is_empty() or _lease_token.is_empty(): + _clear_lease() + _set_host_status("Discovery returned an invalid lease.", true) + else: + _set_host_status("Room is listed publicly.", false) + else: + _set_host_status(_request_failure(response), true) + HostRequestKind.UPDATE: + if transport_ok and response_code == HTTPClient.RESPONSE_OK: + _set_host_status("Room is listed publicly.", false) + elif response_code in [ + HTTPClient.RESPONSE_UNAUTHORIZED, + HTTPClient.RESPONSE_NOT_FOUND, + ]: + _clear_lease() + _host_sync_queued = true + else: + _set_host_status(_request_failure(response), true) + HostRequestKind.DELETE: + _clear_lease() + if response_code not in [ + HTTPClient.RESPONSE_NO_CONTENT, + HTTPClient.RESPONSE_NOT_FOUND, + ] and transport_ok: + _set_host_status(_request_failure(response), true) + _: + pass + if not _should_advertise() and not _lease_room_id.is_empty(): + _host_sync_queued = true + if _host_sync_queued: + _host_sync_queued = false + call_deferred("_synchronize_host_lease") + + +func _on_browse_request_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, +) -> void: + _browse_request_in_flight = false + var response: Dictionary = _parse_response_dictionary(body) + if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK: + rooms_updated.emit([]) + browse_status_changed.emit(_request_failure(response), true) + return + var raw_rooms: Variant = response.get("rooms", []) + if typeof(raw_rooms) != TYPE_ARRAY: + rooms_updated.emit([]) + browse_status_changed.emit("Discovery returned an invalid room list.", true) + return + var rooms: Array[Dictionary] = [] + for value: Variant in raw_rooms: + if typeof(value) != TYPE_DICTIONARY: + continue + var room: Dictionary = value + if _valid_public_room(room): + rooms.append(room.duplicate(true)) + rooms_updated.emit(rooms) + browse_status_changed.emit( + "No public rooms are available." + if rooms.is_empty() + else "%d public room%s found." % [rooms.size(), "" if rooms.size() == 1 else "s"], + false, + ) + + +func _valid_public_room(room: Dictionary) -> bool: + var expected_version: String = str( + ProjectSettings.get_setting("application/config/version", "unknown") + ) + return ( + typeof(room.get("room_id")) == TYPE_STRING + and typeof(room.get("room_name")) == TYPE_STRING + and typeof(room.get("address")) == TYPE_STRING + and typeof(room.get("game_version")) == TYPE_STRING + and str(room.get("game_version")) == expected_version + and _valid_json_integer(room.get("port"), 1, 65535) + and _valid_json_integer(room.get("current_players"), 1, 128) + and _valid_json_integer(room.get("max_players"), 1, 128) + and int(room["current_players"]) <= int(room["max_players"]) + and _valid_json_integer( + room.get("protocol_version"), + NetworkProtocol.PROTOCOL_VERSION, + NetworkProtocol.PROTOCOL_VERSION, + ) + ) + + +func _valid_json_integer(value: Variant, minimum: int, maximum: int) -> bool: + if typeof(value) == TYPE_INT: + return int(value) >= minimum and int(value) <= maximum + if typeof(value) != TYPE_FLOAT: + return false + var number: float = float(value) + return ( + is_finite(number) + and number == floor(number) + and number >= float(minimum) + and number <= float(maximum) + ) + + +func _parse_response_dictionary(body: PackedByteArray) -> Dictionary: + if body.is_empty(): + return {} + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + return parsed if typeof(parsed) == TYPE_DICTIONARY else {} + + +func _request_failure(response: Dictionary) -> String: + var error: Variant = response.get("error", {}) + if typeof(error) == TYPE_DICTIONARY: + var message: String = str((error as Dictionary).get("message", "")).strip_edges() + if not message.is_empty(): + return message + return "Room discovery is temporarily unavailable." + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state == NetworkSession.State.OPEN_HOST: + if _discoverable: + _heartbeat.start() + _synchronize_host_lease() + else: + _set_host_status("Room is open but unlisted.", false) + return + if state in [ + NetworkSession.State.PRIVATE_HOST, + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + if _discoverable: + _discoverable = false + host_settings_changed.emit(_room_name, false) + _heartbeat.stop() + _remove_host_lease() + if state == NetworkSession.State.PRIVATE_HOST: + _set_host_status("Open the game before listing it publicly.", false) + + +func _on_host_openness_changed(is_open: bool) -> void: + if not is_open and _discoverable: + set_discoverable(false) + + +func _on_peer_count_changed(_player_count: int, _max_players: int) -> void: + if _discoverable: + _synchronize_host_lease() + + +func _clear_lease() -> void: + _lease_room_id = "" + _lease_token = "" + + +func _set_host_status(message: String, is_error: bool) -> void: + _host_status_message = message + _host_status_is_error = is_error + host_status_changed.emit(message, is_error) + + +func _sanitize_room_name(value: String) -> String: + var cleaned: String = value.strip_edges().replace("\n", " ").replace("\r", " ") + cleaned = cleaned.replace("\t", " ") + while " " in cleaned: + cleaned = cleaned.replace(" ", " ") + return cleaned.left(MAX_ROOM_NAME_LENGTH) + + +func _load_settings() -> void: + var config := ConfigFile.new() + if config.load(SETTINGS_PATH) == OK: + var saved_name: String = _sanitize_room_name( + str(config.get_value("host", "room_name", DEFAULT_ROOM_NAME)) + ) + if not saved_name.is_empty(): + _room_name = saved_name + + +func _save_settings() -> void: + var config := ConfigFile.new() + config.set_value("host", "room_name", _room_name) + var error: Error = config.save(SETTINGS_PATH) + if error != OK: + push_warning("Could not save the local NETfishing room name.") diff --git a/network/discovery_client.gd.uid b/network/discovery_client.gd.uid new file mode 100644 index 0000000..eebfd6a --- /dev/null +++ b/network/discovery_client.gd.uid @@ -0,0 +1 @@ +uid://d0jv8n2nfqia0 diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd index 6f65e12..1a8136a 100644 --- a/network/network_player_list_service.gd +++ b/network/network_player_list_service.gd @@ -89,6 +89,10 @@ func is_local_host() -> bool: return _session.is_host() +func is_open_host() -> bool: + return _session.is_open_host() + + func get_relationships() -> Array[Dictionary]: return _relationships.get_records() diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 23b80b6..cfe5a0b 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -198,6 +198,7 @@ static func make_server_hello( assigned_peer_id: int, player_count: int, max_players: int, + server_display_name: String = "NETfishing", ) -> Dictionary: return { "accepted": accepted, @@ -205,7 +206,7 @@ static func make_server_hello( "protocol_version": PROTOCOL_VERSION, "session_id": session_id, "assigned_peer_id": assigned_peer_id, - "server_display_name": "NETfishing", + "server_display_name": server_display_name, "player_count": player_count, "max_players": max_players, "capability_flags": PackedStringArray([ diff --git a/network/network_session.gd b/network/network_session.gd index b747a32..2a477cb 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -16,6 +16,7 @@ signal connection_error(message: String) signal peer_authenticated(peer_id: int, display_name: String) signal peer_removed(peer_id: int) signal host_openness_changed(is_open: bool) +signal session_display_name_changed(display_name: String) signal peer_count_changed(player_count: int, max_players: int) signal peer_display_name_changed(peer_id: int, display_name: String) signal peer_profile_changed( @@ -99,6 +100,7 @@ var _local_appearance_snapshot: Dictionary = ( ) var _moderation_disconnect_message := "" var _host_port: int = 0 +var _session_display_name: String = "NETfishing Room" func _ready() -> void: @@ -228,6 +230,20 @@ func get_host_port() -> int: return _host_port if is_host() else 0 +func set_session_display_name(value: String) -> void: + var cleaned: String = value.strip_edges().left(48) + if cleaned.is_empty(): + cleaned = "NETfishing Room" + if cleaned == _session_display_name: + return + _session_display_name = cleaned + session_display_name_changed.emit(_session_display_name) + + +func get_session_display_name() -> String: + return _session_display_name + + static func _can_bind_udp_port(port: int) -> bool: var probe := PacketPeerUDP.new() var error: Error = probe.bind(port) @@ -1038,7 +1054,8 @@ func submit_client_hello(data: Dictionary) -> void: _session_id, sender_id, _registry.size(), - session_max_players + session_max_players, + _session_display_name, ) ) receive_spawn_list.rpc_id(sender_id, _build_spawn_list()) diff --git a/project.godot b/project.godot index a0eb5a3..e2acd17 100644 --- a/project.godot +++ b/project.godot @@ -241,6 +241,10 @@ alternate_fishing={ ] } +[network] + +discovery/base_url="https://discovery.netfishing.org" + [physics] 3d/physics_engine="Jolt Physics" diff --git a/ui/game_ui.gd b/ui/game_ui.gd index feab180..874bf5a 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -277,6 +277,7 @@ func setup( reservations: PlayerAssetReservationService, network_profile_service: NetworkProfileService, network_player_list: NetworkPlayerListService, + discovery: DiscoveryClient, settings_manager: PlayerSettingsManagerType, surface_drawing: NetworkSurfaceDrawingService, art_unlocks: PlayerArtUnlocks, @@ -372,6 +373,7 @@ func setup( reservations, network_profile_service, network_player_list, + discovery, player_jobs, world_time, world_environment, diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 160550e..cfcc1af 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -5,11 +5,14 @@ signal join_requested(endpoint: String) signal back_requested enum Mode { + DISCOVER, DIRECT, SAVED, RECENT, } +const DISCOVERY_REFRESH_SECONDS: float = 8.0 + const ADDRESS_FORMAT_HELP: String = ( "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; " + "include the port shown by a host when it differs." @@ -27,10 +30,12 @@ const DIRECT_WORKFLOW_HELP: String = ( @onready var _name_helper: Label = %NameHelper @onready var _server_list: ItemList = %ServerList @onready var _details: Label = %Details +@onready var _discover_button: Button = %DiscoverButton @onready var _direct_button: Button = %DirectButton @onready var _saved_button: Button = %SavedButton @onready var _recent_button: Button = %RecentButton @onready var _join_button: Button = %JoinButton +@onready var _refresh_button: Button = %RefreshButton @onready var _save_button: Button = %SaveButton @onready var _edit_button: Button = %EditButton @onready var _favorite_button: Button = %FavoriteButton @@ -47,10 +52,14 @@ const DIRECT_WORKFLOW_HELP: String = ( var _network_session: NetworkSession var _saved_servers: SavedServerStore var _server_trust: ServerTrustStore +var _discovery: DiscoveryClient var _gameplay_context: bool = false -var _mode: Mode = Mode.DIRECT +var _mode: Mode = Mode.DISCOVER var _visible_entries: Array[SavedServerEntry] = [] var _selected_entry: SavedServerEntry +var _discovery_rooms: Array[Dictionary] = [] +var _selected_discovery_index: int = -1 +var _discovery_refresh_timer: Timer var _editing_entry_id: String = "" var _name_entry_active: bool = false var _delete_armed: bool = false @@ -63,16 +72,19 @@ func _ready() -> void: "panel", UtilityPageStyle.panel_style() ) for button: BaseButton in [ - _direct_button, _saved_button, _recent_button, _join_button, + _discover_button, _direct_button, _saved_button, _recent_button, + _refresh_button, _join_button, _save_button, _edit_button, _favorite_button, _delete_button, _cancel_button, _back_button, _open_close_button, ]: UtilityPageStyle.apply_ocean_button(button) UtilityPageStyle.apply_ocean_line_edit(_address) UtilityPageStyle.apply_ocean_line_edit(_name_edit) + _discover_button.pressed.connect(_set_mode.bind(Mode.DISCOVER)) _direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT)) _saved_button.pressed.connect(_set_mode.bind(Mode.SAVED)) _recent_button.pressed.connect(_set_mode.bind(Mode.RECENT)) + _refresh_button.pressed.connect(_request_discovery_refresh) _join_button.pressed.connect(_request_join) _save_button.pressed.connect(_on_save_pressed) _edit_button.pressed.connect(_on_edit_pressed) @@ -97,6 +109,11 @@ func _ready() -> void: ) _delete_confirmation.confirmed.connect(_confirm_delete) _delete_confirmation.cancelled.connect(_cancel_delete) + _discovery_refresh_timer = Timer.new() + _discovery_refresh_timer.wait_time = DISCOVERY_REFRESH_SECONDS + _discovery_refresh_timer.one_shot = false + _discovery_refresh_timer.timeout.connect(_request_discovery_refresh) + add_child(_discovery_refresh_timer) hide() @@ -105,11 +122,13 @@ func setup( saved_servers: SavedServerStore, gameplay_context: bool, server_trust: ServerTrustStore = null, + discovery: DiscoveryClient = null, ) -> void: _network_session = network_session _saved_servers = saved_servers _gameplay_context = gameplay_context _server_trust = server_trust + _discovery = discovery if not _network_session.state_changed.is_connected(_on_state_changed): _network_session.state_changed.connect(_on_state_changed) if not _network_session.status_message_changed.is_connected( @@ -131,6 +150,17 @@ func setup( and not _saved_servers.data_changed.is_connected(_on_store_changed) ): _saved_servers.data_changed.connect(_on_store_changed) + if _discovery != null: + if not _discovery.rooms_updated.is_connected( + _on_discovery_rooms_updated + ): + _discovery.rooms_updated.connect(_on_discovery_rooms_updated) + if not _discovery.browse_status_changed.is_connected( + _on_discovery_status_changed + ): + _discovery.browse_status_changed.connect( + _on_discovery_status_changed + ) _refresh() @@ -149,6 +179,7 @@ func open_page(preserved_endpoint: String = "") -> void: func close_page() -> void: _clear_edit_state() + _discovery_refresh_timer.stop() hide() var current_viewport: Viewport = get_viewport() if current_viewport != null: @@ -166,9 +197,15 @@ func set_status(message: String) -> void: func _set_mode(mode: Mode) -> void: _mode = mode _selected_entry = null + _selected_discovery_index = -1 _clear_edit_state() _refresh_entries() _refresh() + if mode == Mode.DISCOVER: + _discovery_refresh_timer.start() + _request_discovery_refresh() + else: + _discovery_refresh_timer.stop() if not is_visible_in_tree(): return if mode == Mode.DIRECT: @@ -183,11 +220,15 @@ func _request_join() -> void: NetworkSession.State.SERVER_LOST, ]: _network_session.reset_failure() - var endpoint_text: String = ( - _selected_entry.normalized_endpoint - if _mode != Mode.DIRECT and _selected_entry != null - else _address.text - ) + var endpoint_text: String = _address.text + if _mode == Mode.DISCOVER: + var room: Dictionary = _selected_discovery_room() + if _discovery_room_is_full(room): + _set_status("That room is full.", true) + return + endpoint_text = _discovery.room_endpoint(room) if _discovery != null else "" + elif _mode != Mode.DIRECT and _selected_entry != null: + endpoint_text = _selected_entry.normalized_endpoint var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) if not endpoint.is_valid(): _set_status(endpoint.error_message, true) @@ -339,6 +380,13 @@ func _cancel_delete() -> void: func _on_list_item_selected(index: int) -> void: + if _mode == Mode.DISCOVER: + if index < 0 or index >= _discovery_rooms.size(): + return + _selected_discovery_index = index + _selected_entry = null + _refresh() + return if index < 0 or index >= _visible_entries.size(): return _selected_entry = _visible_entries[index] @@ -357,6 +405,19 @@ func _select_entry_id(entry_id: String) -> void: func _refresh_entries() -> void: _visible_entries.clear() _server_list.clear() + if _mode == Mode.DISCOVER: + for room: Dictionary in _discovery_rooms: + var player_count: int = int(room.get("current_players", 0)) + var maximum: int = int(room.get("max_players", 0)) + _server_list.add_item( + "%s — %d / %d players%s" % [ + str(room.get("room_name", "Public room")), + player_count, + maximum, + " — full" if player_count >= maximum else "", + ] + ) + return if _saved_servers == null or _mode == Mode.DIRECT: return _visible_entries = ( @@ -394,7 +455,16 @@ func _refresh() -> void: NetworkSession.State.AUTHENTICATING, ] var direct: bool = _mode == Mode.DIRECT - var selected: bool = _selected_entry != null + var discovery_mode: bool = _mode == Mode.DISCOVER + var selected: bool = ( + _selected_discovery_index >= 0 + if discovery_mode + else _selected_entry != null + ) + _discover_button.button_pressed = discovery_mode + _direct_button.button_pressed = direct + _saved_button.button_pressed = _mode == Mode.SAVED + _recent_button.button_pressed = _mode == Mode.RECENT _address.visible = direct or _name_entry_active _address_label.visible = _address.visible _address_helper.visible = _address.visible @@ -407,9 +477,20 @@ func _refresh() -> void: _name_helper.visible = _name_entry_active _server_list.visible = not direct and not _name_entry_active _details.visible = not direct and not _name_entry_active - _join_button.disabled = connecting or (not direct and not selected) + _join_button.disabled = ( + connecting + or (not direct and not selected) + or ( + discovery_mode + and selected + and _discovery_room_is_full(_selected_discovery_room()) + ) + ) _join_button.text = "join\nnow" if direct else "join" - _save_button.visible = direct or _mode == Mode.RECENT or _name_entry_active + _refresh_button.visible = discovery_mode and not _name_entry_active + _save_button.visible = ( + direct or _mode == Mode.RECENT or _name_entry_active + ) _save_button.disabled = connecting or ( _mode == Mode.RECENT and not selected and not _name_entry_active ) @@ -418,10 +499,14 @@ func _refresh() -> void: _favorite_button.visible = _mode == Mode.SAVED and selected _favorite_button.text = ( "unfavorite" - if selected and _selected_entry.favorite + if _mode == Mode.SAVED + and _selected_entry != null + and _selected_entry.favorite else "favorite" ) - _delete_button.visible = not direct and selected + _delete_button.visible = ( + _mode in [Mode.SAVED, Mode.RECENT] and selected + ) _delete_button.text = "remove" if _mode == Mode.RECENT else "delete" _cancel_button.visible = connecting _open_close_button.visible = ( @@ -453,10 +538,20 @@ func _refresh() -> void: ) if not direct: if selected: - _details.text = _format_entry_details(_selected_entry) - elif _visible_entries.is_empty(): _details.text = ( - "No saved servers yet." + _format_discovery_details(_selected_discovery_room()) + if discovery_mode + else _format_entry_details(_selected_entry) + ) + elif ( + _discovery_rooms.is_empty() + if discovery_mode + else _visible_entries.is_empty() + ): + _details.text = ( + "No public rooms are available." + if discovery_mode + else "No saved servers yet." if _mode == Mode.SAVED else "No recent connections yet." ) @@ -464,7 +559,7 @@ func _refresh() -> void: _details.text = "Select a server." var warning: String = ( _saved_servers.get_recovery_warning() - if _saved_servers != null + if _saved_servers != null and not discovery_mode else "" ) if not warning.is_empty() and _status.text.is_empty(): @@ -517,6 +612,69 @@ func _format_entry_details(entry: SavedServerEntry) -> String: return "\n".join(parts) +func _format_discovery_details(room: Dictionary) -> String: + if room.is_empty(): + return "Select a public room." + return "\n".join([ + "Room: %s" % str(room.get("room_name", "Public room")), + "Players: %d / %d" % [ + int(room.get("current_players", 0)), + int(room.get("max_players", 0)), + ], + "Connection: %s" % ( + _discovery.room_endpoint(room) if _discovery != null else "—" + ), + "Direct UDP connection • ping is measured after joining", + ]) + + +func _selected_discovery_room() -> Dictionary: + if ( + _selected_discovery_index < 0 + or _selected_discovery_index >= _discovery_rooms.size() + ): + return {} + return _discovery_rooms[_selected_discovery_index] + + +func _discovery_room_is_full(room: Dictionary) -> bool: + if room.is_empty(): + return false + return int(room.get("current_players", 0)) >= int(room.get("max_players", 0)) + + +func _request_discovery_refresh() -> void: + if ( + _discovery == null + or _mode != Mode.DISCOVER + or not is_visible_in_tree() + ): + return + _discovery.request_rooms() + + +func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void: + var selected_id: String = str( + _selected_discovery_room().get("room_id", "") + ) + _discovery_rooms = rooms.duplicate(true) + _selected_discovery_index = -1 + if _mode == Mode.DISCOVER: + _refresh_entries() + if not selected_id.is_empty(): + for index: int in _discovery_rooms.size(): + if str(_discovery_rooms[index].get("room_id", "")) == selected_id: + _selected_discovery_index = index + _server_list.select(index) + break + _refresh() + + +func _on_discovery_status_changed(message: String, is_error: bool) -> void: + if _mode == Mode.DISCOVER and is_visible_in_tree(): + _set_status(message, is_error) + + func _format_result_code(result_code: String) -> String: match result_code.strip_edges().to_upper(): "SUCCESS": diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn index b2597a6..062a1f6 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -114,22 +114,32 @@ layout_mode = 2 theme_override_constants/separation = 14 alignment = 1 +[node name="DiscoverButton" type="Button" parent="Paper/Margin/Layout/Modes"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 72) +layout_mode = 2 +toggle_mode = true +text = "discover" + [node name="DirectButton" type="Button" parent="Paper/Margin/Layout/Modes"] unique_name_in_owner = true custom_minimum_size = Vector2(100, 72) layout_mode = 2 +toggle_mode = true text = "direct" [node name="SavedButton" type="Button" parent="Paper/Margin/Layout/Modes"] unique_name_in_owner = true custom_minimum_size = Vector2(100, 72) layout_mode = 2 +toggle_mode = true text = "saved" [node name="RecentButton" type="Button" parent="Paper/Margin/Layout/Modes"] unique_name_in_owner = true custom_minimum_size = Vector2(100, 72) layout_mode = 2 +toggle_mode = true text = "recent" [node name="AddressLabel" type="Label" parent="Paper/Margin/Layout"] @@ -258,6 +268,13 @@ layout_mode = 2 theme_override_constants/separation = 7 alignment = 1 +[node name="RefreshButton" type="Button" parent="Paper/Margin/Layout/Actions"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(82, 72) +layout_mode = 2 +text = "refresh" + [node name="JoinButton" type="Button" parent="Paper/Margin/Layout/Actions"] unique_name_in_owner = true custom_minimum_size = Vector2(82, 72) diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index b989972..3d2baa8 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -107,6 +107,7 @@ func setup( network_session: NetworkSessionType, saved_servers: SavedServerStoreType, server_trust: ServerTrustStore, + discovery: DiscoveryClient, ) -> void: _player = player _save_manager = save_manager @@ -114,7 +115,9 @@ func setup( _fishing_spot = fishing_spot _network_session = network_session _saved_servers = saved_servers - _join_game_page.setup(network_session, saved_servers, true, server_trust) + _join_game_page.setup( + network_session, saved_servers, true, server_trust, discovery + ) if not _fishing_spot.bite_activated.is_connected(_on_bite_activated): _fishing_spot.bite_activated.connect(_on_bite_activated) diff --git a/ui/player_menu.gd b/ui/player_menu.gd index cd6c121..63b6313 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -526,6 +526,7 @@ func setup( reservations: PlayerAssetReservationService, network_profile_service: NetworkProfileService, network_player_list: NetworkPlayerListService, + discovery: DiscoveryClient, player_jobs: PlayerJobService, world_time: WorldTimeService, world_environment: WorldEnvironment, @@ -558,7 +559,7 @@ func setup( world_environment, world_sun, ) - _players_page.setup(network_player_list) + _players_page.setup(network_player_list, discovery) _catalog_logbook.setup(collection_log, inventory, catalog) _the_net_page.setup(player_jobs, world_time) _network_mail_service.unread_count_changed.connect( diff --git a/ui/players_page.gd b/ui/players_page.gd index 2f0d6a7..49cc40b 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -2,10 +2,15 @@ class_name PlayersPage extends Control var _service: NetworkPlayerListService +var _discovery: DiscoveryClient var _count_label: Label var _tabs: HBoxContainer var _list: VBoxContainer var _status: Label +var _host_settings_panel: PanelContainer +var _room_name_edit: LineEdit +var _discoverable_toggle: CheckButton +var _host_discovery_status: Label var _current_tab := 0 @@ -15,13 +20,22 @@ func _ready() -> void: _build() -func setup(service: NetworkPlayerListService) -> void: +func setup( + service: NetworkPlayerListService, + discovery: DiscoveryClient, +) -> void: _service = service + _discovery = discovery _service.entries_changed.connect(_refresh) _service.moderation_finished.connect(func(_ok: bool, message: String) -> void: _status.text = message _refresh() ) + if _discovery != null: + _discovery.host_settings_changed.connect( + _on_host_settings_changed + ) + _discovery.host_status_changed.connect(_on_host_status_changed) _refresh() @@ -61,6 +75,7 @@ func _build() -> void: ) _count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER tab_row.add_child(_count_label) + _build_host_settings(root) var scroll := ScrollContainer.new() scroll.custom_minimum_size = Vector2(0, 310) scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -77,6 +92,47 @@ func _build() -> void: root.add_child(_status) +func _build_host_settings(root: VBoxContainer) -> void: + _host_settings_panel = PanelContainer.new() + _host_settings_panel.add_theme_stylebox_override( + "panel", UtilityPageStyle.row_style(false) + ) + root.add_child(_host_settings_panel) + var row := HBoxContainer.new() + row.custom_minimum_size.y = 58.0 + row.add_theme_constant_override("separation", 10) + _host_settings_panel.add_child(row) + var label := Label.new() + label.text = "room name" + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + row.add_child(label) + _room_name_edit = LineEdit.new() + _room_name_edit.custom_minimum_size = Vector2(300.0, 42.0) + _room_name_edit.max_length = DiscoveryClient.MAX_ROOM_NAME_LENGTH + _room_name_edit.placeholder_text = DiscoveryClient.DEFAULT_ROOM_NAME + UtilityPageStyle.apply_ocean_line_edit(_room_name_edit) + _room_name_edit.text_submitted.connect( + func(_value: String) -> void: _commit_room_name() + ) + _room_name_edit.focus_exited.connect(_commit_room_name) + row.add_child(_room_name_edit) + _discoverable_toggle = CheckButton.new() + _discoverable_toggle.text = "list publicly" + _discoverable_toggle.toggled.connect(_on_discoverable_toggled) + UtilityPageStyle.apply_ocean_button(_discoverable_toggle) + row.add_child(_discoverable_toggle) + _host_discovery_status = Label.new() + _host_discovery_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _host_discovery_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _host_discovery_status.add_theme_font_size_override("font_size", 14) + _host_discovery_status.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + row.add_child(_host_discovery_status) + + func _select_tab(index: int) -> void: if index == 2 and (_service == null or not _service.is_local_host()): return @@ -95,6 +151,7 @@ func _refresh() -> void: var button := _tabs.get_child(index) as Button button.button_pressed = index == _current_tab button.visible = index != 2 or _service.is_local_host() + _refresh_host_settings() for child: Node in _list.get_children(): child.queue_free() match _current_tab: @@ -106,6 +163,76 @@ func _refresh() -> void: _build_ban_rows() +func _refresh_host_settings() -> void: + if _host_settings_panel == null: + return + var host_visible: bool = _service.is_local_host() and _current_tab == 0 + _host_settings_panel.visible = host_visible + if not host_visible or _discovery == null: + return + if not _room_name_edit.has_focus(): + _room_name_edit.text = _discovery.get_room_name() + _discoverable_toggle.set_pressed_no_signal(_discovery.is_discoverable()) + var can_publish: bool = ( + _service.is_local_host() + and _discovery.is_configured() + and _service.is_open_host() + ) + _discoverable_toggle.disabled = not can_publish + _discoverable_toggle.tooltip_text = ( + "Advertise this open game in the public room browser." + if can_publish + else "Open the game before listing it publicly." + if _discovery.is_configured() + else "Room discovery is not configured in this build." + ) + _on_host_status_changed( + _discovery.get_host_status_message(), + _discovery.host_status_is_error(), + ) + + +func _commit_room_name() -> void: + if _discovery == null: + return + if not _discovery.set_room_name(_room_name_edit.text): + _room_name_edit.text = _discovery.get_room_name() + else: + _room_name_edit.text = _discovery.get_room_name() + + +func _on_discoverable_toggled(enabled: bool) -> void: + if _discovery == null: + return + if not _discovery.set_discoverable(enabled): + _discoverable_toggle.set_pressed_no_signal( + _discovery.is_discoverable() + ) + + +func _on_host_settings_changed( + room_name: String, + discoverable: bool, +) -> void: + if _room_name_edit != null and not _room_name_edit.has_focus(): + _room_name_edit.text = room_name + if _discoverable_toggle != null: + _discoverable_toggle.set_pressed_no_signal(discoverable) + _refresh_host_settings() + + +func _on_host_status_changed(message: String, is_error: bool) -> void: + if _host_discovery_status == null: + return + _host_discovery_status.text = message + _host_discovery_status.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_DANGER + if is_error + else UtilityPageStyle.OCEAN_TEXT_SECONDARY, + ) + + func _build_active_rows() -> void: if _service.is_local_host(): _build_session_artwork_controls() diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 5031efc..c95ebb3 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -236,10 +236,13 @@ func setup( network_session: NetworkSessionType, saved_servers: SavedServerStoreType, server_trust: ServerTrustStore, + discovery: DiscoveryClient, ) -> void: _save_manager = save_manager _settings_manager = settings_manager - _join_game_page.setup(network_session, saved_servers, false, server_trust) + _join_game_page.setup( + network_session, saved_servers, false, server_trust, discovery + ) _join_game_page.join_requested.connect(join_game_requested.emit) _join_game_page.back_requested.connect(_close_join_game) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE