diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 76ed032..ef0e161 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -38,11 +38,15 @@ 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 +`DiscoveryClient` is an optional directory layer beside `NetworkSession`. A +player host cycles its discovery control through `private`, `direct`, and +`relay`. Private leaves the room unlisted, direct discovery discloses the host +route only after an explicit Join, and relay discovery allocates a UDP privacy +route without making discovery a gameplay authority. Relay joins fail closed +and never silently downgrade to direct. Dedicated servers are always direct: +the game forces direct advertisements and discovery independently rejects a +dedicated relay request. Public room browsing never receives a host address. +The base URL comes from `network/discovery/base_url`, with `straywild_DISCOVERY_URL` available as a development/deployment override. @@ -53,8 +57,9 @@ friendship; unblocking does not recreate it. Discovery may publish opt-in, short-lived friend presence and deliver invitations only while both games are online. It stores hashed capability identifiers rather than identity fingerprints or a complete friend graph, keeps no durable social records, and -provides no offline delivery. Direct joins still use the existing verified -public-room and ENet connection path. +provides no offline delivery. Public joins still use the existing verified +server-identity and ENet connection path. Discovery endpoints remain transient: +they are not shown, added to recent servers, or persisted in the trust store. The host is authoritative. Clients submit requests or evidence; the host derives trusted context from registered peers, authoritative regions, and diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index f187182..e7632e2 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,13 +1,18 @@ straywild -v0.20.2-alpha -Alpha 0.20.2 +v0.20.3-alpha +Alpha 0.20.3 Thank you for supporting this private testing release. straywild is an early multiplayer fishing game. This alpha includes local progression, player-hosted and dedicated multiplayer sessions, public room -discovery, and direct UDP connections. Expect rough edges and please keep a -backup of any save or identity data you care about. +discovery, player-selected direct or privacy-relayed hosting, and optional +direct UDP connections. Player hosts can cycle discovery through private, +direct, and relay. Dedicated servers are always direct and cannot use the +shared relay. The relay hides peers' addresses from one another but not from +the relay operator. Direct connections expose public IP addresses to the +remote peer and are clearly labeled in game. Expect rough edges and please +keep a backup of any save or identity data you care about. INSTALLATION @@ -115,8 +120,9 @@ Please include: KNOWN ALPHA LIMITATIONS -- Public sessions remain direct connections; restrictive NAT or carrier-grade - networks may prevent some players from joining. +- Direct public sessions may fail behind restrictive NAT or carrier-grade + networks. Player-hosted relay sessions add an extra network hop and may have + more latency. Dedicated servers are direct-only. - Multiplayer, dedicated-server deployment, UI, and balance are still alpha. - Some fish and character artwork remains temporary. - RV exteriors and decor use temporary prototype geometry and presentation. diff --git a/export_presets.cfg b/export_presets.cfg index a49f01f..76fdef3 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.2-alpha/windows-x86_64/straywild.exe" +export_path="builds/v0.20.3-alpha/windows-x86_64/straywild.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/straywild.ico" application/console_wrapper_icon="" application/icon_interpolation=0 -application/file_version="0.20.2.0" -application/product_version="0.20.2.0" +application/file_version="0.20.3.0" +application/product_version="0.20.3.0" application/company_name="Woofmeow" application/product_name="straywild" -application/file_description="straywild v0.20.2-alpha" +application/file_description="straywild v0.20.3-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.2-alpha/linux-arm64/straywild.arm64" +export_path="builds/v0.20.3-alpha/linux-arm64/straywild.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.2-alpha/macos/straywild.zip" +export_path="builds/v0.20.3-alpha/macos/straywild.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/straywild_1024.png" application/icon_interpolation=0 -application/short_version="0.20.2" -application/version="0.20.2" +application/short_version="0.20.3" +application/version="0.20.3" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.20.2-alpha/server-linux-x86_64/straywildServer.x86_64" +export_path="builds/v0.20.3-alpha/server-linux-x86_64/straywildServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.2-alpha/android/straywild.apk" +export_path="builds/v0.20.3-alpha/android/straywild.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -177,7 +177,7 @@ architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false version/code=200200 -version/name="v0.20.2-alpha" +version/name="v0.20.3-alpha" package/unique_name="io.woofmeow.netfishing" package/name="straywild" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.2-alpha/linux-x86_64/straywild.x86_64" +export_path="builds/v0.20.3-alpha/linux-x86_64/straywild.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/main/main.gd b/main/main.gd index f7f4f0f..ab013d7 100644 --- a/main/main.gd +++ b/main/main.gd @@ -2234,9 +2234,15 @@ func _on_return_to_title_requested() -> void: _show_title_music(true) -func _on_title_join_game_requested(endpoint: String) -> void: +func _on_title_join_game_requested(requested_route: Variant) -> void: if _quit_in_progress or _gameplay_started: return + var route := _coerce_connection_route(requested_route) + if route == null: + _game_ui.get_title_screen().report_network_error( + "That connection address is invalid." + ) + return var active_slot: Dictionary = _save_slots.ensure_active_slot() if not bool(active_slot.get("ok", false)): _game_ui.get_title_screen().report_network_error( @@ -2247,14 +2253,26 @@ func _on_title_join_game_requested(endpoint: String) -> void: _network_session.disconnect_session("Preparing direct connection.") _join_requested_from_title = true _join_requested_from_pause = false - _pending_join_endpoint = endpoint - if not _network_session.join_direct(endpoint): + _pending_join_endpoint = ( + route.direct_endpoint.normalized_display + if route != null + and not route.is_discovery_join() + and route.direct_endpoint != null + else "" + ) + if not _network_session.join_route(route): _join_requested_from_title = false -func _on_pause_join_game_requested(endpoint: String) -> void: +func _on_pause_join_game_requested(requested_route: Variant) -> void: if _quit_in_progress or not _gameplay_started: return + var route := _coerce_connection_route(requested_route) + if route == null: + _game_ui.get_pause_menu().report_network_error( + "That connection address is invalid." + ) + return if not _save_manager.save_world_time_checkpoint(): _game_ui.get_pause_menu().report_network_error( "Could not save progression before leaving this session." @@ -2262,20 +2280,36 @@ func _on_pause_join_game_requested(endpoint: String) -> void: return _join_requested_from_pause = true _join_requested_from_title = false - _pending_join_endpoint = endpoint + _pending_join_endpoint = ( + route.direct_endpoint.normalized_display + if route != null + and not route.is_discovery_join() + and route.direct_endpoint != null + else "" + ) _game_ui.get_pause_menu().close_for_title_transition() var preserved_public_join: bool = ( _discovery.preserve_public_join_for_session_switch() ) _network_session.disconnect_session("Connecting to another game.") - if not _network_session.join_direct(endpoint): + if not _network_session.join_route(route): if preserved_public_join: _discovery.cancel_pending_public_join() _handle_failed_session_switch( - "Could not begin the direct connection." + "Could not begin the connection." ) +func _coerce_connection_route(value: Variant) -> ConnectionRoute: + if value is ConnectionRoute: + var supplied_route := value as ConnectionRoute + return supplied_route if supplied_route.is_valid() else null + if typeof(value) != TYPE_STRING: + return null + var endpoint := EndpointParser.parse(str(value)) + return ConnectionRoute.direct(endpoint) if endpoint.is_valid() else null + + func _on_friend_request_received( request_id: String, fingerprint: String, @@ -2335,8 +2369,17 @@ func _show_next_social_prompt() -> void: elif kind == "friend_invite": var room: Dictionary = _active_social_prompt.get("room", {}) var room_name := str(room.get("room_name", "their room")) + var relayed := str(room.get("connection_mode", "")) == "relay" + var privacy_message := ( + "This join uses a privacy relay; the relay service still handles " + + "both network addresses." + if relayed + else "This is a direct connection. Joining shares your public IP " + + "address with the host." + ) shown = _game_ui.show_social_prompt( - "%s invited you to join %s." % [display_name, room_name], + "%s invited you to join %s.\n%s" + % [display_name, room_name, privacy_message], "join", "not now", ) @@ -2375,16 +2418,16 @@ func _on_social_prompt_declined() -> void: call_deferred("_show_next_social_prompt") -func _on_social_public_join_prepared(endpoint: String) -> void: +func _on_social_public_join_prepared(route: ConnectionRoute) -> void: if not _pending_social_invite_join: return var from_gameplay := _pending_social_invite_gameplay _pending_social_invite_join = false _pending_social_invite_gameplay = false if from_gameplay: - _on_pause_join_game_requested(endpoint) + _on_pause_join_game_requested(route) else: - _on_title_join_game_requested(endpoint) + _on_title_join_game_requested(route) func _on_social_public_join_status_changed( @@ -2412,7 +2455,7 @@ func _on_network_join_authenticated() -> void: var server_metadata: Dictionary = ( _network_session.get_last_server_metadata() ) - if connected_endpoint != null: + if connected_endpoint != null and not _network_session.is_current_route_discovery(): _saved_servers.record_successful_connection( connected_endpoint, int(server_metadata.get("max_players", 0)), @@ -2455,7 +2498,7 @@ func _on_network_connection_error(message: String) -> void: var failed_endpoint: ConnectionEndpoint = ( _network_session.get_current_endpoint() ) - if failed_endpoint != null: + if failed_endpoint != null and not _network_session.is_current_route_discovery(): _saved_servers.record_connection_failure( failed_endpoint, _connection_result_code(message), diff --git a/network/connection_route.gd b/network/connection_route.gd index af9e625..2f490c7 100644 --- a/network/connection_route.gd +++ b/network/connection_route.gd @@ -3,22 +3,65 @@ extends RefCounted enum Kind { DIRECT, + DISCOVERY_DIRECT, + DISCOVERY_RELAY, } var kind: Kind = Kind.DIRECT var direct_endpoint: ConnectionEndpoint var display_description: String = "" +var discovery_room_id: String = "" static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute: var route := ConnectionRoute.new() route.kind = Kind.DIRECT route.direct_endpoint = endpoint + route.display_description = "direct server" if endpoint != null else "" + return route + + +static func discovery_direct( + endpoint: ConnectionEndpoint, + room_id: String, + room_name: String, +) -> ConnectionRoute: + var route := ConnectionRoute.new() + route.kind = Kind.DISCOVERY_DIRECT + route.direct_endpoint = endpoint + route.discovery_room_id = room_id + var cleaned_name := room_name.strip_edges() route.display_description = ( - endpoint.normalized_display if endpoint != null else "" + cleaned_name if not cleaned_name.is_empty() else "public room" ) return route +static func discovery_relay( + endpoint: ConnectionEndpoint, + room_id: String, + room_name: String, +) -> ConnectionRoute: + var route := discovery_direct(endpoint, room_id, room_name) + route.kind = Kind.DISCOVERY_RELAY + return route + + func is_valid() -> bool: - return kind == Kind.DIRECT and direct_endpoint != null and direct_endpoint.is_valid() + return ( + kind in [Kind.DIRECT, Kind.DISCOVERY_DIRECT, Kind.DISCOVERY_RELAY] + and direct_endpoint != null + and direct_endpoint.is_valid() + and ( + kind == Kind.DIRECT + or not discovery_room_id.is_empty() + ) + ) + + +func is_discovery_join() -> bool: + return kind in [Kind.DISCOVERY_DIRECT, Kind.DISCOVERY_RELAY] + + +func is_relay() -> bool: + return kind == Kind.DISCOVERY_RELAY diff --git a/network/direct_enet_transport.gd b/network/direct_enet_transport.gd index 067b86c..e753246 100644 --- a/network/direct_enet_transport.gd +++ b/network/direct_enet_transport.gd @@ -34,11 +34,12 @@ func connect_to_route(route: ConnectionRoute) -> Error: ) if error != OK: transport_error.emit( - "Unable to connect to %s." % endpoint.normalized_display + "Unable to begin the connection to %s." + % route.display_description ) return error _peer = enet_peer - _route_description = endpoint.normalized_display + _route_description = route.display_description return OK diff --git a/network/discovery_client.gd b/network/discovery_client.gd index 1e9272f..583f104 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -6,7 +6,7 @@ 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) signal host_state_changed(state: int) -signal public_join_prepared(endpoint: String) +signal public_join_prepared(route: ConnectionRoute) signal public_join_status_changed(message: String, is_error: bool) signal public_join_state_changed(state: int) signal friend_presence_updated(friends: Array[Dictionary]) @@ -28,6 +28,9 @@ const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room" const LEGACY_DEDICATED_DEFAULT_ROOM_NAME: String = ( "straywild Dedicated Server" ) +const HOST_CONNECTION_DIRECT: String = "direct" +const HOST_CONNECTION_RELAY: String = "relay" +const HOST_DISCOVERY_PRIVATE: String = "private" const ROOM_NAME_SUFFIX: String = "'s Server" const MAX_ROOM_NAME_LENGTH: int = 48 const HEARTBEAT_INTERVAL_SECONDS: float = 15.0 @@ -35,6 +38,7 @@ const REQUEST_TIMEOUT_SECONDS: float = 8.0 const TRAVERSAL_POLL_INTERVAL_SECONDS: float = 1.0 const JOIN_PROBE_INTERVAL_SECONDS: float = 0.35 const TRAVERSAL_PACKET_PREFIX: String = "straywild_TRAVERSAL_V1 " +const RELAY_AUTH_PACKET_PREFIX: String = "straywild_RELAY_AUTH_V1 " const UPNP_MAPPING_DURATION_SECONDS: int = 3600 const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0 const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0 @@ -90,6 +94,7 @@ var _join_request: HTTPRequest var _traversal_timer: Timer var _join_probe_timer: Timer var _host_verified: bool = false +var _host_connection_preference: String = HOST_CONNECTION_DIRECT var _traversal_host: String = "" var _traversal_port: int = 0 var _host_verification_token: String = "" @@ -97,6 +102,8 @@ var _join_request_in_flight: bool = false var _pending_join_endpoint: String = "" var _pending_join_token: String = "" var _pending_join_room_id: String = "" +var _pending_join_room_name: String = "" +var _pending_join_transport: String = "" var _preserve_pending_join_on_inactive: bool = false var _upnp_thread: Thread var _upnp_mapping_in_progress: bool = false @@ -381,12 +388,25 @@ func get_host_state() -> HostState: return _host_state +func get_host_connection_mode() -> String: + return _host_connection_preference + + +func get_host_discovery_mode() -> String: + return ( + _host_connection_preference + if _discoverable + else HOST_DISCOVERY_PRIVATE + ) + + func get_public_join_state() -> PublicJoinState: return _public_join_state func configure_dedicated_runtime(room_name: String) -> bool: _host_settings_persistence_enabled = false + _host_connection_preference = HOST_CONNECTION_DIRECT return set_room_name(room_name) @@ -453,6 +473,49 @@ func set_discoverable(enabled: bool) -> bool: return true +func set_host_connection_mode(connection_mode: String) -> bool: + if connection_mode not in [ + HOST_CONNECTION_DIRECT, + HOST_CONNECTION_RELAY, + ]: + return false + if ( + connection_mode == HOST_CONNECTION_RELAY + and _session != null + and _session.is_dedicated_host() + ): + _set_host_state(HostState.ERROR) + _set_host_status( + "Dedicated servers must use direct discovery hosting.", true + ) + return false + if _host_connection_preference == connection_mode: + return true + _host_connection_preference = connection_mode + _save_settings() + host_settings_changed.emit(_room_name, _discoverable) + if _discoverable: + _set_host_state(HostState.REGISTERING) + _set_host_status("Updating public connection mode…", false) + _synchronize_host_lease() + return true + + +func cycle_host_discovery_mode() -> bool: + match get_host_discovery_mode(): + HOST_DISCOVERY_PRIVATE: + if not set_host_connection_mode(HOST_CONNECTION_DIRECT): + return false + return set_discoverable(true) + HOST_CONNECTION_DIRECT: + if _session != null and _session.is_dedicated_host(): + return set_discoverable(false) + return set_host_connection_mode(HOST_CONNECTION_RELAY) + HOST_CONNECTION_RELAY: + return set_discoverable(false) + return false + + func is_public_join_preparing() -> bool: return _join_request_in_flight @@ -480,13 +543,16 @@ func prepare_public_join(room: Dictionary) -> bool: "You are already hosting this room.", true ) return false - var endpoint: String = room_endpoint(room) var room_id: String = str(room.get("room_id", "")).strip_edges() - if endpoint.is_empty() or room_id.is_empty(): + if room_id.is_empty(): _set_public_join_state(PublicJoinState.ERROR) return false - _pending_join_endpoint = endpoint + _pending_join_endpoint = "" _pending_join_room_id = room_id + _pending_join_room_name = str( + room.get("room_name", "public room") + ).strip_edges() + _pending_join_transport = str(room.get("connection_mode", "")) _pending_join_token = "" var url: String = "%s/v1/rooms/%s/join-attempts" % [ _base_url, @@ -535,16 +601,6 @@ func request_rooms() -> bool: 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 is_own_room(room: Dictionary) -> bool: return ( not _lease_room_id.is_empty() @@ -640,6 +696,14 @@ func _host_payload() -> Dictionary: ProjectSettings.get_setting("application/config/version", "unknown") ), "protocol_version": NetworkProtocol.PROTOCOL_VERSION, + "host_kind": ( + "dedicated" if _session.is_dedicated_host() else "player" + ), + "connection_mode": ( + HOST_CONNECTION_DIRECT + if _session.is_dedicated_host() + else _host_connection_preference + ), } @@ -688,7 +752,7 @@ func _on_host_request_completed( else HostState.VERIFYING ) _set_host_status( - "Room is listed publicly." + _host_listing_status(room) if _host_verified else "Checking the public route…", false, @@ -706,7 +770,7 @@ func _on_host_request_completed( else HostState.VERIFYING ) _set_host_status( - "Room is listed publicly." + _host_listing_status(room) if _host_verified else "Checking the public route…", false, @@ -755,10 +819,33 @@ func _on_join_request_completed( return _pending_join_token = str(response.get("join_token", "")) _apply_traversal_response(response) + var route_transport := "" + var route_privacy := "" + var route_details: Variant = response.get("route", {}) + if typeof(route_details) == TYPE_DICTIONARY: + var direct_route: Dictionary = route_details + route_transport = str(direct_route.get("transport", "")) + route_privacy = str(direct_route.get("ip_privacy", "")) + if route_transport in ["direct", "relay"]: + var address := str(direct_route.get("address", "")).strip_edges() + var port := int(direct_route.get("port", 0)) + if not address.is_empty() and port >= 1 and port <= 65535: + if ":" in address and not address.begins_with("["): + address = "[%s]" % address + _pending_join_endpoint = "%s:%d" % [address, port] if ( _pending_join_token.is_empty() - or _traversal_host.is_empty() - or _traversal_port < 1 + or _pending_join_endpoint.is_empty() + or route_transport != _pending_join_transport + or ( + route_transport == "direct" + and route_privacy != "peer_visible" + ) + or (route_transport == "relay" and route_privacy != "relayed") + or ( + route_transport == "direct" + and (_traversal_host.is_empty() or _traversal_port < 1) + ) ): _set_public_join_state(PublicJoinState.ERROR) public_join_status_changed.emit( @@ -768,7 +855,25 @@ func _on_join_request_completed( return _set_public_join_state(PublicJoinState.CONNECTING) public_join_status_changed.emit("Connecting…", false) - public_join_prepared.emit(_pending_join_endpoint) + var endpoint := EndpointParser.parse(_pending_join_endpoint) + if not endpoint.is_valid(): + _set_public_join_state(PublicJoinState.ERROR) + public_join_status_changed.emit("Could not reach this room.", true) + _clear_pending_join() + return + public_join_prepared.emit( + ConnectionRoute.discovery_relay( + endpoint, + _pending_join_room_id, + _pending_join_room_name, + ) + if route_transport == "relay" + else ConnectionRoute.discovery_direct( + endpoint, + _pending_join_room_id, + _pending_join_room_name, + ) + ) func _on_traversal_timer_timeout() -> void: @@ -856,6 +961,15 @@ func _send_pending_join_probe() -> void: ): _join_probe_timer.stop() return + if _pending_join_transport == "relay": + var relay_endpoint := EndpointParser.parse(_pending_join_endpoint) + if relay_endpoint.is_valid(): + _session.send_traversal_packet( + relay_endpoint.host, + relay_endpoint.port, + (RELAY_AUTH_PACKET_PREFIX + _pending_join_token).to_utf8_buffer(), + ) + return _send_traversal_probe({ "kind": "join", "room_id": _pending_join_room_id, @@ -1013,10 +1127,24 @@ func _valid_public_room(room: Dictionary) -> bool: 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 str(room.get("connection_mode", "")) in ["direct", "relay"] + and ( + ( + str(room.get("connection_mode", "")) == "direct" + and str(room.get("ip_privacy", "")) == "peer_visible" + ) + or ( + str(room.get("connection_mode", "")) == "relay" + and str(room.get("ip_privacy", "")) == "relayed" + ) + ) + and str(room.get("host_kind", "")) in ["player", "dedicated"] + and not ( + str(room.get("host_kind", "")) == "dedicated" + and str(room.get("connection_mode", "")) == "relay" + ) and _valid_json_integer(room.get("current_players"), 0, 128) and _valid_json_integer(room.get("max_players"), 1, 128) and int(room["current_players"]) <= int(room["max_players"]) @@ -1370,6 +1498,18 @@ func _request_failure(response: Dictionary) -> String: return "Room discovery is temporarily unavailable." +func _host_listing_status(room: Dictionary) -> String: + if str(room.get("connection_mode", "")) == "relay": + return ( + "Room is listed through the privacy relay. Players see a relay " + + "address; the relay service still handles network addresses." + ) + return ( + "Room is listed. Its address is hidden while browsing, but direct " + + "players can see your public IP after joining." + ) + + func _discovery_version_mismatch_message(required_version: String) -> String: var local_version: String = NetworkProtocol.game_version() var rejection: NetworkProtocol.RejectionCode = ( @@ -1493,6 +1633,8 @@ func _clear_pending_join() -> void: _pending_join_endpoint = "" _pending_join_token = "" _pending_join_room_id = "" + _pending_join_room_name = "" + _pending_join_transport = "" _join_probe_timer.stop() @@ -1571,6 +1713,14 @@ func _load_settings() -> void: _presence_sharing = bool(config.get_value( "social", "share_presence", false )) + var saved_connection_mode := str(config.get_value( + "host", "connection_mode", HOST_CONNECTION_DIRECT + )) + if saved_connection_mode in [ + HOST_CONNECTION_DIRECT, + HOST_CONNECTION_RELAY, + ]: + _host_connection_preference = saved_connection_mode func _save_settings() -> void: @@ -1581,6 +1731,9 @@ func _save_settings() -> void: config.set_value( "host", "room_name_uses_default", _room_name_uses_default ) + config.set_value( + "host", "connection_mode", _host_connection_preference + ) config.set_value("social", "share_presence", _presence_sharing) var error: Error = config.save(SETTINGS_PATH) if error != OK: diff --git a/network/network_session.gd b/network/network_session.gd index 9576ceb..13c05d3 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -485,17 +485,30 @@ func join_direct(endpoint_text: String) -> bool: if not endpoint.is_valid(): _fail(endpoint.error_message) return false + return join_route(ConnectionRoute.direct(endpoint)) + + +func join_route(route: ConnectionRoute) -> bool: + if ( + state != State.INACTIVE + or not _profile_ready + or _profile == null + or _spawn_service == null + or route == null + or not route.is_valid() + ): + return false _operation_generation += 1 var generation: int = _operation_generation - _current_route = ConnectionRoute.direct(endpoint) + _current_route = route _set_state( State.CONNECTING, - "Connecting to %s..." % endpoint.normalized_display + "Connecting to %s..." % route.display_description ) _replace_transport() var error: Error = _transport.connect_to_route(_current_route) if error != OK: - _fail("Could not begin the direct connection.") + _fail("Could not begin the connection.") return false multiplayer.multiplayer_peer = _transport.get_multiplayer_peer() _connection_deadline = ( @@ -597,11 +610,23 @@ func get_current_endpoint() -> ConnectionEndpoint: return ( _current_route.direct_endpoint if _current_route != null - and _current_route.kind == ConnectionRoute.Kind.DIRECT + and _current_route.kind in [ + ConnectionRoute.Kind.DIRECT, + ConnectionRoute.Kind.DISCOVERY_DIRECT, + ConnectionRoute.Kind.DISCOVERY_RELAY, + ] else null ) +func is_current_route_discovery() -> bool: + return _current_route != null and _current_route.is_discovery_join() + + +func is_current_route_relay() -> bool: + return _current_route != null and _current_route.is_relay() + + func get_last_server_metadata() -> Dictionary: return { "server_display_name": _last_server_display_name, @@ -1226,11 +1251,16 @@ func receive_server_identity_proof(data: Dictionary) -> void: _pending_server_proof = data.duplicate(true) _server_identity_fingerprint = fingerprint _server_identity_public_key = public_pem - var verification := _server_trust.verify( - get_current_endpoint(), fingerprint + var verification := ( + _server_trust.verify_identity(fingerprint) + if is_current_route_discovery() + else _server_trust.verify(get_current_endpoint(), fingerprint) ) if verification == ServerTrustStore.Verification.MATCH: - _server_trust.touch(get_current_endpoint()) + if is_current_route_discovery(): + _server_trust.touch_identity(fingerprint) + else: + _server_trust.touch(get_current_endpoint()) _send_client_identity_proof() return _set_state( @@ -1238,9 +1268,12 @@ func receive_server_identity_proof(data: Dictionary) -> void: "Confirm this server identity before continuing.", ) _connection_deadline = 0.0 - var expected := str( - _server_trust.get_record(get_current_endpoint()).get("fingerprint", "") + var trust_record: Dictionary = ( + _server_trust.get_identity_record(fingerprint) + if is_current_route_discovery() + else _server_trust.get_record(get_current_endpoint()) ) + var expected := str(trust_record.get("fingerprint", "")) server_trust_required.emit( get_current_route_display(), expected, @@ -1255,11 +1288,15 @@ func resolve_server_trust(accepted: bool) -> void: if not accepted: cancel_connection() return - if not _server_trust.trust( - get_current_endpoint(), - str(_pending_server_proof["server_fingerprint"]), - "straywild", - ): + var fingerprint := str(_pending_server_proof["server_fingerprint"]) + var trusted := ( + _server_trust.trust_identity(fingerprint, get_current_route_display()) + if is_current_route_discovery() + else _server_trust.trust( + get_current_endpoint(), fingerprint, "straywild" + ) + ) + if not trusted: _fail_identity("The server identity could not be pinned.") return _set_state(State.AUTHENTICATING, "Authenticating identity...") diff --git a/network/saved_server_store.gd b/network/saved_server_store.gd index 4e00259..c1ae4f8 100644 --- a/network/saved_server_store.gd +++ b/network/saved_server_store.gd @@ -196,7 +196,7 @@ func record_successful_connection( recent = _make_entry(endpoint, SavedServerEntry.Kind.RECENT) _recent_entries.append(recent) recent["display_name"] = ( - server_name if not server_name.is_empty() else endpoint.normalized_display + server_name if not server_name.is_empty() else "Recent Server" ) _apply_result_metadata( recent, now, "SUCCESS", server_name, protocol_version, @@ -358,7 +358,11 @@ func _make_entry( var now: int = int(Time.get_unix_time_from_system()) return { "entry_id": Crypto.new().generate_random_bytes(16).hex_encode(), - "display_name": endpoint.host, + "display_name": ( + "Saved Server" + if kind == SavedServerEntry.Kind.SAVED + else "Recent Server" + ), "route_kind": "DIRECT", "host": endpoint.host, "normalized_host": endpoint.host, diff --git a/network/server_trust_store.gd b/network/server_trust_store.gd index 1d4dd29..c95865e 100644 --- a/network/server_trust_store.gd +++ b/network/server_trust_store.gd @@ -39,6 +39,17 @@ func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification: ) +func verify_identity(fingerprint: String) -> Verification: + _ensure_loaded() + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + return Verification.CHANGED + return ( + Verification.MATCH + if _records.has(_identity_key(fingerprint)) + else Verification.FIRST_SEEN + ) + + func get_record(endpoint: ConnectionEndpoint) -> Dictionary: _ensure_loaded() if endpoint == null: @@ -46,6 +57,11 @@ func get_record(endpoint: ConnectionEndpoint) -> Dictionary: return Dictionary(_records.get(endpoint.normalized_display, {})).duplicate(true) +func get_identity_record(fingerprint: String) -> Dictionary: + _ensure_loaded() + return Dictionary(_records.get(_identity_key(fingerprint), {})).duplicate(true) + + func trust( endpoint: ConnectionEndpoint, fingerprint: String, @@ -74,6 +90,25 @@ func trust( return _save() +func trust_identity(fingerprint: String, server_name: String = "") -> bool: + _ensure_loaded() + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + return false + var key := _identity_key(fingerprint) + var now := int(Time.get_unix_time_from_system()) + var previous: Dictionary = _records.get(key, {}) + _records[key] = { + "route_kind": "discovery", + "identity_key": key, + "fingerprint": fingerprint, + "first_seen_unix": int(previous.get("first_seen_unix", now)), + "last_seen_unix": now, + "last_observed_server_name": server_name.left(80), + "trust_state": "pinned", + } + return _save() + + func touch(endpoint: ConnectionEndpoint) -> void: _ensure_loaded() if endpoint == null: @@ -85,6 +120,15 @@ func touch(endpoint: ConnectionEndpoint) -> void: _save() +func touch_identity(fingerprint: String) -> void: + _ensure_loaded() + var record: Dictionary = _records.get(_identity_key(fingerprint), {}) + if record.is_empty(): + return + record["last_seen_unix"] = int(Time.get_unix_time_from_system()) + _save() + + func _ensure_loaded() -> void: if _loaded: return @@ -108,12 +152,21 @@ func _ensure_loaded() -> void: continue var record: Dictionary = value var endpoint := str(record.get("normalized_endpoint", "")) + var identity_key := str(record.get("identity_key", "")) var fingerprint := str(record.get("fingerprint", "")) - if not endpoint.is_empty() and NetworkIdentityCrypto.valid_fingerprint(fingerprint): + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + continue + if not endpoint.is_empty(): _records[endpoint] = record.duplicate(true) + elif identity_key == _identity_key(fingerprint): + _records[identity_key] = record.duplicate(true) _expected_hash = PortableFileGuard.hash_file(_store_path) +static func _identity_key(fingerprint: String) -> String: + return "identity:%s" % fingerprint + + func _save() -> bool: if _write_blocked: return false diff --git a/project.godot b/project.godot index 5bcbb4d..be91dfd 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="straywild" -config/version="0.20.2-alpha" +config/version="0.20.3-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/straywild_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index d54ba70..926794c 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.20.2-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.20.3-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild" -readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.3-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.3-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then @@ -78,7 +78,7 @@ Source repository: ${SOURCE_URL} Source revision at build time: ${SOURCE_COMMIT} This private testing release is distributed with an exact corresponding -source archive named straywild-v0.20.2-alpha-source.tar.gz. +source archive named straywild-v0.20.3-alpha-source.tar.gz. If this package includes uncommitted development changes, it must not be publicly redistributed until the exact corresponding source is also made diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index ff108a8..84e862b 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -22,6 +22,7 @@ readonly -a QUICK_TESTS=( "tests/controller_world_interaction_validation.gd" "tests/controller_ui_navigation_validation.gd" "tests/dedicated_server_config_validation.gd" + "tests/discovery_privacy_validation.gd" "tests/digging_prototype_validation.gd" "tests/equipment_catalog_validation.gd" "tests/exported_decal_hotfix_validation.gd" diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index 5cff1bb..e2b99ed 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -178,16 +178,39 @@ func _run() -> void: ) assert(not FileAccess.file_exists(discovery_settings_path)) assert(discovery.set_room_name("Client Room")) + assert( + discovery.get_host_discovery_mode() + == DiscoveryClient.HOST_DISCOVERY_PRIVATE + ) + assert(discovery.set_host_connection_mode( + DiscoveryClient.HOST_CONNECTION_RELAY + )) + assert( + discovery.get_host_connection_mode() + == DiscoveryClient.HOST_CONNECTION_RELAY + ) + assert( + discovery.get_host_discovery_mode() + == DiscoveryClient.HOST_DISCOVERY_PRIVATE + ) var client_settings: PackedByteArray = FileAccess.get_file_as_bytes( discovery_settings_path ) assert(not client_settings.is_empty()) var dedicated_discovery := DiscoveryClient.new() dedicated_discovery.call("_load_settings") + dedicated_discovery.set( + "_host_connection_preference", + DiscoveryClient.HOST_CONNECTION_RELAY, + ) assert( dedicated_discovery.configure_dedicated_runtime("Headless Room") ) assert(dedicated_discovery.get_room_name() == "Headless Room") + assert( + dedicated_discovery.get_host_connection_mode() + == DiscoveryClient.HOST_CONNECTION_DIRECT + ) assert( FileAccess.get_file_as_bytes(discovery_settings_path) == client_settings @@ -210,16 +233,26 @@ func _run() -> void: var listed_room := { "room_id": "empty-dedicated-room", "room_name": "Empty Dedicated Room", - "address": "203.0.113.10", - "port": 7777, "current_players": 0, "max_players": 8, "game_version": str(ProjectSettings.get_setting( "application/config/version", "unknown" )), "protocol_version": NetworkProtocol.PROTOCOL_VERSION, + "connection_mode": "direct", + "ip_privacy": "peer_visible", + "host_kind": "dedicated", } assert(bool(discovery.call("_valid_public_room", listed_room))) + assert(not listed_room.has("address")) + assert(not listed_room.has("port")) + var relayed_room: Dictionary = listed_room.duplicate(true) + relayed_room["connection_mode"] = "relay" + relayed_room["ip_privacy"] = "relayed" + assert(not bool(discovery.call("_valid_public_room", relayed_room))) + var player_relayed_room: Dictionary = relayed_room.duplicate(true) + player_relayed_room["host_kind"] = "player" + assert(bool(discovery.call("_valid_public_room", player_relayed_room))) var incompatible_room: Dictionary = listed_room.duplicate(true) incompatible_room["game_version"] = "0.0.0-alpha" assert(not bool(discovery.call("_valid_public_room", incompatible_room))) diff --git a/tests/discovery_privacy_validation.gd b/tests/discovery_privacy_validation.gd new file mode 100644 index 0000000..47e7e6c --- /dev/null +++ b/tests/discovery_privacy_validation.gd @@ -0,0 +1,98 @@ +extends SceneTree + +const FINGERPRINT: String = ( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" +) +const JoinGamePageScene: PackedScene = preload( + "res://ui/network/join_game_page.tscn" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var endpoint := EndpointParser.parse("203.0.113.20:49152") + assert(endpoint.is_valid()) + var direct_route := ConnectionRoute.direct(endpoint) + assert(direct_route.is_valid()) + assert("203.0.113.20" not in direct_route.display_description) + var route := ConnectionRoute.discovery_direct( + endpoint, + "opaque-room-id", + "Pond Friends", + ) + assert(route.is_valid()) + assert(route.is_discovery_join()) + assert(route.display_description == "Pond Friends") + assert("203.0.113.20" not in route.display_description) + var relay_route := ConnectionRoute.discovery_relay( + endpoint, + "opaque-room-id", + "Pond Friends", + ) + assert(relay_route.is_valid()) + assert(relay_route.is_discovery_join()) + assert(relay_route.is_relay()) + assert("203.0.113.20" not in relay_route.display_description) + + var join_page := JoinGamePageScene.instantiate() as JoinGamePage + root.add_child(join_page) + assert((join_page.get_node("%Address") as LineEdit).secret) + assert(str(join_page.call("_display_endpoint", "203.0.113.20:49152")) == "*****") + join_page.set("_addresses_visible", true) + join_page.call("_refresh_address_privacy_controls") + assert(not (join_page.get_node("%Address") as LineEdit).secret) + assert( + str(join_page.call("_display_endpoint", "203.0.113.20:49152")) + == "203.0.113.20:49152" + ) + var saved_entry := SavedServerEntry.new() + saved_entry.kind = SavedServerEntry.Kind.SAVED + saved_entry.display_name = "203.0.113.20" + saved_entry.host = "203.0.113.20" + saved_entry.normalized_host = "203.0.113.20" + saved_entry.normalized_endpoint = "203.0.113.20:49152" + join_page.set("_addresses_visible", false) + assert(str(join_page.call("_display_entry_name", saved_entry)) == "saved server") + join_page.set("_addresses_visible", true) + assert(str(join_page.call("_display_entry_name", saved_entry)) == "203.0.113.20") + + var data_root := PlayerDataRoot.new() + root.add_child(data_root) + var portable_path := ProjectSettings.globalize_path( + "user://discovery-privacy-validation" + ) + var created := data_root.create_unbound_root(portable_path) + assert(bool(created.get("ok", false))) + assert(data_root.activate_process_root(portable_path)) + + var store := ServerTrustStore.new() + root.add_child(store) + var trust_path := data_root.path_for(&"server_trust") + store.configure_storage(trust_path, data_root) + assert(store.verify_identity(FINGERPRINT) == ServerTrustStore.Verification.FIRST_SEEN) + assert(store.trust_identity(FINGERPRINT, "Pond Friends")) + assert(store.verify_identity(FINGERPRINT) == ServerTrustStore.Verification.MATCH) + var saved_text := FileAccess.get_file_as_string(trust_path) + assert("203.0.113.20" not in saved_text) + assert("normalized_endpoint" not in saved_text) + assert("normalized_host" not in saved_text) + assert("\"port\"" not in saved_text) + + var reloaded := ServerTrustStore.new() + root.add_child(reloaded) + reloaded.configure_storage(trust_path, data_root) + assert( + reloaded.verify_identity(FINGERPRINT) + == ServerTrustStore.Verification.MATCH + ) + var record := reloaded.get_identity_record(FINGERPRINT) + assert(record.get("route_kind") == "discovery") + assert(not record.has("normalized_endpoint")) + assert(not record.has("normalized_host")) + assert(not record.has("port")) + + print("Discovery privacy validation: PASS") + quit() diff --git a/tests/discovery_privacy_validation.gd.uid b/tests/discovery_privacy_validation.gd.uid new file mode 100644 index 0000000..3ac3f8d --- /dev/null +++ b/tests/discovery_privacy_validation.gd.uid @@ -0,0 +1 @@ +uid://dmtlh1kpm7bl1 diff --git a/ui/icons/address_hidden.svg b/ui/icons/address_hidden.svg new file mode 100644 index 0000000..919ce57 --- /dev/null +++ b/ui/icons/address_hidden.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/icons/address_hidden.svg.import b/ui/icons/address_hidden.svg.import new file mode 100644 index 0000000..b66e969 --- /dev/null +++ b/ui/icons/address_hidden.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bot2k4h3wnroh" +path="res://.godot/imported/address_hidden.svg-fd3f3bc53a766848efcd26f62867a980.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://ui/icons/address_hidden.svg" +dest_files=["res://.godot/imported/address_hidden.svg-fd3f3bc53a766848efcd26f62867a980.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/ui/icons/address_visible.svg b/ui/icons/address_visible.svg new file mode 100644 index 0000000..cc9de82 --- /dev/null +++ b/ui/icons/address_visible.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/icons/address_visible.svg.import b/ui/icons/address_visible.svg.import new file mode 100644 index 0000000..37d1c13 --- /dev/null +++ b/ui/icons/address_visible.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dbmqtwisapw4n" +path="res://.godot/imported/address_visible.svg-da2e41f3b965e45cfc3839b82d107ccf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://ui/icons/address_visible.svg" +dest_files=["res://.godot/imported/address_visible.svg-da2e41f3b965e45cfc3839b82d107ccf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 6546517..1cd6c10 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -1,8 +1,8 @@ class_name JoinGamePage extends Control -signal join_requested(endpoint: String) -signal join_confirmation_requested(endpoint: String) +signal join_requested(route: ConnectionRoute) +signal join_confirmation_requested(endpoint: String, relayed: bool) signal back_requested enum Mode { @@ -17,14 +17,23 @@ const DISCOVERY_REFRESH_SECONDS: float = 8.0 const ControllerFocusNavigationType = preload( "res://ui/controller_focus_navigation.gd" ) +const ADDRESS_HIDDEN_ICON: Texture2D = preload( + "res://ui/icons/address_hidden.svg" +) +const ADDRESS_VISIBLE_ICON: Texture2D = preload( + "res://ui/icons/address_visible.svg" +) 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." ) const DIRECT_WORKFLOW_HELP: String = ( - "%s\nJoin Now connects once; Save Server stores this address locally." - % ADDRESS_FORMAT_HELP + "Direct connections expose your public IP address to the remote host. " + + "Only connect to people you trust.\n" + + ADDRESS_FORMAT_HELP + + "\n" + + "Join Now connects once; Save Server stores this address locally." ) @onready var _main_panel: PanelContainer = %MainPanel @@ -33,6 +42,9 @@ const DIRECT_WORKFLOW_HELP: String = ( @onready var _list_content: Control = %ListContent @onready var _list_title: Label = %ListTitle @onready var _online_controls: Control = %OnlineControls +@onready var _address_privacy_controls: Control = %AddressPrivacyControls +@onready var _address_privacy_label: Label = %AddressPrivacyLabel +@onready var _address_visibility_button: Button = %AddressVisibilityButton @onready var _presence_button: Button = %PresenceButton @onready var _room_open_button: Button = %RoomOpenButton @onready var _room_listing_button: Button = %RoomListingButton @@ -84,6 +96,7 @@ var _connection_error_latched: bool = false var _pending_confirmation_endpoint: String = "" var _pending_confirmation_room: Dictionary = {} var _owns_pending_public_join: bool = false +var _addresses_visible: bool = false func _ready() -> void: @@ -96,6 +109,9 @@ func _ready() -> void: _presence_button.pressed.connect(_on_presence_pressed) _room_open_button.pressed.connect(_on_room_open_pressed) _room_listing_button.pressed.connect(_on_room_listing_pressed) + _address_visibility_button.pressed.connect( + _on_address_visibility_pressed + ) _refresh_button.pressed.connect(_request_discovery_refresh) _join_button.pressed.connect(_request_join) _save_button.pressed.connect(_on_save_pressed) @@ -310,6 +326,7 @@ func setup( func open_page(preserved_endpoint: String = "") -> void: + _addresses_visible = false if not preserved_endpoint.is_empty(): _address.text = preserved_endpoint elif _address.text.is_empty(): @@ -368,7 +385,11 @@ func confirm_pending_join() -> bool: return false return true _set_status("Connecting…") - join_requested.emit(endpoint_text) + var endpoint := EndpointParser.parse(endpoint_text) + if not endpoint.is_valid(): + _set_status(endpoint.error_message, true) + return false + join_requested.emit(ConnectionRoute.direct(endpoint)) return true @@ -405,7 +426,7 @@ func _default_mode_status(mode: Mode) -> String: Mode.FRIENDS: return "friend status is live and not stored by discovery" Mode.DIRECT: - return "direct connection • default port 7777" + return "direct connection • your public IP is visible to the host" Mode.SAVED: return "saved servers are stored on this device" Mode.RECENT: @@ -447,18 +468,26 @@ func _request_join() -> void: if _discovery == null: _set_status("Could not prepare the public connection.", true) return - endpoint_text = _discovery.room_endpoint(room) discovery_room = room.duplicate(true) 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) - return + var endpoint: ConnectionEndpoint + if discovery_room.is_empty(): + endpoint = EndpointParser.parse(endpoint_text) + if not endpoint.is_valid(): + _set_status(endpoint.error_message, true) + return if _gameplay_context: - _pending_confirmation_endpoint = endpoint.normalized_display + _pending_confirmation_endpoint = ( + "public room" + if not discovery_room.is_empty() + else endpoint.normalized_display + ) _pending_confirmation_room = discovery_room - join_confirmation_requested.emit(endpoint.normalized_display) + join_confirmation_requested.emit( + _pending_confirmation_endpoint, + _room_uses_relay(discovery_room), + ) return if not discovery_room.is_empty(): _owns_pending_public_join = true @@ -468,22 +497,20 @@ func _request_join() -> void: return _set_status("Connecting…") _address.text = endpoint.normalized_display - join_requested.emit(endpoint.normalized_display) + join_requested.emit(ConnectionRoute.direct(endpoint)) -func _on_public_join_prepared(endpoint_text: String) -> void: +func _on_public_join_prepared(route: ConnectionRoute) -> void: if not _owns_pending_public_join: return _owns_pending_public_join = false if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree(): return - var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) - if not endpoint.is_valid(): - _set_status("Discovery returned an invalid room address.", true) + if route == null or not route.is_valid() or not route.is_discovery_join(): + _set_status("Could not reach this room.", true) return - _address.text = endpoint.normalized_display _set_status("Connecting…") - join_requested.emit(endpoint.normalized_display) + join_requested.emit(route) func _on_public_join_status_changed(message: String, is_error: bool) -> void: @@ -518,7 +545,7 @@ func _on_save_pressed() -> void: ) if existing != null: _set_status( - "Already saved locally as “%s”." % existing.display_name, + "Already saved locally as “%s”." % _display_entry_name(existing), true, ) _set_mode(Mode.SAVED) @@ -530,7 +557,7 @@ func _on_save_pressed() -> void: if _mode == Mode.RECENT and _selected_entry != null and not _selected_entry.last_observed_server_name.is_empty() - else endpoint.host + else "Saved Server" ) _name_entry_active = true _name_edit.show() @@ -559,7 +586,7 @@ func _commit_name_entry() -> void: _set_status( "Server updated." if was_editing - else "Saved locally as “%s”." % saved.display_name + else "Saved locally as “%s”." % _display_entry_name(saved) ) _clear_edit_state() _mode = Mode.SAVED @@ -572,7 +599,17 @@ func _on_edit_pressed() -> void: return _editing_entry_id = _selected_entry.entry_id _address.text = _selected_entry.normalized_endpoint - _name_edit.text = _selected_entry.display_name + _name_edit.text = ( + _selected_entry.display_name + if ( + _addresses_visible + or not _entry_label_reveals_address( + _selected_entry.display_name, + _selected_entry, + ) + ) + else "Saved Server" + ) _name_entry_active = true _name_edit.show() _save_button.text = "save" @@ -789,8 +826,8 @@ func _refresh_entries() -> void: "%s%s — %s%s" % [ prefix, - entry.display_name, - entry.normalized_endpoint, + _display_entry_name(entry), + _display_endpoint(entry.normalized_endpoint), count, ] ) @@ -822,6 +859,7 @@ func _refresh() -> void: var list_content_visible: bool = not direct_content_visible _direct_content.visible = direct_content_visible _list_content.visible = list_content_visible + _refresh_address_privacy_controls() _list_title.text = _current_list_title() _refresh_online_controls(discovery_mode, friends_mode, connecting) _address.visible = direct or _name_entry_active @@ -978,8 +1016,10 @@ func _refresh_online_controls( if not local_host: return var room_open: bool = _network_session.is_open_host() - var room_listed: bool = ( - _discovery != null and _discovery.is_discoverable() + var host_discovery_mode: String = ( + _discovery.get_host_discovery_mode() + if _discovery != null + else DiscoveryClient.HOST_DISCOVERY_PRIVATE ) _room_open_button.text = "room: open" if room_open else "room: closed" _room_open_button.disabled = connecting @@ -988,9 +1028,7 @@ func _refresh_online_controls( if room_open else "Open this room so other players can connect." ) - _room_listing_button.text = ( - "listing: on" if room_listed else "listing: off" - ) + _room_listing_button.text = "discovery | %s" % host_discovery_mode _room_listing_button.disabled = ( connecting or _discovery == null @@ -998,9 +1036,14 @@ func _refresh_online_controls( or not room_open ) _room_listing_button.tooltip_text = ( - "Remove this room from the public room browser." - if room_listed - else "List this open room in the public room browser." + "Stop discovery listing. Existing relay connections remain active " + + "until those players disconnect." + if host_discovery_mode == DiscoveryClient.HOST_CONNECTION_RELAY + else "Switch this public listing to the privacy relay. Relay hosting " + + "hides peer IPs but may add latency and uses shared server capacity." + if host_discovery_mode == DiscoveryClient.HOST_CONNECTION_DIRECT + else "List this room through direct discovery. Joining players and the " + + "host can see one another's public IP addresses." if room_open and _discovery != null and _discovery.is_configured() else "Open the room before enabling its public listing." if _discovery != null and _discovery.is_configured() @@ -1025,6 +1068,7 @@ func _configure_controller_navigation() -> void: online_controls.append(control) var content_controls: Array[Control] = [] for control: Control in [ + _address_visibility_button, _address, _name_edit, _server_list, @@ -1056,6 +1100,7 @@ func _configure_controller_navigation() -> void: _presence_button, _room_open_button, _room_listing_button, + _address_visibility_button, _address, _name_edit, _server_list, @@ -1219,8 +1264,8 @@ func _recover_controller_focus( func _format_entry_details(entry: SavedServerEntry) -> String: var parts: Array[String] = [] if _mode == Mode.SAVED: - parts.append("Name: %s" % entry.display_name) - parts.append("Address: %s" % entry.normalized_endpoint) + parts.append("Name: %s" % _display_entry_name(entry)) + parts.append("Address: %s" % _display_endpoint(entry.normalized_endpoint)) if _server_trust != null: var trust := _server_trust.get_record(entry.get_endpoint()) if trust.is_empty(): @@ -1239,7 +1284,10 @@ func _format_entry_details(entry: SavedServerEntry) -> String: or entry.last_observed_server_name != entry.display_name ) ): - parts.append("Server name: %s" % entry.last_observed_server_name) + parts.append( + "Server name: %s" + % _display_entry_label(entry.last_observed_server_name, entry) + ) if entry.last_observed_max_players > 0: parts.append( "Players: %d / %d" @@ -1259,22 +1307,139 @@ func _format_entry_details(entry: SavedServerEntry) -> String: parts.append( "Last result: %s" % _format_result_code(entry.last_result_code) ) + parts.append("Connection: direct peer-to-peer • not relayed") + parts.append("Joining shares your public IP address with the remote host.") return "\n".join(parts) +func _on_address_visibility_pressed() -> void: + _addresses_visible = not _addresses_visible + if _mode in [Mode.SAVED, Mode.RECENT]: + var selected_id := ( + _selected_entry.entry_id if _selected_entry != null else "" + ) + _refresh_entries() + if not selected_id.is_empty(): + _select_entry_id(selected_id) + _refresh() + + +func _refresh_address_privacy_controls() -> void: + var relevant := ( + _mode in [Mode.DIRECT, Mode.SAVED, Mode.RECENT] + or _name_entry_active + ) + _address_privacy_controls.visible = relevant + _address.secret = not _addresses_visible + _address_privacy_label.text = ( + "addresses visible" + if _addresses_visible + else "addresses hidden for streamer safety" + ) + _address_visibility_button.icon = ( + ADDRESS_VISIBLE_ICON if _addresses_visible else ADDRESS_HIDDEN_ICON + ) + _address_visibility_button.tooltip_text = ( + "Hide server addresses" + if _addresses_visible + else "Show server addresses" + ) + _address_visibility_button.accessibility_name = ( + "hide server addresses" + if _addresses_visible + else "show server addresses" + ) + + +func _display_endpoint(endpoint: String) -> String: + return endpoint if _addresses_visible else "*****" + + +func _display_entry_name(entry: SavedServerEntry) -> String: + if entry == null: + return "server" + if ( + _addresses_visible + or not _entry_label_reveals_address(entry.display_name, entry) + ): + return entry.display_name + return ( + "saved server" + if entry.kind == SavedServerEntry.Kind.SAVED + else "recent server" + ) + + +func _display_entry_label( + label: String, + entry: SavedServerEntry, +) -> String: + if _addresses_visible or not _entry_label_reveals_address(label, entry): + return label + return "*****" + + +func _entry_label_reveals_address( + label: String, + entry: SavedServerEntry, +) -> bool: + if entry == null: + return false + var normalized_label: String = label.strip_edges().to_lower() + if normalized_label.is_empty(): + return false + for candidate: String in [ + entry.normalized_endpoint, + entry.normalized_host, + entry.host, + ]: + var normalized_candidate: String = candidate.strip_edges().to_lower() + if normalized_candidate.is_empty(): + continue + if normalized_label == normalized_candidate: + return true + if ( + normalized_candidate.contains(".") + or normalized_candidate.contains(":") + ) and normalized_label.contains(normalized_candidate): + return true + return false + + func _format_discovery_details(room: Dictionary) -> String: if room.is_empty(): return "Select a public room." + var relayed := _room_uses_relay(room) var lines: Array[String] = [ "Room: %s" % str(room.get("room_name", "Public room")), + "Host: %s" % ( + "dedicated server operator" + if str(room.get("host_kind", "player")) == "dedicated" + else "another player" + ), "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 "—" + ( + "Connection: privacy relay" + if relayed + else "Connection: direct peer-to-peer • not relayed" + ), + ( + "IP privacy: the other player sees the relay address, not your " + + "residential IP. The relay service still handles both addresses." + if relayed + else "IP privacy: joining shares your public IP address with the host." + ), + ( + "Relay failure stops the join; straywild will not silently fall back " + + "to a direct connection. The extra network hop may increase latency." + if relayed + else "The room address is disclosed to your game only after you press " + + "Join; it is not displayed or saved. Use a VPN if you need stronger " + + "privacy." ), - "Direct UDP connection • ping is measured after joining", ] if _discovery != null and _discovery.is_own_room(room): lines.append("This is the room you are currently hosting.") @@ -1292,11 +1457,28 @@ func _format_friend_details(friend: Dictionary) -> String: ), ] if not room.is_empty(): + var relayed := _room_uses_relay(room) lines.append("Room: %s" % str(room.get("room_name", "Public room"))) + lines.append("Host: %s" % ( + "dedicated server operator" + if str(room.get("host_kind", "player")) == "dedicated" + else "another player" + )) lines.append("Players: %d / %d" % [ int(room.get("current_players", 0)), int(room.get("max_players", 0)), ]) + lines.append( + "Connection: privacy relay" + if relayed + else "Connection: direct peer-to-peer • not relayed" + ) + lines.append( + "The other player sees the relay address; the relay service still " + + "handles both network addresses. The extra hop may add latency." + if relayed + else "Joining shares your public IP address with the host." + ) elif bool(friend.get("online", false)): lines.append("This friend is not in a joinable public room.") else: @@ -1338,6 +1520,10 @@ func _discovery_room_is_full(room: Dictionary) -> bool: return int(room.get("current_players", 0)) >= int(room.get("max_players", 0)) +func _room_uses_relay(room: Dictionary) -> bool: + return str(room.get("connection_mode", "")) == "relay" + + func _request_discovery_refresh() -> void: if ( _discovery == null @@ -1435,14 +1621,15 @@ func _on_room_open_pressed() -> void: func _on_room_listing_pressed() -> void: if _discovery == null: return - var enabling: bool = not _discovery.is_discoverable() - if not _discovery.set_discoverable(enabling): + if not _discovery.cycle_host_discovery_mode(): return - _set_status( - "The room is being listed publicly." - if enabling - else "The room is no longer listed publicly." - ) + match _discovery.get_host_discovery_mode(): + DiscoveryClient.HOST_CONNECTION_DIRECT: + _set_status("Discovery is listing this room as a direct connection.") + DiscoveryClient.HOST_CONNECTION_RELAY: + _set_status("Discovery is listing this room through the privacy relay.") + _: + _set_status("Discovery listing is private.") _refresh() diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn index 9374a85..4466fc9 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -1,9 +1,11 @@ -[gd_scene load_steps=5 format=3] +[gd_scene load_steps=7 format=3] [ext_resource type="Script" path="res://ui/network/join_game_page.gd" id="1_script"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] [ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="3_tab"] [ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="4_confirmation"] +[ext_resource type="Texture2D" path="res://ui/icons/address_hidden.svg" id="5_address_hidden"] +[ext_resource type="Texture2D" path="res://ui/icons/address_visible.svg" id="6_address_visible"] [node name="JoinGamePage" type="Control"] visible = false @@ -117,6 +119,33 @@ theme_override_constants/margin_bottom = 20 layout_mode = 2 theme_override_constants/separation = 12 +[node name="AddressPrivacyControls" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="AddressPrivacyLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/AddressPrivacyControls"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 14 +text = "addresses hidden for streamer safety" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="AddressVisibilityButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/AddressPrivacyControls"] +unique_name_in_owner = true +custom_minimum_size = Vector2(44, 40) +layout_mode = 2 +focus_mode = 2 +tooltip_text = "Show server addresses" +icon = ExtResource("5_address_hidden") +expand_icon = true +icon_max_width = 27 +accessibility_name = "show server addresses" + [node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"] layout_mode = 2 size_flags_vertical = 3 @@ -214,6 +243,8 @@ theme_override_font_sizes/font_size = 19 placeholder_text = "example.net or 192.168.1.50:7777" alignment = 1 max_length = 300 +secret = true +secret_character = "*" [node name="AddressHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] unique_name_in_owner = true diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index f07655b..b1cdcd1 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -25,7 +25,7 @@ signal return_to_title_requested signal reset_progress_requested signal quit_requested signal menu_visibility_changed(is_open: bool) -signal join_game_requested(endpoint: String) +signal join_game_requested(route: ConnectionRoute) enum ConfirmationAction { NONE, @@ -240,18 +240,29 @@ func _close_join_game() -> void: _begin_root_entry(true) -func _on_join_confirmation_requested(_endpoint: String) -> void: +func _on_join_confirmation_requested( + _endpoint: String, + relayed: bool, +) -> void: if _action_in_progress or _root_transition_active: _join_game_page.cancel_pending_join_confirmation() return _confirmation_action = ConfirmationAction.JOIN_ANOTHER _confirmation_returns_to_join_game = true + var privacy_message := ( + "this join uses a privacy relay. the other player sees the relay " + + "address instead of your residential IP; the relay service still " + + "handles both addresses and will not silently fall back to direct." + if relayed + else "this is a direct peer-to-peer connection, not a relay. the " + + "remote host can see your public IP address. discovery room " + + "addresses are not displayed or saved." + ) _confirmation_page.configure( - ( - "join another game?\n\n" - + "your progression will be saved first. current players will be " - + "disconnected if you are hosting." - ), + "join another game?\n\n" + + "your progression will be saved first. current players will be " + + "disconnected if you are hosting.\n\n" + + privacy_message, "save and join", "cancel", BubbleConfirmationPageType.InitialFocus.CONFIRM, @@ -261,8 +272,8 @@ func _on_join_confirmation_requested(_endpoint: String) -> void: _show_confirmation() -func _on_confirmed_join_requested(endpoint: String) -> void: - join_game_requested.emit(endpoint) +func _on_confirmed_join_requested(route: ConnectionRoute) -> void: + join_game_requested.emit(route) func report_network_error(message: String) -> void: diff --git a/ui/players_page.gd b/ui/players_page.gd index 68f25c6..31ebad5 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -401,7 +401,10 @@ func _refresh_host_settings() -> void: if not _room_name_edit.has_focus(): _room_name_edit.text = _discovery.get_room_name() var is_open: bool = _service.is_open_host() - var is_discoverable: bool = _discovery.is_discoverable() + var discovery_mode: String = _discovery.get_host_discovery_mode() + var is_discoverable: bool = ( + discovery_mode != DiscoveryClient.HOST_DISCOVERY_PRIVATE + ) _set_host_toggle_state( _online_toggle, _online_state_label, @@ -411,7 +414,7 @@ func _refresh_host_settings() -> void: _set_host_toggle_state( _discoverable_toggle, _discoverable_state_label, - "ON" if is_discoverable else "OFF", + discovery_mode.to_upper(), is_discoverable, ) _online_toggle.disabled = not _service.is_local_host() @@ -427,9 +430,11 @@ func _refresh_host_settings() -> void: ) _discoverable_toggle.disabled = not can_publish _discoverable_toggle.tooltip_text = ( - "Stop advertising this game in the public room browser." - if is_discoverable - else "Advertise this open game in the public room browser." + "Stop discovery listing. Existing relayed players remain connected." + if discovery_mode == DiscoveryClient.HOST_CONNECTION_RELAY + else "Switch discovery from direct hosting to the privacy relay." + if discovery_mode == DiscoveryClient.HOST_CONNECTION_DIRECT + else "List this open game through direct discovery." if can_publish else "Open the game before enabling discovery." if _discovery.is_configured() @@ -460,7 +465,7 @@ func _on_online_pressed() -> void: func _on_discoverable_pressed() -> void: if _discovery == null: return - _discovery.set_discoverable(not _discovery.is_discoverable()) + _discovery.cycle_host_discovery_mode() _refresh_host_settings() @@ -474,7 +479,7 @@ func _on_host_settings_changed( _set_host_toggle_state( _discoverable_toggle, _discoverable_state_label, - "ON" if discoverable else "OFF", + _discovery.get_host_discovery_mode().to_upper(), discoverable, ) _refresh_host_settings() diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 114ee9e..d39be9c 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -67,7 +67,7 @@ signal new_slot_requested( duplicate_source_slot_id: String, ) signal quit_requested -signal join_game_requested(endpoint: String) +signal join_game_requested(route: ConnectionRoute) enum ConfirmationAction { NONE, diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 22f00f2..8c8cae1 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -213,7 +213,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.20.2-alpha" +text = "v0.20.3-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]